//! Settings service — domain logic for project settings and editor configuration. //! //! Extracts business logic from `http/settings.rs` following the conventions in //! `docs/architecture/service-modules.md`: //! - `mod.rs` (this file) — public API, typed [`Error`], orchestration //! - `io.rs` — the ONLY place that performs side effects (filesystem I/O, process spawn) //! - `project.rs` — pure types: [`ProjectSettings`], [`settings_from_config`], //! [`merge_settings_into_toml`] //! - `validate.rs` — pure validation: [`validate_project_settings`] pub(super) mod io; pub mod project; pub mod validate; pub use project::{ProjectSettings, merge_settings_into_toml, settings_from_config}; pub use validate::validate_project_settings; use crate::config::ProjectConfig; use crate::store::StoreOps; use std::path::Path; /// The store key for the configured editor command. pub const EDITOR_COMMAND_KEY: &str = "editor_command"; // ── Error type ──────────────────────────────────────────────────────────────── /// Typed errors returned by `service::settings` functions. /// /// HTTP handlers map these to status codes: /// - [`Error::Validation`] → 400 Bad Request /// - [`Error::NotConfigured`] → 400 Bad Request /// - [`Error::Io`] → 500 Internal Server Error /// - [`Error::Spawn`] → 500 Internal Server Error #[derive(Debug)] pub enum Error { /// A field value failed validation (e.g. unknown QA mode). Validation(String), /// No editor is configured in the store. NotConfigured, /// A filesystem read or write operation failed. Io(String), /// The editor process failed to spawn. Spawn(String), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Validation(msg) => write!(f, "Validation error: {msg}"), Self::NotConfigured => write!(f, "No editor configured"), Self::Io(msg) => write!(f, "I/O error: {msg}"), Self::Spawn(msg) => write!(f, "Spawn error: {msg}"), } } } // ── Public API ──────────────────────────────────────────────────────────────── /// Load the current project settings from disk. /// /// # Errors /// - [`Error::IoError`] if the config file cannot be read or parsed. pub fn load_project_settings(project_root: &Path) -> Result { let config = ProjectConfig::load(project_root).map_err(|e| Error::Io(format!("Load config: {e}")))?; Ok(settings_from_config(&config)) } /// Write the given settings to disk, preserving array sections. /// /// Reads the existing project.toml, merges only the scalar fields from `s`, /// and rewrites the file. Array sections (`[[component]]`, `[[agent]]`) are /// untouched. /// /// # Errors /// - [`Error::IoError`] if the config file cannot be read, parsed, or written. pub fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), Error> { let config_path = project_root.join(".huskies/project.toml"); let content = io::read_config_toml(&config_path)?; let mut val: toml::Value = if content.trim().is_empty() { toml::Value::Table(toml::map::Map::new()) } else { toml::from_str(&content).map_err(|e| Error::Io(format!("Parse config: {e}")))? }; merge_settings_into_toml(&mut val, s)?; let new_content = toml::to_string_pretty(&val).map_err(|e| Error::Io(format!("Serialize config: {e}")))?; io::write_config_toml(&config_path, &new_content)?; Ok(()) } /// Return the configured editor command from the store, or `None` if not set. /// /// Pure: reads from in-memory store only — no filesystem or network I/O. pub fn get_editor_command(store: &dyn StoreOps) -> Option { store .get(EDITOR_COMMAND_KEY) .and_then(|v| v.as_str().map(|s| s.to_string())) } /// Open a file in the configured editor at the optional line number. /// /// # Errors /// - [`Error::NotConfigured`] if no editor has been set in the store. /// - [`Error::SpawnError`] if the editor process fails to start. pub fn open_file_in_editor( store: &dyn StoreOps, path: &str, line: Option, ) -> Result<(), Error> { let editor_command = get_editor_command(store).ok_or(Error::NotConfigured)?; let file_ref = match line { Some(l) => format!("{path}:{l}"), None => path.to_string(), }; io::spawn_editor(&editor_command, &file_ref) } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; // ── Error Display ───────────────────────────────────────────────────────── #[test] fn error_display_validation() { let e = Error::Validation("bad value".to_string()); assert!(e.to_string().contains("Validation error")); assert!(e.to_string().contains("bad value")); } #[test] fn error_display_not_configured() { let e = Error::NotConfigured; assert!(e.to_string().contains("No editor configured")); } #[test] fn error_display_io() { let e = Error::Io("disk full".to_string()); assert!(e.to_string().contains("I/O error")); assert!(e.to_string().contains("disk full")); } #[test] fn error_display_spawn() { let e = Error::Spawn("not found".to_string()); assert!(e.to_string().contains("Spawn error")); assert!(e.to_string().contains("not found")); } // ── load_project_settings ───────────────────────────────────────────────── #[test] fn load_project_settings_returns_defaults_when_no_toml() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let s = load_project_settings(dir.path()).unwrap(); assert_eq!(s.default_qa, "server"); assert_eq!(s.max_retries, 2); assert!(s.rate_limit_notifications); } // ── write_project_settings ──────────────────────────────────────────────── #[test] fn write_project_settings_creates_file() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let s = load_project_settings(dir.path()).unwrap(); write_project_settings(dir.path(), &s).unwrap(); assert!(dir.path().join(".huskies/project.toml").exists()); } #[test] fn write_project_settings_roundtrips() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let mut s = load_project_settings(dir.path()).unwrap(); s.default_qa = "agent".to_string(); s.max_retries = 7; write_project_settings(dir.path(), &s).unwrap(); let loaded = load_project_settings(dir.path()).unwrap(); assert_eq!(loaded.default_qa, "agent"); assert_eq!(loaded.max_retries, 7); } // ── get_editor_command ──────────────────────────────────────────────────── #[test] fn get_editor_command_returns_none_when_unset() { use crate::store::JsonFileStore; let dir = TempDir::new().unwrap(); let store = JsonFileStore::new(dir.path().join("store.json")).unwrap(); assert!(get_editor_command(&store).is_none()); } #[test] fn get_editor_command_returns_value_when_set() { use crate::store::JsonFileStore; use serde_json::json; let dir = TempDir::new().unwrap(); let store = JsonFileStore::new(dir.path().join("store.json")).unwrap(); store.set(EDITOR_COMMAND_KEY, json!("zed")); assert_eq!(get_editor_command(&store), Some("zed".to_string())); } // ── open_file_in_editor ─────────────────────────────────────────────────── #[test] fn open_file_in_editor_returns_not_configured_when_no_editor() { use crate::store::JsonFileStore; let dir = TempDir::new().unwrap(); let store = JsonFileStore::new(dir.path().join("store.json")).unwrap(); let result = open_file_in_editor(&store, "src/main.rs", Some(42)); assert!(matches!(result, Err(Error::NotConfigured))); } #[test] fn open_file_in_editor_returns_spawn_error_for_nonexistent_editor() { use crate::store::JsonFileStore; use serde_json::json; let dir = TempDir::new().unwrap(); let store = JsonFileStore::new(dir.path().join("store.json")).unwrap(); store.set(EDITOR_COMMAND_KEY, json!("this_editor_xyz_does_not_exist")); let result = open_file_in_editor(&store, "src/main.rs", Some(1)); assert!(matches!(result, Err(Error::Spawn(_)))); } #[test] fn open_file_in_editor_succeeds_with_echo() { use crate::store::JsonFileStore; use serde_json::json; let dir = TempDir::new().unwrap(); let store = JsonFileStore::new(dir.path().join("store.json")).unwrap(); store.set(EDITOR_COMMAND_KEY, json!("echo")); let result = open_file_in_editor(&store, "src/main.rs", Some(10)); assert!(result.is_ok()); } #[test] fn open_file_in_editor_formats_path_without_line() { use crate::store::JsonFileStore; use serde_json::json; let dir = TempDir::new().unwrap(); let store = JsonFileStore::new(dir.path().join("store.json")).unwrap(); store.set(EDITOR_COMMAND_KEY, json!("echo")); let result = open_file_in_editor(&store, "src/lib.rs", None); assert!(result.is_ok()); } }