//! Pure domain entity for application settings. //! //! Defines `Settings` (top-level user configuration), `SettingsFlags` //! (grouped boolean toggles), and `InternetMode` (network access level). //! Serialised to `settings.json` by the infrastructure layer. //! //! # Architecture //! This is a pure data structure with **no I/O logic**. Load/save //! responsibilities live in `SettingsRepository` (domain::repository). //! //! ## Settings Fields //! - `internet_mode` — network access policy (Off / ReadOnly / Full) //! - `provider` / `model` — default LLM provider and model name //! - `api_keys` — per-provider API key overrides (name → key) //! - `max_tokens` / `temperature` — generation parameter defaults //! - `review_max_lessons_per_run` — max lessons per auto-review pass //! - `verify_command` — optional shell command to run for verification //! - `workflow_max_concurrency` — max parallel hive-mind nodes //! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration //! - `flags` — grouped boolean feature toggles use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::app_config::AppConfig; /// Controls how much network access the agent is permitted during a session. /// /// ## Variants /// - `Off` — no network access /// - `ReadOnly` — HTTP GET / HEAD only /// - `Full` — any HTTP method permitted #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum InternetMode { /// No network access permitted. #[default] Off, /// HTTP GET / HEAD requests only. ReadOnly, /// Any HTTP method permitted. Full, } /// Grouped boolean feature toggles for the application. /// /// Kept as a separate struct to avoid clippy's /// `default-too-many-fields` threshold on `Settings`. /// /// ## Fields /// - `review_enabled` — enable automatic inline review after edits /// - `session_archive_enabled` — enable periodic session archiving #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SettingsFlags { pub review_enabled: bool, pub session_archive_enabled: bool, } impl Default for SettingsFlags { /// Returns the default flags with all features enabled. fn default() -> Self { Self { review_enabled: true, session_archive_enabled: true, } } } /// Returns the default hive-mind node timeout (600 seconds). fn default_hive_mind_node_timeout_ms() -> u64 { 600_000 } /// Top-level application settings model. /// /// Serialised to `settings.json` by the infrastructure persistence layer. /// Holds LLM provider selection, generation parameters, feature flags, /// and workflow configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub internet_mode: InternetMode, pub provider: String, pub model: String, pub api_keys: HashMap, pub max_tokens: Option, pub temperature: Option, 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, #[serde(flatten)] pub flags: SettingsFlags, #[serde(default = "default_hive_mind_node_timeout_ms")] pub hive_mind_node_timeout_ms: u64, } impl Default for Settings { fn default() -> Self { Self { internet_mode: InternetMode::Off, provider: "zen".to_string(), model: "deepseek-v4-flash-free".to_string(), api_keys: HashMap::new(), max_tokens: None, temperature: None, review_max_lessons_per_run: 5, adaptive_review_max_skip: 3, verify_command: None, verify_timeout_ms: 30_000, workflow_max_concurrency: 5, flags: SettingsFlags::default(), hive_mind_node_timeout_ms: 600_000, } } } /// Pick the effective model name for the main agent. /// /// When `settings.provider` is `"claude"` (auto-detected from /// `~/.claude/settings.json`), the provider's `default_model` (or the /// app-level `default_model`) wins over a possibly-stale persisted /// `settings.model`. Otherwise the user's explicit `settings.model` is used. /// /// Why: the user's custom Claude endpoint (URL + API key from /// `~/.claude/settings.json`) implies Opus as the model; a stale /// `settings.json` (e.g. "deepseek-v4-flash-free") must not override it. pub fn resolve_effective_model(settings: &Settings, app_config: &AppConfig) -> String { if settings.provider == "claude" { if let Some(m) = app_config .providers .get("claude") .and_then(|p| p.default_model.clone()) { return m; } return app_config.default_model.clone(); } settings.model.clone() } #[cfg(test)] mod tests { use super::*; use crate::cms::app_config::AppConfig; fn claude_app_config() -> AppConfig { let mut cfg = AppConfig::default(); cfg.providers.insert( "claude".to_string(), crate::cms::ProviderConfig { api_base: "https://9router.example/v1".to_string(), api_key_env: Some("ANTHROPIC_API_KEY".to_string()), default_model: Some("claude-opus-5".to_string()), default_api_key: Some("sk-test".to_string()), }, ); cfg.default_provider = "claude".to_string(); cfg.default_model = "claude-opus-5".to_string(); cfg } #[test] fn claude_provider_uses_opus_model_over_stale_settings_model() { let settings = Settings { provider: "claude".to_string(), model: "deepseek-v4-flash-free".to_string(), // stale persisted ..Settings::default() }; let model = resolve_effective_model(&settings, &claude_app_config()); assert_eq!(model, "claude-opus-5"); } #[test] fn non_claude_provider_uses_settings_model() { let settings = Settings { provider: "zen".to_string(), model: "my-model".to_string(), ..Settings::default() }; let model = resolve_effective_model(&settings, &AppConfig::default()); assert_eq!(model, "my-model"); } #[test] fn claude_falls_back_to_app_default() { let settings = Settings { provider: "claude".to_string(), model: String::new(), ..Settings::default() }; let cfg = AppConfig::default(); let model = resolve_effective_model(&settings, &cfg); assert_eq!(model, cfg.default_model); } }