From ba21713e6f559b4b7c2040df770381b2be45e3fd Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Thu, 9 Jul 2026 10:15:20 -0300 Subject: [PATCH 1/4] refactor(config): extract bootstrap and validation helpers This commit moves configuration bootstrap, directory setup, and update validation into reusable helpers while keeping the existing synchronous service API intact. The extraction prepares configuration state and validation for actor ownership without changing runtime behavior. This commit is part of the architecture's refactoring phase 14. Signed-off-by: lorenzoberts --- src/config/service.rs | 270 ++++++++++++++++++++++-------------------- 1 file changed, 144 insertions(+), 126 deletions(-) diff --git a/src/config/service.rs b/src/config/service.rs index 200470a3..434c262d 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -27,64 +27,158 @@ pub struct ConfigService { state: ConfigState, } -impl ConfigService { - /// Bootstrap configuration: load file or defaults, persist, apply env overrides, ensure dirs. - /// - /// Loads file or defaults, saves, applies env overrides, ensures directories exist. - pub fn bootstrap(env: &dyn EnvTrait, fs: FS) -> Result { - let repo = JsonConfigRepository::new(env, fs); - let mut state = Self::load_initial_state(env, &repo); - if let Err(e) = repo.save(&state) { - eprintln!("Failed to save default config: {e}"); - } - env_overrides::apply_env_overrides(&mut state, env)?; - normalize_derived_paths(&mut state); - let service = Self { repo, state }; - service.ensure_directories()?; - Ok(service) +/// Loads file or defaults, saves, applies env overrides, normalizes paths, and ensures directories. +pub(crate) fn bootstrap_parts( + env: &dyn EnvTrait, + fs: FS, +) -> Result<(ConfigState, JsonConfigRepository), ConfigError> { + let repo = JsonConfigRepository::new(env, fs); + let mut state = load_initial_state(env, &repo); + if let Err(e) = repo.save(&state) { + eprintln!("Failed to save default config: {e}"); } + env_overrides::apply_env_overrides(&mut state, env)?; + normalize_derived_paths(&mut state); + ensure_directories(&state, repo.fs())?; + Ok((state, repo)) +} - fn load_initial_state(env: &dyn EnvTrait, repo: &JsonConfigRepository) -> ConfigState { - let path = repo.config_path(); - let fs = repo.fs(); - if fs.is_file(Path::new(path)) { - match fs.read_to_string(Path::new(path)) { - Ok(file_contents) => match serde_json::from_str(&file_contents) { - Ok(config) => return config, - Err(e) => eprintln!("Failed to parse config file {path}: {e}"), - }, - Err(e) => { - eprintln!("Failed to read config file {path}: {e}"); - } +fn load_initial_state( + env: &dyn EnvTrait, + repo: &JsonConfigRepository, +) -> ConfigState { + let path = repo.config_path(); + let fs = repo.fs(); + if fs.is_file(Path::new(path)) { + match fs.read_to_string(Path::new(path)) { + Ok(file_contents) => match serde_json::from_str(&file_contents) { + Ok(config) => return config, + Err(e) => eprintln!("Failed to parse config file {path}: {e}"), + }, + Err(e) => { + eprintln!("Failed to read config file {path}: {e}"); } } - ConfigState::new_with_defaults(env) } + ConfigState::new_with_defaults(env) +} - fn ensure_directories(&self) -> Result<(), ConfigError> { - let fs = self.repo.fs(); - let paths = [ - self.state.cache_dir.as_str(), - self.state.data_dir.as_str(), - self.state.patchsets_cache_dir.as_str(), - self.state.logs_path.as_str(), - ]; - for path in paths { - if fs.metadata(Path::new(path)).is_err() { - fs.create_dir_all(Path::new(path))?; - } +pub(crate) fn ensure_directories( + state: &ConfigState, + fs: &dyn FileSystemTrait, +) -> Result<(), ConfigError> { + let paths = [ + state.cache_dir.as_str(), + state.data_dir.as_str(), + state.patchsets_cache_dir.as_str(), + state.logs_path.as_str(), + ]; + for path in paths { + if fs.metadata(Path::new(path)).is_err() { + fs.create_dir_all(Path::new(path))?; } - Ok(()) } + Ok(()) +} - fn validate_dir(fs: &dyn FileSystemTrait, dir_path: &str) -> Result<(), ConfigError> { - let path = Path::new(dir_path); - if fs.exists(path) && fs.is_dir(path) { - return Ok(()); +pub(crate) fn validate_update( + draft: ConfigUpdateDraft, + fs: &dyn FileSystemTrait, +) -> Result { + let page_size = match &draft.page_size { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidPageSize(s.clone())); } - fs.create_dir_all(path) - .map_err(|_| ConfigError::InvalidDirectory(dir_path.to_string()))?; - Ok(()) + Some(s) => Some( + s.trim() + .parse::() + .map_err(|_| ConfigError::InvalidPageSize(s.clone()))?, + ), + }; + + let cache_dir = match &draft.cache_dir { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidDirectory( + "cache directory is empty".into(), + )); + } + Some(s) => { + let t = s.trim(); + validate_dir(fs, t)?; + Some(t.to_string()) + } + }; + + let data_dir = match &draft.data_dir { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidDirectory( + "data directory is empty".into(), + )); + } + Some(s) => { + let t = s.trim(); + validate_dir(fs, t)?; + Some(t.to_string()) + } + }; + + let git_send_email_option = draft.git_send_email_option.clone(); + let git_am_option = draft.git_am_option.clone(); + + let patch_renderer = match &draft.patch_renderer { + None => None, + Some(s) => Some(parse_patch_renderer(s)?), + }; + + let cover_renderer = match &draft.cover_renderer { + None => None, + Some(s) => Some(parse_cover_renderer(s)?), + }; + + let max_log_age = match &draft.max_log_age { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidMaxLogAge(s.clone())); + } + Some(s) => Some( + s.trim() + .parse::() + .map_err(|_| ConfigError::InvalidMaxLogAge(s.clone()))?, + ), + }; + + Ok(ValidatedConfigUpdate { + page_size, + cache_dir, + data_dir, + git_send_email_option, + git_am_option, + patch_renderer, + cover_renderer, + max_log_age, + }) +} + +fn validate_dir(fs: &dyn FileSystemTrait, dir_path: &str) -> Result<(), ConfigError> { + let path = Path::new(dir_path); + if fs.exists(path) && fs.is_dir(path) { + return Ok(()); + } + fs.create_dir_all(path) + .map_err(|_| ConfigError::InvalidDirectory(dir_path.to_string()))?; + Ok(()) +} + +impl ConfigService { + /// Bootstrap configuration: load file or defaults, persist, apply env overrides, ensure dirs. + /// + /// Loads file or defaults, saves, applies env overrides, ensures directories exist. + pub fn bootstrap(env: &dyn EnvTrait, fs: FS) -> Result { + let (state, repo) = bootstrap_parts(env, fs)?; + Ok(Self { repo, state }) } } @@ -97,83 +191,7 @@ impl ConfigServiceApi for ConfigService { &self, draft: ConfigUpdateDraft, ) -> Result { - let fs = self.repo.fs(); - - let page_size = match &draft.page_size { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidPageSize(s.clone())); - } - Some(s) => Some( - s.trim() - .parse::() - .map_err(|_| ConfigError::InvalidPageSize(s.clone()))?, - ), - }; - - let cache_dir = match &draft.cache_dir { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidDirectory( - "cache directory is empty".into(), - )); - } - Some(s) => { - let t = s.trim(); - Self::validate_dir(fs, t)?; - Some(t.to_string()) - } - }; - - let data_dir = match &draft.data_dir { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidDirectory( - "data directory is empty".into(), - )); - } - Some(s) => { - let t = s.trim(); - Self::validate_dir(fs, t)?; - Some(t.to_string()) - } - }; - - let git_send_email_option = draft.git_send_email_option.clone(); - let git_am_option = draft.git_am_option.clone(); - - let patch_renderer = match &draft.patch_renderer { - None => None, - Some(s) => Some(parse_patch_renderer(s)?), - }; - - let cover_renderer = match &draft.cover_renderer { - None => None, - Some(s) => Some(parse_cover_renderer(s)?), - }; - - let max_log_age = match &draft.max_log_age { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidMaxLogAge(s.clone())); - } - Some(s) => Some( - s.trim() - .parse::() - .map_err(|_| ConfigError::InvalidMaxLogAge(s.clone()))?, - ), - }; - - Ok(ValidatedConfigUpdate { - page_size, - cache_dir, - data_dir, - git_send_email_option, - git_am_option, - patch_renderer, - cover_renderer, - max_log_age, - }) + validate_update(draft, self.repo.fs()) } fn apply_update( @@ -182,7 +200,7 @@ impl ConfigServiceApi for ConfigService { ) -> Result { self.state.apply_update(&update); normalize_derived_paths(&mut self.state); - self.ensure_directories()?; + ensure_directories(&self.state, self.repo.fs())?; self.repo.save(&self.state)?; Ok(self.state.to_snapshot()) } From 2c58b6ba4c85b92d0b2a3c5722acfa9ed9cf9910 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Thu, 9 Jul 2026 10:18:50 -0300 Subject: [PATCH 2/4] refactor(config): add ConfigActor protocol and ConfigHandle This commit introduces the configuration actor boundary with ConfigMessage, ConfigHandle, and ConfigActor for snapshot reads, validated updates, persistence, and shutdown. The actor is added in isolation from production wiring so its lifecycle and request protocol can be tested independently. This commit is part of the architecture's refactoring phase 14. Signed-off-by: lorenzoberts --- src/config/actor.rs | 257 +++++++++++++++++++++++++++++++++++++++++ src/config/errors.rs | 3 + src/config/handle.rs | 65 +++++++++++ src/config/messages.rs | 26 +++++ src/config/mod.rs | 8 ++ 5 files changed, 359 insertions(+) create mode 100644 src/config/actor.rs create mode 100644 src/config/handle.rs create mode 100644 src/config/messages.rs diff --git a/src/config/actor.rs b/src/config/actor.rs new file mode 100644 index 00000000..a7f56b7e --- /dev/null +++ b/src/config/actor.rs @@ -0,0 +1,257 @@ +//! Configuration actor: serializes access to mutable config state. +//! +//! All config reads and edits go through [`ConfigHandle`](crate::config::ConfigHandle) +//! as typed request/reply messages. The actor owns [`ConfigState`](crate::config::ConfigState) +//! and persists successful updates through [`JsonConfigRepository`](crate::config::JsonConfigRepository). +use std::ops::ControlFlow; + +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + config::{ + handle::ConfigHandle, + messages::{ConfigMessage, ConfigResult}, + repository::{ConfigRepository, JsonConfigRepository}, + service::{ensure_directories, validate_update}, + state::{normalize_derived_paths, ConfigState}, + ConfigSnapshot, ConfigUpdateDraft, + }, + infrastructure::file_system::FileSystemTrait, +}; + +pub const DEFAULT_CONFIG_CHANNEL_SIZE: usize = 16; + +pub struct ConfigActor { + state: ConfigState, + repo: JsonConfigRepository, + rx: mpsc::Receiver, +} + +impl ConfigActor +where + FS: FileSystemTrait + Send + Sync + 'static, +{ + pub fn new( + state: ConfigState, + repo: JsonConfigRepository, + rx: mpsc::Receiver, + ) -> Self { + Self { state, repo, rx } + } + + pub fn spawn(state: ConfigState, repo: JsonConfigRepository) -> ConfigHandle { + let (tx, rx) = mpsc::channel(DEFAULT_CONFIG_CHANNEL_SIZE); + tracing::debug!( + channel_size = DEFAULT_CONFIG_CHANNEL_SIZE, + "spawning config actor" + ); + tokio::spawn(Self::new(state, repo, rx).run()); + ConfigHandle::new(tx) + } + + pub async fn run(mut self) { + tracing::info!("config actor started"); + while let Some(message) = self.rx.recv().await { + if let ControlFlow::Break(()) = self.handle_message(message) { + break; + } + } + tracing::info!("config actor stopped"); + } + + fn handle_message(&mut self, message: ConfigMessage) -> ControlFlow<()> { + let message_name = message.name(); + tracing::debug!(message = message_name, "config request received"); + + match message { + ConfigMessage::GetSnapshot { reply } => { + send_config_snapshot_reply(message_name, reply, self.state.to_snapshot()); + ControlFlow::Continue(()) + } + ConfigMessage::ValidateAndApply { draft, reply } => { + let result = self.apply(draft); + send_config_reply(message_name, reply, result); + ControlFlow::Continue(()) + } + ConfigMessage::Shutdown => { + tracing::debug!("config actor shutting down"); + ControlFlow::Break(()) + } + } + } + + fn apply(&mut self, draft: ConfigUpdateDraft) -> ConfigResult { + let update = validate_update(draft, self.repo.fs())?; + self.state.apply_update(&update); + normalize_derived_paths(&mut self.state); + ensure_directories(&self.state, self.repo.fs())?; + self.repo.save(&self.state)?; + Ok(self.state.to_snapshot()) + } +} + +fn send_config_snapshot_reply( + message_name: &'static str, + reply: oneshot::Sender, + snapshot: ConfigSnapshot, +) { + if reply.send(snapshot).is_err() { + tracing::warn!( + message = message_name, + "config reply receiver dropped before response" + ); + } +} + +fn send_config_reply( + message_name: &'static str, + reply: oneshot::Sender>, + result: ConfigResult, +) { + if let Err(error) = &result { + tracing::warn!( + message = message_name, + error = %error, + "config request failed" + ); + } + + if reply.send(result).is_err() { + tracing::warn!( + message = message_name, + "config reply receiver dropped before response" + ); + } +} + +#[cfg(test)] +mod tests { + use std::{ + env::VarError, + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + + use crate::{ + config::{ + service::bootstrap_parts, ConfigError, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX, + }, + infrastructure::{env::MockEnvTrait, file_system::OsFileSystem}, + }; + + use super::*; + + static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + + fn unique_test_dir(prefix: &str) -> PathBuf { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let p = std::env::temp_dir().join(format!("patch-hub-{prefix}-{}-{n}", std::process::id())); + fs::create_dir_all(&p).unwrap(); + p + } + + fn default_env() -> (MockEnvTrait, PathBuf) { + let home = unique_test_dir("actor-home"); + let home_s = home.to_string_lossy().into_owned(); + let mut mock = MockEnvTrait::new(); + mock.expect_var() + .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .returning(|_| Err(VarError::NotPresent.into())); + mock.expect_var() + .withf(move |key| key == "HOME") + .returning(move |_| Ok(home_s.clone())); + mock.expect_var() + .withf(|key| { + matches!( + key, + "PATCH_HUB_PAGE_SIZE" + | "PATCH_HUB_CACHE_DIR" + | "PATCH_HUB_DATA_DIR" + | "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS" + | "PATCH_HUB_PATCH_RENDERER" + ) + }) + .returning(|_| Err(VarError::NotPresent.into())); + (mock, home) + } + + fn spawn_test_actor() -> (ConfigHandle, PathBuf) { + let (env, home) = default_env(); + let (state, repo) = bootstrap_parts(&env, OsFileSystem).unwrap(); + (ConfigActor::spawn(state, repo), home) + } + + #[tokio::test] + async fn get_snapshot_returns_actor_state() { + let (handle, _home) = spawn_test_actor(); + + let snapshot = handle.get_snapshot().await.unwrap(); + + assert_eq!(30, snapshot.page_size()); + handle.shutdown().await; + } + + #[tokio::test] + async fn validate_and_apply_returns_updated_snapshot() { + let (handle, _home) = spawn_test_actor(); + + let snapshot = handle + .validate_and_apply(ConfigUpdateDraft { + page_size: Some("77".into()), + ..Default::default() + }) + .await + .unwrap(); + + assert_eq!(77, snapshot.page_size()); + assert_eq!(77, handle.get_snapshot().await.unwrap().page_size()); + handle.shutdown().await; + } + + #[tokio::test] + async fn validate_and_apply_persists_update() { + let (handle, home) = spawn_test_actor(); + let cfg_path = home.join(DEFAULT_CONFIG_PATH_SUFFIX); + + handle + .validate_and_apply(ConfigUpdateDraft { + page_size: Some("88".into()), + ..Default::default() + }) + .await + .unwrap(); + + let raw = fs::read_to_string(&cfg_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(parsed["page_size"], 88); + handle.shutdown().await; + } + + #[tokio::test] + async fn invalid_update_keeps_existing_state() { + let (handle, _home) = spawn_test_actor(); + + let err = handle + .validate_and_apply(ConfigUpdateDraft { + page_size: Some("not-a-number".into()), + ..Default::default() + }) + .await + .unwrap_err(); + + assert!(matches!(err, ConfigError::InvalidPageSize(ref s) if s == "not-a-number")); + assert_eq!(30, handle.get_snapshot().await.unwrap().page_size()); + handle.shutdown().await; + } + + #[tokio::test] + async fn shutdown_stops_actor() { + let (handle, _home) = spawn_test_actor(); + + handle.shutdown().await; + let err = handle.get_snapshot().await.unwrap_err(); + + assert!(matches!(err, ConfigError::ActorUnavailable(_))); + } +} diff --git a/src/config/errors.rs b/src/config/errors.rs index 72d49af7..5db9dc89 100644 --- a/src/config/errors.rs +++ b/src/config/errors.rs @@ -4,6 +4,9 @@ use crate::infrastructure::file_system::FileSystemError; #[derive(Debug, Error)] pub enum ConfigError { + #[allow(dead_code)] + #[error("config actor unavailable: {0}")] + ActorUnavailable(String), #[error("failed to save config: {0}")] Save(String), #[error("invalid page size: {0}")] diff --git a/src/config/handle.rs b/src/config/handle.rs new file mode 100644 index 00000000..6abfe5e8 --- /dev/null +++ b/src/config/handle.rs @@ -0,0 +1,65 @@ +use tokio::sync::{mpsc, oneshot}; + +use crate::config::{ + messages::{ConfigMessage, ConfigResult}, + ConfigError, ConfigSnapshot, ConfigUpdateDraft, +}; + +#[derive(Clone)] +pub struct ConfigHandle { + tx: mpsc::Sender, +} + +impl ConfigHandle { + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + pub async fn get_snapshot(&self) -> ConfigResult { + self.request(|reply| ConfigMessage::GetSnapshot { reply }) + .await + } + + pub async fn validate_and_apply( + &self, + draft: ConfigUpdateDraft, + ) -> ConfigResult { + self.request_result(|reply| ConfigMessage::ValidateAndApply { draft, reply }) + .await + } + + /// Signals the actor to stop processing messages and exit its run loop. + /// + /// Callers should invoke this after the last request that uses this handle has + /// completed. Dropping all clones of the handle also stops the actor, but + /// calling `shutdown` makes the intent explicit and allows ordered teardown. + pub async fn shutdown(&self) { + self.tx.send(ConfigMessage::Shutdown).await.ok(); + } + + async fn request_result( + &self, + build_message: impl FnOnce(oneshot::Sender>) -> ConfigMessage, + ) -> ConfigResult + where + T: Send + 'static, + { + self.request(build_message).await? + } + + async fn request( + &self, + build_message: impl FnOnce(oneshot::Sender) -> ConfigMessage, + ) -> ConfigResult + where + T: Send + 'static, + { + let (reply, rx) = oneshot::channel(); + self.tx + .send(build_message(reply)) + .await + .map_err(|_| ConfigError::ActorUnavailable("request channel closed".to_string()))?; + rx.await + .map_err(|_| ConfigError::ActorUnavailable("reply channel closed".to_string())) + } +} diff --git a/src/config/messages.rs b/src/config/messages.rs new file mode 100644 index 00000000..94bee87c --- /dev/null +++ b/src/config/messages.rs @@ -0,0 +1,26 @@ +use tokio::sync::oneshot; + +use crate::config::{ConfigError, ConfigSnapshot, ConfigUpdateDraft}; + +pub type ConfigResult = Result; + +pub enum ConfigMessage { + GetSnapshot { + reply: oneshot::Sender, + }, + ValidateAndApply { + draft: ConfigUpdateDraft, + reply: oneshot::Sender>, + }, + Shutdown, +} + +impl ConfigMessage { + pub fn name(&self) -> &'static str { + match self { + ConfigMessage::GetSnapshot { .. } => "GetSnapshot", + ConfigMessage::ValidateAndApply { .. } => "ValidateAndApply", + ConfigMessage::Shutdown => "Shutdown", + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 13ad3a6c..9302f50d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,15 +1,23 @@ //! Configuration bounded context: state, persistence, bootstrap, and snapshots. #![allow(unused_imports)] // `pub use` re-exports are the public API of this module +#[allow(dead_code)] +pub mod actor; mod env_overrides; mod errors; +#[allow(dead_code)] +pub mod handle; +#[allow(dead_code)] +pub mod messages; mod parsing; mod repository; mod service; mod state; mod update; +pub use actor::ConfigActor; pub use errors::ConfigError; +pub use handle::ConfigHandle; pub use repository::{resolve_config_path, ConfigRepository, JsonConfigRepository}; pub use service::{ConfigService, ConfigServiceApi}; pub use state::{normalize_derived_paths, ConfigSnapshot, ConfigState, KernelTree}; From 404c4b6904407679850154df858fc6c05fcdc432 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Thu, 9 Jul 2026 10:23:30 -0300 Subject: [PATCH 3/4] refactor(app,config): route configuration updates through ConfigActor This commit wires ConfigActor into startup and passes ConfigHandle through AppServices so edit-config saves use the typed actor protocol. Bootstrap still produces the initial snapshot for logging, CLI handling, Lore setup, and App state, preserving the existing startup path while moving mutations behind the actor boundary. This commit is part of the architecture's refactoring phase 14. Signed-off-by: lorenzoberts --- src/app/actor.rs | 32 +++--------- src/app/flows/edit_config.rs | 92 ++++++++++++++++++++-------------- src/app/mod.rs | 27 ++++++---- src/app/screens/edit_config.rs | 2 +- src/config/errors.rs | 1 - src/config/mod.rs | 4 +- src/config/service.rs | 2 + src/main.rs | 24 +++++---- 8 files changed, 98 insertions(+), 86 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 35be4a70..435b660a 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -201,7 +201,7 @@ async fn on_input( handle_patchset_details(app, input, terminal_handle).await?; } CurrentScreen::EditConfig => { - handle_edit_config(app, input)?; + handle_edit_config(app, input).await?; } CurrentScreen::LatestPatchsets => { handle_latest_patchsets(app, input, loading).await?; @@ -227,7 +227,7 @@ mod tests { state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, AppServices, }, - config::ConfigState, + config::{ConfigHandle, ConfigState}, infrastructure::{ env::MockEnvTrait, file_system::MockFileSystemTrait, shell::MockShellTrait, }, @@ -253,26 +253,9 @@ mod tests { use super::*; - struct NullConfigService; - - impl crate::config::ConfigServiceApi for NullConfigService { - fn snapshot(&self) -> crate::config::ConfigSnapshot { - ConfigState::default().to_snapshot() - } - - fn validate_update( - &self, - _: crate::config::ConfigUpdateDraft, - ) -> Result { - unimplemented!() - } - - fn apply_update( - &mut self, - _: crate::config::ValidatedConfigUpdate, - ) -> Result { - unimplemented!() - } + fn dummy_config_handle() -> ConfigHandle { + let (config_tx, _config_rx) = mpsc::channel(1); + ConfigHandle::new(config_tx) } fn minimal_app_with_env(env: MockEnvTrait) -> App { @@ -313,7 +296,7 @@ mod tests { shell: Box::new(MockShellTrait::new()), fs: Box::new(MockFileSystemTrait::new()), env: Box::new(env), - config: Box::new(NullConfigService), + config: dummy_config_handle(), }, } } @@ -417,7 +400,8 @@ mod tests { let ui_handle = UiActor::spawn(); let app = App::new( - Box::new(NullConfigService), + ConfigState::default().to_snapshot(), + dummy_config_handle(), bootstrap, Box::new(MockFileSystemTrait::new()), Box::new(MockShellTrait::new()), diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 08954865..44b21573 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -5,50 +5,68 @@ use crate::{ input::event::InputEvent, }; -pub fn handle_edit_config(app: &mut App, input: InputEvent) -> color_eyre::Result<()> { - if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { - match edit_config_state.is_editing() { - true => match input { - InputEvent::CancelConfigEdit => { - edit_config_state.clear_edit(); - edit_config_state.toggle_editing(); - } - InputEvent::Backspace => { - edit_config_state.backspace_edit(); - } - InputEvent::TextInput(ch) => { - edit_config_state.append_edit(ch); - } - InputEvent::StageConfigEdit => { - edit_config_state.stage_edit(); - edit_config_state.clear_edit(); - edit_config_state.toggle_editing(); - } - _ => {} - }, - false => match input { - InputEvent::OpenHelp => { - let popup = generate_help_popup(); - app.state.popup = Some(popup); - } - InputEvent::SaveConfig => { - debug!("saving edited configuration"); - app.consolidate_edit_config()?; - app.reset_edit_config(); - app.set_current_screen(CurrentScreen::MailingListSelection); +pub async fn handle_edit_config(app: &mut App, input: InputEvent) -> color_eyre::Result<()> { + let Some(is_editing) = app + .state + .config_state + .edit_config + .as_ref() + .map(|edit_config_state| edit_config_state.is_editing()) + else { + return Ok(()); + }; + + match is_editing { + true => { + if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { + match input { + InputEvent::CancelConfigEdit => { + edit_config_state.clear_edit(); + edit_config_state.toggle_editing(); + } + InputEvent::Backspace => { + edit_config_state.backspace_edit(); + } + InputEvent::TextInput(ch) => { + edit_config_state.append_edit(ch); + } + InputEvent::StageConfigEdit => { + edit_config_state.stage_edit(); + edit_config_state.clear_edit(); + edit_config_state.toggle_editing(); + } + _ => {} } - InputEvent::EditConfigField => { + } + } + false => match input { + InputEvent::OpenHelp => { + let popup = generate_help_popup(); + app.state.popup = Some(popup); + } + InputEvent::SaveConfig => { + debug!("saving edited configuration"); + app.consolidate_edit_config().await?; + app.reset_edit_config(); + app.set_current_screen(CurrentScreen::MailingListSelection); + } + InputEvent::EditConfigField => { + if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { edit_config_state.toggle_editing(); } - InputEvent::NavigateDown => { + } + InputEvent::NavigateDown => { + if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { edit_config_state.highlight_next(); } - InputEvent::NavigateUp => { + } + InputEvent::NavigateUp => { + if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { edit_config_state.highlight_prev(); } - _ => {} - }, - } + } + _ => {} + }, } Ok(()) } diff --git a/src/app/mod.rs b/src/app/mod.rs index d9f55993..0e875283 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -27,7 +27,7 @@ use tracing::{debug, event, info, warn, Level}; use std::path::PathBuf; use crate::{ - config::ConfigServiceApi, + config::{ConfigHandle, ConfigSnapshot}, infrastructure::{ env::EnvTrait, file_system::FileSystemTrait, @@ -62,7 +62,7 @@ pub struct AppServices { pub shell: Box, pub fs: Box, pub env: Box, - pub config: Box, + pub config: ConfigHandle, } /// Result type signalling whether a patchset was successfully loaded. @@ -78,14 +78,17 @@ pub struct App { } impl App { - /// Creates a new instance of `App`. Configuration comes from [`ConfigServiceApi::snapshot`]; - /// Lore bootstrap uses already-warmed cache from `lore_service`. + /// Creates a new instance of `App`. + /// + /// Configuration starts from the already-bootstrapped snapshot owned by the + /// Config actor. Lore bootstrap uses already-warmed cache from `lore_service`. /// /// # Returns /// /// `App` instance with loading configurations and app data. pub fn new( - config_service: Box, + config: ConfigSnapshot, + config_handle: ConfigHandle, bootstrap: BootstrapLoreData, fs: Box, shell: Box, @@ -93,8 +96,6 @@ impl App { lore_api: LoreApiHandle, render: RenderHandle, ) -> color_eyre::Result { - let config = config_service.snapshot(); - event!(Level::INFO, "patch-hub started"); collect_garbage(&config); @@ -130,7 +131,7 @@ impl App { shell, fs, env, - config: config_service, + config: config_handle, }, }) } @@ -489,12 +490,16 @@ impl App { } /// Applies edited values from [`ConfigUiState::edit_config`] into [`AppState::config`]. - pub fn consolidate_edit_config(&mut self) -> color_eyre::Result<()> { + pub async fn consolidate_edit_config(&mut self) -> color_eyre::Result<()> { if let Some(edit_config) = &self.state.config_state.edit_config { debug!("validating and applying config update"); let draft = edit_config.to_update_draft(); - let validated = self.services.config.validate_update(draft)?; - let snapshot = self.services.config.apply_update(validated)?; + let snapshot = self + .services + .config + .validate_and_apply(draft) + .await + .map_err(|e| eyre!("{e:#?}"))?; self.state.config = snapshot; info!("configuration updated and persisted"); } diff --git a/src/app/screens/edit_config.rs b/src/app/screens/edit_config.rs index c56d5cda..a629cb78 100644 --- a/src/app/screens/edit_config.rs +++ b/src/app/screens/edit_config.rs @@ -113,7 +113,7 @@ impl EditConfigState { } } - /// Raw form values for [`crate::config::ConfigServiceApi::validate_update`]. + /// Raw form values for config validation. pub fn to_update_draft(&self) -> ConfigUpdateDraft { ConfigUpdateDraft { page_size: self.config_buffer.get(&EditableConfig::PageSize).cloned(), diff --git a/src/config/errors.rs b/src/config/errors.rs index 5db9dc89..42478c17 100644 --- a/src/config/errors.rs +++ b/src/config/errors.rs @@ -4,7 +4,6 @@ use crate::infrastructure::file_system::FileSystemError; #[derive(Debug, Error)] pub enum ConfigError { - #[allow(dead_code)] #[error("config actor unavailable: {0}")] ActorUnavailable(String), #[error("failed to save config: {0}")] diff --git a/src/config/mod.rs b/src/config/mod.rs index 9302f50d..b4d0f9c6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,13 +1,10 @@ //! Configuration bounded context: state, persistence, bootstrap, and snapshots. #![allow(unused_imports)] // `pub use` re-exports are the public API of this module -#[allow(dead_code)] pub mod actor; mod env_overrides; mod errors; -#[allow(dead_code)] pub mod handle; -#[allow(dead_code)] pub mod messages; mod parsing; mod repository; @@ -19,6 +16,7 @@ pub use actor::ConfigActor; pub use errors::ConfigError; pub use handle::ConfigHandle; pub use repository::{resolve_config_path, ConfigRepository, JsonConfigRepository}; +pub(crate) use service::bootstrap_parts; pub use service::{ConfigService, ConfigServiceApi}; pub use state::{normalize_derived_paths, ConfigSnapshot, ConfigState, KernelTree}; pub use update::{ConfigUpdateDraft, ValidatedConfigUpdate}; diff --git a/src/config/service.rs b/src/config/service.rs index 434c262d..5474a429 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -10,6 +10,7 @@ use crate::config::update::{ConfigUpdateDraft, ValidatedConfigUpdate}; use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; /// Public surface for configuration. +#[allow(dead_code)] pub trait ConfigServiceApi: Send + Sync { fn snapshot(&self) -> ConfigSnapshot; fn validate_update( @@ -176,6 +177,7 @@ impl ConfigService { /// Bootstrap configuration: load file or defaults, persist, apply env overrides, ensure dirs. /// /// Loads file or defaults, saves, applies env overrides, ensures directories exist. + #[allow(dead_code)] pub fn bootstrap(env: &dyn EnvTrait, fs: FS) -> Result { let (state, repo) = bootstrap_parts(env, fs)?; Ok(Self { repo, state }) diff --git a/src/main.rs b/src/main.rs index f06a3e3e..cb8de294 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use app::{actor::AppActor, App}; use clap::Parser; use cli::Cli; use color_eyre::eyre::eyre; -use config::{ConfigService, ConfigServiceApi}; +use config::{bootstrap_parts, ConfigActor}; use infrastructure::{ env::OsEnv, file_system::OsFileSystem, @@ -55,9 +55,8 @@ async fn main() -> color_eyre::Result<()> { infrastructure::errors::install_hooks()?; let env = OsEnv; - let config_service: Box = - Box::new(ConfigService::bootstrap(&env, OsFileSystem).map_err(|e| eyre!(e))?); - let config = config_service.snapshot(); + let (config_state, config_repo) = bootstrap_parts(&env, OsFileSystem).map_err(|e| eyre!(e))?; + let config = config_state.to_snapshot(); // with the config we can update log directory let _guards = multi_log_file_writer.update_log_writer_with_config( @@ -71,6 +70,7 @@ async fn main() -> color_eyre::Result<()> { ControlFlow::Continue(()) => {} } + let config_handle = ConfigActor::spawn(config_state, config_repo); let terminal_handle = TerminalActor::spawn(Box::new(CrosstermTerminalSession::new(init()?))); let ui_handle = UiActor::spawn(); @@ -110,7 +110,11 @@ async fn main() -> color_eyre::Result<()> { let bootstrap = lore_api.get_bootstrap_data().await.unwrap_or_default(); let app = App::new( - config_service, + config_handle + .get_snapshot() + .await + .map_err(|error| eyre!("{error}"))?, + config_handle.clone(), bootstrap, Box::new(OsFileSystem), Box::new(OsShell), @@ -127,10 +131,11 @@ async fn main() -> color_eyre::Result<()> { // Shutdown ordering: // 1. AppActor — exits when the user quits (input channel closes) - // 2. LoreApiActor — no further requests once App is gone - // 3. RenderActor — no further requests once App is gone - // 4. UiActor — no further scene builds once App is gone - // 5. TerminalActor — restores the terminal last so the screen stays usable + // 2. ConfigActor — no further configuration requests once App is gone + // 3. LoreApiActor — no further requests once App is gone + // 4. RenderActor — no further requests once App is gone + // 5. UiActor — no further scene builds once App is gone + // 6. TerminalActor — restores the terminal last so the screen stays usable // during the steps above AppActor::spawn( app, @@ -141,6 +146,7 @@ async fn main() -> color_eyre::Result<()> { ) .run_until_done() .await?; + config_handle.shutdown().await; lore_api.shutdown().await; render.shutdown().await; ui_handle.shutdown().await; From 993eac7a33fe7e757558e783a5eeb3278cb81018 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Thu, 9 Jul 2026 10:25:39 -0300 Subject: [PATCH 4/4] refactor(config): remove synchronous ConfigService API This commit removes the legacy ConfigService wrapper now that runtime configuration updates go exclusively through ConfigHandle. Configuration tests are updated to exercise the bootstrap helpers and actor-backed apply path directly, leaving the config module aligned with the hybrid actor model. This commit completes the architecture's refactoring phase 14. Signed-off-by: lorenzoberts --- src/config/mod.rs | 1 - src/config/service.rs | 55 --------------------- src/config/tests.rs | 111 ++++++++++++++++++++++-------------------- 3 files changed, 57 insertions(+), 110 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index b4d0f9c6..f70e22a7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,7 +17,6 @@ pub use errors::ConfigError; pub use handle::ConfigHandle; pub use repository::{resolve_config_path, ConfigRepository, JsonConfigRepository}; pub(crate) use service::bootstrap_parts; -pub use service::{ConfigService, ConfigServiceApi}; pub use state::{normalize_derived_paths, ConfigSnapshot, ConfigState, KernelTree}; pub use update::{ConfigUpdateDraft, ValidatedConfigUpdate}; diff --git a/src/config/service.rs b/src/config/service.rs index 5474a429..5a774202 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -4,30 +4,10 @@ use crate::config::env_overrides; use crate::config::errors::ConfigError; use crate::config::parsing::{parse_cover_renderer, parse_patch_renderer}; use crate::config::repository::{ConfigRepository, JsonConfigRepository}; -use crate::config::state::ConfigSnapshot; use crate::config::state::{normalize_derived_paths, ConfigState}; use crate::config::update::{ConfigUpdateDraft, ValidatedConfigUpdate}; use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; -/// Public surface for configuration. -#[allow(dead_code)] -pub trait ConfigServiceApi: Send + Sync { - fn snapshot(&self) -> ConfigSnapshot; - fn validate_update( - &self, - draft: ConfigUpdateDraft, - ) -> Result; - fn apply_update( - &mut self, - update: ValidatedConfigUpdate, - ) -> Result; -} - -pub struct ConfigService { - repo: JsonConfigRepository, - state: ConfigState, -} - /// Loads file or defaults, saves, applies env overrides, normalizes paths, and ensures directories. pub(crate) fn bootstrap_parts( env: &dyn EnvTrait, @@ -172,38 +152,3 @@ fn validate_dir(fs: &dyn FileSystemTrait, dir_path: &str) -> Result<(), ConfigEr .map_err(|_| ConfigError::InvalidDirectory(dir_path.to_string()))?; Ok(()) } - -impl ConfigService { - /// Bootstrap configuration: load file or defaults, persist, apply env overrides, ensure dirs. - /// - /// Loads file or defaults, saves, applies env overrides, ensures directories exist. - #[allow(dead_code)] - pub fn bootstrap(env: &dyn EnvTrait, fs: FS) -> Result { - let (state, repo) = bootstrap_parts(env, fs)?; - Ok(Self { repo, state }) - } -} - -impl ConfigServiceApi for ConfigService { - fn snapshot(&self) -> ConfigSnapshot { - self.state.to_snapshot() - } - - fn validate_update( - &self, - draft: ConfigUpdateDraft, - ) -> Result { - validate_update(draft, self.repo.fs()) - } - - fn apply_update( - &mut self, - update: ValidatedConfigUpdate, - ) -> Result { - self.state.apply_update(&update); - normalize_derived_paths(&mut self.state); - ensure_directories(&self.state, self.repo.fs())?; - self.repo.save(&self.state)?; - Ok(self.state.to_snapshot()) - } -} diff --git a/src/config/tests.rs b/src/config/tests.rs index 209f5bba..db9a5931 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -7,12 +7,15 @@ use std::{ sync::atomic::{AtomicU64, Ordering}, }; +use crate::config::actor::ConfigActor; use crate::config::repository::{ConfigRepository, JsonConfigRepository}; +use crate::config::service::{bootstrap_parts, validate_update}; use crate::config::state::{normalize_derived_paths, ConfigState}; -use crate::config::{ - ConfigError, ConfigService, ConfigServiceApi, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX, +use crate::config::{ConfigError, ConfigSnapshot, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX}; +use crate::infrastructure::{ + env::{EnvTrait, MockEnvTrait}, + file_system::OsFileSystem, }; -use crate::infrastructure::{env::MockEnvTrait, file_system::OsFileSystem}; static TEST_SEQ: AtomicU64 = AtomicU64::new(0); @@ -27,6 +30,10 @@ fn unique_test_dir(prefix: &str) -> PathBuf { p } +fn bootstrap_snapshot(env: &dyn EnvTrait) -> ConfigSnapshot { + bootstrap_parts(env, os_fs()).unwrap().0.to_snapshot() +} + /// Writable `HOME` and mock env: no `PATCH_HUB_CONFIG_PATH` (uses `HOME/.config/...`). fn default_env() -> (MockEnvTrait, PathBuf) { let home = unique_test_dir("home"); @@ -100,8 +107,7 @@ fn config_fixture_json(root: &Path) -> String { fn bootstrap_with_default_values() { let (env, home) = default_env(); let h = home.to_string_lossy(); - let service = ConfigService::bootstrap(&env, os_fs()).unwrap(); - let config = service.snapshot(); + let config = bootstrap_snapshot(&env); assert_eq!(30, config.page_size()); assert_eq!( @@ -165,8 +171,7 @@ fn bootstrap_with_config_file() { }) .returning(|_| Err(VarError::NotPresent.into())); - let service = ConfigService::bootstrap(&mock, os_fs()).unwrap(); - let config = service.snapshot(); + let config = bootstrap_snapshot(&mock); // `normalize_derived_paths` recomputes these from `cache_dir` / `data_dir` after load. let patchsets_cache_dir = fixture_root.join("cache_dir").join("patchsets"); @@ -253,8 +258,7 @@ fn bootstrap_with_env_vars() { .withf(|key| key == "PATCH_HUB_PATCH_RENDERER") .returning(|_| Err(VarError::NotPresent.into())); - let service = ConfigService::bootstrap(&mock, os_fs()).unwrap(); - let config = service.snapshot(); + let config = bootstrap_snapshot(&mock); assert_eq!(42, config.page_size()); assert_eq!( @@ -286,8 +290,7 @@ fn bootstrap_with_env_vars() { #[test] fn bootstrap_config_precedence() { let (env, home) = default_env(); - let service = ConfigService::bootstrap(&env, os_fs()).unwrap(); - assert_eq!(30, service.snapshot().page_size()); + assert_eq!(30, bootstrap_snapshot(&env).page_size()); let fixture_root = unique_test_dir("prec"); let tmp_path = fixture_root.join("config.json"); @@ -318,8 +321,7 @@ fn bootstrap_config_precedence() { }) .returning(|_| Err(VarError::NotPresent.into())); - let service = ConfigService::bootstrap(&env_with_file, os_fs()).unwrap(); - assert_eq!(1234, service.snapshot().page_size()); + assert_eq!(1234, bootstrap_snapshot(&env_with_file).page_size()); let tmp_path_s2 = tmp_path.to_string_lossy().into_owned(); let home_s2 = home.to_string_lossy().into_owned(); @@ -349,8 +351,7 @@ fn bootstrap_config_precedence() { }) .returning(|_| Err(VarError::NotPresent.into())); - let service = ConfigService::bootstrap(&env_with_file_and_var, os_fs()).unwrap(); - assert_eq!(42, service.snapshot().page_size()); + assert_eq!(42, bootstrap_snapshot(&env_with_file_and_var).page_size()); let _ = fs::remove_file(&tmp_path); } @@ -394,7 +395,7 @@ fn bootstrap_rejects_invalid_patch_hub_page_size_env() { }) .returning(|_| Err(VarError::NotPresent.into())); - match ConfigService::bootstrap(&mock, os_fs()) { + match bootstrap_parts(&mock, os_fs()) { Ok(_) => panic!("expected bootstrap to fail"), Err(err) => { assert!(matches!(err, ConfigError::InvalidPageSize(ref s) if s == "not-a-number")) @@ -430,7 +431,7 @@ fn bootstrap_rejects_invalid_patch_hub_patch_renderer_env() { .withf(|key| key == "PATCH_HUB_PATCH_RENDERER") .returning(|_| Ok("not-a-real-renderer".into())); - match ConfigService::bootstrap(&mock, os_fs()) { + match bootstrap_parts(&mock, os_fs()) { Ok(_) => panic!("expected bootstrap to fail"), Err(err) => assert!(matches!( err, @@ -471,27 +472,27 @@ fn normalize_derived_paths_recomputes_cache_and_data_subpaths() { #[test] fn validate_update_rejects_invalid_page_size() { - let (env, _home) = default_env(); - let service = ConfigService::bootstrap(&env, os_fs()).unwrap(); - let err = service - .validate_update(ConfigUpdateDraft { + let err = validate_update( + ConfigUpdateDraft { page_size: Some("xyz".into()), ..Default::default() - }) - .unwrap_err(); + }, + &os_fs(), + ) + .unwrap_err(); assert!(matches!(err, ConfigError::InvalidPageSize(ref s) if s == "xyz")); } #[test] fn validate_update_rejects_invalid_patch_renderer() { - let (env, _home) = default_env(); - let service = ConfigService::bootstrap(&env, os_fs()).unwrap(); - let err = service - .validate_update(ConfigUpdateDraft { + let err = validate_update( + ConfigUpdateDraft { patch_renderer: Some("nope".into()), ..Default::default() - }) - .unwrap_err(); + }, + &os_fs(), + ) + .unwrap_err(); assert!(matches!( err, ConfigError::InvalidPatchRenderer(ref s) if s == "nope" @@ -500,14 +501,14 @@ fn validate_update_rejects_invalid_patch_renderer() { #[test] fn validate_update_rejects_invalid_cover_renderer() { - let (env, _home) = default_env(); - let service = ConfigService::bootstrap(&env, os_fs()).unwrap(); - let err = service - .validate_update(ConfigUpdateDraft { + let err = validate_update( + ConfigUpdateDraft { cover_renderer: Some("delta".into()), ..Default::default() - }) - .unwrap_err(); + }, + &os_fs(), + ) + .unwrap_err(); assert!(matches!( err, ConfigError::InvalidCoverRenderer(ref s) if s == "delta" @@ -516,14 +517,14 @@ fn validate_update_rejects_invalid_cover_renderer() { #[test] fn validate_update_rejects_invalid_max_log_age() { - let (env, _home) = default_env(); - let service = ConfigService::bootstrap(&env, os_fs()).unwrap(); - let err = service - .validate_update(ConfigUpdateDraft { + let err = validate_update( + ConfigUpdateDraft { max_log_age: Some("not-a-number".into()), ..Default::default() - }) - .unwrap_err(); + }, + &os_fs(), + ) + .unwrap_err(); assert!(matches!( err, ConfigError::InvalidMaxLogAge(ref s) if s == "not-a-number" @@ -532,17 +533,17 @@ fn validate_update_rejects_invalid_max_log_age() { #[test] fn validate_update_rejects_cache_dir_that_is_existing_file() { - let (env, _home) = default_env(); - let service = ConfigService::bootstrap(&env, os_fs()).unwrap(); let root = unique_test_dir("not-a-dir"); let blocking = root.join("blocking-file"); fs::write(&blocking, b"x").unwrap(); - let err = service - .validate_update(ConfigUpdateDraft { + let err = validate_update( + ConfigUpdateDraft { cache_dir: Some(blocking.to_string_lossy().into_owned()), ..Default::default() - }) - .unwrap_err(); + }, + &os_fs(), + ) + .unwrap_err(); assert!(matches!(err, ConfigError::InvalidDirectory(_))); } @@ -578,22 +579,24 @@ fn json_config_repository_save_creates_parent_and_leaves_no_tmp_stale() { ); } -#[test] -fn apply_update_persists_to_config_file() { +#[tokio::test] +async fn validate_and_apply_persists_to_config_file() { let (env, home) = default_env(); - let mut service = ConfigService::bootstrap(&env, os_fs()).unwrap(); + let (state, repo) = bootstrap_parts(&env, os_fs()).unwrap(); + let handle = ConfigActor::spawn(state, repo); let cfg_path = home.join(DEFAULT_CONFIG_PATH_SUFFIX); - let validated = service - .validate_update(ConfigUpdateDraft { + let snapshot = handle + .validate_and_apply(ConfigUpdateDraft { page_size: Some("77".into()), ..Default::default() }) + .await .unwrap(); - service.apply_update(validated).unwrap(); - assert_eq!(service.snapshot().page_size(), 77); + assert_eq!(snapshot.page_size(), 77); let raw = fs::read_to_string(&cfg_path).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); assert_eq!(parsed["page_size"], 77); + handle.shutdown().await; }