use serde::{Deserialize, Serialize}; use serde_norway::{Mapping, Value}; use std::fmt; use std::fs; use std::path::Path; use crate::model::ModelChoice; use crate::settings::{GenerationPreferences, RuntimePreferences}; /// Settings the application persists beside its database, as a YAML file users /// and agents can edit by hand. Only values that differ from the defaults are /// written; anything missing falls back to the default. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] pub struct Config { pub model: ModelChoice, pub idle_timeout_minutes: i32, pub a2ui_enabled: bool, pub dev_brain: DevBrainConfig, pub endpoint: EndpointConfig, pub generation: GenerationPreferences, pub runtime: RuntimePreferences, pub git: GitConfig, pub interface: InterfaceConfig, } impl Default for Config { fn default() -> Self { Self { model: ModelChoice::default(), idle_timeout_minutes: 10, a2ui_enabled: true, dev_brain: DevBrainConfig::default(), endpoint: EndpointConfig::default(), generation: GenerationPreferences::default(), runtime: RuntimePreferences::default(), git: GitConfig::default(), interface: InterfaceConfig::default(), } } } pub const GIT_DIFF_ALGORITHMS: [GitDiffAlgorithm; 3] = [ GitDiffAlgorithm::Default, GitDiffAlgorithm::Patience, GitDiffAlgorithm::Minimal, ]; pub const GIT_DIFF_LAYOUTS: [GitDiffLayout; 2] = [GitDiffLayout::Unified, GitDiffLayout::Split]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum GitDiffLayout { #[default] Unified, Split, } impl fmt::Display for GitDiffLayout { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Unified => "1 column", Self::Split => "2 columns", }) } } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum GitDiffAlgorithm { #[default] Default, Patience, Minimal, } impl fmt::Display for GitDiffAlgorithm { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Default => "Default (Myers)", Self::Patience => "Patience", Self::Minimal => "Minimal", }) } } pub const GIT_DIFF_WHITESPACE_MODES: [GitDiffWhitespace; 4] = [ GitDiffWhitespace::ShowAll, GitDiffWhitespace::IgnoreAll, GitDiffWhitespace::IgnoreChanges, GitDiffWhitespace::IgnoreEndOfLine, ]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum GitDiffWhitespace { #[default] ShowAll, IgnoreAll, IgnoreChanges, IgnoreEndOfLine, } impl fmt::Display for GitDiffWhitespace { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::ShowAll => "Show all changes", Self::IgnoreAll => "Ignore all whitespace", Self::IgnoreChanges => "Ignore whitespace amount", Self::IgnoreEndOfLine => "Ignore end-of-line whitespace", }) } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] pub struct GitConfig { pub diff_layout: GitDiffLayout, pub diff_algorithm: GitDiffAlgorithm, pub context_lines: u32, pub interhunk_lines: u32, pub indent_heuristic: bool, pub whitespace: GitDiffWhitespace, pub ignore_blank_lines: bool, } impl Default for GitConfig { fn default() -> Self { Self { diff_layout: GitDiffLayout::Unified, diff_algorithm: GitDiffAlgorithm::Default, context_lines: 3, interhunk_lines: 0, indent_heuristic: false, whitespace: GitDiffWhitespace::ShowAll, ignore_blank_lines: false, } } } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] pub struct DevBrainConfig { pub enabled: bool, pub vault_path: Option, } impl DevBrainConfig { pub fn vault(&self) -> Result { if !self.enabled { return Err("Dev Brain is disabled.".into()); } let path = self .vault_path .as_deref() .filter(|path| !path.trim().is_empty()) .ok_or_else(|| "Choose an Obsidian vault before enabling Dev Brain.".to_owned())?; crate::dev_brain::validate_vault(Path::new(path)) } fn validate(&self) -> Result<(), String> { if self.enabled { self.vault().map(|_| ()) } else { Ok(()) } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] pub struct EndpointConfig { pub port: i32, pub enabled: bool, pub cors: bool, } impl Default for EndpointConfig { fn default() -> Self { Self { port: 4000, enabled: true, cors: false, } } } /// Window state the application restores on the next launch. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] pub struct InterfaceConfig { pub sidebar_collapsed: bool, pub sidebar_width: i32, /// Project the app reopens on. Cleared when that project goes away. pub last_project_id: Option, } impl Default for InterfaceConfig { fn default() -> Self { Self { sidebar_collapsed: false, sidebar_width: 276, last_project_id: None, } } } impl Config { /// A missing file is the default configuration; a malformed one is an error, /// so a typo never silently resets everything. pub fn load(path: &Path) -> Result { let text = match fs::read_to_string(path) { Ok(text) => text, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(Self::default()); } Err(error) => return Err(format!("Could not read {}: {error}", path.display())), }; let config: Self = serde_norway::from_str(&text) .map_err(|error| format!("Could not read {}: {error}", path.display()))?; config.validate()?; Ok(config) } pub fn save(&self, path: &Path) -> Result<(), String> { self.validate()?; let value = serde_norway::to_value(self).map_err(|error| error.to_string())?; let defaults = serde_norway::to_value(Self::default()).map_err(|error| error.to_string())?; let value = without_defaults(value, &defaults) .unwrap_or_else(|| Value::Mapping(Mapping::default())); let text = serde_norway::to_string(&value).map_err(|error| error.to_string())?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } fs::write(path, text) .map_err(|error| format!("Could not write {}: {error}", path.display())) } pub fn validate(&self) -> Result<(), String> { if !(1..=1440).contains(&self.idle_timeout_minutes) { return Err("Idle timeout must be between 1 and 1440 minutes.".into()); } if !(1..=65_535).contains(&self.endpoint.port) { return Err("Endpoint port must be between 1 and 65535.".into()); } self.generation.validate()?; self.runtime.validate(self.model)?; self.dev_brain.validate() } } /// Drops every value that still matches the default, so the file lists only what /// the user actually set. Mappings that end up empty disappear with their key. fn without_defaults(value: Value, defaults: &Value) -> Option { if let (Value::Mapping(entries), Value::Mapping(defaults)) = (&value, defaults) { let kept = entries .iter() .filter_map(|(key, entry)| { let default = defaults.get(key)?; Some((key.clone(), without_defaults(entry.clone(), default)?)) }) .collect::(); return (!kept.is_empty()).then_some(Value::Mapping(kept)); } (&value != defaults).then_some(value) } #[cfg(test)] mod tests { use super::*; use crate::settings::{ReasoningMode, SsdPreferences, StreamingCacheBudget}; #[test] fn defaults_write_an_empty_file_and_reload_unchanged() { let directory = std::env::temp_dir().join(format!("ds4-config-{}", std::process::id())); let path = directory.join("config.yaml"); Config::default().save(&path).unwrap(); assert_eq!(fs::read_to_string(&path).unwrap().trim(), "{}"); assert_eq!(Config::load(&path).unwrap(), Config::default()); fs::remove_dir_all(&directory).unwrap(); } #[test] fn only_changed_values_are_written_and_read_back() { let directory = std::env::temp_dir().join(format!("ds4-config-set-{}", std::process::id())); let path = directory.join("config.yaml"); let config = Config { model: ModelChoice::Glm52, a2ui_enabled: false, generation: GenerationPreferences { context_tokens: 65_536, reasoning_mode: ReasoningMode::Direct, ..GenerationPreferences::default() }, runtime: RuntimePreferences { ssd: SsdPreferences { enabled: true, cache: Some(StreamingCacheBudget::Gib(64)), ..SsdPreferences::default() }, ..RuntimePreferences::default() }, git: GitConfig { diff_layout: GitDiffLayout::Split, diff_algorithm: GitDiffAlgorithm::Patience, context_lines: 5, whitespace: GitDiffWhitespace::IgnoreEndOfLine, ..GitConfig::default() }, ..Config::default() }; config.save(&path).unwrap(); let text = fs::read_to_string(&path).unwrap(); assert_eq!( text, "model: glm-5.2\na2ui_enabled: false\n\ generation:\n context_tokens: 65536\n reasoning_mode: none\n\ runtime:\n ssd:\n enabled: true\n cache: 64GB\n\ git:\n diff_layout: split\n diff_algorithm: patience\n context_lines: 5\n whitespace: ignore-end-of-line\n" ); assert_eq!(Config::load(&path).unwrap(), config); fs::remove_dir_all(&directory).unwrap(); } #[test] fn invalid_files_are_reported_instead_of_reset() { let directory = std::env::temp_dir().join(format!("ds4-config-bad-{}", std::process::id())); let path = directory.join("config.yaml"); fs::create_dir_all(&directory).unwrap(); fs::write(&path, "idle_timeout_minutes: 0\n").unwrap(); assert!(Config::load(&path).is_err()); fs::write(&path, "nonsense: 1\n").unwrap(); assert!(Config::load(&path).is_err()); fs::remove_dir_all(&directory).unwrap(); // A missing file is simply the defaults. assert_eq!(Config::load(&path).unwrap(), Config::default()); } }