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/// Environment override for the config directory, checked ahead of the
450/// platform location.
451///
452/// The platform location comes from `ProjectDirs`, which honors
453/// `XDG_CONFIG_HOME` on unix but resolves the Roaming `AppData` *known
454/// folder* on Windows — a path no environment variable redirects. So a test
455/// (or a portable install, or a CI job) had no way to point Mermaid at a
456/// scratch config on Windows, and every test that spawned the real binary
457/// wrote `last_used_model` into the developer's own `config.toml`. This
458/// variable is that missing knob, and it works identically on all three
459/// platforms.
460pub const CONFIG_DIR_ENV: &str = "MERMAID_CONFIG_DIR";
461
462/// Get the configuration directory
463///
464/// [`CONFIG_DIR_ENV`] wins when set; otherwise the platform location, then a
465/// `~/.config/mermaid` fallback.
466///
467/// # Errors
468///
469/// Creating the directory, and — only on the fallback path, when the platform
470/// reports no config location — neither `HOME` nor `USERPROFILE` being set.
471/// The directory is created here, so an `Ok` path exists.
472pub fn get_config_dir() -> Result<PathBuf> {
473    if let Some(dir) = std::env::var_os(CONFIG_DIR_ENV).filter(|dir| !dir.is_empty()) {
474        let config_dir = PathBuf::from(dir);
475        std::fs::create_dir_all(&config_dir)?;
476        return Ok(config_dir);
477    }
478    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
479        let config_dir = proj_dirs.config_dir();
480        std::fs::create_dir_all(config_dir)?;
481        Ok(config_dir.to_path_buf())
482    } else {
483        // Fallback to home directory
484        let home = std::env::var("HOME")
485            .or_else(|_| std::env::var("USERPROFILE"))
486            .context("Could not determine home directory")?;
487        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
488        std::fs::create_dir_all(&config_dir)?;
489        Ok(config_dir)
490    }
491}
492
493/// Save a full configuration to file. Private on purpose: serializing the
494/// whole typed `Config` freezes every default (and would freeze merged
495/// project/session values) into the file, so the only legitimate callers are
496/// `init_config` (writing pristine defaults to an absent file) and tests.
497/// Runtime persistence goes through [`update_user_config_key`] /
498/// [`remove_user_config_key`], which rewrite only their own keys.
499fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
500    let path = if let Some(p) = path {
501        p
502    } else {
503        get_config_dir()?.join("config.toml")
504    };
505    write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
506}
507
508/// Write raw config bytes atomically and owner-only.
509///
510/// The config can carry literal secrets — `mcp_servers[].env`,
511/// `mcp_servers[].args`, `mcp_servers[].headers`, and
512/// `providers[].extra_headers` all accept inline credential values — so it
513/// must not be left world-readable, and a crash
514/// mid-write must not truncate it. Write atomically (temp → fsync → rename),
515/// creating the temp 0600 on Unix so the renamed file is never even briefly
516/// world-readable (this also tightens a pre-existing config, since the new
517/// file replaces the old one). Windows relies on the per-user profile ACL.
518fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
519    #[cfg(unix)]
520    mermaid_runtime::write_atomic_with_mode(path, bytes, 0o600)
521        .with_context(|| format!("Failed to write config to {}", path.display()))?;
522    #[cfg(not(unix))]
523    mermaid_runtime::write_atomic(path, bytes)
524        .with_context(|| format!("Failed to write config to {}", path.display()))?;
525    Ok(())
526}
527
528/// Create a default configuration file if it doesn't exist
529///
530/// # Errors
531///
532/// Resolving the config dir, serializing the defaults, and the write. An
533/// existing config is not an error and is never overwritten — the file is only
534/// written when it is absent.
535pub fn init_config() -> Result<()> {
536    let config_file = get_config_path()?;
537
538    if config_file.exists() {
539        println!("Configuration already exists at: {}", config_file.display());
540    } else {
541        let default_config = Config::default();
542        save_config(&default_config, Some(config_file.clone()))?;
543        println!("Created configuration at: {}", config_file.display());
544    }
545
546    Ok(())
547}
548
549/// Serializes the read-modify-write persistence path. The `persist_*` helpers
550/// run as concurrent detached tasks (dispatched by the effect runner) that all
551/// load → mutate → save the same file; without a lock two quick toggles
552/// (`/model` then Alt+T) can interleave their loads and lose one write. Held
553/// only across the synchronous fs work — never across an `.await`.
554static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
555
556/// Read the raw USER config table, apply `mutate`, and write it back — under
557/// `PERSIST_LOCK` so concurrent persists can't clobber each other. Operating
558/// on the raw table (never the merged typed `Config`) means a persist rewrites
559/// only its own keys: unknown keys survive, defaults are not frozen in, and
560/// project-layer or session-flag values can never leak into the user file.
561/// A malformed file propagates the parse error rather than being overwritten
562/// with defaults (#111).
563fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
564    update_user_config_table_at(&get_config_path()?, mutate)
565}
566
567/// [`update_user_config_table`] against an explicit path (test seam).
568fn update_user_config_table_at(
569    path: &std::path::Path,
570    mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
571) -> Result<()> {
572    let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
573    let mut table = read_config_table(path)?;
574    // Converge the on-disk legacy output cap while we're rewriting anyway.
575    migrate_legacy_max_tokens(&mut table);
576    migrate_legacy_model_profiles(&mut table);
577    mutate(&mut table)?;
578    write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
579}
580
581/// Set one key (pre-split path segments, so map keys containing dots — e.g.
582/// `reasoning_per_model."ollama/qwen3:8b"` — address correctly) in the USER
583/// config file, leaving every other key untouched.
584///
585/// # Errors
586///
587/// The read-modify-write of the user file: resolving the config dir, reading
588/// and parsing the existing TOML, re-serializing it, and the write. The write
589/// is atomic and 0600, so a failure leaves the previous config intact rather
590/// than a truncated or world-readable one. This is the error surface every
591/// `persist_*` helper below inherits.
592pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
593    update_user_config_table(|table| deep_set_segments(table, path, value))
594}
595
596/// Persist the whole `[plan]` table (the `/plan config` picker). Values the
597/// user set through the picker are explicit choices, so writing them —
598/// including ones that currently match defaults — is correct; unset Options
599/// stay absent via `skip_serializing_if`.
600///
601/// # Errors
602///
603/// Serializing `plan` to TOML, then [`update_user_config_key`]'s.
604pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
605    update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
606}
607
608/// Remove one key (pre-split path segments) from the USER config file.
609/// Returns whether the key existed.
610///
611/// # Errors
612///
613/// [`update_user_config_key`]'s. A key that was not there is `Ok(false)`, not
614/// an error.
615pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
616    let mut removed = false;
617    update_user_config_table(|table| {
618        removed = deep_remove_segments(table, path);
619        Ok(())
620    })?;
621    Ok(removed)
622}
623
624/// Persist the last used model to the user config file.
625///
626/// # Errors
627///
628/// [`update_user_config_key`]'s.
629pub fn persist_last_model(model: &str) -> Result<()> {
630    update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
631}
632
633/// Persist the TUI theme choice (`/theme dark|light`).
634///
635/// # Errors
636///
637/// [`update_user_config_key`]'s.
638pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
639    update_user_config_key(
640        &["ui", "theme"],
641        toml::Value::String(theme.as_str().to_string()),
642    )
643}
644
645/// Persist the user's default reasoning level. Used by the `/reasoning` slash
646/// command and the Alt+T cycle handler so the choice survives across sessions.
647///
648/// # Errors
649///
650/// Serializing `level`, then [`update_user_config_key`]'s.
651pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
652    update_user_config_key(
653        &["default_model", "reasoning"],
654        toml::Value::try_from(level)?,
655    )
656}
657
658/// Persist a reasoning level for a specific model ID
659/// (e.g. `<provider>/<model>`). The TUI calls this from Alt+T,
660/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
661/// the choice sticks per-model rather than bleeding into other models on
662/// next session start.
663///
664/// # Errors
665///
666/// Serializing `level`, then [`update_user_config_key`]'s.
667pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
668    update_user_config_key(
669        &["reasoning_per_model", model_id],
670        toml::Value::try_from(level)?,
671    )
672}
673
674/// Persist (or clear) a per-model Ollama `num_ctx` override. `Some(n)` sets it,
675/// `None` removes the entry (returning that model to auto-fit).
676///
677/// # Errors
678///
679/// [`update_user_config_key`]'s for `Some`, [`remove_user_config_key`]'s for
680/// `None` — the same read-modify-write either way. Clearing an entry that was
681/// not set is not an error.
682pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
683    match num_ctx {
684        Some(n) => update_user_config_key(
685            &["ollama_num_ctx_per_model", model_id],
686            toml::Value::Integer(i64::from(n)),
687        ),
688        None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
689    }
690}
691
692/// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
693///
694/// # Errors
695///
696/// [`update_user_config_key`]'s.
697pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
698    update_user_config_key(
699        &["ollama", "allow_ram_offload"],
700        toml::Value::Boolean(enabled),
701    )
702}
703
704/// Resolve which model to use: CLI arg > `last_used` > `[default_model]` > a
705/// local Ollama model > a configured provider's `default_model`.
706///
707/// # Errors
708///
709/// An alias that cannot be resolved, and the terminal case where nothing is
710/// pinned, no local Ollama model is installed, and no configured provider
711/// carries a `default_model` — that message offers both routes rather than
712/// demanding an Ollama install. An absent Ollama is not an error on its own:
713/// the local probe simply contributes nothing. A stopped one still answers,
714/// from its on-disk store, without being woken.
715pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
716    if let Some(model) = cli_model {
717        if let Some(resolved) = resolve_model_alias(model, config)? {
718            return Ok(resolved);
719        }
720        return Ok(model.to_string());
721    }
722    if let Some(last_model) = &config.last_used_model {
723        if let Some(resolved) = resolve_model_alias(last_model, config)? {
724            return Ok(resolved);
725        }
726        return Ok(last_model.clone());
727    }
728    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
729        return Ok(format!(
730            "{}/{}",
731            config.default_model.provider, config.default_model.name
732        ));
733    }
734    // Nothing pinned. Ollama is Mermaid's default backend, not a prerequisite:
735    // prefer a local model when one is installed, then a remote provider the
736    // user has given an explicit `default_model`, and only then give up — with
737    // a message that offers both routes instead of demanding an Ollama install
738    // from someone who set `ANTHROPIC_API_KEY` and never wanted local models.
739    let local = crate::ollama::local_models(config).await;
740    if let Some(first) = local.as_ref().and_then(|models| models.first()) {
741        return Ok(format!("ollama/{first}"));
742    }
743    if let Some(model_id) = configured_provider_default_model(config) {
744        return Ok(model_id);
745    }
746    Err(no_model_configured_error(config, local.is_some()))
747}
748
749/// A `[providers.<name>].default_model` belonging to a provider whose API key
750/// resolves right now. It is a model id the user typed themselves, so using it
751/// as the startup default requires no guess about which models a vendor
752/// currently ships — Mermaid never invents model names.
753fn configured_provider_default_model(config: &Config) -> Option<String> {
754    for provider in crate::providers::configured_remote_providers(config) {
755        let model = config
756            .providers
757            .get(&provider.name)
758            .and_then(|entry| entry.default_model.as_deref())
759            .map(str::trim)
760            .filter(|model| !model.is_empty());
761        let Some(model) = model else { continue };
762        // The field holds a bare model name, but an id that already carries
763        // its provider prefix (or an OpenRouter-style `vendor/model`) must not
764        // be double-prefixed into `openrouter/openrouter/...`.
765        if model.starts_with(&format!("{}/", provider.name)) {
766            return Some(model.to_string());
767        }
768        return Some(format!("{}/{}", provider.name, model));
769    }
770    None
771}
772
773/// The startup error for "no model is configured yet".
774///
775/// Ollama is one of two ways to get a model, so this never tells a user who
776/// already has a provider key that they must install it. `ollama_installed`
777/// distinguishes "install Ollama" from "you have Ollama, pull a model".
778fn no_model_configured_error(config: &Config, ollama_installed: bool) -> anyhow::Error {
779    let providers = crate::providers::configured_remote_providers(config);
780    let mut lines = vec!["No model configured yet.".to_string(), String::new()];
781
782    if let Some(first) = providers.first() {
783        let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
784        lines.push(format!("Remote providers ready: {}", names.join(", ")));
785        lines.push("Name a model to use one, e.g.:".to_string());
786        lines.push(format!("    mermaid --model {}/<model>", first.name));
787        lines.push(
788            "Mermaid remembers the last model you used, so --model is a one-time step; \
789             `mermaid list` shows what is available."
790                .to_string(),
791        );
792        lines.push(String::new());
793        lines.push("Or pin one in config.toml:".to_string());
794        lines.push(format!("    [providers.{}]", first.name));
795        lines.push("    default_model = \"<model>\"".to_string());
796    } else {
797        lines.push(
798            "For a remote model, set a provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY,"
799                .to_string(),
800        );
801        lines.push("GOOGLE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, …) and name a".to_string());
802        lines.push("model: mermaid --model anthropic/<model>".to_string());
803    }
804
805    lines.push(String::new());
806    if ollama_installed {
807        lines.push("For a local model, pull one first: ollama pull qwen3:8b".to_string());
808    } else {
809        lines.push(
810            "For local models, install Ollama (https://ollama.com/download), then: \
811             ollama pull qwen3:8b"
812                .to_string(),
813        );
814    }
815    lines.push("`mermaid doctor` reports what is and isn't ready.".to_string());
816
817    anyhow::anyhow!(lines.join("\n"))
818}
819
820fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
821    let alias = requested.strip_prefix("alias:").unwrap_or(requested);
822    if let Some(model) = config.model_aliases.get(alias) {
823        anyhow::ensure!(
824            !model.trim().is_empty(),
825            "model alias `{alias}` is configured with an empty model id"
826        );
827        return Ok(Some(model.clone()));
828    }
829    if requested.starts_with("alias:") {
830        anyhow::bail!("model alias `{alias}` is not configured; add it under [model_aliases]");
831    }
832    Ok(None)
833}
834
835/// Render `SessionFlags` as the `Session` layer's raw table.
836///
837/// A free function, not an inherent method: `SessionFlags` is defined in
838/// `mermaid-domain` and this needs the merge helpers, which are behavior and
839/// live here. The orphan rule makes that split explicit rather than optional.
840///
841/// `-c` overrides go in first; the dedicated flags deep-set on top of them,
842/// preserving the ordering where `--no-network` beats
843/// `-c safety.network=allow`.
844pub(crate) fn session_flags_table(flags: &SessionFlags) -> Result<toml::Table> {
845    let mut table = toml::Table::new();
846    apply_cli_overrides(&mut table, &flags.overrides)?;
847    if flags.deny_network {
848        deep_set_segments(
849            &mut table,
850            &["safety", "network"],
851            toml::Value::String("deny".into()),
852        )?;
853    }
854    if flags.confine_fs {
855        deep_set_segments(
856            &mut table,
857            &["safety", "filesystem"],
858            toml::Value::String("project".into()),
859        )?;
860    }
861    if let Some(n) = flags.max_tokens {
862        deep_set_segments(
863            &mut table,
864            &["default_model", "max_tokens"],
865            toml::Value::Integer(n as i64),
866        )?;
867    }
868    if flags.allow_untrusted_tools {
869        deep_set_segments(
870            &mut table,
871            &["safety", "allow_untrusted_headless_tools"],
872            toml::Value::Boolean(true),
873        )?;
874    }
875    Ok(table)
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use mermaid_runtime::SafetyMode;
882    use std::collections::HashMap;
883
884    /// The matched pair for the override: set, it redirects the config dir on
885    /// every platform (the isolation the pty/e2e suites rely on so a test run
886    /// can never write the developer's real `config.toml` — Windows resolves
887    /// the platform location through a known folder no HOME/XDG var moves);
888    /// empty, it is "unset", so a stray `MERMAID_CONFIG_DIR=` in a shell
889    /// profile cannot silently point the config at the current directory.
890    #[test]
891    fn config_dir_env_override_wins_and_empty_is_unset() {
892        let sandbox = std::env::temp_dir().join(format!(
893            "mermaid-config-dir-override-{}",
894            std::process::id()
895        ));
896        temp_env::with_var(
897            CONFIG_DIR_ENV,
898            Some(sandbox.to_str().expect("utf8 temp path")),
899            || {
900                let resolved = get_config_dir().expect("override resolves");
901                assert_eq!(resolved, sandbox);
902                assert!(sandbox.is_dir(), "the override dir is created on use");
903            },
904        );
905        temp_env::with_var(CONFIG_DIR_ENV, Some(""), || {
906            let resolved = get_config_dir().expect("platform dir resolves");
907            assert_ne!(resolved, PathBuf::from(""), "empty must not become cwd");
908            assert!(resolved.is_absolute(), "got {}", resolved.display());
909        });
910        let _ = std::fs::remove_dir_all(&sandbox);
911    }
912
913    #[test]
914    fn legacy_default_max_tokens_migrates_to_auto() {
915        // The frozen pre-AUTO default (4096) on disk is coerced to 0 = AUTO…
916        let mut table: toml::Table =
917            toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
918        migrate_legacy_max_tokens(&mut table);
919        migrate_legacy_model_profiles(&mut table);
920        let (config, _) = finalize_config(table).unwrap();
921        assert_eq!(config.default_model.max_tokens, 0);
922
923        // …while any other explicit cap is preserved.
924        let mut table: toml::Table =
925            toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
926        migrate_legacy_max_tokens(&mut table);
927        migrate_legacy_model_profiles(&mut table);
928        let (config, _) = finalize_config(table).unwrap();
929        assert_eq!(config.default_model.max_tokens, 8192);
930
931        // A config without the key is untouched (stays the 0 default).
932        let mut table = toml::Table::new();
933        migrate_legacy_max_tokens(&mut table);
934        migrate_legacy_model_profiles(&mut table);
935        let (config, _) = finalize_config(table).unwrap();
936        assert_eq!(config.default_model.max_tokens, 0);
937    }
938
939    #[test]
940    fn legacy_model_profiles_table_migrates_to_model_aliases() {
941        // Loads stop warning immediately...
942        let mut table: toml::Table =
943            toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
944        migrate_legacy_model_profiles(&mut table);
945        let (config, ignored) = finalize_config(table).unwrap();
946        assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
947        assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
948        // ...and a file with BOTH keeps the new table.
949        let mut table: toml::Table =
950            toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
951                .unwrap();
952        migrate_legacy_model_profiles(&mut table);
953        let (config, ignored) = finalize_config(table).unwrap();
954        assert_eq!(config.model_aliases["fast"], "new");
955        assert!(ignored.is_empty());
956        // ...and the persist path rewrites the key on disk.
957        let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
958        std::fs::create_dir_all(&dir).unwrap();
959        let path = dir.join("config.toml");
960        std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
961        update_user_config_table_at(&path, |_| Ok(())).unwrap();
962        let blob = std::fs::read_to_string(&path).unwrap();
963        assert!(blob.contains("[model_aliases]"), "{blob}");
964        assert!(!blob.contains("model_profiles"), "{blob}");
965        let _ = std::fs::remove_dir_all(&dir);
966    }
967
968    #[test]
969    fn ui_theme_deserializes_defaults_and_rejects_typos() {
970        let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
971        assert_eq!(config.ui.theme, ThemeChoice::Light);
972        // Absent → dark, both from an empty file and from Config::default().
973        let config: Config = toml::from_str("").unwrap();
974        assert_eq!(config.ui.theme, ThemeChoice::Dark);
975        assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
976        // Typos are a clear deserialize error, not a silent fallback.
977        assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
978    }
979
980    #[test]
981    fn finalize_config_flags_unknown_keys() {
982        let table: toml::Table =
983            toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
984                .unwrap();
985        let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
986        assert_eq!(config.default_model.max_tokens, 512);
987        assert!(
988            ignored.iter().any(|p| p == "unknown_top"),
989            "got {ignored:?}"
990        );
991        assert!(
992            ignored.iter().any(|p| p.contains("bogus")),
993            "got {ignored:?}"
994        );
995    }
996
997    #[test]
998    fn cli_overrides_beat_file_and_create_nested_tables() {
999        // Override beats the file value...
1000        let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
1001        apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
1002        let (config, ignored) = finalize_config(table).unwrap();
1003        assert_eq!(config.default_model.max_tokens, 8192);
1004        assert!(ignored.is_empty());
1005        // ...and creates a section absent from the file.
1006        let mut empty = toml::Table::new();
1007        apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
1008        assert_eq!(
1009            finalize_config(empty).unwrap().0.default_model.max_tokens,
1010            256
1011        );
1012    }
1013
1014    #[test]
1015    fn parse_override_value_keeps_toml_types_with_string_fallback() {
1016        assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
1017        assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
1018        assert_eq!(
1019            parse_override_value("ollama/qwen"),
1020            toml::Value::String("ollama/qwen".to_string())
1021        );
1022    }
1023
1024    #[test]
1025    fn cli_override_invalid_format_errors() {
1026        let mut table = toml::Table::new();
1027        assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
1028        assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
1029    }
1030
1031    #[test]
1032    fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
1033        let mut base: toml::Table = toml::from_str(
1034            "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
1035        )
1036        .unwrap();
1037        let overlay: toml::Table =
1038            toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
1039                .unwrap();
1040        deep_merge(&mut base, overlay);
1041        // Sibling keys inside a merged table survive...
1042        assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
1043        // ...the overlaid scalar wins...
1044        assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
1045        // ...arrays replace wholesale (no concat)...
1046        assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
1047        // ...and untouched top-level keys survive.
1048        assert_eq!(base["top"].as_integer(), Some(1));
1049    }
1050
1051    #[test]
1052    fn deep_merge_overlay_wins_on_kind_conflict() {
1053        // Scalar over table and table over scalar both resolve to the overlay.
1054        let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
1055        let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
1056        deep_merge(&mut base, overlay);
1057        assert_eq!(base["a"].as_integer(), Some(5));
1058        assert_eq!(base["b"]["y"].as_integer(), Some(3));
1059    }
1060
1061    #[test]
1062    fn merge_layers_precedence_and_layer_attributed_warnings() {
1063        let user: toml::Table = toml::from_str(
1064            "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
1065        )
1066        .unwrap();
1067        let session: toml::Table =
1068            toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
1069        let (config, warnings) = merge_layers(vec![
1070            LayerSource {
1071                layer: ConfigLayer::User,
1072                origin: "/tmp/user.toml".to_string(),
1073                table: user,
1074            },
1075            LayerSource {
1076                layer: ConfigLayer::Session,
1077                origin: "command line".to_string(),
1078                table: session,
1079            },
1080        ])
1081        .expect("merges");
1082        // Later layer wins; earlier layer's untouched keys survive.
1083        assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
1084        assert_eq!(config.default_model.max_tokens, 100);
1085        // Each unknown key names its own layer + origin.
1086        assert!(
1087            warnings
1088                .iter()
1089                .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
1090            "got {warnings:?}"
1091        );
1092        assert!(
1093            warnings
1094                .iter()
1095                .any(|w| w.contains("session_typo") && w.contains("session flags")),
1096            "got {warnings:?}"
1097        );
1098    }
1099
1100    #[test]
1101    fn take_profiles_excises_and_tolerates_absence() {
1102        let mut table: toml::Table =
1103            toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
1104        let profiles = take_profiles(&mut table);
1105        assert!(table.is_empty(), "profiles must be excised: {table:?}");
1106        assert!(profiles.contains_key("fast"));
1107        // Absent -> empty, table untouched.
1108        let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
1109        assert!(take_profiles(&mut table).is_empty());
1110        assert_eq!(table.len(), 1);
1111        // Malformed (non-table) -> dropped, empty result.
1112        let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
1113        assert!(take_profiles(&mut table).is_empty());
1114        assert!(table.is_empty());
1115    }
1116
1117    #[test]
1118    fn resolve_profile_layer_errors_name_available_profiles() {
1119        let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
1120        let path = std::path::Path::new("/tmp/config.toml");
1121        let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
1122        assert!(err.to_string().contains("available: fast, work"), "{err}");
1123        // No profiles at all -> a distinct, actionable error.
1124        let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
1125        assert!(
1126            err.to_string().contains("no config profiles defined"),
1127            "{err}"
1128        );
1129        // Non-table profile value -> hard error.
1130        let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
1131        let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
1132        assert!(err.to_string().contains("not a table"), "{err}");
1133        // Hit -> Profile layer with attributing origin.
1134        let profiles: toml::Table =
1135            toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
1136        let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
1137        assert_eq!(layer.layer, ConfigLayer::Profile);
1138        assert!(layer.origin.contains("profile:work"));
1139    }
1140
1141    #[test]
1142    fn profile_layer_beats_user_loses_to_project_and_session() {
1143        let user: toml::Table = toml::from_str(
1144            "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
1145        )
1146        .unwrap();
1147        let profile: toml::Table = toml::from_str(
1148            "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
1149        )
1150        .unwrap();
1151        let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
1152        let session: toml::Table =
1153            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1154        let (config, warnings) = merge_layers(vec![
1155            LayerSource {
1156                layer: ConfigLayer::User,
1157                origin: "/tmp/user.toml".to_string(),
1158                table: user,
1159            },
1160            LayerSource {
1161                layer: ConfigLayer::Profile,
1162                origin: "profile:work (/tmp/user.toml)".to_string(),
1163                table: profile,
1164            },
1165            LayerSource {
1166                layer: ConfigLayer::Project,
1167                origin: "/repo/.mermaid/config.toml".to_string(),
1168                table: project,
1169            },
1170            LayerSource {
1171                layer: ConfigLayer::Session,
1172                origin: "command line".to_string(),
1173                table: session,
1174            },
1175        ])
1176        .expect("merges");
1177        // Project beats profile; session beats everything; profile beats user
1178        // where later layers are silent.
1179        assert_eq!(config.default_model.temperature, 0.5);
1180        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1181        assert_eq!(config.default_model.max_tokens, 100);
1182        // Unknown keys inside the profile attribute to it.
1183        assert!(
1184            warnings.iter().any(|w| w.contains("profile_typo")
1185                && w.contains("config profile (profile:work (/tmp/user.toml))")),
1186            "got {warnings:?}"
1187        );
1188    }
1189
1190    #[test]
1191    fn persists_never_touch_profile_tables() {
1192        let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
1193        std::fs::create_dir_all(&dir).expect("create temp dir");
1194        let path = dir.join("config.toml");
1195        std::fs::write(
1196            &path,
1197            "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
1198        )
1199        .expect("seed");
1200
1201        update_user_config_table_at(&path, |table| {
1202            deep_set_segments(
1203                table,
1204                &["safety", "mode"],
1205                toml::Value::String("auto".to_string()),
1206            )
1207        })
1208        .expect("persist");
1209
1210        let table: toml::Table =
1211            toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
1212        assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
1213        // The overlay table survives persists byte-for-byte semantically.
1214        assert_eq!(
1215            table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
1216            Some(0.1)
1217        );
1218        let _ = std::fs::remove_dir_all(&dir);
1219    }
1220
1221    #[test]
1222    fn session_flags_table_maps_each_flag() {
1223        let flags = SessionFlags {
1224            overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
1225            deny_network: true,
1226            confine_fs: true,
1227            max_tokens: Some(512),
1228            allow_untrusted_tools: true,
1229            profile: None,
1230        };
1231        let (config, _) = finalize_config(session_flags_table(&flags).unwrap()).unwrap();
1232        assert_eq!(config.safety.network, NetworkPolicy::Deny);
1233        assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
1234        assert_eq!(config.default_model.max_tokens, 512);
1235        assert!(config.safety.allow_untrusted_headless_tools);
1236        assert_eq!(config.web.searxng_url, "http://x:1");
1237    }
1238
1239    #[test]
1240    fn session_dedicated_flags_beat_dash_c() {
1241        // `--no-network` wins over a contradictory `-c safety.network=allow`
1242        // (the dedicated flags deep-set after the -c overrides).
1243        let flags = SessionFlags {
1244            overrides: vec!["safety.network=allow".to_string()],
1245            deny_network: true,
1246            ..Default::default()
1247        };
1248        let (config, _) = finalize_config(session_flags_table(&flags).unwrap()).unwrap();
1249        assert_eq!(config.safety.network, NetworkPolicy::Deny);
1250    }
1251
1252    #[test]
1253    fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
1254        // A layer that doesn't deserialize on its own contributes no warnings…
1255        let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
1256        let mut warnings = Vec::new();
1257        collect_layer_warnings(
1258            &LayerSource {
1259                layer: ConfigLayer::User,
1260                origin: "x".to_string(),
1261                table: bad.clone(),
1262            },
1263            &mut warnings,
1264        );
1265        assert!(warnings.is_empty());
1266        // …and the merged deserialize is what errors…
1267        assert!(
1268            merge_layers(vec![LayerSource {
1269                layer: ConfigLayer::User,
1270                origin: "x".to_string(),
1271                table: bad.clone(),
1272            }])
1273            .is_err()
1274        );
1275        // …unless a later layer fixes the value (session repairing a bad file).
1276        let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
1277        let (config, _) = merge_layers(vec![
1278            LayerSource {
1279                layer: ConfigLayer::User,
1280                origin: "x".to_string(),
1281                table: bad,
1282            },
1283            LayerSource {
1284                layer: ConfigLayer::Session,
1285                origin: "command line".to_string(),
1286                table: fix,
1287            },
1288        ])
1289        .expect("later layer repairs the earlier one");
1290        assert_eq!(config.safety.mode, SafetyMode::Ask);
1291    }
1292
1293    #[test]
1294    fn project_layer_beats_user_and_loses_to_session() {
1295        let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
1296        let project: toml::Table = toml::from_str(
1297            "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
1298        )
1299        .unwrap();
1300        let session: toml::Table =
1301            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1302        let (config, _) = merge_layers(vec![
1303            LayerSource {
1304                layer: ConfigLayer::User,
1305                origin: "user".to_string(),
1306                table: user,
1307            },
1308            LayerSource {
1309                layer: ConfigLayer::Project,
1310                origin: "project".to_string(),
1311                table: project,
1312            },
1313            LayerSource {
1314                layer: ConfigLayer::Session,
1315                origin: "command line".to_string(),
1316                table: session,
1317            },
1318        ])
1319        .expect("merges");
1320        // Session beats project beats user for the contested key…
1321        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1322        // …while the project's uncontested key lands.
1323        assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
1324    }
1325
1326    #[test]
1327    fn session_flags_survive_corrupt_user_layer_fallback() {
1328        // The or_warn fallback re-applies the session flags over bare defaults;
1329        // pin the exact expression it uses.
1330        let flags = SessionFlags {
1331            deny_network: true,
1332            ..Default::default()
1333        };
1334        let config = session_flags_table(&flags)
1335            .ok()
1336            .and_then(|table| finalize_config(table).ok())
1337            .map(|(config, _)| config)
1338            .unwrap_or_default();
1339        assert_eq!(config.safety.network, NetworkPolicy::Deny);
1340    }
1341
1342    #[test]
1343    fn deep_set_segments_addresses_keys_containing_dots() {
1344        // A model id with dots must be ONE key, which dotted parsing cannot
1345        // express — the latent bug the segment API fixes.
1346        let mut table = toml::Table::new();
1347        deep_set_segments(
1348            &mut table,
1349            &["reasoning_per_model", "gemini/gemini-2.5-pro"],
1350            toml::Value::String("high".to_string()),
1351        )
1352        .unwrap();
1353        let (config, ignored) = finalize_config(table).unwrap();
1354        assert!(ignored.is_empty(), "got {ignored:?}");
1355        assert_eq!(
1356            config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
1357            Some(&ReasoningLevel::High)
1358        );
1359    }
1360
1361    #[test]
1362    fn deep_remove_segments_removes_leaf_only() {
1363        let mut table: toml::Table =
1364            toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
1365                .unwrap();
1366        assert!(deep_remove_segments(
1367            &mut table,
1368            &["ollama_num_ctx_per_model", "ollama/a"]
1369        ));
1370        // Sibling survives; parent table survives; missing keys report false.
1371        assert_eq!(
1372            table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
1373            Some(2)
1374        );
1375        assert!(!deep_remove_segments(
1376            &mut table,
1377            &["ollama_num_ctx_per_model", "ollama/a"]
1378        ));
1379        assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
1380    }
1381
1382    #[test]
1383    fn update_user_config_table_preserves_unknown_keys() {
1384        let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
1385        std::fs::create_dir_all(&dir).expect("create temp dir");
1386        let path = dir.join("config.toml");
1387        // A file with an unknown key (maybe from a newer mermaid) and one known
1388        // setting the persist must not disturb.
1389        std::fs::write(
1390            &path,
1391            "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
1392        )
1393        .expect("seed");
1394
1395        update_user_config_table_at(&path, |table| {
1396            deep_set_segments(
1397                table,
1398                &["last_used_model"],
1399                toml::Value::String("ollama/new".to_string()),
1400            )
1401        })
1402        .expect("persist");
1403
1404        let blob = std::fs::read_to_string(&path).expect("read back");
1405        let table: toml::Table = toml::from_str(&blob).expect("parse back");
1406        // The targeted key changed…
1407        assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
1408        // …the unknown key survived (typed round-trips would have dropped it)…
1409        assert_eq!(table["future_key"].as_str(), Some("kept"));
1410        // …and no defaults were frozen in (only the keys that were there).
1411        assert!(!blob.contains("safety"), "defaults must not be frozen in");
1412        assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
1413
1414        let _ = std::fs::remove_dir_all(&dir);
1415    }
1416
1417    #[test]
1418    fn mcp_tool_allowed_honors_enabled_and_disabled() {
1419        // Default (both empty) allows everything.
1420        let cfg = McpServerConfig::default();
1421        assert!(cfg.tool_allowed("anything"));
1422        // enabled_tools acts as an allowlist.
1423        let cfg = McpServerConfig {
1424            enabled_tools: vec!["read".into(), "search".into()],
1425            ..Default::default()
1426        };
1427        assert!(cfg.tool_allowed("read"));
1428        assert!(!cfg.tool_allowed("write"));
1429        // disabled_tools wins over enabled_tools.
1430        let cfg = McpServerConfig {
1431            enabled_tools: vec!["read".into(), "write".into()],
1432            disabled_tools: vec!["write".into()],
1433            ..Default::default()
1434        };
1435        assert!(cfg.tool_allowed("read"));
1436        assert!(!cfg.tool_allowed("write"));
1437    }
1438
1439    #[test]
1440    fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
1441        // command-only → stdio.
1442        let cfg = McpServerConfig {
1443            command: "npx".to_string(),
1444            ..Default::default()
1445        };
1446        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
1447        // url-only → http.
1448        let cfg = McpServerConfig {
1449            url: Some("https://example.com/mcp".to_string()),
1450            ..Default::default()
1451        };
1452        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
1453        // Both set → error.
1454        let cfg = McpServerConfig {
1455            command: "npx".to_string(),
1456            url: Some("https://example.com/mcp".to_string()),
1457            ..Default::default()
1458        };
1459        assert!(
1460            cfg.transport_kind()
1461                .unwrap_err()
1462                .to_string()
1463                .contains("mutually exclusive")
1464        );
1465        // Neither set → error.
1466        let cfg = McpServerConfig::default();
1467        assert!(
1468            cfg.transport_kind()
1469                .unwrap_err()
1470                .to_string()
1471                .contains("neither")
1472        );
1473    }
1474
1475    #[test]
1476    fn mcp_transport_kind_gates_url_scheme() {
1477        let with_url = |url: &str| McpServerConfig {
1478            url: Some(url.to_string()),
1479            ..Default::default()
1480        };
1481        // https anywhere is fine; http only to loopback (plaintext to a
1482        // routable host would leak auth headers).
1483        assert!(
1484            with_url("https://mcp.example.com/x")
1485                .transport_kind()
1486                .is_ok()
1487        );
1488        assert!(
1489            with_url("http://localhost:8080/mcp")
1490                .transport_kind()
1491                .is_ok()
1492        );
1493        assert!(
1494            with_url("http://127.0.0.1:8080/mcp")
1495                .transport_kind()
1496                .is_ok()
1497        );
1498        assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
1499        assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
1500        assert!(with_url("not a url").transport_kind().is_err());
1501    }
1502
1503    #[test]
1504    fn mcp_server_config_debug_masks_header_values() {
1505        let mut headers = HashMap::new();
1506        headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
1507        let mut env_headers = HashMap::new();
1508        env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
1509        let cfg = McpServerConfig {
1510            url: Some("https://example.com/mcp".to_string()),
1511            headers,
1512            env_headers,
1513            ..Default::default()
1514        };
1515        let rendered = format!("{cfg:?}");
1516        assert!(!rendered.contains("sk-secret"), "{rendered}");
1517        assert!(rendered.contains("Authorization"), "{rendered}");
1518        // env_headers values are env var NAMES, safe to render.
1519        assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
1520    }
1521
1522    #[test]
1523    fn mcp_url_config_round_trips_through_toml_without_command() {
1524        // `mermaid add --url` persists via toml::Value::try_from; a bare None
1525        // url or a forced empty `command` key would break that round-trip.
1526        let cfg = McpServerConfig {
1527            url: Some("https://example.com/mcp".to_string()),
1528            ..Default::default()
1529        };
1530        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
1531        assert!(
1532            !blob.contains("command"),
1533            "empty command must be omitted: {blob}"
1534        );
1535        let back: McpServerConfig = toml::from_str(&blob).unwrap();
1536        assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
1537        assert!(back.command.is_empty());
1538        // And a stdio config must not serialize a `url` key at all.
1539        let cfg = McpServerConfig {
1540            command: "npx".to_string(),
1541            ..Default::default()
1542        };
1543        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
1544        assert!(!blob.contains("url"), "{blob}");
1545    }
1546
1547    /// Configs persisted before Step 4 don't have a `reasoning` field on
1548    /// `[default_model]`. Loading them must succeed and yield the
1549    /// `Medium` default — otherwise existing user configs break on
1550    /// upgrade.
1551    #[test]
1552    fn model_settings_deserializes_without_reasoning_field() {
1553        let toml_blob = r#"
1554            provider = "ollama"
1555            name = "qwen3-coder:30b"
1556            temperature = 0.7
1557            max_tokens = 4096
1558        "#;
1559        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
1560        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
1561        assert_eq!(settings.provider, "ollama");
1562    }
1563
1564    #[test]
1565    fn model_settings_round_trips_reasoning_high() {
1566        let original = ModelSettings {
1567            provider: "anthropic".to_string(),
1568            name: "claude-sonnet-4-6".to_string(),
1569            temperature: 0.5,
1570            max_tokens: 8192,
1571            reasoning: ReasoningLevel::High,
1572        };
1573        let toml_blob = toml::to_string(&original).expect("serialize");
1574        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
1575        assert_eq!(back.reasoning, ReasoningLevel::High);
1576        assert_eq!(back.name, "claude-sonnet-4-6");
1577    }
1578
1579    #[test]
1580    fn agents_config_defaults_and_parses_custom_types() {
1581        // Absent section → defaults (20-minute timeout, no custom types).
1582        let config: Config = toml::from_str("").expect("empty config parses");
1583        assert_eq!(config.agents.timeout_secs, 1200);
1584        assert!(config.agents.types.is_empty());
1585
1586        let config: Config = toml::from_str(
1587            r#"
1588[agents]
1589timeout_secs = 300
1590
1591[agents.types.scout]
1592tools = ["read_file", "execute_command"]
1593safety = "read_only"
1594preamble = "You are a scout."
1595model = "ollama/qwen3:8b"
1596"#,
1597        )
1598        .expect("agents section parses");
1599        assert_eq!(config.agents.timeout_secs, 300);
1600        let scout = &config.agents.types["scout"];
1601        assert_eq!(
1602            scout.tools.as_deref(),
1603            Some(&["read_file".to_string(), "execute_command".to_string()][..])
1604        );
1605        assert_eq!(scout.safety.as_deref(), Some("read_only"));
1606        assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
1607    }
1608
1609    #[test]
1610    fn configured_model_alias_resolves_explicit_prefix() {
1611        let mut config = Config::default();
1612        config
1613            .model_aliases
1614            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
1615        assert_eq!(
1616            resolve_model_alias("fast", &config).unwrap(),
1617            Some("ollama/qwen3-coder:14b".to_string())
1618        );
1619        assert_eq!(
1620            resolve_model_alias("alias:fast", &config).unwrap(),
1621            Some("ollama/qwen3-coder:14b".to_string())
1622        );
1623    }
1624
1625    #[test]
1626    fn alias_prefix_requires_configuration() {
1627        let config = Config::default();
1628        assert!(resolve_model_alias("alias:vision", &config).is_err());
1629        assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
1630    }
1631
1632    /// `persist_default_reasoning` writes to the real config path, so
1633    /// this test goes through `save_config(_, Some(path))` directly to
1634    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
1635    /// Uses `std::env::temp_dir` (matching the pattern in
1636    /// `session::conversation` and `utils::logger`) — no external
1637    /// `tempfile` crate dependency.
1638    #[test]
1639    fn save_and_reload_preserves_reasoning_field() {
1640        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
1641        std::fs::create_dir_all(&dir).expect("create temp dir");
1642        let path = dir.join("config.toml");
1643
1644        let mut cfg = Config::default();
1645        cfg.default_model.provider = "ollama".to_string();
1646        cfg.default_model.name = "qwen3-coder:30b".to_string();
1647        cfg.default_model.reasoning = ReasoningLevel::Low;
1648
1649        save_config(&cfg, Some(path.clone())).expect("save");
1650
1651        let blob = std::fs::read_to_string(&path).expect("read");
1652        let loaded: Config = toml::from_str(&blob).expect("parse back");
1653        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
1654
1655        let _ = std::fs::remove_dir_all(&dir);
1656    }
1657
1658    /// Per-model entries serialize as a TOML table with quoted keys (the
1659    /// model IDs contain `/`). This test verifies the round-trip works
1660    /// through both serialization and deserialization, matching what
1661    /// `persist_reasoning_for_model` would produce in real use.
1662    #[test]
1663    fn save_and_reload_preserves_reasoning_per_model_table() {
1664        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
1665        std::fs::create_dir_all(&dir).expect("create temp dir");
1666        let path = dir.join("config.toml");
1667
1668        let mut cfg = Config::default();
1669        cfg.reasoning_per_model.insert(
1670            "anthropic/claude-sonnet-4-6".to_string(),
1671            ReasoningLevel::High,
1672        );
1673        cfg.reasoning_per_model
1674            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
1675
1676        save_config(&cfg, Some(path.clone())).expect("save");
1677
1678        let blob = std::fs::read_to_string(&path).expect("read");
1679        let loaded: Config = toml::from_str(&blob).expect("parse back");
1680        assert_eq!(
1681            loaded
1682                .reasoning_per_model
1683                .get("anthropic/claude-sonnet-4-6"),
1684            Some(&ReasoningLevel::High)
1685        );
1686        assert_eq!(
1687            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
1688            Some(&ReasoningLevel::Low)
1689        );
1690
1691        let _ = std::fs::remove_dir_all(&dir);
1692    }
1693
1694    /// `/context <n>` overrides round-trip through the per-model TOML table, and
1695    /// the offload toggle persists on `[ollama]`.
1696    #[test]
1697    fn save_and_reload_preserves_ollama_context_overrides() {
1698        let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
1699        std::fs::create_dir_all(&dir).expect("create temp dir");
1700        let path = dir.join("config.toml");
1701
1702        let mut cfg = Config::default();
1703        cfg.ollama_num_ctx_per_model
1704            .insert("ollama/ornith:9b".to_string(), 131_072);
1705        cfg.ollama.allow_ram_offload = true;
1706        cfg.ollama.max_auto_num_ctx = Some(65_536);
1707
1708        save_config(&cfg, Some(path.clone())).expect("save");
1709        let blob = std::fs::read_to_string(&path).expect("read");
1710        let loaded: Config = toml::from_str(&blob).expect("parse back");
1711
1712        assert_eq!(
1713            loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
1714            Some(&131_072)
1715        );
1716        assert!(loaded.ollama.allow_ram_offload);
1717        assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
1718
1719        let _ = std::fs::remove_dir_all(&dir);
1720    }
1721
1722    /// Older configs have neither the per-model `num_ctx` table nor the new
1723    /// `[ollama]` keys; loading must default cleanly (empty map, offload off).
1724    #[test]
1725    fn config_deserializes_without_ollama_context_keys() {
1726        let toml_blob = r#"
1727[ollama]
1728host = "localhost"
1729port = 11434
1730"#;
1731        let cfg: Config = toml::from_str(toml_blob).expect("parse");
1732        assert!(cfg.ollama_num_ctx_per_model.is_empty());
1733        assert!(!cfg.ollama.allow_ram_offload);
1734        assert_eq!(cfg.ollama.max_auto_num_ctx, None);
1735        // Configs from before the auto-start knob default it ON — reviving a
1736        // dead local server is the out-of-the-box behavior.
1737        assert!(cfg.ollama.auto_start);
1738    }
1739
1740    /// Configs from before Step 5b don't have a `reasoning_per_model`
1741    /// section. Loading them must succeed with an empty map — otherwise
1742    /// upgrade breaks every existing user.
1743    #[test]
1744    fn config_deserializes_without_reasoning_per_model() {
1745        let toml_blob = r#"
1746            last_used_model = "ollama/qwen3-coder:30b"
1747
1748            [default_model]
1749            provider = "ollama"
1750            name = "qwen3-coder:30b"
1751            temperature = 0.7
1752            max_tokens = 4096
1753        "#;
1754        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
1755        assert!(cfg.reasoning_per_model.is_empty());
1756        assert!(!cfg.prompt.is_customized());
1757    }
1758
1759    /// Config holds inline-secret-capable fields (`mcp_servers[].env`, `args`,
1760    /// `headers`, `providers[].extra_headers`), so it must be written
1761    /// owner-only rather than inheriting a world-readable umask.
1762    #[cfg(unix)]
1763    #[test]
1764    fn save_config_writes_owner_only_perms() {
1765        use std::os::unix::fs::PermissionsExt;
1766        let dir = std::env::temp_dir().join("mermaid_test_config_perms");
1767        std::fs::create_dir_all(&dir).expect("create temp dir");
1768        let path = dir.join("config.toml");
1769        // Pre-create a world-readable file to prove we also tighten existing.
1770        std::fs::write(&path, "stale").expect("seed");
1771        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
1772
1773        save_config(&Config::default(), Some(path.clone())).expect("save");
1774        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1775        assert_eq!(mode, 0o600, "config must be written owner-only");
1776
1777        let _ = std::fs::remove_dir_all(&dir);
1778    }
1779
1780    #[test]
1781    fn config_defaults_computer_use_auto_screenshot_on() {
1782        // An empty/legacy config must keep the auto-screenshot behavior (#98).
1783        let cfg: Config = toml::from_str("").expect("empty config");
1784        assert!(cfg.computer_use.auto_screenshot);
1785    }
1786
1787    #[test]
1788    fn prompt_config_replaces_and_appends_without_persisting() {
1789        let mut cfg = Config::default();
1790        cfg.prompt.system_prompt = Some("base".to_string());
1791        cfg.prompt
1792            .append_system_prompt
1793            .push("extra instructions".to_string());
1794
1795        assert_eq!(
1796            cfg.prompt.render_system_prompt("default"),
1797            "base\n\nextra instructions"
1798        );
1799
1800        let blob = toml::to_string(&cfg).expect("serialize");
1801        assert!(!blob.contains("extra instructions"));
1802        let loaded: Config = toml::from_str(&blob).expect("deserialize");
1803        assert!(!loaded.prompt.is_customized());
1804    }
1805
1806    /// An absent `[compaction]` section must reproduce the constants exactly —
1807    /// making the policy configurable must not change anyone's behavior.
1808    #[test]
1809    fn absent_compaction_section_matches_the_built_in_policy() {
1810        let c: Config = toml::from_str("").expect("empty config parses");
1811        assert_eq!(
1812            c.compaction.policy(),
1813            mermaid_domain::CompactionPolicy::default(),
1814        );
1815    }
1816
1817    #[test]
1818    fn compaction_settings_reach_the_policy() {
1819        let c: Config = toml::from_str(
1820            "[compaction]\n\
1821             auto_enabled = false\n\
1822             auto_threshold_percent = 60\n\
1823             tail_turns = 5\n\
1824             tail_token_budget = 12000\n\
1825             summary_max_tokens = 3000\n",
1826        )
1827        .expect("compaction section parses");
1828        let policy = c.compaction.policy();
1829        assert!(!policy.auto_enabled);
1830        assert_eq!(policy.auto_threshold_percent, 60);
1831        assert_eq!(policy.tail_turns, 5);
1832        assert_eq!(policy.tail_token_budget, 12_000);
1833        assert_eq!(policy.summary_max_tokens, 3_000);
1834        // Unset keys keep their defaults rather than zeroing out.
1835        let defaults = mermaid_domain::CompactionPolicy::default();
1836        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
1837    }
1838
1839    /// A hand-edited config degrades to the nearest workable value rather than
1840    /// putting compaction in a state where it silently cannot run.
1841    #[test]
1842    fn nonsense_compaction_settings_are_clamped() {
1843        let c: Config = toml::from_str(
1844            "[compaction]\n\
1845             auto_threshold_percent = 250\n\
1846             tail_turns = 0\n\
1847             tail_token_budget = 0\n\
1848             summary_max_tokens = 0\n\
1849             summarizer_input_token_budget = 0\n\
1850             tool_output_max_chars = 0\n\
1851             min_response_reserve_tokens = 50000\n\
1852             max_response_reserve_tokens = 1000\n",
1853        )
1854        .expect("config parses");
1855        let policy = c.compaction.policy();
1856        let defaults = mermaid_domain::CompactionPolicy::default();
1857
1858        assert_eq!(policy.auto_threshold_percent, 100, "percent clamps to 100");
1859        assert_eq!(
1860            policy.tail_turns, 1,
1861            "a checkpoint needs a live turn after it"
1862        );
1863        // Zero would mean "no budget at all"; fall back rather than disable.
1864        assert_eq!(policy.tail_token_budget, defaults.tail_token_budget);
1865        assert_eq!(policy.summary_max_tokens, defaults.summary_max_tokens);
1866        assert_eq!(
1867            policy.summarizer_input_token_budget,
1868            defaults.summarizer_input_token_budget
1869        );
1870        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
1871
1872        // Swapped reserve bounds are ordered, not obeyed: `response_reserve`
1873        // clamps with `.max(min).min(max)`, so an inverted pair would return
1874        // the smaller value and under-reserve on every single turn.
1875        assert_eq!(policy.min_response_reserve_tokens, 1_000);
1876        assert_eq!(policy.max_response_reserve_tokens, 50_000);
1877        assert!(policy.min_response_reserve_tokens <= policy.max_response_reserve_tokens);
1878    }
1879
1880    /// `auto_threshold_percent = 0` would compact on every single turn, before
1881    /// there is anything to compact.
1882    #[test]
1883    fn zero_compaction_threshold_clamps_up() {
1884        let c: Config =
1885            toml::from_str("[compaction]\nauto_threshold_percent = 0\n").expect("parses");
1886        assert_eq!(c.compaction.policy().auto_threshold_percent, 1);
1887    }
1888
1889    #[test]
1890    fn plan_config_defaults_parse_and_do_not_freeze() {
1891        // Absent section: dialog on, nothing pinned.
1892        let c: Config = toml::from_str("").expect("empty config parses");
1893        assert!(!c.plan.auto_approve);
1894        assert!(c.plan.post_approve.is_none());
1895        // Explicit values parse.
1896        let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
1897            .expect("plan section parses");
1898        assert!(c.plan.auto_approve);
1899        assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
1900        assert_eq!(
1901            toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
1902                .expect("wait parses")
1903                .plan
1904                .post_approve,
1905            Some(PlanPostApprove::Wait)
1906        );
1907        // The unset pin is never frozen into a saved config (Option +
1908        // skip_serializing_if), so a future default change still reaches
1909        // existing files.
1910        let blob = toml::to_string(&Config::default()).expect("serialize");
1911        assert!(!blob.contains("post_approve"));
1912    }
1913
1914    /// Config with one remote provider carrying an explicit `default_model`.
1915    fn config_with_provider_default(provider: &str, model: &str) -> Config {
1916        let mut config = Config::default();
1917        config.providers.insert(
1918            provider.to_string(),
1919            UserProviderConfig {
1920                default_model: Some(model.to_string()),
1921                ..Default::default()
1922            },
1923        );
1924        config
1925    }
1926
1927    /// The whole point of the Ollama-optional path: a machine whose only
1928    /// backend is Anthropic must resolve a model without Ollama in the picture.
1929    #[test]
1930    fn provider_default_model_resolves_without_ollama() {
1931        let config = config_with_provider_default("anthropic", "claude-x");
1932        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1933            assert_eq!(
1934                configured_provider_default_model(&config).as_deref(),
1935                Some("anthropic/claude-x")
1936            );
1937        });
1938    }
1939
1940    /// An unconfigured provider's `default_model` is not a usable default —
1941    /// building it would fail on the missing key at the first request.
1942    #[test]
1943    fn provider_default_model_ignored_without_a_key() {
1944        let config = config_with_provider_default("anthropic", "claude-x");
1945        temp_env::with_vars([("ANTHROPIC_API_KEY", None::<&str>)], || {
1946            // The keyring is the machine's, so only assert the env-var half:
1947            // with no key in the environment there is nothing to prefer.
1948            if mermaid_model::utils::provider_key_source("anthropic", "ANTHROPIC_API_KEY", None)
1949                == "none"
1950            {
1951                assert_eq!(configured_provider_default_model(&config), None);
1952            }
1953        });
1954    }
1955
1956    /// OpenRouter ids are `vendor/model`, which must be prefixed once, not
1957    /// twice — and an id that already names its provider is left alone.
1958    #[test]
1959    fn provider_default_model_is_prefixed_exactly_once() {
1960        temp_env::with_vars([("OPENROUTER_API_KEY", Some("sk-test"))], || {
1961            let vendor_model = config_with_provider_default("openrouter", "z-ai/glm-5.2");
1962            assert_eq!(
1963                configured_provider_default_model(&vendor_model).as_deref(),
1964                Some("openrouter/z-ai/glm-5.2")
1965            );
1966            let already_prefixed =
1967                config_with_provider_default("openrouter", "openrouter/z-ai/glm-5.2");
1968            assert_eq!(
1969                configured_provider_default_model(&already_prefixed).as_deref(),
1970                Some("openrouter/z-ai/glm-5.2")
1971            );
1972        });
1973    }
1974
1975    /// The regression this replaced: startup used to end at "Ollama is not
1976    /// installed", which reads as "Mermaid needs Ollama". With a provider key
1977    /// present the message must be about naming a model, not about Ollama.
1978    #[test]
1979    fn missing_model_error_does_not_demand_ollama_when_a_provider_is_ready() {
1980        let config = Config::default();
1981        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1982            let msg = no_model_configured_error(&config, false).to_string();
1983            assert!(msg.contains("anthropic"), "{msg}");
1984            assert!(msg.contains("mermaid --model anthropic/<model>"), "{msg}");
1985            assert!(msg.contains("[providers.anthropic]"), "{msg}");
1986            // Ollama may still be mentioned as the local option, but never as
1987            // a prerequisite for running Mermaid at all.
1988            assert!(!msg.contains("Ollama is not installed"), "{msg}");
1989        });
1990    }
1991
1992    /// Run `f` with every built-in provider's key env var unset, so a key in
1993    /// the developer's own shell can't change what the message says.
1994    fn with_no_provider_keys<T>(f: impl FnOnce() -> T) -> T {
1995        let cleared: Vec<(&str, Option<&str>)> = [
1996            crate::providers::model::anthropic::DEFAULT_API_KEY_ENV,
1997            crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
1998            crate::providers::model::gemini::LEGACY_API_KEY_ENV,
1999            crate::providers::model::meta::DEFAULT_API_KEY_ENV,
2000        ]
2001        .iter()
2002        .map(|env| (*env, None))
2003        .chain(
2004            mermaid_model::models::PROVIDER_REGISTRY
2005                .iter()
2006                .map(|profile| (profile.api_key_env, None)),
2007        )
2008        .collect();
2009        temp_env::with_vars(cleared, f)
2010    }
2011
2012    /// With nothing configured at all, both routes are offered — the remote
2013    /// one first, since it needs no install.
2014    #[test]
2015    fn missing_model_error_offers_both_routes_when_nothing_is_configured() {
2016        with_no_provider_keys(|| {
2017            let msg = no_model_configured_error(&Config::default(), false).to_string();
2018            assert!(msg.contains("https://ollama.com/download"), "{msg}");
2019            // A keyring login would legitimately name a provider instead; only
2020            // assert the no-provider wording when there really is none.
2021            if !msg.contains("Remote providers ready") {
2022                assert!(msg.contains("ANTHROPIC_API_KEY"), "{msg}");
2023            }
2024        });
2025    }
2026
2027    /// End-to-end through `resolve_model_id` itself: nothing pinned, no local
2028    /// model reachable, one configured provider — Mermaid starts on that
2029    /// provider instead of erroring out about Ollama.
2030    #[test]
2031    fn resolve_model_id_falls_back_to_a_configured_provider() {
2032        let mut config = config_with_provider_default("anthropic", "claude-x");
2033        // Point at a dead port with autostart off, so "no local model" holds
2034        // whether or not this machine has Ollama installed — and point
2035        // OLLAMA_MODELS at an empty store, so the on-disk fallback (which
2036        // answers for a dead server precisely so listings survive it) cannot
2037        // report this machine's real models into the test.
2038        config.ollama.host = "http://127.0.0.1".to_string();
2039        config.ollama.port = 1;
2040        config.ollama.auto_start = false;
2041        let empty_store =
2042            std::env::temp_dir().join(format!("mermaid-empty-ollama-store-{}", std::process::id()));
2043        std::fs::create_dir_all(&empty_store).expect("create empty store");
2044        temp_env::with_vars(
2045            [
2046                ("ANTHROPIC_API_KEY", Some("sk-test")),
2047                ("OLLAMA_MODELS", empty_store.to_str()),
2048            ],
2049            || {
2050                let runtime = tokio::runtime::Runtime::new().expect("runtime");
2051                let resolved = runtime
2052                    .block_on(resolve_model_id(None, &config))
2053                    .expect("a configured provider is enough to resolve a model");
2054                assert_eq!(resolved, "anthropic/claude-x");
2055            },
2056        );
2057        let _ = std::fs::remove_dir_all(&empty_store);
2058    }
2059
2060    /// An installed-but-empty Ollama needs a pull, not another install.
2061    #[test]
2062    fn missing_model_error_says_pull_when_ollama_is_installed() {
2063        let msg = no_model_configured_error(&Config::default(), true).to_string();
2064        assert!(msg.contains("ollama pull qwen3:8b"), "{msg}");
2065        assert!(!msg.contains("https://ollama.com/download"), "{msg}");
2066    }
2067}