1use 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
16fn take_profiles(table: &mut toml::Table) -> toml::Table {
23 match table.remove("profiles") {
24 Some(toml::Value::Table(profiles)) => profiles,
25 _ => toml::Table::new(),
28 }
29}
30
31fn 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
70pub 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
90pub struct LayeredLoad {
93 pub config: Config,
95 pub warnings: Vec<String>,
97 pub notices: Vec<String>,
99}
100
101pub 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 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 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.extend(unknown_key_warnings);
162 Ok(LayeredLoad {
163 config,
164 warnings: sanitizer_warnings,
165 notices,
166 })
167}
168
169#[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#[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
211pub(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
226pub(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
240fn 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
262fn 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
279fn 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
298fn 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
313fn 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 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
337fn 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
354fn 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
363fn 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
373fn 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
393pub(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#[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 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
444pub fn get_config_path() -> Result<PathBuf> {
446 Ok(get_config_dir()?.join("config.toml"))
447}
448
449pub fn get_config_dir() -> Result<PathBuf> {
457 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
458 let config_dir = proj_dirs.config_dir();
459 std::fs::create_dir_all(config_dir)?;
460 Ok(config_dir.to_path_buf())
461 } else {
462 let home = std::env::var("HOME")
464 .or_else(|_| std::env::var("USERPROFILE"))
465 .context("Could not determine home directory")?;
466 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
467 std::fs::create_dir_all(&config_dir)?;
468 Ok(config_dir)
469 }
470}
471
472fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
479 let path = if let Some(p) = path {
480 p
481 } else {
482 get_config_dir()?.join("config.toml")
483 };
484 write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
485}
486
487fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
498 #[cfg(unix)]
499 mermaid_runtime::write_atomic_with_mode(path, bytes, 0o600)
500 .with_context(|| format!("Failed to write config to {}", path.display()))?;
501 #[cfg(not(unix))]
502 mermaid_runtime::write_atomic(path, bytes)
503 .with_context(|| format!("Failed to write config to {}", path.display()))?;
504 Ok(())
505}
506
507pub fn init_config() -> Result<()> {
515 let config_file = get_config_path()?;
516
517 if config_file.exists() {
518 println!("Configuration already exists at: {}", config_file.display());
519 } else {
520 let default_config = Config::default();
521 save_config(&default_config, Some(config_file.clone()))?;
522 println!("Created configuration at: {}", config_file.display());
523 }
524
525 Ok(())
526}
527
528static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
534
535fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
543 update_user_config_table_at(&get_config_path()?, mutate)
544}
545
546fn update_user_config_table_at(
548 path: &std::path::Path,
549 mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
550) -> Result<()> {
551 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
552 let mut table = read_config_table(path)?;
553 migrate_legacy_max_tokens(&mut table);
555 migrate_legacy_model_profiles(&mut table);
556 mutate(&mut table)?;
557 write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
558}
559
560pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
572 update_user_config_table(|table| deep_set_segments(table, path, value))
573}
574
575pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
584 update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
585}
586
587pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
595 let mut removed = false;
596 update_user_config_table(|table| {
597 removed = deep_remove_segments(table, path);
598 Ok(())
599 })?;
600 Ok(removed)
601}
602
603pub fn persist_last_model(model: &str) -> Result<()> {
609 update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
610}
611
612pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
618 update_user_config_key(
619 &["ui", "theme"],
620 toml::Value::String(theme.as_str().to_string()),
621 )
622}
623
624pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
631 update_user_config_key(
632 &["default_model", "reasoning"],
633 toml::Value::try_from(level)?,
634 )
635}
636
637pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
647 update_user_config_key(
648 &["reasoning_per_model", model_id],
649 toml::Value::try_from(level)?,
650 )
651}
652
653pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
662 match num_ctx {
663 Some(n) => update_user_config_key(
664 &["ollama_num_ctx_per_model", model_id],
665 toml::Value::Integer(i64::from(n)),
666 ),
667 None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
668 }
669}
670
671pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
677 update_user_config_key(
678 &["ollama", "allow_ram_offload"],
679 toml::Value::Boolean(enabled),
680 )
681}
682
683pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
694 if let Some(model) = cli_model {
695 if let Some(resolved) = resolve_model_alias(model, config)? {
696 return Ok(resolved);
697 }
698 return Ok(model.to_string());
699 }
700 if let Some(last_model) = &config.last_used_model {
701 if let Some(resolved) = resolve_model_alias(last_model, config)? {
702 return Ok(resolved);
703 }
704 return Ok(last_model.clone());
705 }
706 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
707 return Ok(format!(
708 "{}/{}",
709 config.default_model.provider, config.default_model.name
710 ));
711 }
712 let local = crate::ollama::local_models(config).await;
718 if let Some(first) = local.as_ref().and_then(|models| models.first()) {
719 return Ok(format!("ollama/{first}"));
720 }
721 if let Some(model_id) = configured_provider_default_model(config) {
722 return Ok(model_id);
723 }
724 Err(no_model_configured_error(config, local.is_some()))
725}
726
727fn configured_provider_default_model(config: &Config) -> Option<String> {
732 for provider in crate::providers::configured_remote_providers(config) {
733 let model = config
734 .providers
735 .get(&provider.name)
736 .and_then(|entry| entry.default_model.as_deref())
737 .map(str::trim)
738 .filter(|model| !model.is_empty());
739 let Some(model) = model else { continue };
740 if model.starts_with(&format!("{}/", provider.name)) {
744 return Some(model.to_string());
745 }
746 return Some(format!("{}/{}", provider.name, model));
747 }
748 None
749}
750
751fn no_model_configured_error(config: &Config, ollama_installed: bool) -> anyhow::Error {
757 let providers = crate::providers::configured_remote_providers(config);
758 let mut lines = vec!["No model configured yet.".to_string(), String::new()];
759
760 if let Some(first) = providers.first() {
761 let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
762 lines.push(format!("Remote providers ready: {}", names.join(", ")));
763 lines.push("Name a model to use one, e.g.:".to_string());
764 lines.push(format!(" mermaid --model {}/<model>", first.name));
765 lines.push(
766 "Mermaid remembers the last model you used, so --model is a one-time step; \
767 `mermaid list` shows what is available."
768 .to_string(),
769 );
770 lines.push(String::new());
771 lines.push("Or pin one in config.toml:".to_string());
772 lines.push(format!(" [providers.{}]", first.name));
773 lines.push(" default_model = \"<model>\"".to_string());
774 } else {
775 lines.push(
776 "For a remote model, set a provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY,"
777 .to_string(),
778 );
779 lines.push("GOOGLE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, …) and name a".to_string());
780 lines.push("model: mermaid --model anthropic/<model>".to_string());
781 }
782
783 lines.push(String::new());
784 if ollama_installed {
785 lines.push("For a local model, pull one first: ollama pull qwen3:8b".to_string());
786 } else {
787 lines.push(
788 "For local models, install Ollama (https://ollama.com/download), then: \
789 ollama pull qwen3:8b"
790 .to_string(),
791 );
792 }
793 lines.push("`mermaid doctor` reports what is and isn't ready.".to_string());
794
795 anyhow::anyhow!(lines.join("\n"))
796}
797
798fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
799 let alias = requested.strip_prefix("alias:").unwrap_or(requested);
800 if let Some(model) = config.model_aliases.get(alias) {
801 anyhow::ensure!(
802 !model.trim().is_empty(),
803 "model alias `{alias}` is configured with an empty model id"
804 );
805 return Ok(Some(model.clone()));
806 }
807 if requested.starts_with("alias:") {
808 anyhow::bail!("model alias `{alias}` is not configured; add it under [model_aliases]");
809 }
810 Ok(None)
811}
812
813pub(crate) fn session_flags_table(flags: &SessionFlags) -> Result<toml::Table> {
823 let mut table = toml::Table::new();
824 apply_cli_overrides(&mut table, &flags.overrides)?;
825 if flags.deny_network {
826 deep_set_segments(
827 &mut table,
828 &["safety", "network"],
829 toml::Value::String("deny".into()),
830 )?;
831 }
832 if flags.confine_fs {
833 deep_set_segments(
834 &mut table,
835 &["safety", "filesystem"],
836 toml::Value::String("project".into()),
837 )?;
838 }
839 if let Some(n) = flags.max_tokens {
840 deep_set_segments(
841 &mut table,
842 &["default_model", "max_tokens"],
843 toml::Value::Integer(n as i64),
844 )?;
845 }
846 if flags.allow_untrusted_tools {
847 deep_set_segments(
848 &mut table,
849 &["safety", "allow_untrusted_headless_tools"],
850 toml::Value::Boolean(true),
851 )?;
852 }
853 Ok(table)
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use mermaid_runtime::SafetyMode;
860 use std::collections::HashMap;
861
862 #[test]
863 fn legacy_default_max_tokens_migrates_to_auto() {
864 let mut table: toml::Table =
866 toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
867 migrate_legacy_max_tokens(&mut table);
868 migrate_legacy_model_profiles(&mut table);
869 let (config, _) = finalize_config(table).unwrap();
870 assert_eq!(config.default_model.max_tokens, 0);
871
872 let mut table: toml::Table =
874 toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
875 migrate_legacy_max_tokens(&mut table);
876 migrate_legacy_model_profiles(&mut table);
877 let (config, _) = finalize_config(table).unwrap();
878 assert_eq!(config.default_model.max_tokens, 8192);
879
880 let mut table = toml::Table::new();
882 migrate_legacy_max_tokens(&mut table);
883 migrate_legacy_model_profiles(&mut table);
884 let (config, _) = finalize_config(table).unwrap();
885 assert_eq!(config.default_model.max_tokens, 0);
886 }
887
888 #[test]
889 fn legacy_model_profiles_table_migrates_to_model_aliases() {
890 let mut table: toml::Table =
892 toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
893 migrate_legacy_model_profiles(&mut table);
894 let (config, ignored) = finalize_config(table).unwrap();
895 assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
896 assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
897 let mut table: toml::Table =
899 toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
900 .unwrap();
901 migrate_legacy_model_profiles(&mut table);
902 let (config, ignored) = finalize_config(table).unwrap();
903 assert_eq!(config.model_aliases["fast"], "new");
904 assert!(ignored.is_empty());
905 let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
907 std::fs::create_dir_all(&dir).unwrap();
908 let path = dir.join("config.toml");
909 std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
910 update_user_config_table_at(&path, |_| Ok(())).unwrap();
911 let blob = std::fs::read_to_string(&path).unwrap();
912 assert!(blob.contains("[model_aliases]"), "{blob}");
913 assert!(!blob.contains("model_profiles"), "{blob}");
914 let _ = std::fs::remove_dir_all(&dir);
915 }
916
917 #[test]
918 fn ui_theme_deserializes_defaults_and_rejects_typos() {
919 let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
920 assert_eq!(config.ui.theme, ThemeChoice::Light);
921 let config: Config = toml::from_str("").unwrap();
923 assert_eq!(config.ui.theme, ThemeChoice::Dark);
924 assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
925 assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
927 }
928
929 #[test]
930 fn finalize_config_flags_unknown_keys() {
931 let table: toml::Table =
932 toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
933 .unwrap();
934 let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
935 assert_eq!(config.default_model.max_tokens, 512);
936 assert!(
937 ignored.iter().any(|p| p == "unknown_top"),
938 "got {ignored:?}"
939 );
940 assert!(
941 ignored.iter().any(|p| p.contains("bogus")),
942 "got {ignored:?}"
943 );
944 }
945
946 #[test]
947 fn cli_overrides_beat_file_and_create_nested_tables() {
948 let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
950 apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
951 let (config, ignored) = finalize_config(table).unwrap();
952 assert_eq!(config.default_model.max_tokens, 8192);
953 assert!(ignored.is_empty());
954 let mut empty = toml::Table::new();
956 apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
957 assert_eq!(
958 finalize_config(empty).unwrap().0.default_model.max_tokens,
959 256
960 );
961 }
962
963 #[test]
964 fn parse_override_value_keeps_toml_types_with_string_fallback() {
965 assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
966 assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
967 assert_eq!(
968 parse_override_value("ollama/qwen"),
969 toml::Value::String("ollama/qwen".to_string())
970 );
971 }
972
973 #[test]
974 fn cli_override_invalid_format_errors() {
975 let mut table = toml::Table::new();
976 assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
977 assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
978 }
979
980 #[test]
981 fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
982 let mut base: toml::Table = toml::from_str(
983 "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
984 )
985 .unwrap();
986 let overlay: toml::Table =
987 toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
988 .unwrap();
989 deep_merge(&mut base, overlay);
990 assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
992 assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
994 assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
996 assert_eq!(base["top"].as_integer(), Some(1));
998 }
999
1000 #[test]
1001 fn deep_merge_overlay_wins_on_kind_conflict() {
1002 let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
1004 let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
1005 deep_merge(&mut base, overlay);
1006 assert_eq!(base["a"].as_integer(), Some(5));
1007 assert_eq!(base["b"]["y"].as_integer(), Some(3));
1008 }
1009
1010 #[test]
1011 fn merge_layers_precedence_and_layer_attributed_warnings() {
1012 let user: toml::Table = toml::from_str(
1013 "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
1014 )
1015 .unwrap();
1016 let session: toml::Table =
1017 toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
1018 let (config, warnings) = merge_layers(vec![
1019 LayerSource {
1020 layer: ConfigLayer::User,
1021 origin: "/tmp/user.toml".to_string(),
1022 table: user,
1023 },
1024 LayerSource {
1025 layer: ConfigLayer::Session,
1026 origin: "command line".to_string(),
1027 table: session,
1028 },
1029 ])
1030 .expect("merges");
1031 assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
1033 assert_eq!(config.default_model.max_tokens, 100);
1034 assert!(
1036 warnings
1037 .iter()
1038 .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
1039 "got {warnings:?}"
1040 );
1041 assert!(
1042 warnings
1043 .iter()
1044 .any(|w| w.contains("session_typo") && w.contains("session flags")),
1045 "got {warnings:?}"
1046 );
1047 }
1048
1049 #[test]
1050 fn take_profiles_excises_and_tolerates_absence() {
1051 let mut table: toml::Table =
1052 toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
1053 let profiles = take_profiles(&mut table);
1054 assert!(table.is_empty(), "profiles must be excised: {table:?}");
1055 assert!(profiles.contains_key("fast"));
1056 let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
1058 assert!(take_profiles(&mut table).is_empty());
1059 assert_eq!(table.len(), 1);
1060 let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
1062 assert!(take_profiles(&mut table).is_empty());
1063 assert!(table.is_empty());
1064 }
1065
1066 #[test]
1067 fn resolve_profile_layer_errors_name_available_profiles() {
1068 let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
1069 let path = std::path::Path::new("/tmp/config.toml");
1070 let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
1071 assert!(err.to_string().contains("available: fast, work"), "{err}");
1072 let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
1074 assert!(
1075 err.to_string().contains("no config profiles defined"),
1076 "{err}"
1077 );
1078 let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
1080 let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
1081 assert!(err.to_string().contains("not a table"), "{err}");
1082 let profiles: toml::Table =
1084 toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
1085 let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
1086 assert_eq!(layer.layer, ConfigLayer::Profile);
1087 assert!(layer.origin.contains("profile:work"));
1088 }
1089
1090 #[test]
1091 fn profile_layer_beats_user_loses_to_project_and_session() {
1092 let user: toml::Table = toml::from_str(
1093 "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
1094 )
1095 .unwrap();
1096 let profile: toml::Table = toml::from_str(
1097 "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
1098 )
1099 .unwrap();
1100 let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
1101 let session: toml::Table =
1102 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1103 let (config, warnings) = merge_layers(vec![
1104 LayerSource {
1105 layer: ConfigLayer::User,
1106 origin: "/tmp/user.toml".to_string(),
1107 table: user,
1108 },
1109 LayerSource {
1110 layer: ConfigLayer::Profile,
1111 origin: "profile:work (/tmp/user.toml)".to_string(),
1112 table: profile,
1113 },
1114 LayerSource {
1115 layer: ConfigLayer::Project,
1116 origin: "/repo/.mermaid/config.toml".to_string(),
1117 table: project,
1118 },
1119 LayerSource {
1120 layer: ConfigLayer::Session,
1121 origin: "command line".to_string(),
1122 table: session,
1123 },
1124 ])
1125 .expect("merges");
1126 assert_eq!(config.default_model.temperature, 0.5);
1129 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1130 assert_eq!(config.default_model.max_tokens, 100);
1131 assert!(
1133 warnings.iter().any(|w| w.contains("profile_typo")
1134 && w.contains("config profile (profile:work (/tmp/user.toml))")),
1135 "got {warnings:?}"
1136 );
1137 }
1138
1139 #[test]
1140 fn persists_never_touch_profile_tables() {
1141 let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
1142 std::fs::create_dir_all(&dir).expect("create temp dir");
1143 let path = dir.join("config.toml");
1144 std::fs::write(
1145 &path,
1146 "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
1147 )
1148 .expect("seed");
1149
1150 update_user_config_table_at(&path, |table| {
1151 deep_set_segments(
1152 table,
1153 &["safety", "mode"],
1154 toml::Value::String("auto".to_string()),
1155 )
1156 })
1157 .expect("persist");
1158
1159 let table: toml::Table =
1160 toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
1161 assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
1162 assert_eq!(
1164 table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
1165 Some(0.1)
1166 );
1167 let _ = std::fs::remove_dir_all(&dir);
1168 }
1169
1170 #[test]
1171 fn session_flags_table_maps_each_flag() {
1172 let flags = SessionFlags {
1173 overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
1174 deny_network: true,
1175 confine_fs: true,
1176 max_tokens: Some(512),
1177 allow_untrusted_tools: true,
1178 profile: None,
1179 };
1180 let (config, _) = finalize_config(session_flags_table(&flags).unwrap()).unwrap();
1181 assert_eq!(config.safety.network, NetworkPolicy::Deny);
1182 assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
1183 assert_eq!(config.default_model.max_tokens, 512);
1184 assert!(config.safety.allow_untrusted_headless_tools);
1185 assert_eq!(config.web.searxng_url, "http://x:1");
1186 }
1187
1188 #[test]
1189 fn session_dedicated_flags_beat_dash_c() {
1190 let flags = SessionFlags {
1193 overrides: vec!["safety.network=allow".to_string()],
1194 deny_network: true,
1195 ..Default::default()
1196 };
1197 let (config, _) = finalize_config(session_flags_table(&flags).unwrap()).unwrap();
1198 assert_eq!(config.safety.network, NetworkPolicy::Deny);
1199 }
1200
1201 #[test]
1202 fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
1203 let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
1205 let mut warnings = Vec::new();
1206 collect_layer_warnings(
1207 &LayerSource {
1208 layer: ConfigLayer::User,
1209 origin: "x".to_string(),
1210 table: bad.clone(),
1211 },
1212 &mut warnings,
1213 );
1214 assert!(warnings.is_empty());
1215 assert!(
1217 merge_layers(vec![LayerSource {
1218 layer: ConfigLayer::User,
1219 origin: "x".to_string(),
1220 table: bad.clone(),
1221 }])
1222 .is_err()
1223 );
1224 let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
1226 let (config, _) = merge_layers(vec![
1227 LayerSource {
1228 layer: ConfigLayer::User,
1229 origin: "x".to_string(),
1230 table: bad,
1231 },
1232 LayerSource {
1233 layer: ConfigLayer::Session,
1234 origin: "command line".to_string(),
1235 table: fix,
1236 },
1237 ])
1238 .expect("later layer repairs the earlier one");
1239 assert_eq!(config.safety.mode, SafetyMode::Ask);
1240 }
1241
1242 #[test]
1243 fn project_layer_beats_user_and_loses_to_session() {
1244 let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
1245 let project: toml::Table = toml::from_str(
1246 "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
1247 )
1248 .unwrap();
1249 let session: toml::Table =
1250 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1251 let (config, _) = merge_layers(vec![
1252 LayerSource {
1253 layer: ConfigLayer::User,
1254 origin: "user".to_string(),
1255 table: user,
1256 },
1257 LayerSource {
1258 layer: ConfigLayer::Project,
1259 origin: "project".to_string(),
1260 table: project,
1261 },
1262 LayerSource {
1263 layer: ConfigLayer::Session,
1264 origin: "command line".to_string(),
1265 table: session,
1266 },
1267 ])
1268 .expect("merges");
1269 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1271 assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
1273 }
1274
1275 #[test]
1276 fn session_flags_survive_corrupt_user_layer_fallback() {
1277 let flags = SessionFlags {
1280 deny_network: true,
1281 ..Default::default()
1282 };
1283 let config = session_flags_table(&flags)
1284 .ok()
1285 .and_then(|table| finalize_config(table).ok())
1286 .map(|(config, _)| config)
1287 .unwrap_or_default();
1288 assert_eq!(config.safety.network, NetworkPolicy::Deny);
1289 }
1290
1291 #[test]
1292 fn deep_set_segments_addresses_keys_containing_dots() {
1293 let mut table = toml::Table::new();
1296 deep_set_segments(
1297 &mut table,
1298 &["reasoning_per_model", "gemini/gemini-2.5-pro"],
1299 toml::Value::String("high".to_string()),
1300 )
1301 .unwrap();
1302 let (config, ignored) = finalize_config(table).unwrap();
1303 assert!(ignored.is_empty(), "got {ignored:?}");
1304 assert_eq!(
1305 config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
1306 Some(&ReasoningLevel::High)
1307 );
1308 }
1309
1310 #[test]
1311 fn deep_remove_segments_removes_leaf_only() {
1312 let mut table: toml::Table =
1313 toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
1314 .unwrap();
1315 assert!(deep_remove_segments(
1316 &mut table,
1317 &["ollama_num_ctx_per_model", "ollama/a"]
1318 ));
1319 assert_eq!(
1321 table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
1322 Some(2)
1323 );
1324 assert!(!deep_remove_segments(
1325 &mut table,
1326 &["ollama_num_ctx_per_model", "ollama/a"]
1327 ));
1328 assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
1329 }
1330
1331 #[test]
1332 fn update_user_config_table_preserves_unknown_keys() {
1333 let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
1334 std::fs::create_dir_all(&dir).expect("create temp dir");
1335 let path = dir.join("config.toml");
1336 std::fs::write(
1339 &path,
1340 "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
1341 )
1342 .expect("seed");
1343
1344 update_user_config_table_at(&path, |table| {
1345 deep_set_segments(
1346 table,
1347 &["last_used_model"],
1348 toml::Value::String("ollama/new".to_string()),
1349 )
1350 })
1351 .expect("persist");
1352
1353 let blob = std::fs::read_to_string(&path).expect("read back");
1354 let table: toml::Table = toml::from_str(&blob).expect("parse back");
1355 assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
1357 assert_eq!(table["future_key"].as_str(), Some("kept"));
1359 assert!(!blob.contains("safety"), "defaults must not be frozen in");
1361 assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
1362
1363 let _ = std::fs::remove_dir_all(&dir);
1364 }
1365
1366 #[test]
1367 fn mcp_tool_allowed_honors_enabled_and_disabled() {
1368 let cfg = McpServerConfig::default();
1370 assert!(cfg.tool_allowed("anything"));
1371 let cfg = McpServerConfig {
1373 enabled_tools: vec!["read".into(), "search".into()],
1374 ..Default::default()
1375 };
1376 assert!(cfg.tool_allowed("read"));
1377 assert!(!cfg.tool_allowed("write"));
1378 let cfg = McpServerConfig {
1380 enabled_tools: vec!["read".into(), "write".into()],
1381 disabled_tools: vec!["write".into()],
1382 ..Default::default()
1383 };
1384 assert!(cfg.tool_allowed("read"));
1385 assert!(!cfg.tool_allowed("write"));
1386 }
1387
1388 #[test]
1389 fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
1390 let cfg = McpServerConfig {
1392 command: "npx".to_string(),
1393 ..Default::default()
1394 };
1395 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
1396 let cfg = McpServerConfig {
1398 url: Some("https://example.com/mcp".to_string()),
1399 ..Default::default()
1400 };
1401 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
1402 let cfg = McpServerConfig {
1404 command: "npx".to_string(),
1405 url: Some("https://example.com/mcp".to_string()),
1406 ..Default::default()
1407 };
1408 assert!(
1409 cfg.transport_kind()
1410 .unwrap_err()
1411 .to_string()
1412 .contains("mutually exclusive")
1413 );
1414 let cfg = McpServerConfig::default();
1416 assert!(
1417 cfg.transport_kind()
1418 .unwrap_err()
1419 .to_string()
1420 .contains("neither")
1421 );
1422 }
1423
1424 #[test]
1425 fn mcp_transport_kind_gates_url_scheme() {
1426 let with_url = |url: &str| McpServerConfig {
1427 url: Some(url.to_string()),
1428 ..Default::default()
1429 };
1430 assert!(
1433 with_url("https://mcp.example.com/x")
1434 .transport_kind()
1435 .is_ok()
1436 );
1437 assert!(
1438 with_url("http://localhost:8080/mcp")
1439 .transport_kind()
1440 .is_ok()
1441 );
1442 assert!(
1443 with_url("http://127.0.0.1:8080/mcp")
1444 .transport_kind()
1445 .is_ok()
1446 );
1447 assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
1448 assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
1449 assert!(with_url("not a url").transport_kind().is_err());
1450 }
1451
1452 #[test]
1453 fn mcp_server_config_debug_masks_header_values() {
1454 let mut headers = HashMap::new();
1455 headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
1456 let mut env_headers = HashMap::new();
1457 env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
1458 let cfg = McpServerConfig {
1459 url: Some("https://example.com/mcp".to_string()),
1460 headers,
1461 env_headers,
1462 ..Default::default()
1463 };
1464 let rendered = format!("{cfg:?}");
1465 assert!(!rendered.contains("sk-secret"), "{rendered}");
1466 assert!(rendered.contains("Authorization"), "{rendered}");
1467 assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
1469 }
1470
1471 #[test]
1472 fn mcp_url_config_round_trips_through_toml_without_command() {
1473 let cfg = McpServerConfig {
1476 url: Some("https://example.com/mcp".to_string()),
1477 ..Default::default()
1478 };
1479 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
1480 assert!(
1481 !blob.contains("command"),
1482 "empty command must be omitted: {blob}"
1483 );
1484 let back: McpServerConfig = toml::from_str(&blob).unwrap();
1485 assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
1486 assert!(back.command.is_empty());
1487 let cfg = McpServerConfig {
1489 command: "npx".to_string(),
1490 ..Default::default()
1491 };
1492 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
1493 assert!(!blob.contains("url"), "{blob}");
1494 }
1495
1496 #[test]
1501 fn model_settings_deserializes_without_reasoning_field() {
1502 let toml_blob = r#"
1503 provider = "ollama"
1504 name = "qwen3-coder:30b"
1505 temperature = 0.7
1506 max_tokens = 4096
1507 "#;
1508 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
1509 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
1510 assert_eq!(settings.provider, "ollama");
1511 }
1512
1513 #[test]
1514 fn model_settings_round_trips_reasoning_high() {
1515 let original = ModelSettings {
1516 provider: "anthropic".to_string(),
1517 name: "claude-sonnet-4-6".to_string(),
1518 temperature: 0.5,
1519 max_tokens: 8192,
1520 reasoning: ReasoningLevel::High,
1521 };
1522 let toml_blob = toml::to_string(&original).expect("serialize");
1523 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
1524 assert_eq!(back.reasoning, ReasoningLevel::High);
1525 assert_eq!(back.name, "claude-sonnet-4-6");
1526 }
1527
1528 #[test]
1529 fn agents_config_defaults_and_parses_custom_types() {
1530 let config: Config = toml::from_str("").expect("empty config parses");
1532 assert_eq!(config.agents.timeout_secs, 1200);
1533 assert!(config.agents.types.is_empty());
1534
1535 let config: Config = toml::from_str(
1536 r#"
1537[agents]
1538timeout_secs = 300
1539
1540[agents.types.scout]
1541tools = ["read_file", "execute_command"]
1542safety = "read_only"
1543preamble = "You are a scout."
1544model = "ollama/qwen3:8b"
1545"#,
1546 )
1547 .expect("agents section parses");
1548 assert_eq!(config.agents.timeout_secs, 300);
1549 let scout = &config.agents.types["scout"];
1550 assert_eq!(
1551 scout.tools.as_deref(),
1552 Some(&["read_file".to_string(), "execute_command".to_string()][..])
1553 );
1554 assert_eq!(scout.safety.as_deref(), Some("read_only"));
1555 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
1556 }
1557
1558 #[test]
1559 fn configured_model_alias_resolves_explicit_prefix() {
1560 let mut config = Config::default();
1561 config
1562 .model_aliases
1563 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
1564 assert_eq!(
1565 resolve_model_alias("fast", &config).unwrap(),
1566 Some("ollama/qwen3-coder:14b".to_string())
1567 );
1568 assert_eq!(
1569 resolve_model_alias("alias:fast", &config).unwrap(),
1570 Some("ollama/qwen3-coder:14b".to_string())
1571 );
1572 }
1573
1574 #[test]
1575 fn alias_prefix_requires_configuration() {
1576 let config = Config::default();
1577 assert!(resolve_model_alias("alias:vision", &config).is_err());
1578 assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
1579 }
1580
1581 #[test]
1588 fn save_and_reload_preserves_reasoning_field() {
1589 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
1590 std::fs::create_dir_all(&dir).expect("create temp dir");
1591 let path = dir.join("config.toml");
1592
1593 let mut cfg = Config::default();
1594 cfg.default_model.provider = "ollama".to_string();
1595 cfg.default_model.name = "qwen3-coder:30b".to_string();
1596 cfg.default_model.reasoning = ReasoningLevel::Low;
1597
1598 save_config(&cfg, Some(path.clone())).expect("save");
1599
1600 let blob = std::fs::read_to_string(&path).expect("read");
1601 let loaded: Config = toml::from_str(&blob).expect("parse back");
1602 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
1603
1604 let _ = std::fs::remove_dir_all(&dir);
1605 }
1606
1607 #[test]
1612 fn save_and_reload_preserves_reasoning_per_model_table() {
1613 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
1614 std::fs::create_dir_all(&dir).expect("create temp dir");
1615 let path = dir.join("config.toml");
1616
1617 let mut cfg = Config::default();
1618 cfg.reasoning_per_model.insert(
1619 "anthropic/claude-sonnet-4-6".to_string(),
1620 ReasoningLevel::High,
1621 );
1622 cfg.reasoning_per_model
1623 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
1624
1625 save_config(&cfg, Some(path.clone())).expect("save");
1626
1627 let blob = std::fs::read_to_string(&path).expect("read");
1628 let loaded: Config = toml::from_str(&blob).expect("parse back");
1629 assert_eq!(
1630 loaded
1631 .reasoning_per_model
1632 .get("anthropic/claude-sonnet-4-6"),
1633 Some(&ReasoningLevel::High)
1634 );
1635 assert_eq!(
1636 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
1637 Some(&ReasoningLevel::Low)
1638 );
1639
1640 let _ = std::fs::remove_dir_all(&dir);
1641 }
1642
1643 #[test]
1646 fn save_and_reload_preserves_ollama_context_overrides() {
1647 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
1648 std::fs::create_dir_all(&dir).expect("create temp dir");
1649 let path = dir.join("config.toml");
1650
1651 let mut cfg = Config::default();
1652 cfg.ollama_num_ctx_per_model
1653 .insert("ollama/ornith:9b".to_string(), 131_072);
1654 cfg.ollama.allow_ram_offload = true;
1655 cfg.ollama.max_auto_num_ctx = Some(65_536);
1656
1657 save_config(&cfg, Some(path.clone())).expect("save");
1658 let blob = std::fs::read_to_string(&path).expect("read");
1659 let loaded: Config = toml::from_str(&blob).expect("parse back");
1660
1661 assert_eq!(
1662 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
1663 Some(&131_072)
1664 );
1665 assert!(loaded.ollama.allow_ram_offload);
1666 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
1667
1668 let _ = std::fs::remove_dir_all(&dir);
1669 }
1670
1671 #[test]
1674 fn config_deserializes_without_ollama_context_keys() {
1675 let toml_blob = r#"
1676[ollama]
1677host = "localhost"
1678port = 11434
1679"#;
1680 let cfg: Config = toml::from_str(toml_blob).expect("parse");
1681 assert!(cfg.ollama_num_ctx_per_model.is_empty());
1682 assert!(!cfg.ollama.allow_ram_offload);
1683 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
1684 assert!(cfg.ollama.auto_start);
1687 }
1688
1689 #[test]
1693 fn config_deserializes_without_reasoning_per_model() {
1694 let toml_blob = r#"
1695 last_used_model = "ollama/qwen3-coder:30b"
1696
1697 [default_model]
1698 provider = "ollama"
1699 name = "qwen3-coder:30b"
1700 temperature = 0.7
1701 max_tokens = 4096
1702 "#;
1703 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
1704 assert!(cfg.reasoning_per_model.is_empty());
1705 assert!(!cfg.prompt.is_customized());
1706 }
1707
1708 #[cfg(unix)]
1712 #[test]
1713 fn save_config_writes_owner_only_perms() {
1714 use std::os::unix::fs::PermissionsExt;
1715 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
1716 std::fs::create_dir_all(&dir).expect("create temp dir");
1717 let path = dir.join("config.toml");
1718 std::fs::write(&path, "stale").expect("seed");
1720 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
1721
1722 save_config(&Config::default(), Some(path.clone())).expect("save");
1723 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1724 assert_eq!(mode, 0o600, "config must be written owner-only");
1725
1726 let _ = std::fs::remove_dir_all(&dir);
1727 }
1728
1729 #[test]
1730 fn config_defaults_computer_use_auto_screenshot_on() {
1731 let cfg: Config = toml::from_str("").expect("empty config");
1733 assert!(cfg.computer_use.auto_screenshot);
1734 }
1735
1736 #[test]
1737 fn prompt_config_replaces_and_appends_without_persisting() {
1738 let mut cfg = Config::default();
1739 cfg.prompt.system_prompt = Some("base".to_string());
1740 cfg.prompt
1741 .append_system_prompt
1742 .push("extra instructions".to_string());
1743
1744 assert_eq!(
1745 cfg.prompt.render_system_prompt("default"),
1746 "base\n\nextra instructions"
1747 );
1748
1749 let blob = toml::to_string(&cfg).expect("serialize");
1750 assert!(!blob.contains("extra instructions"));
1751 let loaded: Config = toml::from_str(&blob).expect("deserialize");
1752 assert!(!loaded.prompt.is_customized());
1753 }
1754
1755 #[test]
1758 fn absent_compaction_section_matches_the_built_in_policy() {
1759 let c: Config = toml::from_str("").expect("empty config parses");
1760 assert_eq!(
1761 c.compaction.policy(),
1762 mermaid_domain::CompactionPolicy::default(),
1763 );
1764 }
1765
1766 #[test]
1767 fn compaction_settings_reach_the_policy() {
1768 let c: Config = toml::from_str(
1769 "[compaction]\n\
1770 auto_enabled = false\n\
1771 auto_threshold_percent = 60\n\
1772 tail_turns = 5\n\
1773 tail_token_budget = 12000\n\
1774 summary_max_tokens = 3000\n",
1775 )
1776 .expect("compaction section parses");
1777 let policy = c.compaction.policy();
1778 assert!(!policy.auto_enabled);
1779 assert_eq!(policy.auto_threshold_percent, 60);
1780 assert_eq!(policy.tail_turns, 5);
1781 assert_eq!(policy.tail_token_budget, 12_000);
1782 assert_eq!(policy.summary_max_tokens, 3_000);
1783 let defaults = mermaid_domain::CompactionPolicy::default();
1785 assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
1786 }
1787
1788 #[test]
1791 fn nonsense_compaction_settings_are_clamped() {
1792 let c: Config = toml::from_str(
1793 "[compaction]\n\
1794 auto_threshold_percent = 250\n\
1795 tail_turns = 0\n\
1796 tail_token_budget = 0\n\
1797 summary_max_tokens = 0\n\
1798 summarizer_input_token_budget = 0\n\
1799 tool_output_max_chars = 0\n\
1800 min_response_reserve_tokens = 50000\n\
1801 max_response_reserve_tokens = 1000\n",
1802 )
1803 .expect("config parses");
1804 let policy = c.compaction.policy();
1805 let defaults = mermaid_domain::CompactionPolicy::default();
1806
1807 assert_eq!(policy.auto_threshold_percent, 100, "percent clamps to 100");
1808 assert_eq!(
1809 policy.tail_turns, 1,
1810 "a checkpoint needs a live turn after it"
1811 );
1812 assert_eq!(policy.tail_token_budget, defaults.tail_token_budget);
1814 assert_eq!(policy.summary_max_tokens, defaults.summary_max_tokens);
1815 assert_eq!(
1816 policy.summarizer_input_token_budget,
1817 defaults.summarizer_input_token_budget
1818 );
1819 assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
1820
1821 assert_eq!(policy.min_response_reserve_tokens, 1_000);
1825 assert_eq!(policy.max_response_reserve_tokens, 50_000);
1826 assert!(policy.min_response_reserve_tokens <= policy.max_response_reserve_tokens);
1827 }
1828
1829 #[test]
1832 fn zero_compaction_threshold_clamps_up() {
1833 let c: Config =
1834 toml::from_str("[compaction]\nauto_threshold_percent = 0\n").expect("parses");
1835 assert_eq!(c.compaction.policy().auto_threshold_percent, 1);
1836 }
1837
1838 #[test]
1839 fn plan_config_defaults_parse_and_do_not_freeze() {
1840 let c: Config = toml::from_str("").expect("empty config parses");
1842 assert!(!c.plan.auto_approve);
1843 assert!(c.plan.post_approve.is_none());
1844 let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
1846 .expect("plan section parses");
1847 assert!(c.plan.auto_approve);
1848 assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
1849 assert_eq!(
1850 toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
1851 .expect("wait parses")
1852 .plan
1853 .post_approve,
1854 Some(PlanPostApprove::Wait)
1855 );
1856 let blob = toml::to_string(&Config::default()).expect("serialize");
1860 assert!(!blob.contains("post_approve"));
1861 }
1862
1863 fn config_with_provider_default(provider: &str, model: &str) -> Config {
1865 let mut config = Config::default();
1866 config.providers.insert(
1867 provider.to_string(),
1868 UserProviderConfig {
1869 default_model: Some(model.to_string()),
1870 ..Default::default()
1871 },
1872 );
1873 config
1874 }
1875
1876 #[test]
1879 fn provider_default_model_resolves_without_ollama() {
1880 let config = config_with_provider_default("anthropic", "claude-x");
1881 temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1882 assert_eq!(
1883 configured_provider_default_model(&config).as_deref(),
1884 Some("anthropic/claude-x")
1885 );
1886 });
1887 }
1888
1889 #[test]
1892 fn provider_default_model_ignored_without_a_key() {
1893 let config = config_with_provider_default("anthropic", "claude-x");
1894 temp_env::with_vars([("ANTHROPIC_API_KEY", None::<&str>)], || {
1895 if mermaid_model::utils::provider_key_source("anthropic", "ANTHROPIC_API_KEY", None)
1898 == "none"
1899 {
1900 assert_eq!(configured_provider_default_model(&config), None);
1901 }
1902 });
1903 }
1904
1905 #[test]
1908 fn provider_default_model_is_prefixed_exactly_once() {
1909 temp_env::with_vars([("OPENROUTER_API_KEY", Some("sk-test"))], || {
1910 let vendor_model = config_with_provider_default("openrouter", "z-ai/glm-5.2");
1911 assert_eq!(
1912 configured_provider_default_model(&vendor_model).as_deref(),
1913 Some("openrouter/z-ai/glm-5.2")
1914 );
1915 let already_prefixed =
1916 config_with_provider_default("openrouter", "openrouter/z-ai/glm-5.2");
1917 assert_eq!(
1918 configured_provider_default_model(&already_prefixed).as_deref(),
1919 Some("openrouter/z-ai/glm-5.2")
1920 );
1921 });
1922 }
1923
1924 #[test]
1928 fn missing_model_error_does_not_demand_ollama_when_a_provider_is_ready() {
1929 let config = Config::default();
1930 temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1931 let msg = no_model_configured_error(&config, false).to_string();
1932 assert!(msg.contains("anthropic"), "{msg}");
1933 assert!(msg.contains("mermaid --model anthropic/<model>"), "{msg}");
1934 assert!(msg.contains("[providers.anthropic]"), "{msg}");
1935 assert!(!msg.contains("Ollama is not installed"), "{msg}");
1938 });
1939 }
1940
1941 fn with_no_provider_keys<T>(f: impl FnOnce() -> T) -> T {
1944 let cleared: Vec<(&str, Option<&str>)> = [
1945 crate::providers::model::anthropic::DEFAULT_API_KEY_ENV,
1946 crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
1947 crate::providers::model::gemini::LEGACY_API_KEY_ENV,
1948 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
1949 ]
1950 .iter()
1951 .map(|env| (*env, None))
1952 .chain(
1953 mermaid_model::models::PROVIDER_REGISTRY
1954 .iter()
1955 .map(|profile| (profile.api_key_env, None)),
1956 )
1957 .collect();
1958 temp_env::with_vars(cleared, f)
1959 }
1960
1961 #[test]
1964 fn missing_model_error_offers_both_routes_when_nothing_is_configured() {
1965 with_no_provider_keys(|| {
1966 let msg = no_model_configured_error(&Config::default(), false).to_string();
1967 assert!(msg.contains("https://ollama.com/download"), "{msg}");
1968 if !msg.contains("Remote providers ready") {
1971 assert!(msg.contains("ANTHROPIC_API_KEY"), "{msg}");
1972 }
1973 });
1974 }
1975
1976 #[test]
1980 fn resolve_model_id_falls_back_to_a_configured_provider() {
1981 let mut config = config_with_provider_default("anthropic", "claude-x");
1982 config.ollama.host = "http://127.0.0.1".to_string();
1985 config.ollama.port = 1;
1986 config.ollama.auto_start = false;
1987 temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
1988 let runtime = tokio::runtime::Runtime::new().expect("runtime");
1989 let resolved = runtime
1990 .block_on(resolve_model_id(None, &config))
1991 .expect("a configured provider is enough to resolve a model");
1992 assert_eq!(resolved, "anthropic/claude-x");
1993 });
1994 }
1995
1996 #[test]
1998 fn missing_model_error_says_pull_when_ollama_is_installed() {
1999 let msg = no_model_configured_error(&Config::default(), true).to_string();
2000 assert!(msg.contains("ollama pull qwen3:8b"), "{msg}");
2001 assert!(!msg.contains("https://ollama.com/download"), "{msg}");
2002 }
2003}