//! User-configurable settings persisted as JSON in the store's base directory. //! //! `Settings::load` / `Settings::save` are the only entry points; every field //! falls back to a hardcoded default via `Default for Settings` when the file //! is missing or fails to parse. use serde::{Deserialize, Serialize}; /// Controls how much network access the agent is permitted during a session. /// /// `Off` disables outbound requests entirely, `ReadOnly` allows fetches but /// no mutating calls, `Full` permits everything. Defaults to `Off`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Default)] pub enum InternetMode { #[default] Off, ReadOnly, Full, } /// Default per-node timeout for hive-mind nodes: 10 minutes. fn default_hive_mind_node_timeout_ms() -> u64 { 600_000 } /// Top-level application settings, serialized to `settings.json` in the store dir. /// /// Why: a single flat struct rather than nested config so the JSON file stays /// human-editable; unknown/missing fields on load fall back to `Default`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub internet_mode: InternetMode, pub provider: String, pub model: String, pub api_keys: std::collections::HashMap, pub max_tokens: Option, pub temperature: Option, pub review_enabled: bool, pub review_max_lessons_per_run: usize, pub adaptive_review_max_skip: u32, pub verify_command: Option, pub verify_timeout_ms: u64, pub workflow_max_concurrency: usize, pub session_archive_enabled: bool, pub lsp_auto_provision: bool, pub lsp_languages: Vec, /// Wall-clock deadline for a single hive-mind processing node (cycle /// node or synthesis node). Prevents one stuck node from hanging an /// entire hive-mind convergence forever. #[serde(default = "default_hive_mind_node_timeout_ms")] pub hive_mind_node_timeout_ms: u64, } impl Default for Settings { fn default() -> Self { Settings { internet_mode: InternetMode::Off, provider: "zen".to_string(), model: "deepseek-v4-flash-free".to_string(), api_keys: std::collections::HashMap::new(), max_tokens: None, temperature: None, review_enabled: true, review_max_lessons_per_run: 5, adaptive_review_max_skip: 3, verify_command: None, verify_timeout_ms: 30000, workflow_max_concurrency: 5, session_archive_enabled: true, lsp_auto_provision: true, lsp_languages: Vec::new(), hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(), } } } impl Settings { /// Load settings from `/settings.json`. /// /// Flow: read file → parse JSON → fall back to `Settings::default()` on /// any failure (missing file, unreadable, malformed JSON). /// /// Return: always succeeds; never surfaces I/O or parse errors to the caller. pub fn load() -> Self { let store = super::store::Store::new(); let path = store.base_dir.join("settings.json"); std::fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } /// Serialize and write settings to `/settings.json`, /// using write-then-rename with fsync for crash safety. /// /// Flow: ensure base dir exists → pretty-print JSON → write to a temp /// file → sync to disk → rename over the real path → sync the directory. /// /// Return: `Err` if the directory can't be created or the write fails. pub fn save(&self) -> std::io::Result<()> { let store = super::store::Store::new(); std::fs::create_dir_all(&store.base_dir)?; let path = store.base_dir.join("settings.json"); let tmp = store.base_dir.join("settings.json.tmp"); let s = serde_json::to_string_pretty(self)?; std::fs::write(&tmp, s)?; let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, path)?; let _ = std::fs::File::open(&store.base_dir).and_then(|d| d.sync_all()); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn default_hive_mind_node_timeout_is_ten_minutes() { let settings = Settings::default(); assert_eq!(settings.hive_mind_node_timeout_ms, 600_000); } #[test] fn missing_hive_mind_node_timeout_field_falls_back_to_default() { // Simulates loading a settings.json written before this field // existed — #[serde(default = ...)] must fill it in rather than // failing the whole parse (which would silently reset every // other saved setting to default too). let old_json = r#"{ "internet_mode": "Off", "provider": "zen", "model": "deepseek-v4-flash-free", "api_keys": {}, "max_tokens": null, "temperature": null, "review_enabled": true, "review_max_lessons_per_run": 5, "adaptive_review_max_skip": 3, "verify_command": null, "verify_timeout_ms": 30000, "workflow_max_concurrency": 5, "session_archive_enabled": true, "lsp_auto_provision": true, "lsp_languages": [] }"#; let parsed: Settings = serde_json::from_str(old_json) .expect("must parse even without the new field present"); assert_eq!(parsed.hive_mind_node_timeout_ms, 600_000); } }