//! Pure settings types and TOML merge logic — no side effects. //! //! Owns [`ProjectSettings`] (the API-facing settings payload), //! [`settings_from_config`] (conversion from `ProjectConfig`), and //! [`merge_settings_into_toml`] (the pure TOML key-updating logic used by the //! write path in `mod.rs` + `io.rs`). use crate::agents::AgentModel; use crate::config::ProjectConfig; use serde::{Deserialize, Serialize}; /// Project-level settings exposed via `GET /api/settings` and `PUT /api/settings`. /// /// Only contains the scalar fields of `ProjectConfig` — array sections /// (`[[component]]`, `[[agent]]`, `[watcher]`) are preserved in the TOML file /// and are not editable through this API. #[derive(Debug, Serialize, Deserialize)] pub struct ProjectSettings { /// Project-wide default QA mode: "server", "agent", or "human". Default: "server". pub default_qa: String, /// Default model for coder-stage agents (e.g. `Sonnet`). pub default_coder_model: Option, /// Maximum number of concurrent coder-stage agents. pub max_coders: Option, /// Maximum retries per story per pipeline stage before marking as blocked. Default: 2. pub max_retries: u32, /// Optional base branch name (e.g. "main", "master"). pub base_branch: Option, /// Whether to send RateLimitWarning chat notifications. Default: true. pub rate_limit_notifications: bool, /// IANA timezone name (e.g. "Europe/London"). pub timezone: Option, /// WebSocket URL of a remote huskies node to sync CRDT state with. pub rendezvous: Option, /// How often (seconds) to check 5_done/ for items to archive. Default: 60. pub watcher_sweep_interval_secs: u64, /// How long (seconds) an item must remain in 5_done/ before archiving. Default: 14400. pub watcher_done_retention_secs: u64, } /// Convert a [`ProjectConfig`] into a [`ProjectSettings`] payload. /// /// Pure: performs no I/O. pub fn settings_from_config(cfg: &ProjectConfig) -> ProjectSettings { ProjectSettings { default_qa: cfg.default_qa.clone(), default_coder_model: cfg.default_coder_model.clone(), max_coders: cfg.max_coders.map(|v| v as u32), max_retries: cfg.max_retries, base_branch: cfg.base_branch.clone(), rate_limit_notifications: cfg.rate_limit_notifications, timezone: cfg.timezone.clone(), rendezvous: cfg.rendezvous.clone(), watcher_sweep_interval_secs: cfg.watcher.sweep_interval_secs, watcher_done_retention_secs: cfg.watcher.done_retention_secs, } } /// Merge the scalar settings from `s` into an existing TOML value in-place. /// /// Array sections (`[[component]]`, `[[agent]]`) and unknown keys are preserved. /// Pure: performs no I/O. /// /// # Errors /// - [`super::Error::IoError`] if `val` is not a TOML table. pub fn merge_settings_into_toml( val: &mut toml::Value, s: &ProjectSettings, ) -> Result<(), super::Error> { let table = val .as_table_mut() .ok_or_else(|| super::Error::Io("Config is not a TOML table".to_string()))?; // Scalar root fields — always written table.insert( "default_qa".to_string(), toml::Value::String(s.default_qa.clone()), ); table.insert( "max_retries".to_string(), toml::Value::Integer(s.max_retries as i64), ); table.insert( "rate_limit_notifications".to_string(), toml::Value::Boolean(s.rate_limit_notifications), ); // Optional scalar fields — insert when Some, remove when None match &s.default_coder_model { Some(v) => { table.insert( "default_coder_model".to_string(), toml::Value::String(v.as_str().to_string()), ); } None => { table.remove("default_coder_model"); } } match s.max_coders { Some(v) => { table.insert("max_coders".to_string(), toml::Value::Integer(v as i64)); } None => { table.remove("max_coders"); } } match &s.base_branch { Some(v) => { table.insert("base_branch".to_string(), toml::Value::String(v.clone())); } None => { table.remove("base_branch"); } } match &s.timezone { Some(v) => { table.insert("timezone".to_string(), toml::Value::String(v.clone())); } None => { table.remove("timezone"); } } match &s.rendezvous { Some(v) => { table.insert("rendezvous".to_string(), toml::Value::String(v.clone())); } None => { table.remove("rendezvous"); } } // [watcher] sub-table let watcher_entry = table .entry("watcher".to_string()) .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); if let toml::Value::Table(wt) = watcher_entry { wt.insert( "sweep_interval_secs".to_string(), toml::Value::Integer(s.watcher_sweep_interval_secs as i64), ); wt.insert( "done_retention_secs".to_string(), toml::Value::Integer(s.watcher_done_retention_secs as i64), ); } Ok(()) } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; fn default_settings() -> ProjectSettings { settings_from_config(&ProjectConfig::default()) } fn empty_toml() -> toml::Value { toml::Value::Table(toml::map::Map::new()) } // ── settings_from_config ────────────────────────────────────────────────── #[test] fn settings_from_config_reflects_defaults() { let s = default_settings(); assert_eq!(s.default_qa, "server"); assert_eq!(s.max_retries, 2); assert!(s.rate_limit_notifications); assert!(s.default_coder_model.is_none()); assert!(s.max_coders.is_none()); assert!(s.base_branch.is_none()); assert!(s.timezone.is_none()); assert!(s.rendezvous.is_none()); } #[test] fn settings_from_config_copies_all_scalar_fields() { let cfg = ProjectConfig { default_qa: "human".to_string(), default_coder_model: Some(AgentModel::Opus), max_coders: Some(4), max_retries: 5, base_branch: Some("main".to_string()), rate_limit_notifications: false, timezone: Some("UTC".to_string()), rendezvous: Some("ws://host:3001/crdt-sync".to_string()), watcher: crate::config::WatcherConfig { sweep_interval_secs: 30, done_retention_secs: 7200, }, ..Default::default() }; let s = settings_from_config(&cfg); assert_eq!(s.default_qa, "human"); assert_eq!(s.default_coder_model, Some(AgentModel::Opus)); assert_eq!(s.max_coders, Some(4)); assert_eq!(s.max_retries, 5); assert_eq!(s.base_branch, Some("main".to_string())); assert!(!s.rate_limit_notifications); assert_eq!(s.timezone, Some("UTC".to_string())); assert_eq!(s.rendezvous, Some("ws://host:3001/crdt-sync".to_string())); assert_eq!(s.watcher_sweep_interval_secs, 30); assert_eq!(s.watcher_done_retention_secs, 7200); } #[test] fn settings_from_config_max_coders_usize_to_u32() { let cfg = ProjectConfig { max_coders: Some(3usize), ..Default::default() }; let s = settings_from_config(&cfg); assert_eq!(s.max_coders, Some(3u32)); } // ── merge_settings_into_toml ────────────────────────────────────────────── #[test] fn merge_writes_scalar_root_fields() { let mut val = empty_toml(); let s = ProjectSettings { default_qa: "agent".to_string(), default_coder_model: None, max_coders: None, max_retries: 3, base_branch: None, rate_limit_notifications: false, timezone: None, rendezvous: None, watcher_sweep_interval_secs: 60, watcher_done_retention_secs: 14400, }; merge_settings_into_toml(&mut val, &s).unwrap(); let t = val.as_table().unwrap(); assert_eq!(t["default_qa"].as_str(), Some("agent")); assert_eq!(t["max_retries"].as_integer(), Some(3)); assert_eq!(t["rate_limit_notifications"].as_bool(), Some(false)); } #[test] fn merge_inserts_optional_fields_when_some() { let mut val = empty_toml(); let s = ProjectSettings { default_qa: "server".to_string(), default_coder_model: Some(AgentModel::Sonnet), max_coders: Some(2), max_retries: 2, base_branch: Some("main".to_string()), rate_limit_notifications: true, timezone: Some("America/New_York".to_string()), rendezvous: Some("ws://host/crdt-sync".to_string()), watcher_sweep_interval_secs: 60, watcher_done_retention_secs: 14400, }; merge_settings_into_toml(&mut val, &s).unwrap(); let t = val.as_table().unwrap(); assert_eq!(t["default_coder_model"].as_str(), Some("sonnet")); assert_eq!(t["max_coders"].as_integer(), Some(2)); assert_eq!(t["base_branch"].as_str(), Some("main")); assert_eq!(t["timezone"].as_str(), Some("America/New_York")); assert_eq!(t["rendezvous"].as_str(), Some("ws://host/crdt-sync")); } #[test] fn merge_removes_optional_fields_when_none() { let mut val = empty_toml(); // First set them let s_with = ProjectSettings { default_qa: "server".to_string(), default_coder_model: Some(AgentModel::Sonnet), max_coders: Some(3), max_retries: 2, base_branch: Some("master".to_string()), rate_limit_notifications: true, timezone: Some("UTC".to_string()), rendezvous: None, watcher_sweep_interval_secs: 60, watcher_done_retention_secs: 14400, }; merge_settings_into_toml(&mut val, &s_with).unwrap(); // Then clear them let s_clear = ProjectSettings { default_qa: "server".to_string(), default_coder_model: None, max_coders: None, max_retries: 2, base_branch: None, rate_limit_notifications: true, timezone: None, rendezvous: None, watcher_sweep_interval_secs: 60, watcher_done_retention_secs: 14400, }; merge_settings_into_toml(&mut val, &s_clear).unwrap(); let t = val.as_table().unwrap(); assert!(!t.contains_key("default_coder_model")); assert!(!t.contains_key("max_coders")); assert!(!t.contains_key("base_branch")); assert!(!t.contains_key("timezone")); } #[test] fn merge_writes_watcher_sub_table() { let mut val = empty_toml(); let s = ProjectSettings { default_qa: "server".to_string(), default_coder_model: None, max_coders: None, max_retries: 2, base_branch: None, rate_limit_notifications: true, timezone: None, rendezvous: None, watcher_sweep_interval_secs: 45, watcher_done_retention_secs: 3600, }; merge_settings_into_toml(&mut val, &s).unwrap(); let t = val.as_table().unwrap(); let wt = t["watcher"].as_table().unwrap(); assert_eq!(wt["sweep_interval_secs"].as_integer(), Some(45)); assert_eq!(wt["done_retention_secs"].as_integer(), Some(3600)); } #[test] fn merge_preserves_unknown_toml_keys() { let existing_toml = r#" [[agent]] name = "coder-1" model = "sonnet" stage = "coder" [[component]] name = "server" path = "." "#; let mut val: toml::Value = toml::from_str(existing_toml).unwrap(); let s = default_settings(); merge_settings_into_toml(&mut val, &s).unwrap(); // Re-serialize and verify agent/component sections are preserved let output = toml::to_string_pretty(&val).unwrap(); assert!( output.contains("coder-1"), "agent section should be preserved" ); assert!( output.contains("component"), "component section should be preserved" ); } #[test] fn merge_returns_error_for_non_table_toml() { let mut val = toml::Value::String("not a table".to_string()); let s = default_settings(); let result = merge_settings_into_toml(&mut val, &s); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), super::super::Error::Io(_))); } #[test] fn merge_formatting_produces_valid_toml() { let mut val = empty_toml(); let s = ProjectSettings { default_qa: "human".to_string(), default_coder_model: Some(AgentModel::Opus), max_coders: Some(2), max_retries: 4, base_branch: Some("develop".to_string()), rate_limit_notifications: false, timezone: Some("Europe/London".to_string()), rendezvous: Some("ws://remote:3001/crdt-sync".to_string()), watcher_sweep_interval_secs: 120, watcher_done_retention_secs: 28800, }; merge_settings_into_toml(&mut val, &s).unwrap(); let output = toml::to_string_pretty(&val).unwrap(); // Verify round-trip: the output must be valid TOML let reparsed: toml::Value = toml::from_str(&output).unwrap(); let t = reparsed.as_table().unwrap(); assert_eq!(t["default_qa"].as_str(), Some("human")); assert_eq!(t["default_coder_model"].as_str(), Some("opus")); assert_eq!(t["max_coders"].as_integer(), Some(2)); } }