use serde::{Deserialize, Serialize}; use serde_norway::{Mapping, Value}; use std::collections::BTreeMap; use std::fmt; use std::fs; use std::path::Path; use crate::model::{MODEL_CHOICES, ModelChoice}; use crate::settings::{ DEFAULT_SYSTEM_PROMPT, GenerationPreferences, REASONING_MODES, ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, }; #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[serde(default, deny_unknown_fields)] pub(crate) struct ModelPreferences { pub(crate) reasoning_mode: ReasoningMode, pub(crate) speculative: SpeculativePreferences, pub(crate) ssd: SsdPreferences, } /// 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 default_permission_mode: PermissionMode, pub idle_timeout_minutes: i32, pub a2ui_enabled: bool, pub dev_brain: DevBrainConfig, pub endpoint: EndpointConfig, pub system_prompt: String, pub(crate) generation_profiles: BTreeMap>, pub(crate) model_profiles: BTreeMap, pub runtime: RuntimePreferences, pub git: GitConfig, pub interface: InterfaceConfig, } impl Default for Config { fn default() -> Self { Self { model: ModelChoice::default(), default_permission_mode: PermissionMode::default(), idle_timeout_minutes: 10, a2ui_enabled: true, dev_brain: DevBrainConfig::default(), endpoint: EndpointConfig::default(), system_prompt: DEFAULT_SYSTEM_PROMPT.into(), generation_profiles: MODEL_CHOICES .into_iter() .map(|model| { ( model, REASONING_MODES .into_iter() .map(|mode| (mode, GenerationPreferences::default())) .collect(), ) }) .collect(), model_profiles: MODEL_CHOICES .into_iter() .map(|model| (model, ModelPreferences::default())) .collect(), runtime: RuntimePreferences::default(), git: GitConfig::default(), interface: InterfaceConfig::default(), } } } pub const PERMISSION_MODES: [PermissionMode; 2] = [PermissionMode::Heuristic, PermissionMode::Ai]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum PermissionMode { #[default] Heuristic, Ai, } impl PermissionMode { pub(crate) fn as_id(self) -> &'static str { match self { Self::Heuristic => "heuristic", Self::Ai => "ai", } } pub(crate) fn from_id(id: &str) -> Option { match id { "heuristic" => Some(Self::Heuristic), "ai" => Some(Self::Ai), _ => None, } } } impl fmt::Display for PermissionMode { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Heuristic => "Heuristic", Self::Ai => "AI based", }) } } 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, pub window_size: [u32; 2], pub window_position: Option<[i32; 2]>, /// 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, window_size: [1120, 720], window_position: None, 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 mut value: Value = serde_norway::from_str(&text) .map_err(|error| format!("Could not read {}: {error}", path.display()))?; drop_legacy_model_settings(&mut value); let mut config: Self = serde_norway::from_value(value) .map_err(|error| format!("Could not read {}: {error}", path.display()))?; config.fill_profile_defaults(); 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()); } for (model, profiles) in &self.generation_profiles { for (mode, generation) in profiles { let mut generation = generation.clone(); generation.system_prompt = self.system_prompt.clone(); generation.reasoning_mode = *mode; generation.validate().map_err(|error| { format!("Generation settings for {model} / {mode}: {error}") })?; } } for (model, preferences) in &self.model_profiles { preferences.speculative.validate(*model)?; preferences.ssd.validate(*model)?; if preferences.reasoning_mode == ReasoningMode::Max && !self .supported_reasoning_modes(*model) .contains(&ReasoningMode::Max) { return Err(format!( "Default thinking mode for {model} requires its Think Max profile to use at least 393216 context tokens." )); } } self.runtime_for(self.model).validate(self.model)?; self.dev_brain.validate() } pub(crate) fn reasoning_mode(&self, model: ModelChoice) -> ReasoningMode { self.model_profiles .get(&model) .map_or(ReasoningMode::default(), |profile| profile.reasoning_mode) } pub(crate) fn generation_for( &self, model: ModelChoice, reasoning_mode: ReasoningMode, ) -> GenerationPreferences { let mut generation = self .generation_profiles .get(&model) .and_then(|profiles| profiles.get(&reasoning_mode)) .cloned() .unwrap_or_default(); generation.system_prompt = self.system_prompt.clone(); generation.reasoning_mode = reasoning_mode; generation } pub(crate) fn active_generation(&self) -> GenerationPreferences { self.generation_for(self.model, self.reasoning_mode(self.model)) } pub(crate) fn runtime_for(&self, model: ModelChoice) -> RuntimePreferences { let mut runtime = self.runtime.clone(); if let Some(profile) = self.model_profiles.get(&model) { runtime.speculative = profile.speculative.clone(); runtime.ssd = profile.ssd.clone(); } runtime } pub(crate) fn supported_reasoning_modes(&self, model: ModelChoice) -> &'static [ReasoningMode] { let max = self.generation_for(model, ReasoningMode::Max); max.supported_reasoning_modes() } fn fill_profile_defaults(&mut self) { for model in MODEL_CHOICES { self.model_profiles.entry(model).or_default(); let profiles = self.generation_profiles.entry(model).or_default(); for mode in REASONING_MODES { profiles.entry(mode).or_default(); } } } } fn drop_legacy_model_settings(value: &mut Value) { let Value::Mapping(root) = value else { return; }; let generation_key = Value::String("generation".into()); let prompt_key = Value::String("system_prompt".into()); if let Some(Value::Mapping(mut generation)) = root.remove(&generation_key) && !root.contains_key(&prompt_key) && let Some(prompt) = generation.remove(&prompt_key) { root.insert(prompt_key, prompt); } if let Some(Value::Mapping(runtime)) = root.get_mut(Value::String("runtime".into())) { runtime.remove(Value::String("speculative".into())); runtime.remove(Value::String("ssd".into())); } } /// 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 kept = match defaults.get(key) { Some(default) => without_defaults(entry.clone(), default)?, None => entry.clone(), }; Some((key.clone(), kept)) }) .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 mut config = Config { model: ModelChoice::Glm52, default_permission_mode: PermissionMode::Ai, a2ui_enabled: false, git: GitConfig { diff_layout: GitDiffLayout::Split, diff_algorithm: GitDiffAlgorithm::Patience, context_lines: 5, whitespace: GitDiffWhitespace::IgnoreEndOfLine, ..GitConfig::default() }, interface: InterfaceConfig { window_size: [1280, 800], window_position: Some([120, -40]), ..InterfaceConfig::default() }, ..Config::default() }; config .generation_profiles .get_mut(&ModelChoice::Glm52) .unwrap() .get_mut(&ReasoningMode::Direct) .unwrap() .context_tokens = 65_536; let model = config.model_profiles.get_mut(&ModelChoice::Glm52).unwrap(); model.reasoning_mode = ReasoningMode::Direct; model.ssd = SsdPreferences { enabled: true, cache: Some(StreamingCacheBudget::Gib(64)), ..SsdPreferences::default() }; config.save(&path).unwrap(); let text = fs::read_to_string(&path).unwrap(); assert_eq!( text, "model: glm-5.2\ndefault_permission_mode: ai\na2ui_enabled: false\n\ generation_profiles:\n glm-5.2:\n none:\n context_tokens: 65536\n\ model_profiles:\n glm-5.2:\n reasoning_mode: none\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\ interface:\n window_size:\n - 1280\n - 800\n window_position:\n - 120\n - -40\n" ); assert_eq!(Config::load(&path).unwrap(), config); fs::remove_dir_all(&directory).unwrap(); } #[test] fn legacy_flat_model_settings_are_dropped_but_the_prompt_is_kept() { let directory = std::env::temp_dir().join(format!("ds4-config-legacy-{}", std::process::id())); let path = directory.join("config.yaml"); fs::create_dir_all(&directory).unwrap(); fs::write( &path, "generation:\n system_prompt: keep me\n temperature: 0.2\nruntime:\n execution:\n quality: true\n ssd:\n enabled: true\n", ) .unwrap(); let config = Config::load(&path).unwrap(); assert_eq!(config.system_prompt, "keep me"); assert_eq!(config.active_generation().temperature, None); assert!(!config.runtime_for(config.model).ssd.enabled); assert!(config.runtime.execution.quality); 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()); } }