Root cause of 'model+effort switch works on Mac, not on Windows': the switch APPLIES in-session (the dispatch/apply chain is platform-identical, verified adversarially) but its persistence never sticks on Windows. Every tmp+rename atomic write except auth/storage.rs committed with a bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a sharing violation whenever AV/search-indexer/cloud-sync transiently holds the destination open. Consequences: [models].default never persisted (next launch = original model), the session summary's current model never persisted (resume = original model), and the models cache went silently stale (all its write errors were swallowed). - New kigi_shell_base::util::fs::replace_file — THE commit step for tmp+rename: plain rename on Unix; on Windows delete-first + two short backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on failure, error always returned. Windows branch type-checked against x86_64-pc-windows-msvc. - Adopted at every replace site: config.toml (save_config / atomic_write_string / mcp saves), models cache (plus unique tmp suffixes and tracing::warn on failure — writes were fully silent), session storage (summary/current-model, jsonl, plan/signals/ announcement/goal/graph state), auth.json, active-sessions registry, prompt history, claude/kimi import, campaigns state, goal artifacts. Directory-move renames (worktree pool, corrupt-file backups) keep plain rename — their destinations don't pre-exist. Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean, msvc-target check of the new cfg(windows) code clean.
1626 lines
62 KiB
Rust
1626 lines
62 KiB
Rust
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::<TomlValue>(&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<u32> = 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<u32> = 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<String> {
|
|
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<u32> = 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<u32> = 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<String, TomlValue>,
|
|
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<String, TomlValue>,
|
|
incoming: TomlMap<String, TomlValue>,
|
|
) {
|
|
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<T: serde::Serialize>(
|
|
table: &mut TomlMap<String, TomlValue>,
|
|
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: 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<String, TomlValue> = 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<String, TomlValue> = 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<bool>` 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.<field> = ...)`. 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<T>` 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<T>` field NOT in `present` is
|
|
/// absent from `table`.
|
|
fn assert_cli_option_fields_absent(table: &TomlMap<String, TomlValue>, 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<bool>` + `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.<id>] > 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<u8>,
|
|
user_per_model: Option<u8>,
|
|
gb_global: Option<u8>,
|
|
) -> 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<u8>) -> 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>) -> 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<String>,
|
|
}
|
|
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: FnOnce(&mut Config)>(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"
|
|
);
|
|
}
|
|
}
|