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 const CONFIG_DIR_ENV: &str = "MERMAID_CONFIG_DIR";
461
462pub 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 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
493fn 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
508fn 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
528pub 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
549static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
555
556fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
564 update_user_config_table_at(&get_config_path()?, mutate)
565}
566
567fn 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 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
581pub 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
596pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
605 update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
606}
607
608pub 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
624pub fn persist_last_model(model: &str) -> Result<()> {
630 update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
631}
632
633pub 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
645pub 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
658pub 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
674pub 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
692pub 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
704pub 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 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
749fn 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 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
773fn 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
835pub(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 #[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 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 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 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 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 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 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 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 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 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 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 assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
1043 assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
1045 assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
1047 assert_eq!(base["top"].as_integer(), Some(1));
1049 }
1050
1051 #[test]
1052 fn deep_merge_overlay_wins_on_kind_conflict() {
1053 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 assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
1084 assert_eq!(config.default_model.max_tokens, 100);
1085 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1322 assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
1324 }
1325
1326 #[test]
1327 fn session_flags_survive_corrupt_user_layer_fallback() {
1328 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 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 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 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 assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
1408 assert_eq!(table["future_key"].as_str(), Some("kept"));
1410 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 let cfg = McpServerConfig::default();
1421 assert!(cfg.tool_allowed("anything"));
1422 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 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 let cfg = McpServerConfig {
1443 command: "npx".to_string(),
1444 ..Default::default()
1445 };
1446 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
1447 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 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 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 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 assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
1520 }
1521
1522 #[test]
1523 fn mcp_url_config_round_trips_through_toml_without_command() {
1524 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 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 #[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 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 #[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 #[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 #[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 #[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 assert!(cfg.ollama.auto_start);
1738 }
1739
1740 #[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 #[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 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 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 #[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 let defaults = mermaid_domain::CompactionPolicy::default();
1836 assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
1837 }
1838
1839 #[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 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 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 #[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 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 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 let blob = toml::to_string(&Config::default()).expect("serialize");
1911 assert!(!blob.contains("post_approve"));
1912 }
1913
1914 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 #[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 #[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 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 #[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 #[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 assert!(!msg.contains("Ollama is not installed"), "{msg}");
1989 });
1990 }
1991
1992 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 #[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 if !msg.contains("Remote providers ready") {
2022 assert!(msg.contains("ANTHROPIC_API_KEY"), "{msg}");
2023 }
2024 });
2025 }
2026
2027 #[test]
2031 fn resolve_model_id_falls_back_to_a_configured_provider() {
2032 let mut config = config_with_provider_default("anthropic", "claude-x");
2033 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 #[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}