Skip to main content

mermaid_cli/app/
config.rs

1//! Loading, layering, merging and persisting [`mermaid_domain::Config`].
2//!
3//! The types themselves live in `src/domain/config.rs` — see that module for
4//! why. This half is the impure one: it reads files, walks the layer cascade
5//! (defaults < user < project < session flags) and writes back.
6
7use anyhow::{Context, Result};
8use directories::ProjectDirs;
9use std::path::PathBuf;
10
11use mermaid_model::constants::LEGACY_DEFAULT_MAX_TOKENS;
12use mermaid_model::models::ReasoningLevel;
13
14use mermaid_domain::config::*;
15
16/// Remove the `profiles` table from a raw user-config table and return it
17/// (empty when absent). `[profiles.<name>]` overlays must NEVER reach
18/// `Config` deserialization — they are a container of layer tables, not
19/// config keys — so every user-file read excises them before
20/// `finalize_config` (which would otherwise warn about unknown keys) and
21/// before any safety baseline is computed.
22fn take_profiles(table: &mut toml::Table) -> toml::Table {
23    match table.remove("profiles") {
24        Some(toml::Value::Table(profiles)) => profiles,
25        // A non-table `profiles` key is malformed; drop it (the profile
26        // lookup errors clearly when one was requested).
27        _ => toml::Table::new(),
28    }
29}
30
31/// Resolve `--profile <name>` against the user file's excised `[profiles.*]`
32/// table: the named overlay as a `Profile` layer, or a hard error naming the
33/// available profiles (sorted).
34fn resolve_profile_layer(
35    profiles: &toml::Table,
36    name: &str,
37    config_path: &std::path::Path,
38) -> Result<LayerSource> {
39    match profiles.get(name) {
40        Some(toml::Value::Table(overlay)) => Ok(LayerSource {
41            layer: ConfigLayer::Profile,
42            origin: format!("profile:{} ({})", name, config_path.display()),
43            table: overlay.clone(),
44        }),
45        Some(_) => anyhow::bail!(
46            "config profile '{}' is not a table; define it as [profiles.{}] in {}",
47            name,
48            name,
49            config_path.display()
50        ),
51        None => {
52            let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
53            available.sort_unstable();
54            if available.is_empty() {
55                anyhow::bail!(
56                    "no config profiles defined; add [profiles.{}] to {}",
57                    name,
58                    config_path.display()
59                );
60            }
61            anyhow::bail!(
62                "unknown config profile '{}'; available: {}",
63                name,
64                available.join(", ")
65            )
66        },
67    }
68}
69
70/// Load the user-scope configuration (defaults + the user file, no project or
71/// session layers). This is the view persistence baselines, the daemon, and
72/// runtime re-reads use — anything that must not observe another repo's
73/// project config or a one-off CLI flag.
74///
75/// # Errors
76///
77/// Resolving (and creating) the config dir, reading the user file, and
78/// deserializing the merged table into a typed [`Config`]. An absent config
79/// file is not an error — that is the defaults. Unknown keys are not either;
80/// they are collected as warnings, which this view discards.
81pub fn load_config() -> Result<Config> {
82    let config_path = get_config_path()?;
83    let mut table = read_config_table(&config_path)?;
84    migrate_legacy_max_tokens(&mut table);
85    migrate_legacy_model_profiles(&mut table);
86    let _ = take_profiles(&mut table);
87    Ok(finalize_config(table)?.0)
88}
89
90/// A completed layered load: the merged config plus the messages the startup
91/// path surfaces.
92pub struct LayeredLoad {
93    /// The merged, typed configuration.
94    pub config: Config,
95    /// Layer-attributed unknown-key and project-sanitizer warnings.
96    pub warnings: Vec<String>,
97    /// Informational lines (e.g. "using project config …").
98    pub notices: Vec<String>,
99}
100
101/// Load the full layered configuration:
102/// defaults < user file < project file < session flags.
103/// `cwd` locates the project layer (`<git-root>/.mermaid/config.toml`,
104/// sanitized + safety-clamped); pass `None` to skip it (daemon, tests).
105///
106/// # Errors
107///
108/// Everything [`load_config`] reports, plus a `flags.profile` that names no
109/// `[profiles.*]` table. A project layer that is missing, unreadable, or
110/// carries keys the sanitizer strips is not an error — the layer is skipped or
111/// clamped and the reason comes back in `warnings`, because a repo's config
112/// must never be able to abort someone's session.
113pub fn load_layered_config(
114    cwd: Option<&std::path::Path>,
115    flags: &SessionFlags,
116) -> Result<LayeredLoad> {
117    let config_path = get_config_path()?;
118    let mut user_table = read_config_table(&config_path)?;
119    migrate_legacy_max_tokens(&mut user_table);
120    migrate_legacy_model_profiles(&mut user_table);
121    // Excise [profiles.*] BEFORE anything deserializes the user table (the
122    // safety baseline below and finalize_config's unknown-key scan).
123    let profiles = take_profiles(&mut user_table);
124    let mut layers = vec![LayerSource {
125        layer: ConfigLayer::User,
126        origin: config_path.display().to_string(),
127        table: user_table.clone(),
128    }];
129    let mut sanitizer_warnings = Vec::new();
130    let mut notices = Vec::new();
131    if let Some(name) = flags.profile.as_deref() {
132        let layer = resolve_profile_layer(&profiles, name, &config_path)?;
133        notices.push(format!(
134            "using config profile '{}' (from {})",
135            name,
136            config_path.display()
137        ));
138        layers.push(layer);
139    }
140    if let Some(cwd) = cwd {
141        // The tighten-only safety clamp compares against the user-scope
142        // (defaults + user file) values.
143        let base_safety = finalize_config(user_table)?.0.safety;
144        let (layer, warnings, notice) =
145            super::project_config::load_project_layer(cwd, &base_safety);
146        sanitizer_warnings.extend(warnings);
147        notices.extend(notice);
148        if let Some(layer) = layer {
149            layers.push(layer);
150        }
151    }
152    layers.push(LayerSource {
153        layer: ConfigLayer::Session,
154        origin: "command line".to_string(),
155        table: session_flags_table(flags)?,
156    });
157    let (mut config, unknown_key_warnings) = merge_layers(layers)?;
158    config.active_profile = flags.profile.clone();
159    // Sanitizer warnings first: they explain keys that will also be absent
160    // from the merged result.
161    sanitizer_warnings.extend(unknown_key_warnings);
162    Ok(LayeredLoad {
163        config,
164        warnings: sanitizer_warnings,
165        notices,
166    })
167}
168
169/// The project-scoped view (defaults + user + project, NO session flags) for
170/// runtime re-reads keyed to a workdir — e.g. the memory settings consulted
171/// per operation. Never fails and never prints; warnings/notices were already
172/// surfaced by the startup load.
173#[must_use]
174pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
175    fn load(cwd: &std::path::Path) -> Result<Config> {
176        let config_path = get_config_path()?;
177        let mut user_table = read_config_table(&config_path)?;
178        migrate_legacy_max_tokens(&mut user_table);
179        migrate_legacy_model_profiles(&mut user_table);
180        let _ = take_profiles(&mut user_table);
181        let base_safety = finalize_config(user_table.clone())?.0.safety;
182        let mut layers = vec![LayerSource {
183            layer: ConfigLayer::User,
184            origin: config_path.display().to_string(),
185            table: user_table,
186        }];
187        let (layer, _warnings, _notice) =
188            super::project_config::load_project_layer(cwd, &base_safety);
189        if let Some(layer) = layer {
190            layers.push(layer);
191        }
192        Ok(merge_layers(layers)?.0)
193    }
194    load(cwd).unwrap_or_default()
195}
196
197/// Like [`load_config`] (user scope, no session flags) but never fails: on a
198/// malformed config, warn on stderr (secret-redacted, #F13) and fall back to
199/// defaults (#111). For standalone subcommands that only read user settings.
200#[must_use]
201pub fn load_config_or_warn() -> Config {
202    load_config().unwrap_or_else(|e| {
203        eprintln!(
204            "mermaid: {}",
205            mermaid_model::utils::redact_secrets(&format!("{e:#}"))
206        );
207        Config::default()
208    })
209}
210
211/// Read and parse one layer's TOML file; a missing file is an empty table.
212pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
213    if !path.exists() {
214        return Ok(toml::Table::new());
215    }
216    let raw = std::fs::read_to_string(path)
217        .with_context(|| format!("Failed to read {}", path.display()))?;
218    toml::from_str::<toml::Table>(&raw).with_context(|| {
219        format!(
220            "Failed to parse {}. Run 'mermaid init' to regenerate.",
221            path.display()
222        )
223    })
224}
225
226/// Deep-merge the layers in order (later wins) and deserialize the result
227/// once. Unknown-key warnings are collected per layer so each names the file
228/// (or flag set) that actually contains the typo.
229pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
230    let mut warnings = Vec::new();
231    let mut merged = toml::Table::new();
232    for layer in layers {
233        collect_layer_warnings(&layer, &mut warnings);
234        deep_merge(&mut merged, layer.table);
235    }
236    let (config, _) = finalize_config(merged)?;
237    Ok((config, warnings))
238}
239
240/// Run one layer's table through `serde_ignored` purely for warning
241/// attribution. A layer that fails to deserialize on its own contributes no
242/// warnings — the authoritative merged deserialize in `merge_layers` surfaces
243/// any real error (and a later layer may legitimately fix an earlier one's
244/// value).
245fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
246    let mut ignored = Vec::new();
247    let result: Result<Config, _> =
248        serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
249            ignored.push(path.to_string())
250        });
251    if result.is_ok() {
252        for path in ignored {
253            warnings.push(format!(
254                "unknown config key '{path}' in {} ({}) — check for a typo",
255                layer.layer.name(),
256                layer.origin
257            ));
258        }
259    }
260}
261
262/// Recursively merge `overlay` into `base`: tables merge key-by-key, while
263/// scalars and arrays replace wholesale (arrays are atomic values here — an
264/// element-wise merge could never express removing an entry). A kind conflict
265/// (table over scalar or vice versa) resolves to the overlay's value.
266fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
267    for (key, value) in overlay {
268        match (base.get_mut(&key), value) {
269            (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
270                deep_merge(base_table, overlay_table);
271            },
272            (_, value) => {
273                base.insert(key, value);
274            },
275        }
276    }
277}
278
279/// One-time migration for the AUTO output-budget change. Existing config files
280/// froze the old `default_model.max_tokens = 4096` default to disk (`save_config`
281/// serializes every field), which would otherwise pin the stale cap forever.
282/// Coerce that legacy value to `0` (AUTO) so upgraded users get the model-scaled
283/// budget. Applied to the on-disk table *before* CLI overrides, so an explicit
284/// `-c default_model.max_tokens=4096` still wins. The only unpreserved case is a
285/// user who hand-wrote exactly `4096` in config.toml — an unusual deliberate
286/// value, and AUTO is the better default regardless.
287fn migrate_legacy_max_tokens(table: &mut toml::Table) {
288    if let Some(dm) = table
289        .get_mut("default_model")
290        .and_then(|v| v.as_table_mut())
291        && dm.get("max_tokens").and_then(|v| v.as_integer())
292            == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
293    {
294        dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
295    }
296}
297
298/// Migrate the pre-profiles `[model_profiles]` table to its new name,
299/// `[model_aliases]` (the `profile` name now belongs to `--profile` config
300/// overlays). Runs wherever `migrate_legacy_max_tokens` runs: config loads
301/// stop warning immediately, and the next persist converges the file on
302/// disk. A file that somehow has BOTH tables keeps `model_aliases`.
303fn migrate_legacy_model_profiles(table: &mut toml::Table) {
304    if table.contains_key("model_aliases") {
305        table.remove("model_profiles");
306        return;
307    }
308    if let Some(profiles) = table.remove("model_profiles") {
309        table.insert("model_aliases".to_string(), profiles);
310    }
311}
312
313/// Deserialize a (possibly merged) config `Table` into `Config`, collecting the
314/// dotted paths of any keys `Config` doesn't recognize so the caller can warn.
315/// An empty table yields `Config::default()` (every field is `#[serde(default)]`).
316fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
317    let mut ignored = Vec::new();
318    let mut config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
319        ignored.push(path.to_string());
320    })
321    .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
322    // `plan` is a live session mode, not a persistent default: entering it
323    // allocates a plan file, which config loading has no session to do it for.
324    // `safety.mode = "plan"` would otherwise start a session that reports
325    // "planning" with no plan to write. Fall back to the default and let
326    // `/plan`, `/safety plan`, or Shift+Tab do the real thing. It is also what
327    // `mode_after_plan` reads, so this must never be `plan` itself.
328    if config.safety.mode.is_planning() {
329        config.safety.mode = SafetyConfig::default().mode;
330        ignored.push(
331            "safety.mode (plan is entered with /plan or Shift+Tab, not configured)".to_string(),
332        );
333    }
334    Ok((config, ignored))
335}
336
337/// Apply repeatable `-c KEY=VALUE` overrides onto a config table. `KEY` is a
338/// dotted path (`default_model.model`); `VALUE` is parsed as a TOML scalar so
339/// `true`/`3`/`"x"` keep their types, with a bare word treated as a string.
340fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
341    for raw in overrides {
342        let (key, val) = raw
343            .split_once('=')
344            .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
345        let key = key.trim();
346        if key.is_empty() {
347            anyhow::bail!("invalid -c override '{raw}' (empty key)");
348        }
349        deep_set(table, key, parse_override_value(val.trim()))?;
350    }
351    Ok(())
352}
353
354/// Parse an override value as a standalone TOML value, falling back to a plain
355/// string when it isn't valid TOML on its own (e.g. `ollama/qwen`).
356fn parse_override_value(s: &str) -> toml::Value {
357    toml::from_str::<toml::Table>(&format!("x = {s}"))
358        .ok()
359        .and_then(|t| t.get("x").cloned())
360        .unwrap_or_else(|| toml::Value::String(s.to_string()))
361}
362
363/// Set a dotted `key` path in `table` to `value`, creating intermediate
364/// tables. Dotted-path parsing means a `-c` override cannot address a map key
365/// that itself contains a dot (e.g. a `reasoning_per_model` model id) — a
366/// documented syntax limitation; internal persists use
367/// [`deep_set_segments`] directly and are immune.
368fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
369    let parts: Vec<&str> = key.split('.').collect();
370    deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
371}
372
373/// Set a pre-split `path` in `table` to `value`, creating intermediate tables.
374/// Segments are literal keys — a segment containing a dot addresses exactly
375/// that key (which dotted parsing cannot express).
376fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
377    let Some((leaf, parents)) = path.split_last() else {
378        anyhow::bail!("empty config key path");
379    };
380    let mut cur = table;
381    for part in parents {
382        let next = cur
383            .entry((*part).to_string())
384            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
385        cur = next
386            .as_table_mut()
387            .with_context(|| format!("'{part}' is not a table"))?;
388    }
389    cur.insert((*leaf).to_string(), value);
390    Ok(())
391}
392
393/// Remove a pre-split `path` from `table`. Returns whether a value was
394/// actually removed. Never creates intermediate tables; a missing parent
395/// simply means there was nothing to remove.
396pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
397    let Some((leaf, parents)) = path.split_last() else {
398        return false;
399    };
400    let mut cur = table;
401    for part in parents {
402        match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
403            Some(next) => cur = next,
404            None => return false,
405        }
406    }
407    cur.remove(*leaf).is_some()
408}
409
410/// Like [`load_layered_config`] but never fails — the startup entry point.
411/// On success, prints notices and layer-attributed warnings to stderr. On a
412/// malformed layer, warns (secret-redacted, #F13) and degrades: the session
413/// flags are re-applied over bare defaults so `--no-network`/`-c` survive a
414/// corrupt user file rather than being silently dropped with it.
415#[must_use]
416pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
417    match load_layered_config(cwd, flags) {
418        Ok(load) => {
419            for notice in &load.notices {
420                eprintln!("mermaid: {notice}");
421            }
422            for warning in &load.warnings {
423                eprintln!("mermaid: warning: {warning}");
424            }
425            load.config
426        },
427        Err(e) => {
428            // A TOML parse error renders the offending source line, which can be
429            // a secret-bearing one (`extra_headers`/`env`/`api_key_env`); scrub
430            // credential-shaped content before it reaches stderr (#F13).
431            eprintln!(
432                "mermaid: {}",
433                mermaid_model::utils::redact_secrets(&format!("{e:#}"))
434            );
435            session_flags_table(flags)
436                .ok()
437                .and_then(|table| finalize_config(table).ok())
438                .map(|(config, _)| config)
439                .unwrap_or_default()
440        },
441    }
442}
443
444/// Get the path to the single config file
445pub fn get_config_path() -> Result<PathBuf> {
446    Ok(get_config_dir()?.join("config.toml"))
447}
448
449/// Get the configuration directory
450///
451/// # Errors
452///
453/// Creating the directory, and — only on the fallback path, when the platform
454/// reports no config location — neither `HOME` nor `USERPROFILE` being set.
455/// The directory is created here, so an `Ok` path exists.
456pub fn get_config_dir() -> Result<PathBuf> {
457    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
458        let config_dir = proj_dirs.config_dir();
459        std::fs::create_dir_all(config_dir)?;
460        Ok(config_dir.to_path_buf())
461    } else {
462        // Fallback to home directory
463        let home = std::env::var("HOME")
464            .or_else(|_| std::env::var("USERPROFILE"))
465            .context("Could not determine home directory")?;
466        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
467        std::fs::create_dir_all(&config_dir)?;
468        Ok(config_dir)
469    }
470}
471
472/// Save a full configuration to file. Private on purpose: serializing the
473/// whole typed `Config` freezes every default (and would freeze merged
474/// project/session values) into the file, so the only legitimate callers are
475/// `init_config` (writing pristine defaults to an absent file) and tests.
476/// Runtime persistence goes through [`update_user_config_key`] /
477/// [`remove_user_config_key`], which rewrite only their own keys.
478fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
479    let path = if let Some(p) = path {
480        p
481    } else {
482        get_config_dir()?.join("config.toml")
483    };
484    write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
485}
486
487/// Write raw config bytes atomically and owner-only.
488///
489/// The config can carry literal secrets — `mcp_servers[].env`,
490/// `mcp_servers[].args`, `mcp_servers[].headers`, and
491/// `providers[].extra_headers` all accept inline credential values — so it
492/// must not be left world-readable, and a crash
493/// mid-write must not truncate it. Write atomically (temp → fsync → rename),
494/// creating the temp 0600 on Unix so the renamed file is never even briefly
495/// world-readable (this also tightens a pre-existing config, since the new
496/// file replaces the old one). Windows relies on the per-user profile ACL.
497fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
498    #[cfg(unix)]
499    mermaid_runtime::write_atomic_with_mode(path, bytes, 0o600)
500        .with_context(|| format!("Failed to write config to {}", path.display()))?;
501    #[cfg(not(unix))]
502    mermaid_runtime::write_atomic(path, bytes)
503        .with_context(|| format!("Failed to write config to {}", path.display()))?;
504    Ok(())
505}
506
507/// Create a default configuration file if it doesn't exist
508///
509/// # Errors
510///
511/// Resolving the config dir, serializing the defaults, and the write. An
512/// existing config is not an error and is never overwritten — the file is only
513/// written when it is absent.
514pub fn init_config() -> Result<()> {
515    let config_file = get_config_path()?;
516
517    if config_file.exists() {
518        println!("Configuration already exists at: {}", config_file.display());
519    } else {
520        let default_config = Config::default();
521        save_config(&default_config, Some(config_file.clone()))?;
522        println!("Created configuration at: {}", config_file.display());
523    }
524
525    Ok(())
526}
527
528/// Serializes the read-modify-write persistence path. The `persist_*` helpers
529/// run as concurrent detached tasks (dispatched by the effect runner) that all
530/// load → mutate → save the same file; without a lock two quick toggles
531/// (`/model` then Alt+T) can interleave their loads and lose one write. Held
532/// only across the synchronous fs work — never across an `.await`.
533static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
534
535/// Read the raw USER config table, apply `mutate`, and write it back — under
536/// `PERSIST_LOCK` so concurrent persists can't clobber each other. Operating
537/// on the raw table (never the merged typed `Config`) means a persist rewrites
538/// only its own keys: unknown keys survive, defaults are not frozen in, and
539/// project-layer or session-flag values can never leak into the user file.
540/// A malformed file propagates the parse error rather than being overwritten
541/// with defaults (#111).
542fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
543    update_user_config_table_at(&get_config_path()?, mutate)
544}
545
546/// [`update_user_config_table`] against an explicit path (test seam).
547fn update_user_config_table_at(
548    path: &std::path::Path,
549    mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
550) -> Result<()> {
551    let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
552    let mut table = read_config_table(path)?;
553    // Converge the on-disk legacy output cap while we're rewriting anyway.
554    migrate_legacy_max_tokens(&mut table);
555    migrate_legacy_model_profiles(&mut table);
556    mutate(&mut table)?;
557    write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
558}
559
560/// Set one key (pre-split path segments, so map keys containing dots — e.g.
561/// `reasoning_per_model."ollama/qwen3:8b"` — address correctly) in the USER
562/// config file, leaving every other key untouched.
563///
564/// # Errors
565///
566/// The read-modify-write of the user file: resolving the config dir, reading
567/// and parsing the existing TOML, re-serializing it, and the write. The write
568/// is atomic and 0600, so a failure leaves the previous config intact rather
569/// than a truncated or world-readable one. This is the error surface every
570/// `persist_*` helper below inherits.
571pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
572    update_user_config_table(|table| deep_set_segments(table, path, value))
573}
574
575/// Persist the whole `[plan]` table (the `/plan config` picker). Values the
576/// user set through the picker are explicit choices, so writing them —
577/// including ones that currently match defaults — is correct; unset Options
578/// stay absent via `skip_serializing_if`.
579///
580/// # Errors
581///
582/// Serializing `plan` to TOML, then [`update_user_config_key`]'s.
583pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
584    update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
585}
586
587/// Remove one key (pre-split path segments) from the USER config file.
588/// Returns whether the key existed.
589///
590/// # Errors
591///
592/// [`update_user_config_key`]'s. A key that was not there is `Ok(false)`, not
593/// an error.
594pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
595    let mut removed = false;
596    update_user_config_table(|table| {
597        removed = deep_remove_segments(table, path);
598        Ok(())
599    })?;
600    Ok(removed)
601}
602
603/// Persist the last used model to the user config file.
604///
605/// # Errors
606///
607/// [`update_user_config_key`]'s.
608pub fn persist_last_model(model: &str) -> Result<()> {
609    update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
610}
611
612/// Persist the TUI theme choice (`/theme dark|light`).
613///
614/// # Errors
615///
616/// [`update_user_config_key`]'s.
617pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
618    update_user_config_key(
619        &["ui", "theme"],
620        toml::Value::String(theme.as_str().to_string()),
621    )
622}
623
624/// Persist the user's default reasoning level. Used by the `/reasoning` slash
625/// command and the Alt+T cycle handler so the choice survives across sessions.
626///
627/// # Errors
628///
629/// Serializing `level`, then [`update_user_config_key`]'s.
630pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
631    update_user_config_key(
632        &["default_model", "reasoning"],
633        toml::Value::try_from(level)?,
634    )
635}
636
637/// Persist a reasoning level for a specific model ID
638/// (e.g. `<provider>/<model>`). The TUI calls this from Alt+T,
639/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
640/// the choice sticks per-model rather than bleeding into other models on
641/// next session start.
642///
643/// # Errors
644///
645/// Serializing `level`, then [`update_user_config_key`]'s.
646pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
647    update_user_config_key(
648        &["reasoning_per_model", model_id],
649        toml::Value::try_from(level)?,
650    )
651}
652
653/// Persist (or clear) a per-model Ollama `num_ctx` override. `Some(n)` sets it,
654/// `None` removes the entry (returning that model to auto-fit).
655///
656/// # Errors
657///
658/// [`update_user_config_key`]'s for `Some`, [`remove_user_config_key`]'s for
659/// `None` — the same read-modify-write either way. Clearing an entry that was
660/// not set is not an error.
661pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
662    match num_ctx {
663        Some(n) => update_user_config_key(
664            &["ollama_num_ctx_per_model", model_id],
665            toml::Value::Integer(i64::from(n)),
666        ),
667        None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
668    }
669}
670
671/// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
672///
673/// # Errors
674///
675/// [`update_user_config_key`]'s.
676pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
677    update_user_config_key(
678        &["ollama", "allow_ram_offload"],
679        toml::Value::Boolean(enabled),
680    )
681}
682
683/// Resolve which model to use: CLI arg > `last_used` > `[default_model]` > a
684/// local Ollama model > a configured provider's `default_model`.
685///
686/// # Errors
687///
688/// An alias that cannot be resolved, and the terminal case where nothing is
689/// pinned, no local Ollama model is installed, and no configured provider
690/// carries a `default_model` — that message offers both routes rather than
691/// demanding an Ollama install. A stopped or absent Ollama is not an error on
692/// its own: the local probe simply contributes nothing.
693pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
694    if let Some(model) = cli_model {
695        if let Some(resolved) = resolve_model_alias(model, config)? {
696            return Ok(resolved);
697        }
698        return Ok(model.to_string());
699    }
700    if let Some(last_model) = &config.last_used_model {
701        if let Some(resolved) = resolve_model_alias(last_model, config)? {
702            return Ok(resolved);
703        }
704        return Ok(last_model.clone());
705    }
706    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
707        return Ok(format!(
708            "{}/{}",
709            config.default_model.provider, config.default_model.name
710        ));
711    }
712    // Nothing pinned. Ollama is Mermaid's default backend, not a prerequisite:
713    // prefer a local model when one is installed, then a remote provider the
714    // user has given an explicit `default_model`, and only then give up — with
715    // a message that offers both routes instead of demanding an Ollama install
716    // from someone who set `ANTHROPIC_API_KEY` and never wanted local models.
717    let local = crate::ollama::local_models(config).await;
718    if let Some(first) = local.as_ref().and_then(|models| models.first()) {
719        return Ok(format!("ollama/{first}"));
720    }
721    if let Some(model_id) = configured_provider_default_model(config) {
722        return Ok(model_id);
723    }
724    Err(no_model_configured_error(config, local.is_some()))
725}
726
727/// A `[providers.<name>].default_model` belonging to a provider whose API key
728/// resolves right now. It is a model id the user typed themselves, so using it
729/// as the startup default requires no guess about which models a vendor
730/// currently ships — Mermaid never invents model names.
731fn configured_provider_default_model(config: &Config) -> Option<String> {
732    for provider in crate::providers::configured_remote_providers(config) {
733        let model = config
734            .providers
735            .get(&provider.name)
736            .and_then(|entry| entry.default_model.as_deref())
737            .map(str::trim)
738            .filter(|model| !model.is_empty());
739        let Some(model) = model else { continue };
740        // The field holds a bare model name, but an id that already carries
741        // its provider prefix (or an OpenRouter-style `vendor/model`) must not
742        // be double-prefixed into `openrouter/openrouter/...`.
743        if model.starts_with(&format!("{}/", provider.name)) {
744            return Some(model.to_string());
745        }
746        return Some(format!("{}/{}", provider.name, model));
747    }
748    None
749}
750
751/// The startup error for "no model is configured yet".
752///
753/// Ollama is one of two ways to get a model, so this never tells a user who
754/// already has a provider key that they must install it. `ollama_installed`
755/// distinguishes "install Ollama" from "you have Ollama, pull a model".
756fn no_model_configured_error(config: &Config, ollama_installed: bool) -> anyhow::Error {
757    let providers = crate::providers::configured_remote_providers(config);
758    let mut lines = vec!["No model configured yet.".to_string(), String::new()];
759
760    if let Some(first) = providers.first() {
761        let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
762        lines.push(format!("Remote providers ready: {}", names.join(", ")));
763        lines.push("Name a model to use one, e.g.:".to_string());
764        lines.push(format!("    mermaid --model {}/<model>", first.name));
765        lines.push(
766            "Mermaid remembers the last model you used, so --model is a one-time step; \
767             `mermaid list` shows what is available."
768                .to_string(),
769        );
770        lines.push(String::new());
771        lines.push("Or pin one in config.toml:".to_string());
772        lines.push(format!("    [providers.{}]", first.name));
773        lines.push("    default_model = \"<model>\"".to_string());
774    } else {
775        lines.push(
776            "For a remote model, set a provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY,"
777                .to_string(),
778        );
779        lines.push("GOOGLE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, …) and name a".to_string());
780        lines.push("model: mermaid --model anthropic/<model>".to_string());
781    }
782
783    lines.push(String::new());
784    if ollama_installed {
785        lines.push("For a local model, pull one first: ollama pull qwen3:8b".to_string());
786    } else {
787        lines.push(
788            "For local models, install Ollama (https://ollama.com/download), then: \
789             ollama pull qwen3:8b"
790                .to_string(),
791        );
792    }
793    lines.push("`mermaid doctor` reports what is and isn't ready.".to_string());
794
795    anyhow::anyhow!(lines.join("\n"))
796}
797
798fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
799    let alias = requested.strip_prefix("alias:").unwrap_or(requested);
800    if let Some(model) = config.model_aliases.get(alias) {
801        anyhow::ensure!(
802            !model.trim().is_empty(),
803            "model alias `{alias}` is configured with an empty model id"
804        );
805        return Ok(Some(model.clone()));
806    }
807    if requested.starts_with("alias:") {
808        anyhow::bail!("model alias `{alias}` is not configured; add it under [model_aliases]");
809    }
810    Ok(None)
811}
812
813/// Render `SessionFlags` as the `Session` layer's raw table.
814///
815/// A free function, not an inherent method: `SessionFlags` is defined in
816/// `mermaid-domain` and this needs the merge helpers, which are behavior and
817/// live here. The orphan rule makes that split explicit rather than optional.
818///
819/// `-c` overrides go in first; the dedicated flags deep-set on top of them,
820/// preserving the ordering where `--no-network` beats
821/// `-c safety.network=allow`.
822pub(crate) fn session_flags_table(flags: &SessionFlags) -> Result<toml::Table> {
823    let mut table = toml::Table::new();
824    apply_cli_overrides(&mut table, &flags.overrides)?;
825    if flags.deny_network {
826        deep_set_segments(
827            &mut table,
828            &["safety", "network"],
829            toml::Value::String("deny".into()),
830        )?;
831    }
832    if flags.confine_fs {
833        deep_set_segments(
834            &mut table,
835            &["safety", "filesystem"],
836            toml::Value::String("project".into()),
837        )?;
838    }
839    if let Some(n) = flags.max_tokens {
840        deep_set_segments(
841            &mut table,
842            &["default_model", "max_tokens"],
843            toml::Value::Integer(n as i64),
844        )?;
845    }
846    if flags.allow_untrusted_tools {
847        deep_set_segments(
848            &mut table,
849            &["safety", "allow_untrusted_headless_tools"],
850            toml::Value::Boolean(true),
851        )?;
852    }
853    Ok(table)
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use mermaid_runtime::SafetyMode;
860    use std::collections::HashMap;
861
862    #[test]
863    fn legacy_default_max_tokens_migrates_to_auto() {
864        // The frozen pre-AUTO default (4096) on disk is coerced to 0 = AUTO…
865        let mut table: toml::Table =
866            toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
867        migrate_legacy_max_tokens(&mut table);
868        migrate_legacy_model_profiles(&mut table);
869        let (config, _) = finalize_config(table).unwrap();
870        assert_eq!(config.default_model.max_tokens, 0);
871
872        // …while any other explicit cap is preserved.
873        let mut table: toml::Table =
874            toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
875        migrate_legacy_max_tokens(&mut table);
876        migrate_legacy_model_profiles(&mut table);
877        let (config, _) = finalize_config(table).unwrap();
878        assert_eq!(config.default_model.max_tokens, 8192);
879
880        // A config without the key is untouched (stays the 0 default).
881        let mut table = toml::Table::new();
882        migrate_legacy_max_tokens(&mut table);
883        migrate_legacy_model_profiles(&mut table);
884        let (config, _) = finalize_config(table).unwrap();
885        assert_eq!(config.default_model.max_tokens, 0);
886    }
887
888    #[test]
889    fn legacy_model_profiles_table_migrates_to_model_aliases() {
890        // Loads stop warning immediately...
891        let mut table: toml::Table =
892            toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
893        migrate_legacy_model_profiles(&mut table);
894        let (config, ignored) = finalize_config(table).unwrap();
895        assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
896        assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
897        // ...and a file with BOTH keeps the new table.
898        let mut table: toml::Table =
899            toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
900                .unwrap();
901        migrate_legacy_model_profiles(&mut table);
902        let (config, ignored) = finalize_config(table).unwrap();
903        assert_eq!(config.model_aliases["fast"], "new");
904        assert!(ignored.is_empty());
905        // ...and the persist path rewrites the key on disk.
906        let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
907        std::fs::create_dir_all(&dir).unwrap();
908        let path = dir.join("config.toml");
909        std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
910        update_user_config_table_at(&path, |_| Ok(())).unwrap();
911        let blob = std::fs::read_to_string(&path).unwrap();
912        assert!(blob.contains("[model_aliases]"), "{blob}");
913        assert!(!blob.contains("model_profiles"), "{blob}");
914        let _ = std::fs::remove_dir_all(&dir);
915    }
916
917    #[test]
918    fn ui_theme_deserializes_defaults_and_rejects_typos() {
919        let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
920        assert_eq!(config.ui.theme, ThemeChoice::Light);
921        // Absent → dark, both from an empty file and from Config::default().
922        let config: Config = toml::from_str("").unwrap();
923        assert_eq!(config.ui.theme, ThemeChoice::Dark);
924        assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
925        // Typos are a clear deserialize error, not a silent fallback.
926        assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
927    }
928
929    #[test]
930    fn finalize_config_flags_unknown_keys() {
931        let table: toml::Table =
932            toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
933                .unwrap();
934        let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
935        assert_eq!(config.default_model.max_tokens, 512);
936        assert!(
937            ignored.iter().any(|p| p == "unknown_top"),
938            "got {ignored:?}"
939        );
940        assert!(
941            ignored.iter().any(|p| p.contains("bogus")),
942            "got {ignored:?}"
943        );
944    }
945
946    #[test]
947    fn cli_overrides_beat_file_and_create_nested_tables() {
948        // Override beats the file value...
949        let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
950        apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
951        let (config, ignored) = finalize_config(table).unwrap();
952        assert_eq!(config.default_model.max_tokens, 8192);
953        assert!(ignored.is_empty());
954        // ...and creates a section absent from the file.
955        let mut empty = toml::Table::new();
956        apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
957        assert_eq!(
958            finalize_config(empty).unwrap().0.default_model.max_tokens,
959            256
960        );
961    }
962
963    #[test]
964    fn parse_override_value_keeps_toml_types_with_string_fallback() {
965        assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
966        assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
967        assert_eq!(
968            parse_override_value("ollama/qwen"),
969            toml::Value::String("ollama/qwen".to_string())
970        );
971    }
972
973    #[test]
974    fn cli_override_invalid_format_errors() {
975        let mut table = toml::Table::new();
976        assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
977        assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
978    }
979
980    #[test]
981    fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
982        let mut base: toml::Table = toml::from_str(
983            "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
984        )
985        .unwrap();
986        let overlay: toml::Table =
987            toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
988                .unwrap();
989        deep_merge(&mut base, overlay);
990        // Sibling keys inside a merged table survive...
991        assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
992        // ...the overlaid scalar wins...
993        assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
994        // ...arrays replace wholesale (no concat)...
995        assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
996        // ...and untouched top-level keys survive.
997        assert_eq!(base["top"].as_integer(), Some(1));
998    }
999
1000    #[test]
1001    fn deep_merge_overlay_wins_on_kind_conflict() {
1002        // Scalar over table and table over scalar both resolve to the overlay.
1003        let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
1004        let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
1005        deep_merge(&mut base, overlay);
1006        assert_eq!(base["a"].as_integer(), Some(5));
1007        assert_eq!(base["b"]["y"].as_integer(), Some(3));
1008    }
1009
1010    #[test]
1011    fn merge_layers_precedence_and_layer_attributed_warnings() {
1012        let user: toml::Table = toml::from_str(
1013            "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
1014        )
1015        .unwrap();
1016        let session: toml::Table =
1017            toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
1018        let (config, warnings) = merge_layers(vec![
1019            LayerSource {
1020                layer: ConfigLayer::User,
1021                origin: "/tmp/user.toml".to_string(),
1022                table: user,
1023            },
1024            LayerSource {
1025                layer: ConfigLayer::Session,
1026                origin: "command line".to_string(),
1027                table: session,
1028            },
1029        ])
1030        .expect("merges");
1031        // Later layer wins; earlier layer's untouched keys survive.
1032        assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
1033        assert_eq!(config.default_model.max_tokens, 100);
1034        // Each unknown key names its own layer + origin.
1035        assert!(
1036            warnings
1037                .iter()
1038                .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
1039            "got {warnings:?}"
1040        );
1041        assert!(
1042            warnings
1043                .iter()
1044                .any(|w| w.contains("session_typo") && w.contains("session flags")),
1045            "got {warnings:?}"
1046        );
1047    }
1048
1049    #[test]
1050    fn take_profiles_excises_and_tolerates_absence() {
1051        let mut table: toml::Table =
1052            toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
1053        let profiles = take_profiles(&mut table);
1054        assert!(table.is_empty(), "profiles must be excised: {table:?}");
1055        assert!(profiles.contains_key("fast"));
1056        // Absent -> empty, table untouched.
1057        let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
1058        assert!(take_profiles(&mut table).is_empty());
1059        assert_eq!(table.len(), 1);
1060        // Malformed (non-table) -> dropped, empty result.
1061        let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
1062        assert!(take_profiles(&mut table).is_empty());
1063        assert!(table.is_empty());
1064    }
1065
1066    #[test]
1067    fn resolve_profile_layer_errors_name_available_profiles() {
1068        let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
1069        let path = std::path::Path::new("/tmp/config.toml");
1070        let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
1071        assert!(err.to_string().contains("available: fast, work"), "{err}");
1072        // No profiles at all -> a distinct, actionable error.
1073        let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
1074        assert!(
1075            err.to_string().contains("no config profiles defined"),
1076            "{err}"
1077        );
1078        // Non-table profile value -> hard error.
1079        let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
1080        let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
1081        assert!(err.to_string().contains("not a table"), "{err}");
1082        // Hit -> Profile layer with attributing origin.
1083        let profiles: toml::Table =
1084            toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
1085        let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
1086        assert_eq!(layer.layer, ConfigLayer::Profile);
1087        assert!(layer.origin.contains("profile:work"));
1088    }
1089
1090    #[test]
1091    fn profile_layer_beats_user_loses_to_project_and_session() {
1092        let user: toml::Table = toml::from_str(
1093            "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
1094        )
1095        .unwrap();
1096        let profile: toml::Table = toml::from_str(
1097            "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
1098        )
1099        .unwrap();
1100        let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
1101        let session: toml::Table =
1102            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1103        let (config, warnings) = merge_layers(vec![
1104            LayerSource {
1105                layer: ConfigLayer::User,
1106                origin: "/tmp/user.toml".to_string(),
1107                table: user,
1108            },
1109            LayerSource {
1110                layer: ConfigLayer::Profile,
1111                origin: "profile:work (/tmp/user.toml)".to_string(),
1112                table: profile,
1113            },
1114            LayerSource {
1115                layer: ConfigLayer::Project,
1116                origin: "/repo/.mermaid/config.toml".to_string(),
1117                table: project,
1118            },
1119            LayerSource {
1120                layer: ConfigLayer::Session,
1121                origin: "command line".to_string(),
1122                table: session,
1123            },
1124        ])
1125        .expect("merges");
1126        // Project beats profile; session beats everything; profile beats user
1127        // where later layers are silent.
1128        assert_eq!(config.default_model.temperature, 0.5);
1129        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1130        assert_eq!(config.default_model.max_tokens, 100);
1131        // Unknown keys inside the profile attribute to it.
1132        assert!(
1133            warnings.iter().any(|w| w.contains("profile_typo")
1134                && w.contains("config profile (profile:work (/tmp/user.toml))")),
1135            "got {warnings:?}"
1136        );
1137    }
1138
1139    #[test]
1140    fn persists_never_touch_profile_tables() {
1141        let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
1142        std::fs::create_dir_all(&dir).expect("create temp dir");
1143        let path = dir.join("config.toml");
1144        std::fs::write(
1145            &path,
1146            "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
1147        )
1148        .expect("seed");
1149
1150        update_user_config_table_at(&path, |table| {
1151            deep_set_segments(
1152                table,
1153                &["safety", "mode"],
1154                toml::Value::String("auto".to_string()),
1155            )
1156        })
1157        .expect("persist");
1158
1159        let table: toml::Table =
1160            toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
1161        assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
1162        // The overlay table survives persists byte-for-byte semantically.
1163        assert_eq!(
1164            table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
1165            Some(0.1)
1166        );
1167        let _ = std::fs::remove_dir_all(&dir);
1168    }
1169
1170    #[test]
1171    fn session_flags_table_maps_each_flag() {
1172        let flags = SessionFlags {
1173            overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
1174            deny_network: true,
1175            confine_fs: true,
1176            max_tokens: Some(512),
1177            allow_untrusted_tools: true,
1178            profile: None,
1179        };
1180        let (config, _) = finalize_config(session_flags_table(&flags).unwrap()).unwrap();
1181        assert_eq!(config.safety.network, NetworkPolicy::Deny);
1182        assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
1183        assert_eq!(config.default_model.max_tokens, 512);
1184        assert!(config.safety.allow_untrusted_headless_tools);
1185        assert_eq!(config.web.searxng_url, "http://x:1");
1186    }
1187
1188    #[test]
1189    fn session_dedicated_flags_beat_dash_c() {
1190        // `--no-network` wins over a contradictory `-c safety.network=allow`
1191        // (the dedicated flags deep-set after the -c overrides).
1192        let flags = SessionFlags {
1193            overrides: vec!["safety.network=allow".to_string()],
1194            deny_network: true,
1195            ..Default::default()
1196        };
1197        let (config, _) = finalize_config(session_flags_table(&flags).unwrap()).unwrap();
1198        assert_eq!(config.safety.network, NetworkPolicy::Deny);
1199    }
1200
1201    #[test]
1202    fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
1203        // A layer that doesn't deserialize on its own contributes no warnings…
1204        let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
1205        let mut warnings = Vec::new();
1206        collect_layer_warnings(
1207            &LayerSource {
1208                layer: ConfigLayer::User,
1209                origin: "x".to_string(),
1210                table: bad.clone(),
1211            },
1212            &mut warnings,
1213        );
1214        assert!(warnings.is_empty());
1215        // …and the merged deserialize is what errors…
1216        assert!(
1217            merge_layers(vec![LayerSource {
1218                layer: ConfigLayer::User,
1219                origin: "x".to_string(),
1220                table: bad.clone(),
1221            }])
1222            .is_err()
1223        );
1224        // …unless a later layer fixes the value (session repairing a bad file).
1225        let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
1226        let (config, _) = merge_layers(vec![
1227            LayerSource {
1228                layer: ConfigLayer::User,
1229                origin: "x".to_string(),
1230                table: bad,
1231            },
1232            LayerSource {
1233                layer: ConfigLayer::Session,
1234                origin: "command line".to_string(),
1235                table: fix,
1236            },
1237        ])
1238        .expect("later layer repairs the earlier one");
1239        assert_eq!(config.safety.mode, SafetyMode::Ask);
1240    }
1241
1242    #[test]
1243    fn project_layer_beats_user_and_loses_to_session() {
1244        let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
1245        let project: toml::Table = toml::from_str(
1246            "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
1247        )
1248        .unwrap();
1249        let session: toml::Table =
1250            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1251        let (config, _) = merge_layers(vec![
1252            LayerSource {
1253                layer: ConfigLayer::User,
1254                origin: "user".to_string(),
1255                table: user,
1256            },
1257            LayerSource {
1258                layer: ConfigLayer::Project,
1259                origin: "project".to_string(),
1260                table: project,
1261            },
1262            LayerSource {
1263                layer: ConfigLayer::Session,
1264                origin: "command line".to_string(),
1265                table: session,
1266            },
1267        ])
1268        .expect("merges");
1269        // Session beats project beats user for the contested key…
1270        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1271        // …while the project's uncontested key lands.
1272        assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
1273    }
1274
1275    #[test]
1276    fn session_flags_survive_corrupt_user_layer_fallback() {
1277        // The or_warn fallback re-applies the session flags over bare defaults;
1278        // pin the exact expression it uses.
1279        let flags = SessionFlags {
1280            deny_network: true,
1281            ..Default::default()
1282        };
1283        let config = session_flags_table(&flags)
1284            .ok()
1285            .and_then(|table| finalize_config(table).ok())
1286            .map(|(config, _)| config)
1287            .unwrap_or_default();
1288        assert_eq!(config.safety.network, NetworkPolicy::Deny);
1289    }
1290
1291    #[test]
1292    fn deep_set_segments_addresses_keys_containing_dots() {
1293        // A model id with dots must be ONE key, which dotted parsing cannot
1294        // express — the latent bug the segment API fixes.
1295        let mut table = toml::Table::new();
1296        deep_set_segments(
1297            &mut table,
1298            &["reasoning_per_model", "gemini/gemini-2.5-pro"],
1299            toml::Value::String("high".to_string()),
1300        )
1301        .unwrap();
1302        let (config, ignored) = finalize_config(table).unwrap();
1303        assert!(ignored.is_empty(), "got {ignored:?}");
1304        assert_eq!(
1305            config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
1306            Some(&ReasoningLevel::High)
1307        );
1308    }
1309
1310    #[test]
1311    fn deep_remove_segments_removes_leaf_only() {
1312        let mut table: toml::Table =
1313            toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
1314                .unwrap();
1315        assert!(deep_remove_segments(
1316            &mut table,
1317            &["ollama_num_ctx_per_model", "ollama/a"]
1318        ));
1319        // Sibling survives; parent table survives; missing keys report false.
1320        assert_eq!(
1321            table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
1322            Some(2)
1323        );
1324        assert!(!deep_remove_segments(
1325            &mut table,
1326            &["ollama_num_ctx_per_model", "ollama/a"]
1327        ));
1328        assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
1329    }
1330
1331    #[test]
1332    fn update_user_config_table_preserves_unknown_keys() {
1333        let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
1334        std::fs::create_dir_all(&dir).expect("create temp dir");
1335        let path = dir.join("config.toml");
1336        // A file with an unknown key (maybe from a newer mermaid) and one known
1337        // setting the persist must not disturb.
1338        std::fs::write(
1339            &path,
1340            "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
1341        )
1342        .expect("seed");
1343
1344        update_user_config_table_at(&path, |table| {
1345            deep_set_segments(
1346                table,
1347                &["last_used_model"],
1348                toml::Value::String("ollama/new".to_string()),
1349            )
1350        })
1351        .expect("persist");
1352
1353        let blob = std::fs::read_to_string(&path).expect("read back");
1354        let table: toml::Table = toml::from_str(&blob).expect("parse back");
1355        // The targeted key changed…
1356        assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
1357        // …the unknown key survived (typed round-trips would have dropped it)…
1358        assert_eq!(table["future_key"].as_str(), Some("kept"));
1359        // …and no defaults were frozen in (only the keys that were there).
1360        assert!(!blob.contains("safety"), "defaults must not be frozen in");
1361        assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
1362
1363        let _ = std::fs::remove_dir_all(&dir);
1364    }
1365
1366    #[test]
1367    fn mcp_tool_allowed_honors_enabled_and_disabled() {
1368        // Default (both empty) allows everything.
1369        let cfg = McpServerConfig::default();
1370        assert!(cfg.tool_allowed("anything"));
1371        // enabled_tools acts as an allowlist.
1372        let cfg = McpServerConfig {
1373            enabled_tools: vec!["read".into(), "search".into()],
1374            ..Default::default()
1375        };
1376        assert!(cfg.tool_allowed("read"));
1377        assert!(!cfg.tool_allowed("write"));
1378        // disabled_tools wins over enabled_tools.
1379        let cfg = McpServerConfig {
1380            enabled_tools: vec!["read".into(), "write".into()],
1381            disabled_tools: vec!["write".into()],
1382            ..Default::default()
1383        };
1384        assert!(cfg.tool_allowed("read"));
1385        assert!(!cfg.tool_allowed("write"));
1386    }
1387
1388    #[test]
1389    fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
1390        // command-only → stdio.
1391        let cfg = McpServerConfig {
1392            command: "npx".to_string(),
1393            ..Default::default()
1394        };
1395        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
1396        // url-only → http.
1397        let cfg = McpServerConfig {
1398            url: Some("https://example.com/mcp".to_string()),
1399            ..Default::default()
1400        };
1401        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
1402        // Both set → error.
1403        let cfg = McpServerConfig {
1404            command: "npx".to_string(),
1405            url: Some("https://example.com/mcp".to_string()),
1406            ..Default::default()
1407        };
1408        assert!(
1409            cfg.transport_kind()
1410                .unwrap_err()
1411                .to_string()
1412                .contains("mutually exclusive")
1413        );
1414        // Neither set → error.
1415        let cfg = McpServerConfig::default();
1416        assert!(
1417            cfg.transport_kind()
1418                .unwrap_err()
1419                .to_string()
1420                .contains("neither")
1421        );
1422    }
1423
1424    #[test]
1425    fn mcp_transport_kind_gates_url_scheme() {
1426        let with_url = |url: &str| McpServerConfig {
1427            url: Some(url.to_string()),
1428            ..Default::default()
1429        };
1430        // https anywhere is fine; http only to loopback (plaintext to a
1431        // routable host would leak auth headers).
1432        assert!(
1433            with_url("https://mcp.example.com/x")
1434                .transport_kind()
1435                .is_ok()
1436        );
1437        assert!(
1438            with_url("http://localhost:8080/mcp")
1439                .transport_kind()
1440                .is_ok()
1441        );
1442        assert!(
1443            with_url("http://127.0.0.1:8080/mcp")
1444                .transport_kind()
1445                .is_ok()
1446        );
1447        assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
1448        assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
1449        assert!(with_url("not a url").transport_kind().is_err());
1450    }
1451
1452    #[test]
1453    fn mcp_server_config_debug_masks_header_values() {
1454        let mut headers = HashMap::new();
1455        headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
1456        let mut env_headers = HashMap::new();
1457        env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
1458        let cfg = McpServerConfig {
1459            url: Some("https://example.com/mcp".to_string()),
1460            headers,
1461            env_headers,
1462            ..Default::default()
1463        };
1464        let rendered = format!("{cfg:?}");
1465        assert!(!rendered.contains("sk-secret"), "{rendered}");
1466        assert!(rendered.contains("Authorization"), "{rendered}");
1467        // env_headers values are env var NAMES, safe to render.
1468        assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
1469    }
1470
1471    #[test]
1472    fn mcp_url_config_round_trips_through_toml_without_command() {
1473        // `mermaid add --url` persists via toml::Value::try_from; a bare None
1474        // url or a forced empty `command` key would break that round-trip.
1475        let cfg = McpServerConfig {
1476            url: Some("https://example.com/mcp".to_string()),
1477            ..Default::default()
1478        };
1479        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
1480        assert!(
1481            !blob.contains("command"),
1482            "empty command must be omitted: {blob}"
1483        );
1484        let back: McpServerConfig = toml::from_str(&blob).unwrap();
1485        assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
1486        assert!(back.command.is_empty());
1487        // And a stdio config must not serialize a `url` key at all.
1488        let cfg = McpServerConfig {
1489            command: "npx".to_string(),
1490            ..Default::default()
1491        };
1492        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
1493        assert!(!blob.contains("url"), "{blob}");
1494    }
1495
1496    /// Configs persisted before Step 4 don't have a `reasoning` field on
1497    /// `[default_model]`. Loading them must succeed and yield the
1498    /// `Medium` default — otherwise existing user configs break on
1499    /// upgrade.
1500    #[test]
1501    fn model_settings_deserializes_without_reasoning_field() {
1502        let toml_blob = r#"
1503            provider = "ollama"
1504            name = "qwen3-coder:30b"
1505            temperature = 0.7
1506            max_tokens = 4096
1507        "#;
1508        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
1509        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
1510        assert_eq!(settings.provider, "ollama");
1511    }
1512
1513    #[test]
1514    fn model_settings_round_trips_reasoning_high() {
1515        let original = ModelSettings {
1516            provider: "anthropic".to_string(),
1517            name: "claude-sonnet-4-6".to_string(),
1518            temperature: 0.5,
1519            max_tokens: 8192,
1520            reasoning: ReasoningLevel::High,
1521        };
1522        let toml_blob = toml::to_string(&original).expect("serialize");
1523        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
1524        assert_eq!(back.reasoning, ReasoningLevel::High);
1525        assert_eq!(back.name, "claude-sonnet-4-6");
1526    }
1527
1528    #[test]
1529    fn agents_config_defaults_and_parses_custom_types() {
1530        // Absent section → defaults (20-minute timeout, no custom types).
1531        let config: Config = toml::from_str("").expect("empty config parses");
1532        assert_eq!(config.agents.timeout_secs, 1200);
1533        assert!(config.agents.types.is_empty());
1534
1535        let config: Config = toml::from_str(
1536            r#"
1537[agents]
1538timeout_secs = 300
1539
1540[agents.types.scout]
1541tools = ["read_file", "execute_command"]
1542safety = "read_only"
1543preamble = "You are a scout."
1544model = "ollama/qwen3:8b"
1545"#,
1546        )
1547        .expect("agents section parses");
1548        assert_eq!(config.agents.timeout_secs, 300);
1549        let scout = &config.agents.types["scout"];
1550        assert_eq!(
1551            scout.tools.as_deref(),
1552            Some(&["read_file".to_string(), "execute_command".to_string()][..])
1553        );
1554        assert_eq!(scout.safety.as_deref(), Some("read_only"));
1555        assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
1556    }
1557
1558    #[test]
1559    fn configured_model_alias_resolves_explicit_prefix() {
1560        let mut config = Config::default();
1561        config
1562            .model_aliases
1563            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
1564        assert_eq!(
1565            resolve_model_alias("fast", &config).unwrap(),
1566            Some("ollama/qwen3-coder:14b".to_string())
1567        );
1568        assert_eq!(
1569            resolve_model_alias("alias:fast", &config).unwrap(),
1570            Some("ollama/qwen3-coder:14b".to_string())
1571        );
1572    }
1573
1574    #[test]
1575    fn alias_prefix_requires_configuration() {
1576        let config = Config::default();
1577        assert!(resolve_model_alias("alias:vision", &config).is_err());
1578        assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
1579    }
1580
1581    /// `persist_default_reasoning` writes to the real config path, so
1582    /// this test goes through `save_config(_, Some(path))` directly to
1583    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
1584    /// Uses `std::env::temp_dir` (matching the pattern in
1585    /// `session::conversation` and `utils::logger`) — no external
1586    /// `tempfile` crate dependency.
1587    #[test]
1588    fn save_and_reload_preserves_reasoning_field() {
1589        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
1590        std::fs::create_dir_all(&dir).expect("create temp dir");
1591        let path = dir.join("config.toml");
1592
1593        let mut cfg = Config::default();
1594        cfg.default_model.provider = "ollama".to_string();
1595        cfg.default_model.name = "qwen3-coder:30b".to_string();
1596        cfg.default_model.reasoning = ReasoningLevel::Low;
1597
1598        save_config(&cfg, Some(path.clone())).expect("save");
1599
1600        let blob = std::fs::read_to_string(&path).expect("read");
1601        let loaded: Config = toml::from_str(&blob).expect("parse back");
1602        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
1603
1604        let _ = std::fs::remove_dir_all(&dir);
1605    }
1606
1607    /// Per-model entries serialize as a TOML table with quoted keys (the
1608    /// model IDs contain `/`). This test verifies the round-trip works
1609    /// through both serialization and deserialization, matching what
1610    /// `persist_reasoning_for_model` would produce in real use.
1611    #[test]
1612    fn save_and_reload_preserves_reasoning_per_model_table() {
1613        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
1614        std::fs::create_dir_all(&dir).expect("create temp dir");
1615        let path = dir.join("config.toml");
1616
1617        let mut cfg = Config::default();
1618        cfg.reasoning_per_model.insert(
1619            "anthropic/claude-sonnet-4-6".to_string(),
1620            ReasoningLevel::High,
1621        );
1622        cfg.reasoning_per_model
1623            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
1624
1625        save_config(&cfg, Some(path.clone())).expect("save");
1626
1627        let blob = std::fs::read_to_string(&path).expect("read");
1628        let loaded: Config = toml::from_str(&blob).expect("parse back");
1629        assert_eq!(
1630            loaded
1631                .reasoning_per_model
1632                .get("anthropic/claude-sonnet-4-6"),
1633            Some(&ReasoningLevel::High)
1634        );
1635        assert_eq!(
1636            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
1637            Some(&ReasoningLevel::Low)
1638        );
1639
1640        let _ = std::fs::remove_dir_all(&dir);
1641    }
1642
1643    /// `/context <n>` overrides round-trip through the per-model TOML table, and
1644    /// the offload toggle persists on `[ollama]`.
1645    #[test]
1646    fn save_and_reload_preserves_ollama_context_overrides() {
1647        let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
1648        std::fs::create_dir_all(&dir).expect("create temp dir");
1649        let path = dir.join("config.toml");
1650
1651        let mut cfg = Config::default();
1652        cfg.ollama_num_ctx_per_model
1653            .insert("ollama/ornith:9b".to_string(), 131_072);
1654        cfg.ollama.allow_ram_offload = true;
1655        cfg.ollama.max_auto_num_ctx = Some(65_536);
1656
1657        save_config(&cfg, Some(path.clone())).expect("save");
1658        let blob = std::fs::read_to_string(&path).expect("read");
1659        let loaded: Config = toml::from_str(&blob).expect("parse back");
1660
1661        assert_eq!(
1662            loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
1663            Some(&131_072)
1664        );
1665        assert!(loaded.ollama.allow_ram_offload);
1666        assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
1667
1668        let _ = std::fs::remove_dir_all(&dir);
1669    }
1670
1671    /// Older configs have neither the per-model `num_ctx` table nor the new
1672    /// `[ollama]` keys; loading must default cleanly (empty map, offload off).
1673    #[test]
1674    fn config_deserializes_without_ollama_context_keys() {
1675        let toml_blob = r#"
1676[ollama]
1677host = "localhost"
1678port = 11434
1679"#;
1680        let cfg: Config = toml::from_str(toml_blob).expect("parse");
1681        assert!(cfg.ollama_num_ctx_per_model.is_empty());
1682        assert!(!cfg.ollama.allow_ram_offload);
1683        assert_eq!(cfg.ollama.max_auto_num_ctx, None);
1684        // Configs from before the auto-start knob default it ON — reviving a
1685        // dead local server is the out-of-the-box behavior.
1686        assert!(cfg.ollama.auto_start);
1687    }
1688
1689    /// Configs from before Step 5b don't have a `reasoning_per_model`
1690    /// section. Loading them must succeed with an empty map — otherwise
1691    /// upgrade breaks every existing user.
1692    #[test]
1693    fn config_deserializes_without_reasoning_per_model() {
1694        let toml_blob = r#"
1695            last_used_model = "ollama/qwen3-coder:30b"
1696
1697            [default_model]
1698            provider = "ollama"
1699            name = "qwen3-coder:30b"
1700            temperature = 0.7
1701            max_tokens = 4096
1702        "#;
1703        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
1704        assert!(cfg.reasoning_per_model.is_empty());
1705        assert!(!cfg.prompt.is_customized());
1706    }
1707
1708    /// Config holds inline-secret-capable fields (`mcp_servers[].env`, `args`,
1709    /// `headers`, `providers[].extra_headers`), so it must be written
1710    /// owner-only rather than inheriting a world-readable umask.
1711    #[cfg(unix)]
1712    #[test]
1713    fn save_config_writes_owner_only_perms() {
1714        use std::os::unix::fs::PermissionsExt;
1715        let dir = std::env::temp_dir().join("mermaid_test_config_perms");
1716        std::fs::create_dir_all(&dir).expect("create temp dir");
1717        let path = dir.join("config.toml");
1718        // Pre-create a world-readable file to prove we also tighten existing.
1719        std::fs::write(&path, "stale").expect("seed");
1720        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
1721
1722        save_config(&Config::default(), Some(path.clone())).expect("save");
1723        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1724        assert_eq!(mode, 0o600, "config must be written owner-only");
1725
1726        let _ = std::fs::remove_dir_all(&dir);
1727    }
1728
1729    #[test]
1730    fn config_defaults_computer_use_auto_screenshot_on() {
1731        // An empty/legacy config must keep the auto-screenshot behavior (#98).
1732        let cfg: Config = toml::from_str("").expect("empty config");
1733        assert!(cfg.computer_use.auto_screenshot);
1734    }
1735
1736    #[test]
1737    fn prompt_config_replaces_and_appends_without_persisting() {
1738        let mut cfg = Config::default();
1739        cfg.prompt.system_prompt = Some("base".to_string());
1740        cfg.prompt
1741            .append_system_prompt
1742            .push("extra instructions".to_string());
1743
1744        assert_eq!(
1745            cfg.prompt.render_system_prompt("default"),
1746            "base\n\nextra instructions"
1747        );
1748
1749        let blob = toml::to_string(&cfg).expect("serialize");
1750        assert!(!blob.contains("extra instructions"));
1751        let loaded: Config = toml::from_str(&blob).expect("deserialize");
1752        assert!(!loaded.prompt.is_customized());
1753    }
1754
1755    /// An absent `[compaction]` section must reproduce the constants exactly —
1756    /// making the policy configurable must not change anyone's behavior.
1757    #[test]
1758    fn absent_compaction_section_matches_the_built_in_policy() {
1759        let c: Config = toml::from_str("").expect("empty config parses");
1760        assert_eq!(
1761            c.compaction.policy(),
1762            mermaid_domain::CompactionPolicy::default(),
1763        );
1764    }
1765
1766    #[test]
1767    fn compaction_settings_reach_the_policy() {
1768        let c: Config = toml::from_str(
1769            "[compaction]\n\
1770             auto_enabled = false\n\
1771             auto_threshold_percent = 60\n\
1772             tail_turns = 5\n\
1773             tail_token_budget = 12000\n\
1774             summary_max_tokens = 3000\n",
1775        )
1776        .expect("compaction section parses");
1777        let policy = c.compaction.policy();
1778        assert!(!policy.auto_enabled);
1779        assert_eq!(policy.auto_threshold_percent, 60);
1780        assert_eq!(policy.tail_turns, 5);
1781        assert_eq!(policy.tail_token_budget, 12_000);
1782        assert_eq!(policy.summary_max_tokens, 3_000);
1783        // Unset keys keep their defaults rather than zeroing out.
1784        let defaults = mermaid_domain::CompactionPolicy::default();
1785        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
1786    }
1787
1788    /// A hand-edited config degrades to the nearest workable value rather than
1789    /// putting compaction in a state where it silently cannot run.
1790    #[test]
1791    fn nonsense_compaction_settings_are_clamped() {
1792        let c: Config = toml::from_str(
1793            "[compaction]\n\
1794             auto_threshold_percent = 250\n\
1795             tail_turns = 0\n\
1796             tail_token_budget = 0\n\
1797             summary_max_tokens = 0\n\
1798             summarizer_input_token_budget = 0\n\
1799             tool_output_max_chars = 0\n\
1800             min_response_reserve_tokens = 50000\n\
1801             max_response_reserve_tokens = 1000\n",
1802        )
1803        .expect("config parses");
1804        let policy = c.compaction.policy();
1805        let defaults = mermaid_domain::CompactionPolicy::default();
1806
1807        assert_eq!(policy.auto_threshold_percent, 100, "percent clamps to 100");
1808        assert_eq!(
1809            policy.tail_turns, 1,
1810            "a checkpoint needs a live turn after it"
1811        );
1812        // Zero would mean "no budget at all"; fall back rather than disable.
1813        assert_eq!(policy.tail_token_budget, defaults.tail_token_budget);
1814        assert_eq!(policy.summary_max_tokens, defaults.summary_max_tokens);
1815        assert_eq!(
1816            policy.summarizer_input_token_budget,
1817            defaults.summarizer_input_token_budget
1818        );
1819        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
1820
1821        // Swapped reserve bounds are ordered, not obeyed: `response_reserve`
1822        // clamps with `.max(min).min(max)`, so an inverted pair would return
1823        // the smaller value and under-reserve on every single turn.
1824        assert_eq!(policy.min_response_reserve_tokens, 1_000);
1825        assert_eq!(policy.max_response_reserve_tokens, 50_000);
1826        assert!(policy.min_response_reserve_tokens <= policy.max_response_reserve_tokens);
1827    }
1828
1829    /// `auto_threshold_percent = 0` would compact on every single turn, before
1830    /// there is anything to compact.
1831    #[test]
1832    fn zero_compaction_threshold_clamps_up() {
1833        let c: Config =
1834            toml::from_str("[compaction]\nauto_threshold_percent = 0\n").expect("parses");
1835        assert_eq!(c.compaction.policy().auto_threshold_percent, 1);
1836    }
1837
1838    #[test]
1839    fn plan_config_defaults_parse_and_do_not_freeze() {
1840        // Absent section: dialog on, nothing pinned.
1841        let c: Config = toml::from_str("").expect("empty config parses");
1842        assert!(!c.plan.auto_approve);
1843        assert!(c.plan.post_approve.is_none());
1844        // Explicit values parse.
1845        let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
1846            .expect("plan section parses");
1847        assert!(c.plan.auto_approve);
1848        assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
1849        assert_eq!(
1850            toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
1851                .expect("wait parses")
1852                .plan
1853                .post_approve,
1854            Some(PlanPostApprove::Wait)
1855        );
1856        // The unset pin is never frozen into a saved config (Option +
1857        // skip_serializing_if), so a future default change still reaches
1858        // existing files.
1859        let blob = toml::to_string(&Config::default()).expect("serialize");
1860        assert!(!blob.contains("post_approve"));
1861    }
1862
1863    /// Config with one remote provider carrying an explicit `default_model`.
1864    fn config_with_provider_default(provider: &str, model: &str) -> Config {
1865        let mut config = Config::default();
1866        config.providers.insert(
1867            provider.to_string(),
1868            UserProviderConfig {
1869                default_model: Some(model.to_string()),
1870                ..Default::default()
1871            },
1872        );
1873        config
1874    }
1875
1876    /// The whole point of the Ollama-optional path: a machine whose only
1877    /// backend is Anthropic must resolve a model without Ollama in the picture.
1878    #[test]
1879    fn provider_default_model_resolves_without_ollama() {
1880        let config = config_with_provider_default("anthropic", "claude-x");
1881        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1882            assert_eq!(
1883                configured_provider_default_model(&config).as_deref(),
1884                Some("anthropic/claude-x")
1885            );
1886        });
1887    }
1888
1889    /// An unconfigured provider's `default_model` is not a usable default —
1890    /// building it would fail on the missing key at the first request.
1891    #[test]
1892    fn provider_default_model_ignored_without_a_key() {
1893        let config = config_with_provider_default("anthropic", "claude-x");
1894        temp_env::with_vars([("ANTHROPIC_API_KEY", None::<&str>)], || {
1895            // The keyring is the machine's, so only assert the env-var half:
1896            // with no key in the environment there is nothing to prefer.
1897            if mermaid_model::utils::provider_key_source("anthropic", "ANTHROPIC_API_KEY", None)
1898                == "none"
1899            {
1900                assert_eq!(configured_provider_default_model(&config), None);
1901            }
1902        });
1903    }
1904
1905    /// OpenRouter ids are `vendor/model`, which must be prefixed once, not
1906    /// twice — and an id that already names its provider is left alone.
1907    #[test]
1908    fn provider_default_model_is_prefixed_exactly_once() {
1909        temp_env::with_vars([("OPENROUTER_API_KEY", Some("sk-test"))], || {
1910            let vendor_model = config_with_provider_default("openrouter", "z-ai/glm-5.2");
1911            assert_eq!(
1912                configured_provider_default_model(&vendor_model).as_deref(),
1913                Some("openrouter/z-ai/glm-5.2")
1914            );
1915            let already_prefixed =
1916                config_with_provider_default("openrouter", "openrouter/z-ai/glm-5.2");
1917            assert_eq!(
1918                configured_provider_default_model(&already_prefixed).as_deref(),
1919                Some("openrouter/z-ai/glm-5.2")
1920            );
1921        });
1922    }
1923
1924    /// The regression this replaced: startup used to end at "Ollama is not
1925    /// installed", which reads as "Mermaid needs Ollama". With a provider key
1926    /// present the message must be about naming a model, not about Ollama.
1927    #[test]
1928    fn missing_model_error_does_not_demand_ollama_when_a_provider_is_ready() {
1929        let config = Config::default();
1930        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1931            let msg = no_model_configured_error(&config, false).to_string();
1932            assert!(msg.contains("anthropic"), "{msg}");
1933            assert!(msg.contains("mermaid --model anthropic/<model>"), "{msg}");
1934            assert!(msg.contains("[providers.anthropic]"), "{msg}");
1935            // Ollama may still be mentioned as the local option, but never as
1936            // a prerequisite for running Mermaid at all.
1937            assert!(!msg.contains("Ollama is not installed"), "{msg}");
1938        });
1939    }
1940
1941    /// Run `f` with every built-in provider's key env var unset, so a key in
1942    /// the developer's own shell can't change what the message says.
1943    fn with_no_provider_keys<T>(f: impl FnOnce() -> T) -> T {
1944        let cleared: Vec<(&str, Option<&str>)> = [
1945            crate::providers::model::anthropic::DEFAULT_API_KEY_ENV,
1946            crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
1947            crate::providers::model::gemini::LEGACY_API_KEY_ENV,
1948            crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1949        ]
1950        .iter()
1951        .map(|env| (*env, None))
1952        .chain(
1953            mermaid_model::models::PROVIDER_REGISTRY
1954                .iter()
1955                .map(|profile| (profile.api_key_env, None)),
1956        )
1957        .collect();
1958        temp_env::with_vars(cleared, f)
1959    }
1960
1961    /// With nothing configured at all, both routes are offered — the remote
1962    /// one first, since it needs no install.
1963    #[test]
1964    fn missing_model_error_offers_both_routes_when_nothing_is_configured() {
1965        with_no_provider_keys(|| {
1966            let msg = no_model_configured_error(&Config::default(), false).to_string();
1967            assert!(msg.contains("https://ollama.com/download"), "{msg}");
1968            // A keyring login would legitimately name a provider instead; only
1969            // assert the no-provider wording when there really is none.
1970            if !msg.contains("Remote providers ready") {
1971                assert!(msg.contains("ANTHROPIC_API_KEY"), "{msg}");
1972            }
1973        });
1974    }
1975
1976    /// End-to-end through `resolve_model_id` itself: nothing pinned, no local
1977    /// model reachable, one configured provider — Mermaid starts on that
1978    /// provider instead of erroring out about Ollama.
1979    #[test]
1980    fn resolve_model_id_falls_back_to_a_configured_provider() {
1981        let mut config = config_with_provider_default("anthropic", "claude-x");
1982        // Point at a dead port with autostart off, so "no local model" holds
1983        // whether or not this machine has Ollama installed.
1984        config.ollama.host = "http://127.0.0.1".to_string();
1985        config.ollama.port = 1;
1986        config.ollama.auto_start = false;
1987        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1988            let runtime = tokio::runtime::Runtime::new().expect("runtime");
1989            let resolved = runtime
1990                .block_on(resolve_model_id(None, &config))
1991                .expect("a configured provider is enough to resolve a model");
1992            assert_eq!(resolved, "anthropic/claude-x");
1993        });
1994    }
1995
1996    /// An installed-but-empty Ollama needs a pull, not another install.
1997    #[test]
1998    fn missing_model_error_says_pull_when_ollama_is_installed() {
1999        let msg = no_model_configured_error(&Config::default(), true).to_string();
2000        assert!(msg.contains("ollama pull qwen3:8b"), "{msg}");
2001        assert!(!msg.contains("https://ollama.com/download"), "{msg}");
2002    }
2003}