use super::load::load_config_from_toml; use super::mcp::{Config, user_config_path}; use anyhow::Result; use kigi_agent::prompt::skills::SkillsConfig; use toml::Value as TomlValue; use toml::map::Map as TomlMap; /// Process-wide write lock for `~/.kigi/config.toml`. /// /// Serializes the read-modify-write in `save_config` so two rapid /// settings toggles can't interleave and clobber each other. static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); pub async fn save_config(config: &Config) -> Result<()> { let _guard = SAVE_LOCK.lock().await; let path = user_config_path(); let mut root: TomlValue = match tokio::fs::read_to_string(&path).await { Ok(s) => { // Refuse to overwrite an unparseable config — silent fallback // to an empty table would permanently drop unmodeled sections. match toml::from_str::(&s) { Ok(v) => v, Err(parse_err) => { return Err(anyhow::anyhow!( "refusing to overwrite unparseable {}: {}; save a backup \ and fix the syntax error before retrying", path.display(), parse_err, )); } } } Err(_) => TomlValue::Table(TomlMap::new()), }; if !matches!(root, TomlValue::Table(_)) { root = TomlValue::Table(TomlMap::new()); } let table = root.as_table_mut().expect("root must be a table"); merge_section(table, "cli", &config.cli); merge_section(table, "models", &config.models); merge_section(table, "ui", &config.ui); merge_section(table, "harness", &config.harness); merge_section(table, "session", &config.session); merge_ask_user_question_section(table, &config.ask_user_question); if config.skills == SkillsConfig::default() { table.remove("skills"); } else { merge_section(table, "skills", &config.skills); } let toml_str = toml::to_string_pretty(&root)?; if let Some(parent) = path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } // Preserve existing file permissions across the tmp+rename swap. #[cfg(unix)] let prior_mode: Option = match tokio::fs::metadata(&path).await { Ok(m) => { use std::os::unix::fs::PermissionsExt; Some(m.permissions().mode()) } Err(_) => None, }; #[cfg(not(unix))] let prior_mode: Option = None; // Unique tmp filename (PID + nanos) avoids inode sharing if a // future caller bypasses SAVE_LOCK. let suffix = { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); format!("toml.tmp.{}.{}", std::process::id(), nanos) }; let tmp = path.with_extension(suffix); tokio::fs::write(&tmp, toml_str).await?; #[cfg(unix)] if let Some(mode) = prior_mode { use std::os::unix::fs::PermissionsExt; // Set mode before rename so permissions never widen atomically. let _ = tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode)).await; } let _ = prior_mode; // Windows-safe replace (delete-first + retry on sharing violations) — // a bare rename made `/model` persistence silently fail on Windows // whenever AV/indexer/cloud-sync held config.toml open. tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path)) .await .map_err(|e| anyhow::anyhow!("config replace task: {e}"))??; Ok(()) } /// Read a file, treating only `NotFound` as empty. Hard read errors (EACCES, /// EIO) propagate so callers don't clobber an unreadable file on the next write. pub(crate) fn read_to_string_or_empty(path: &std::path::Path) -> std::io::Result { match std::fs::read_to_string(path) { Ok(s) => Ok(s), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), Err(e) => Err(e), } } /// Atomic write via temp file + `rename` (mirrors [`save_config`]) so a crash /// mid-write can't truncate `config.toml`. Preserves the dest mode on unix. pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } #[cfg(unix)] let prior_mode: Option = match std::fs::metadata(path) { Ok(m) => { use std::os::unix::fs::PermissionsExt; Some(m.permissions().mode()) } Err(_) => None, }; #[cfg(not(unix))] let prior_mode: Option = None; let suffix = { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); format!("toml.tmp.{}.{}", std::process::id(), nanos) }; let tmp = path.with_extension(suffix); std::fs::write(&tmp, content)?; #[cfg(unix)] if let Some(mode) = prior_mode { use std::os::unix::fs::PermissionsExt; // Set mode before rename so permissions never widen atomically. let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode)); } let _ = prior_mode; // Windows-safe replace; cleans up the tmp file on failure itself. crate::util::fs::replace_file(&tmp, path) } /// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is /// deliberately NOT merged wholesale — its other sub-tables (`bash` etc.) /// carry serialized defaults that must never land in the user file — so only /// this settings-writable sub-table round-trips. fn merge_ask_user_question_section( table: &mut TomlMap, ask: &crate::tools::config::AskUserQuestionToolConfig, ) { // All-None means nothing to write; skip so an empty [toolset] header // never appears in config.toml. if ask.timeout_enabled.is_none() && ask.timeout_secs.is_none() { return; } let toolset = table .entry("toolset".to_string()) .or_insert_with(|| TomlValue::Table(TomlMap::new())); // Mirror merge_section's recovery: replace a non-table `toolset` scalar so // a user-initiated write never silently vanishes after the success toast. if !matches!(toolset, TomlValue::Table(_)) { *toolset = TomlValue::Table(TomlMap::new()); } if let TomlValue::Table(toolset_table) = toolset { merge_section(toolset_table, "ask_user_question", ask); } } /// Merge serialized fields of `value` into `table[key]`, preserving any /// existing keys not present in the serialized output. This prevents /// unmodeled fields (e.g. pager-written `show_timestamps`, `auto_dark_theme`) /// from being silently dropped when `save_config` round-trips the struct. /// Deep-merge `incoming` into `existing`: nested tables recurse; scalars replace. fn merge_toml_tables( existing: &mut TomlMap, incoming: TomlMap, ) { for (field_key, field_val) in incoming { match (existing.get_mut(&field_key), field_val) { (Some(TomlValue::Table(dst)), TomlValue::Table(src)) => { merge_toml_tables(dst, src); } (_, v) => { existing.insert(field_key, v); } } } } fn merge_section( table: &mut TomlMap, key: &str, value: &T, ) { match TomlValue::try_from(value) { Ok(TomlValue::Table(new_fields)) if !new_fields.is_empty() => { let section = table .entry(key.to_string()) .or_insert_with(|| TomlValue::Table(TomlMap::new())); if let TomlValue::Table(existing) = section { merge_toml_tables(existing, new_fields); } else { *section = TomlValue::Table(new_fields); } } // Serialized struct is empty (all-Option structs like CliConfig/HarnessConfig // with every field at None). Preserve the existing section untouched. Ok(TomlValue::Table(_)) => {} Ok(_) | Err(_) => { table.remove(key); } } } /// Update settings with a read-modify-write, preserving unrelated fields. pub async fn update_config(f: F) -> Result<()> where F: FnOnce(&mut Config), { let root: TomlValue = crate::config::load_from_disk().unwrap_or_else(|_| TomlValue::Table(TomlMap::new())); let mut cfg = load_config_from_toml(&root); f(&mut cfg); save_config(&cfg).await } #[cfg(test)] mod tests { use super::super::load::load_config_from_toml; use super::super::mcp::{McpConfig, parse_mcp_config_with_oauth}; use super::*; use toml::Value as TomlValue; use toml::map::Map as TomlMap; /// The `[toolset.ask_user_question]` settings write merges only that /// sub-table: the toggled field lands, hand-written sibling keys survive, /// and no other `[toolset]` defaults (bash etc.) are splatted into /// the user file. All-None leaves the file untouched. #[test] fn ask_user_question_merge_writes_subtable_without_splatting_toolset() { let root_val: TomlValue = toml::from_str("[toolset.ask_user_question]\ntimeout_secs = 30\n").unwrap(); let mut root = root_val.as_table().unwrap().clone(); let ask = crate::tools::config::AskUserQuestionToolConfig { timeout_enabled: Some(false), ..Default::default() }; merge_ask_user_question_section(&mut root, &ask); let toolset = root.get("toolset").and_then(|v| v.as_table()).unwrap(); assert_eq!(toolset.len(), 1, "only ask_user_question may be written"); let ask_tbl = toolset .get("ask_user_question") .and_then(|v| v.as_table()) .unwrap(); assert_eq!( ask_tbl.get("timeout_enabled").and_then(|v| v.as_bool()), Some(false) ); assert_eq!( ask_tbl.get("timeout_secs").and_then(|v| v.as_integer()), Some(30), "hand-written sibling keys must survive the merge" ); // The update_config read side parses the same sub-table back, closing // the read-modify-write loop. let reparsed = load_config_from_toml(&TomlValue::Table(root.clone())); assert_eq!(reparsed.ask_user_question.timeout_enabled, Some(false)); assert_eq!(reparsed.ask_user_question.timeout_secs, Some(30)); let mut empty_root: TomlMap = TomlMap::new(); merge_ask_user_question_section( &mut empty_root, &crate::tools::config::AskUserQuestionToolConfig::default(), ); assert!( empty_root.is_empty(), "all-None must not create an empty [toolset] header" ); // A non-table `toolset` scalar is replaced (merge_section parity) so // the toggle still lands instead of silently vanishing. let mut scalar_root: TomlMap = TomlMap::new(); scalar_root.insert("toolset".into(), TomlValue::String("bogus".into())); merge_ask_user_question_section(&mut scalar_root, &ask); assert_eq!( scalar_root .get("toolset") .and_then(|v| v.get("ask_user_question")) .and_then(|a| a.get("timeout_enabled")) .and_then(|v| v.as_bool()), Some(false), "scalar [toolset] must be replaced so the write lands" ); } #[test] fn transport_oauth_client_id_takes_priority_over_block() { let json = r#"{ "mcpServers": { "svc": { "type": "http", "url": "https://svc.example/mcp", "oauth_client_id": "transport-client", "oauth": { "clientId": "block-client" } } } }"#; let config: McpConfig = serde_json::from_str(json).expect("parse .mcp.json"); let svc = config.mcp_servers.get("svc").unwrap(); let oauth = svc.oauth_config().expect("oauth_config"); assert_eq!(oauth.client_id.as_deref(), Some("transport-client")); } #[test] fn parse_mcp_config_with_oauth_extracts_byo_client_id() { let json = r#"{ "mcpServers": { "slack": { "type": "http", "url": "https://mcp.slack.example/mcp", "oauth": { "clientId": "slack-byo-client" } }, "plain": { "type": "http", "url": "https://plain.example/mcp" } } }"#; let config: McpConfig = serde_json::from_str(json).expect("parse .mcp.json"); let (servers, oauth) = parse_mcp_config_with_oauth(&config, "test", &|s| s.to_string()); assert_eq!(servers.len(), 2); assert_eq!(oauth.len(), 1); assert_eq!( oauth.get("slack").unwrap().client_id.as_deref(), Some("slack-byo-client") ); assert!(!oauth.contains_key("plain")); } // -- Cursor MCP loading -- #[test] fn merge_section_preserves_unmodeled_fields() { let mut table = TomlMap::new(); let mut ui = TomlMap::new(); ui.insert("show_timestamps".into(), TomlValue::Boolean(true)); ui.insert( "auto_dark_theme".into(), TomlValue::String("tokyonight".into()), ); ui.insert("custom_user_key".into(), TomlValue::Integer(42)); table.insert("ui".into(), TomlValue::Table(ui)); let cfg = crate::agent::config::UiConfig::default(); merge_section(&mut table, "ui", &cfg); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("show_timestamps").and_then(|v| v.as_bool()), Some(true), "pre-existing show_timestamps should survive merge with default struct" ); assert_eq!( ui.get("auto_dark_theme").and_then(|v| v.as_str()), Some("tokyonight"), "pre-existing auto_dark_theme should survive merge with default struct" ); assert_eq!( ui.get("custom_user_key").and_then(|v| v.as_integer()), Some(42), "truly unmodeled user-added key should survive merge" ); } #[test] fn merge_section_nested_display_refresh_preserves_future_knob() { let mut table = TomlMap::new(); let mut ui = TomlMap::new(); let mut dr = TomlMap::new(); dr.insert("probe_enabled".into(), TomlValue::Boolean(true)); dr.insert("future_knob".into(), TomlValue::Integer(42)); ui.insert("display_refresh".into(), TomlValue::Table(dr)); table.insert("ui".into(), TomlValue::Table(ui)); let mut cfg = crate::agent::config::UiConfig::default(); cfg.display_refresh.probe_enabled = Some(false); merge_section(&mut table, "ui", &cfg); let nested = table .get("ui") .and_then(|v| v.as_table()) .and_then(|u| u.get("display_refresh")) .and_then(|v| v.as_table()) .expect("display_refresh table"); assert_eq!( nested.get("probe_enabled").and_then(|v| v.as_bool()), Some(false) ); assert_eq!( nested.get("future_knob").and_then(|v| v.as_integer()), Some(42), "unknown nested keys must survive shallow-looking settings writes" ); } #[test] fn merge_section_updates_modeled_fields_preserving_unmodeled() { let mut table = TomlMap::new(); let mut ui = TomlMap::new(); ui.insert("yolo".into(), TomlValue::Boolean(false)); ui.insert("show_timestamps".into(), TomlValue::Boolean(true)); ui.insert( "auto_light_theme".into(), TomlValue::String("kigiday".into()), ); table.insert("ui".into(), TomlValue::Table(ui)); let cfg = crate::agent::config::UiConfig { yolo: true, ..Default::default() }; merge_section(&mut table, "ui", &cfg); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("yolo").and_then(|v| v.as_bool()), Some(true), "modeled field yolo should be updated" ); assert_eq!( ui.get("show_timestamps").and_then(|v| v.as_bool()), Some(true), "pre-existing field not in serialized output should be preserved" ); assert_eq!( ui.get("auto_light_theme").and_then(|v| v.as_str()), Some("kigiday"), "pre-existing field not in serialized output should be preserved" ); } #[test] fn merge_section_creates_new_section() { let mut table = TomlMap::new(); assert!(table.get("ui").is_none()); let cfg = crate::agent::config::UiConfig { yolo: true, ..Default::default() }; merge_section(&mut table, "ui", &cfg); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!(ui.get("yolo").and_then(|v| v.as_bool()), Some(true)); } /// Regression test: pager-side commits of a /// [session] field (e.g., `auto_compact_threshold_percent`) must /// NOT inject `load_envrc` into the user's config when the user /// has never set it. Before the fix, `SessionConfig::load_envrc` /// was plain `bool` with default `true` and no /// `skip_serializing_if`, so EVERY pager save wrote /// `[session].load_envrc = true` to disk — silently overriding any /// managed-config `load_envrc = false` policy. /// /// The fix widens `load_envrc` to `Option` with /// `skip_serializing_if = "Option::is_none"`. After the fix, a /// `SessionConfig::default()` (load_envrc: None, /// auto_compact_threshold_percent: None) merges into a /// pre-existing `[session]` table WITHOUT touching the user's /// `load_envrc` key. #[test] fn merge_section_session_default_does_not_leak_load_envrc() { let mut table = TomlMap::new(); // The starting state: user has no [session] section on disk // (managed config might set load_envrc = false; user TOML is // silent on the matter). assert!(table.get("session").is_none()); // Pager calls update_config to set a totally unrelated [ui] // field. The closure exits with cfg.session == // SessionConfig::default() (both fields None). save_config // then calls merge_section("session", &cfg.session). let cfg = crate::agent::config::SessionConfig::default(); merge_section(&mut table, "session", &cfg); // After the fix, [session] is either absent OR present-but- // empty. Crucially, it must NOT contain `load_envrc`. if let Some(session) = table.get("session").and_then(|v| v.as_table()) { assert!( session.get("load_envrc").is_none(), "PR 12 R1 Bug 1: pager-side save with default SessionConfig must \ NOT serialize load_envrc — that would override managed-config \ policy. Found: {:?}", session.get("load_envrc"), ); assert!( session.get("auto_compact_threshold_percent").is_none(), "default auto_compact_threshold_percent must not be serialized either" ); } // If the table is wholly absent or empty, that's also fine. } /// Companion to the above: when the user explicitly commits a /// non-default `auto_compact_threshold_percent`, the field is /// serialized but `load_envrc` (still default None) is NOT. /// Pins the asymmetry — committing one [session] field does not /// "claim" the rest. #[test] fn merge_section_session_explicit_value_does_not_drag_load_envrc() { let mut table = TomlMap::new(); // Pre-existing managed-config-set value. let mut session = TomlMap::new(); session.insert("load_envrc".into(), TomlValue::Boolean(false)); table.insert("session".into(), TomlValue::Table(session)); // User commits auto_compact_threshold_percent via the modal. // The cfg.session has load_envrc: None (user never touched it) // and auto_compact_threshold_percent: Some(70). let cfg = crate::agent::config::SessionConfig { auto_compact_threshold_percent: Some(70), load_envrc: None, }; merge_section(&mut table, "session", &cfg); let session = table.get("session").unwrap().as_table().unwrap(); // The user's commit landed. assert_eq!( session .get("auto_compact_threshold_percent") .and_then(|v| v.as_integer()), Some(70), ); // The pre-existing load_envrc = false IS preserved (unmodeled- // field survival via the merge_section invariant) — the fix // doesn't break the historical preservation contract. assert_eq!( session.get("load_envrc").and_then(|v| v.as_bool()), Some(false), "pre-existing load_envrc must survive a partial settings save" ); } /// Follow-on: when the user DOES explicitly set /// `load_envrc = false` via TOML, the value round-trips through /// `load_config_from_toml` → mutate → `merge_section` correctly. /// `None` means "absent on disk"; `Some(false)` means "user /// explicitly disabled". The distinction must survive a save. #[test] fn session_load_envrc_explicit_false_round_trips() { let raw_config: TomlValue = toml::from_str( r#" [session] load_envrc = false "#, ) .unwrap(); let cfg = load_config_from_toml(&raw_config); assert_eq!( cfg.session.load_envrc, Some(false), "explicit load_envrc = false on disk must load as Some(false), not None" ); // Now round-trip through save: merge into a fresh table and // verify load_envrc = false stays present. let mut table = TomlMap::new(); merge_section(&mut table, "session", &cfg.session); let session = table.get("session").unwrap().as_table().unwrap(); assert_eq!( session.get("load_envrc").and_then(|v| v.as_bool()), Some(false), "explicit load_envrc = false must survive a save" ); } #[test] fn merge_section_empty_struct_preserves_existing_section() { let mut table = TomlMap::new(); let mut harness = TomlMap::new(); harness.insert("custom_key".into(), TomlValue::Boolean(true)); harness.insert("another_key".into(), TomlValue::String("value".into())); table.insert("harness".into(), TomlValue::Table(harness)); // HarnessConfig has all-Option fields; default serializes to empty table let cfg = crate::agent::config::HarnessConfig::default(); merge_section(&mut table, "harness", &cfg); let harness = table.get("harness").unwrap().as_table().unwrap(); assert_eq!( harness.get("custom_key").and_then(|v| v.as_bool()), Some(true), "existing fields must survive when struct serializes empty" ); assert_eq!( harness.get("another_key").and_then(|v| v.as_str()), Some("value"), ); } #[test] fn ui_config_round_trip_preserves_pager_fields() { let toml_str = r#" [ui] yolo = true show_timestamps = false auto_dark_theme = "tokyonight" auto_light_theme = "kigiday" "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); let cfg = load_config_from_toml(&root); assert!(cfg.ui.yolo); assert_eq!(cfg.ui.show_timestamps, Some(false)); assert_eq!(cfg.ui.auto_dark_theme.as_deref(), Some("tokyonight")); assert_eq!(cfg.ui.auto_light_theme.as_deref(), Some("kigiday")); // Simulate save_config: serialize back through merge_section let mut table = root.as_table().unwrap().clone(); merge_section(&mut table, "ui", &cfg.ui); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("show_timestamps").and_then(|v| v.as_bool()), Some(false) ); assert_eq!( ui.get("auto_dark_theme").and_then(|v| v.as_str()), Some("tokyonight") ); assert_eq!( ui.get("auto_light_theme").and_then(|v| v.as_str()), Some("kigiday") ); assert_eq!(ui.get("yolo").and_then(|v| v.as_bool()), Some(true)); } #[test] fn ui_config_hunk_tracker_mode_round_trips() { // Parse from `[ui].hunk_tracker_mode`... let root: TomlValue = toml::from_str("[ui]\nhunk_tracker_mode = \"off\"\n").unwrap(); let cfg = load_config_from_toml(&root); assert_eq!(cfg.ui.hunk_tracker_mode.as_deref(), Some("off")); // ...and serialize back through merge_section. let mut table = root.as_table().unwrap().clone(); merge_section(&mut table, "ui", &cfg.ui); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("hunk_tracker_mode").and_then(|v| v.as_str()), Some("off") ); // Default (None) is skipped on the wire — "not set". let serialized = TomlValue::try_from(crate::agent::config::UiConfig::default()).unwrap(); assert!( serialized .as_table() .unwrap() .get("hunk_tracker_mode") .is_none(), "hunk_tracker_mode=None must not appear in serialized output" ); } #[test] fn ui_config_serialization_behavior() { let cfg = crate::agent::config::UiConfig::default(); let val = TomlValue::try_from(&cfg).unwrap(); let table = val.as_table().unwrap(); // Non-Option fields always serialize (even at default) so merge_section // can overwrite stale values in the file. assert!( table.get("yolo").is_some(), "yolo must always serialize so revert-to-default persists" ); assert!( table.get("compact_mode").is_some(), "compact_mode must always serialize so revert-to-default persists" ); assert!( table.get("max_thoughts_width").is_some(), "max_thoughts_width must always serialize so revert-to-default persists" ); // Option fields at None are skipped — they represent "not set". assert!( table.get("show_timestamps").is_none(), "show_timestamps=None should not appear in serialized output" ); assert!( table.get("auto_dark_theme").is_none(), "auto_dark_theme=None should not appear in serialized output" ); assert!( table.get("theme").is_none(), "theme=None should not appear in serialized output" ); } /// The settings-modal helpers in the parent module are 3-line /// wrappers around `update_config(|cfg| cfg.ui. = ...)`. To /// guard against future drift between the wrapper and the schema /// field, this test simulates each helper's closure against an /// in-memory `Config` and asserts the field was set correctly. We /// deliberately avoid disk I/O so the test stays hermetic. /// /// The pattern mirrors exactly what `update_config` does internally: /// `let mut cfg = load_config_from_toml(...); f(&mut cfg);`. #[test] fn merge_section_full_save_config_simulation() { // Simulate the full save_config flow: existing config with pager-written // fields, load it, modify an unrelated field, save back. let original = r#" [ui] show_timestamps = true auto_dark_theme = "tokyonight" auto_light_theme = "kigiday" [models] default = "kigi-3" [cli] auto_update = true "#; let root: TomlValue = toml::from_str(original).unwrap(); let mut cfg = load_config_from_toml(&root); // User changes default model (unrelated to UI) cfg.models.default = Some("kigi-4".to_string()); // Simulate save_config let mut table = root.as_table().unwrap().clone(); merge_section(&mut table, "cli", &cfg.cli); merge_section(&mut table, "models", &cfg.models); merge_section(&mut table, "ui", &cfg.ui); merge_section(&mut table, "harness", &cfg.harness); // Verify pager fields survived let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("show_timestamps").and_then(|v| v.as_bool()), Some(true) ); assert_eq!( ui.get("auto_dark_theme").and_then(|v| v.as_str()), Some("tokyonight") ); assert_eq!( ui.get("auto_light_theme").and_then(|v| v.as_str()), Some("kigiday") ); // Verify the model change went through let models = table.get("models").unwrap().as_table().unwrap(); assert_eq!( models.get("default").and_then(|v| v.as_str()), Some("kigi-4") ); } #[test] fn merge_section_revert_to_default_overwrites_old_value() { // Regression test: setting a modeled field back to its // default must persist (overwrite the old non-default value). let mut table = TomlMap::new(); let mut ui = TomlMap::new(); ui.insert("yolo".into(), TomlValue::Boolean(true)); ui.insert("compact_mode".into(), TomlValue::Boolean(true)); table.insert("ui".into(), TomlValue::Table(ui)); // Revert both to false (their defaults) let cfg = crate::agent::config::UiConfig::default(); merge_section(&mut table, "ui", &cfg); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("yolo").and_then(|v| v.as_bool()), Some(false), "yolo=false must overwrite the old yolo=true" ); assert_eq!( ui.get("compact_mode").and_then(|v| v.as_bool()), Some(false), "compact_mode=false must overwrite the old compact_mode=true" ); } #[test] fn merge_section_replaces_non_table_section() { let mut table = TomlMap::new(); table.insert("ui".into(), TomlValue::String("garbage".into())); let cfg = crate::agent::config::UiConfig { yolo: true, ..Default::default() }; merge_section(&mut table, "ui", &cfg); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("yolo").and_then(|v| v.as_bool()), Some(true), "non-table section should be replaced with proper table" ); } #[test] fn models_config_serializes_only_some_fields() { let m = crate::agent::config::ModelsConfig { default: Some("kigi-3".to_string()), ..Default::default() }; let v = TomlValue::try_from(&m).expect("serialize ModelsConfig"); if let TomlValue::Table(t) = v { assert_eq!(t.len(), 1); assert!(t.contains_key("default")); assert!(!t.contains_key("session_summary")); assert!(!t.contains_key("image_description")); assert!(!t.contains_key("hidden_models")); assert!(!t.contains_key("disabled_models")); assert!(!t.contains_key("allowed_models")); assert!(!t.contains_key("agent_type")); assert_eq!(t.get("default").and_then(|x| x.as_str()), Some("kigi-3")); } else { panic!("expected table from serialization"); } } /// Canonical list of every `Option` field in [`CliConfig`]. Kept in one /// place so both serialization and merge-section tests automatically cover /// newly-added fields without copy-pasting assertion lists. const CLI_CONFIG_OPTION_FIELDS: &[&str] = &[ "auto_update", "dismissed_version", "installer", "npm_registry", "channel", "use_leader", "show_tips", "worktree_type", "session_registry", "minimum_version", ]; /// Assert that every `CliConfig` `Option` field NOT in `present` is /// absent from `table`. fn assert_cli_option_fields_absent(table: &TomlMap, present: &[&str]) { for field in CLI_CONFIG_OPTION_FIELDS { if !present.contains(field) { assert!( !table.contains_key(*field), "expected Option field `{field}` to be absent when not set", ); } } } #[test] fn cli_config_serializes_only_some_fields() { let c = crate::agent::config::CliConfig { auto_update: Some(true), channel: Some("beta".to_string()), ..Default::default() }; let v = TomlValue::try_from(&c).expect("serialize CliConfig"); if let TomlValue::Table(t) = v { assert_eq!(t.len(), 2); assert!(t.contains_key("auto_update")); assert!(t.contains_key("channel")); assert_cli_option_fields_absent(&t, &["auto_update", "channel"]); assert_eq!(t.get("auto_update").and_then(|x| x.as_bool()), Some(true)); assert_eq!(t.get("channel").and_then(|x| x.as_str()), Some("beta")); } else { panic!("expected table from serialization"); } } #[test] fn merge_section_cli_only_updates_set_fields_preserves_unmodeled() { let mut table = TomlMap::new(); let mut cli = TomlMap::new(); cli.insert("use_leader".into(), TomlValue::Boolean(true)); cli.insert("show_tips".into(), TomlValue::Boolean(false)); cli.insert( "custom_pager_key".into(), TomlValue::String("keep-this".into()), ); table.insert("cli".into(), TomlValue::Table(cli)); let cfg = crate::agent::config::CliConfig { auto_update: Some(false), dismissed_version: Some("v1.2.3".to_string()), ..Default::default() }; merge_section(&mut table, "cli", &cfg); let c = table.get("cli").unwrap().as_table().unwrap(); assert_eq!(c.get("auto_update").and_then(|v| v.as_bool()), Some(false)); assert_eq!( c.get("dismissed_version").and_then(|v| v.as_str()), Some("v1.2.3") ); assert_eq!(c.get("use_leader").and_then(|v| v.as_bool()), Some(true)); assert_eq!(c.get("show_tips").and_then(|v| v.as_bool()), Some(false)); assert_eq!( c.get("custom_pager_key").and_then(|v| v.as_str()), Some("keep-this") ); assert_cli_option_fields_absent( c, &[ "auto_update", "dismissed_version", "use_leader", "show_tips", ], ); } #[test] fn merge_section_models_only_updates_set_fields_preserves_others() { let mut table = TomlMap::new(); let mut models = TomlMap::new(); models.insert( "session_summary".into(), TomlValue::String("old-title".into()), ); models.insert("unmodeled_foo".into(), TomlValue::String("keep-me".into())); table.insert("models".into(), TomlValue::Table(models)); let cfg = crate::agent::config::ModelsConfig { default: Some("kigi-new".to_string()), ..Default::default() }; merge_section(&mut table, "models", &cfg); let m = table.get("models").unwrap().as_table().unwrap(); assert_eq!(m.get("default").and_then(|v| v.as_str()), Some("kigi-new")); assert_eq!( m.get("session_summary").and_then(|v| v.as_str()), Some("old-title") ); assert_eq!( m.get("unmodeled_foo").and_then(|v| v.as_str()), Some("keep-me") ); assert!(!m.contains_key("image_description")); } #[test] fn persist_preferred_model_flow_roundtrips_via_load_and_new_from_toml_cfg() { let original = "[models]\ndefault = \"kigi-old\"\n"; let root: TomlValue = toml::from_str(original).unwrap(); let mut cfg = load_config_from_toml(&root); cfg.models.default = Some("kigi-persisted".to_string()); let mut table = if let TomlValue::Table(t) = root { t } else { TomlMap::new() }; merge_section(&mut table, "models", &cfg.models); let reloaded_root = TomlValue::Table(table); let reloaded = load_config_from_toml(&reloaded_root); assert_eq!(reloaded.models.default.as_deref(), Some("kigi-persisted")); let cfg2 = crate::agent::config::Config::new_from_toml_cfg(&reloaded_root) .expect("new_from_toml_cfg"); assert_eq!(cfg2.models.default.as_deref(), Some("kigi-persisted")); } // ── merge_section pin tests for CLI/session setters ────────────────── // // Pin the schema-level write shape: each setter writes to the correct // TOML section, and `None` fields don't serialize (skip_serializing_if). #[test] fn merge_section_cli_show_tips_writes_under_cli_section() { let mut table = TomlMap::new(); let cfg = crate::agent::config::CliConfig { show_tips: Some(false), ..Default::default() }; merge_section(&mut table, "cli", &cfg); let c = table.get("cli").unwrap().as_table().unwrap(); assert_eq!( c.get("show_tips").and_then(|v| v.as_bool()), Some(false), "set_show_tips must persist Some(false) at `[cli].show_tips`" ); } #[test] fn merge_section_cli_show_tips_none_does_not_serialize() { // `None` fields must not serialize (skip_serializing_if invariant). let mut table = TomlMap::new(); let cfg = crate::agent::config::CliConfig::default(); assert!(cfg.show_tips.is_none()); merge_section(&mut table, "cli", &cfg); if let Some(c) = table.get("cli").and_then(|v| v.as_table()) { assert!( c.get("show_tips").is_none(), "default show_tips: None must not serialize — \ managed-config layering depends on absent-means-defer" ); } } #[test] fn merge_section_cli_session_picker_grouped_writes_under_cli_section() { let mut table = TomlMap::new(); let cfg = crate::agent::config::CliConfig { session_picker_grouped: Some(false), ..Default::default() }; merge_section(&mut table, "cli", &cfg); let c = table.get("cli").unwrap().as_table().unwrap(); assert_eq!( c.get("session_picker_grouped").and_then(|v| v.as_bool()), Some(false), "Some(false) must round-trip to `[cli].session_picker_grouped`" ); } #[test] fn merge_section_cli_auto_update_writes_under_cli_section() { let mut table = TomlMap::new(); let cfg = crate::agent::config::CliConfig { auto_update: Some(false), ..Default::default() }; merge_section(&mut table, "cli", &cfg); let c = table.get("cli").unwrap().as_table().unwrap(); assert_eq!( c.get("auto_update").and_then(|v| v.as_bool()), Some(false), "set_auto_update must persist Some(false) at `[cli].auto_update`" ); } #[test] fn merge_section_cli_use_leader_writes_under_cli_section() { let mut table = TomlMap::new(); let cfg = crate::agent::config::CliConfig { use_leader: Some(true), ..Default::default() }; merge_section(&mut table, "cli", &cfg); let c = table.get("cli").unwrap().as_table().unwrap(); assert_eq!( c.get("use_leader").and_then(|v| v.as_bool()), Some(true), "Some(true) must round-trip to `[cli].use_leader`" ); } /// Verify `Option` + `skip_serializing_if` prevents one /// `[session]` field from dragging unrelated fields. #[test] fn merge_section_session_load_envrc_writes_under_session_section() { let mut table = TomlMap::new(); let cfg = crate::agent::config::SessionConfig { load_envrc: Some(false), ..Default::default() }; merge_section(&mut table, "session", &cfg); let s = table.get("session").unwrap().as_table().unwrap(); assert_eq!( s.get("load_envrc").and_then(|v| v.as_bool()), Some(false), "Some(false) must round-trip to `[session].load_envrc`" ); } /// Committing `load_envrc` alone must not inject `auto_compact_threshold_percent`. #[test] fn merge_section_session_load_envrc_does_not_drag_auto_compact() { let mut table = TomlMap::new(); let cfg = crate::agent::config::SessionConfig { load_envrc: Some(true), auto_compact_threshold_percent: None, }; merge_section(&mut table, "session", &cfg); let s = table.get("session").unwrap().as_table().unwrap(); assert_eq!(s.get("load_envrc").and_then(|v| v.as_bool()), Some(true)); assert!( s.get("auto_compact_threshold_percent").is_none(), "default auto_compact_threshold_percent: None must not serialize \ when only load_envrc is being committed" ); } // ── resolve_auto_compact_threshold_percent: precedence matrix ────────── // // Covers every boundary in the resolver chain: // env > user [model.] > user [session] > GB per-model > GB global > 85 // // Env-var tests share a process-wide mutex to avoid set_var races. mod resolve_auto_compact { use super::super::super::RemoteSettings; use super::super::super::resolve::{ DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT, ENV_AUTO_COMPACT_THRESHOLD_PERCENT, resolve_auto_compact_threshold_percent, }; use crate::agent::config::{Config, ConfigModelOverride, ModelInfo}; use std::sync::Mutex; const TEST_MODEL: &str = "kigi-4.5"; const OTHER_MODEL: &str = "kigi-4.3"; /// Serialize tests that mutate `KIGI_AUTO_COMPACT_THRESHOLD_PERCENT`. static ENV_LOCK: Mutex<()> = Mutex::new(()); /// Build a `Config` populated with optional per-source values for the /// `TEST_MODEL`. Any `None` argument means "that source is unset". fn make_cfg( user_session: Option, user_per_model: Option, gb_global: Option, ) -> Config { let mut cfg = Config::default(); cfg.session.auto_compact_threshold_percent = user_session; if let Some(v) = user_per_model { cfg.config_models.insert( TEST_MODEL.to_owned(), ConfigModelOverride { auto_compact_threshold_percent: Some(v), ..ConfigModelOverride::default() }, ); } if let Some(v) = gb_global { cfg.remote_settings = Some(RemoteSettings { auto_compact_threshold_percent: Some(v), ..RemoteSettings::default() }); } cfg } /// ModelInfo populated with the GB per-model value (or none). fn model_info(gb_per_model: Option) -> ModelInfo { let mut info = ModelInfo::fallback(TEST_MODEL); info.auto_compact_threshold_percent = gb_per_model; info } /// Run the resolver against the assembled inputs. fn resolve(cfg: &Config, gb_per_model: Option) -> u8 { let info = model_info(gb_per_model); resolve_auto_compact_threshold_percent(cfg, TEST_MODEL, Some(&info)) } /// RAII guard that swaps the env var for the duration of a test and /// restores the previous value on drop. Acquires `ENV_LOCK` so two /// env-var tests never run concurrently. struct EnvVarGuard { _lock: std::sync::MutexGuard<'static, ()>, prev: Option, } impl EnvVarGuard { fn set(value: &str) -> Self { let lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let prev = std::env::var(ENV_AUTO_COMPACT_THRESHOLD_PERCENT).ok(); // SAFETY: serialized via ENV_LOCK; tests in this module never // observe each other's writes mid-flight. unsafe { std::env::set_var(ENV_AUTO_COMPACT_THRESHOLD_PERCENT, value) }; Self { _lock: lock, prev } } fn unset() -> Self { let lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let prev = std::env::var(ENV_AUTO_COMPACT_THRESHOLD_PERCENT).ok(); unsafe { std::env::remove_var(ENV_AUTO_COMPACT_THRESHOLD_PERCENT) }; Self { _lock: lock, prev } } } impl Drop for EnvVarGuard { fn drop(&mut self) { match self.prev.take() { Some(v) => unsafe { std::env::set_var(ENV_AUTO_COMPACT_THRESHOLD_PERCENT, v) }, None => unsafe { std::env::remove_var(ENV_AUTO_COMPACT_THRESHOLD_PERCENT) }, } } } // ── Tier 6: default (all unset) ───────────────────────────────── #[test] fn all_unset_returns_default_85() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(None, None, None); assert_eq!(resolve(&cfg, None), DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT); } #[test] fn all_unset_no_model_info_returns_default_85() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(None, None, None); assert_eq!( resolve_auto_compact_threshold_percent(&cfg, TEST_MODEL, None), DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT ); } // ── Tier 5: GB global ─────────────────────────────────────────── #[test] fn gb_global_only() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(None, None, Some(40)); assert_eq!(resolve(&cfg, None), 40); } // ── Tier 4 > Tier 5: GB per-model beats GB global ─────────────── #[test] fn gb_per_model_beats_gb_global() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(None, None, Some(40)); assert_eq!(resolve(&cfg, Some(90)), 90); } // ── Tier 3 > Tier 4: user global beats GB per-model ───────────── #[test] fn user_session_beats_gb_per_model() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(Some(75), None, None); assert_eq!(resolve(&cfg, Some(90)), 75); } #[test] fn user_session_beats_gb_global() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(Some(75), None, Some(40)); assert_eq!(resolve(&cfg, None), 75); } // ── Tier 2 > Tier 3: user per-model beats user global ─────────── #[test] fn user_per_model_beats_user_session() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(Some(75), Some(70), None); assert_eq!(resolve(&cfg, None), 70); } #[test] fn user_per_model_beats_gb_per_model() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(None, Some(70), None); assert_eq!(resolve(&cfg, Some(90)), 70); } #[test] fn user_per_model_beats_gb_global() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(None, Some(70), Some(40)); assert_eq!(resolve(&cfg, None), 70); } #[test] fn user_per_model_beats_everything_below_env() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(Some(75), Some(70), Some(40)); assert_eq!(resolve(&cfg, Some(90)), 70); } // ── Tier 1: env wins over everything ──────────────────────────── #[test] fn env_beats_user_per_model() { let _g = EnvVarGuard::set("50"); let cfg = make_cfg(Some(75), Some(70), Some(40)); assert_eq!(resolve(&cfg, Some(90)), 50); } #[test] fn env_at_lower_bound_is_honored() { let _g = EnvVarGuard::set("0"); let cfg = make_cfg(Some(75), None, None); assert_eq!(resolve(&cfg, None), 0); } #[test] fn env_at_upper_bound_is_honored() { let _g = EnvVarGuard::set("100"); let cfg = make_cfg(Some(75), None, None); assert_eq!(resolve(&cfg, None), 100); } // ── Env-tier failure modes fall through ───────────────────────── #[test] fn env_out_of_range_high_falls_through() { let _g = EnvVarGuard::set("101"); let cfg = make_cfg(Some(75), None, None); assert_eq!(resolve(&cfg, None), 75); } #[test] fn env_out_of_range_negative_falls_through() { let _g = EnvVarGuard::set("-1"); let cfg = make_cfg(Some(75), None, None); assert_eq!(resolve(&cfg, None), 75); } #[test] fn env_unparseable_falls_through() { let _g = EnvVarGuard::set("not-a-number"); let cfg = make_cfg(Some(75), None, None); assert_eq!(resolve(&cfg, None), 75); } #[test] fn env_empty_falls_through_to_default() { let _g = EnvVarGuard::set(""); let cfg = make_cfg(None, None, None); assert_eq!(resolve(&cfg, None), DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT); } // ── Per-model entry for a DIFFERENT model must not match ──────── #[test] fn user_per_model_for_other_model_does_not_match() { let _g = EnvVarGuard::unset(); let mut cfg = Config::default(); cfg.session.auto_compact_threshold_percent = None; cfg.config_models.insert( OTHER_MODEL.to_owned(), ConfigModelOverride { auto_compact_threshold_percent: Some(70), ..ConfigModelOverride::default() }, ); assert_eq!(resolve(&cfg, None), DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT); } #[test] fn user_per_model_for_other_model_falls_through_to_user_session() { let _g = EnvVarGuard::unset(); let mut cfg = Config::default(); cfg.session.auto_compact_threshold_percent = Some(75); cfg.config_models.insert( OTHER_MODEL.to_owned(), ConfigModelOverride { auto_compact_threshold_percent: Some(70), ..ConfigModelOverride::default() }, ); assert_eq!(resolve(&cfg, None), 75); } // ── ModelInfo-less call still walks the rest of the chain ─────── #[test] fn missing_model_info_falls_through_to_gb_global() { let _g = EnvVarGuard::unset(); let cfg = make_cfg(None, None, Some(40)); assert_eq!( resolve_auto_compact_threshold_percent(&cfg, TEST_MODEL, None), 40 ); } // ── No remote_settings still works ────────────────────────────── #[test] fn no_remote_settings_falls_through_to_default() { let _g = EnvVarGuard::unset(); let cfg = Config { remote_settings: None, ..Config::default() }; assert_eq!(resolve(&cfg, None), DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT); } // ── ConfigModelOverride should NOT collapse into ModelInfo ────── // // Regression guard. If `ConfigModelOverride::apply` ever starts // merging `auto_compact_threshold_percent` into `ModelInfo`, the // resolver would see user-per-model as GB-per-model and ordering // between "user per-model" and "user global" would collapse. #[test] fn apply_does_not_merge_auto_compact_threshold_percent_into_model_info() { use crate::agent::config::{EndpointsConfig, ModelEntry}; let endpoints = EndpointsConfig::default(); let base = ModelEntry::fallback(TEST_MODEL, &endpoints); let over = ConfigModelOverride { auto_compact_threshold_percent: Some(42), ..ConfigModelOverride::default() }; let merged = over.apply(TEST_MODEL, Some(base), &endpoints); assert_eq!( merged.info.auto_compact_threshold_percent, None, "ConfigModelOverride::apply must NOT merge `auto_compact_threshold_percent` \ into ModelInfo — the resolver depends on the field staying empty so \ user-per-model and GB-per-model remain distinguishable tiers" ); } } #[test] fn settings_helpers_target_correct_ui_fields() { fn apply(f: F) -> Config { let mut cfg = load_config_from_toml(&TomlValue::Table(TomlMap::new())); f(&mut cfg); cfg } // set_compact_mode wraps `cfg.ui.compact_mode = value` (plain bool). let cfg = apply(|cfg| cfg.ui.compact_mode = true); assert!(cfg.ui.compact_mode, "set_compact_mode must set bool field"); let cfg = apply(|cfg| cfg.ui.compact_mode = false); assert!(!cfg.ui.compact_mode); // set_show_timestamps wraps `cfg.ui.show_timestamps = Some(value)`. let cfg = apply(|cfg| cfg.ui.show_timestamps = Some(true)); assert_eq!(cfg.ui.show_timestamps, Some(true)); let cfg = apply(|cfg| cfg.ui.show_timestamps = Some(false)); assert_eq!(cfg.ui.show_timestamps, Some(false)); // set_simple_mode wraps `cfg.ui.simple_mode = Some(value)`. let cfg = apply(|cfg| cfg.ui.simple_mode = Some(true)); assert_eq!(cfg.ui.simple_mode, Some(true)); let cfg = apply(|cfg| cfg.ui.simple_mode = Some(false)); assert_eq!(cfg.ui.simple_mode, Some(false)); // set_theme wraps `cfg.ui.theme = Some(value)` (canonical name). let cfg = apply(|cfg| cfg.ui.theme = Some("tokyonight".to_string())); assert_eq!(cfg.ui.theme, Some("tokyonight".to_string())); let cfg = apply(|cfg| cfg.ui.theme = Some("auto".to_string())); assert_eq!(cfg.ui.theme, Some("auto".to_string())); // set_auto_dark_theme / set_auto_light_theme wrap // `cfg.ui.auto_{dark,light}_theme = Some(value)`. let cfg = apply(|cfg| cfg.ui.auto_dark_theme = Some("tokyonight".to_string())); assert_eq!(cfg.ui.auto_dark_theme, Some("tokyonight".to_string())); let cfg = apply(|cfg| cfg.ui.auto_light_theme = Some("kigiday".to_string())); assert_eq!(cfg.ui.auto_light_theme, Some("kigiday".to_string())); // set_hunk_tracker_mode wraps `cfg.ui.hunk_tracker_mode = Some(value)`. let cfg = apply(|cfg| cfg.ui.hunk_tracker_mode = Some("off".to_string())); assert_eq!(cfg.ui.hunk_tracker_mode, Some("off".to_string())); // `[ui].screen_mode` is a manual config.toml preference (CLI flags do // not write it); ensure the field still round-trips through merge. let cfg = apply(|cfg| cfg.ui.screen_mode = Some("minimal".to_string())); assert_eq!(cfg.ui.screen_mode, Some("minimal".to_string())); let cfg = apply(|cfg| cfg.ui.screen_mode = Some("fullscreen".to_string())); assert_eq!(cfg.ui.screen_mode, Some("fullscreen".to_string())); } /// Theme merge round-trip: verifies the theme field is set and /// unmodeled fields survive. Same pattern as `set_compact_mode_round_trips`. #[test] fn set_theme_round_trips_through_merge() { let original = r#" [ui] compact_mode = true theme = "kiginight" auto_dark_theme = "tokyonight" custom_user_key = "preserve-me" "#; let root: TomlValue = toml::from_str(original).unwrap(); let mut cfg = load_config_from_toml(&root); cfg.ui.theme = Some("tokyonight".to_string()); let mut table = root.as_table().unwrap().clone(); merge_section(&mut table, "ui", &cfg.ui); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("theme").and_then(|v| v.as_str()), Some("tokyonight"), "theme must be set" ); assert_eq!( ui.get("compact_mode").and_then(|v| v.as_bool()), Some(true), "unrelated modeled field must survive" ); assert_eq!( ui.get("auto_dark_theme").and_then(|v| v.as_str()), Some("tokyonight"), "auto_dark_theme must survive" ); assert_eq!( ui.get("custom_user_key").and_then(|v| v.as_str()), Some("preserve-me"), "unmodeled field must survive" ); } /// Same as above but for `set_auto_dark_theme` and `set_auto_light_theme`. #[test] fn set_auto_dark_and_light_theme_round_trip_through_merge() { let original = r#" [ui] theme = "auto" auto_dark_theme = "kiginight" auto_light_theme = "kigiday" custom_unknown_key = 42 "#; let root: TomlValue = toml::from_str(original).unwrap(); let mut cfg = load_config_from_toml(&root); cfg.ui.auto_dark_theme = Some("tokyonight".to_string()); cfg.ui.auto_light_theme = Some("rosepine-moon".to_string()); let mut table = root.as_table().unwrap().clone(); merge_section(&mut table, "ui", &cfg.ui); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("auto_dark_theme").and_then(|v| v.as_str()), Some("tokyonight"), ); assert_eq!( ui.get("auto_light_theme").and_then(|v| v.as_str()), Some("rosepine-moon"), ); assert_eq!( ui.get("theme").and_then(|v| v.as_str()), Some("auto"), "theme=auto must survive" ); assert_eq!( ui.get("custom_unknown_key").and_then(|v| v.as_integer()), Some(42), "unmodeled field must survive" ); } /// Compact-mode merge round-trip: flipped field persists, /// unrelated modeled and unmodeled fields survive. #[test] fn set_compact_mode_round_trips_through_merge() { let original = r#" [ui] compact_mode = false auto_dark_theme = "tokyonight" show_timestamps = true custom_user_key = "preserve-me" "#; let root: TomlValue = toml::from_str(original).unwrap(); let mut cfg = load_config_from_toml(&root); cfg.ui.compact_mode = true; let mut table = root.as_table().unwrap().clone(); merge_section(&mut table, "ui", &cfg.ui); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("compact_mode").and_then(|v| v.as_bool()), Some(true), "compact_mode must be flipped" ); assert_eq!( ui.get("auto_dark_theme").and_then(|v| v.as_str()), Some("tokyonight"), "unrelated UI field must survive" ); assert_eq!( ui.get("show_timestamps").and_then(|v| v.as_bool()), Some(true), "show_timestamps must survive" ); assert_eq!( ui.get("custom_user_key").and_then(|v| v.as_str()), Some("preserve-me"), "unmodeled (unknown to the UiConfig schema) field must survive — \ this is the merge_section invariant the new helpers depend on" ); } /// Same merge round-trip for `show_timestamps` and `simple_mode`. #[test] fn set_show_timestamps_and_simple_mode_round_trip_through_merge() { let original = r#" [ui] compact_mode = true custom_unknown_key = 42 "#; let root: TomlValue = toml::from_str(original).unwrap(); let mut cfg = load_config_from_toml(&root); cfg.ui.show_timestamps = Some(false); cfg.ui.simple_mode = Some(false); let mut table = root.as_table().unwrap().clone(); merge_section(&mut table, "ui", &cfg.ui); let ui = table.get("ui").unwrap().as_table().unwrap(); assert_eq!( ui.get("show_timestamps").and_then(|v| v.as_bool()), Some(false), ); assert_eq!(ui.get("simple_mode").and_then(|v| v.as_bool()), Some(false)); assert_eq!( ui.get("compact_mode").and_then(|v| v.as_bool()), Some(true), "unrelated modeled field must survive" ); assert_eq!( ui.get("custom_unknown_key").and_then(|v| v.as_integer()), Some(42), "unmodeled (unknown to the schema) field must survive" ); } }