1use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE};
2use crate::models::ReasoningLevel;
3use crate::runtime::{PolicyOverride, SafetyMode};
4use anyhow::{Context, Result};
5use directories::ProjectDirs;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Config {
13 #[serde(default)]
15 pub last_used_model: Option<String>,
16
17 #[serde(default)]
19 pub default_model: ModelSettings,
20
21 #[serde(default)]
23 pub ollama: OllamaConfig,
24
25 #[serde(default)]
27 pub web: WebConfig,
28
29 #[serde(default)]
31 pub non_interactive: NonInteractiveConfig,
32
33 #[serde(default)]
35 pub mcp_servers: HashMap<String, McpServerConfig>,
36
37 #[serde(default)]
51 pub providers: HashMap<String, UserProviderConfig>,
52
53 #[serde(default)]
65 pub reasoning_per_model: HashMap<String, ReasoningLevel>,
66
67 #[serde(default)]
76 pub ollama_num_ctx_per_model: HashMap<String, u32>,
77
78 #[serde(default)]
90 pub model_profiles: HashMap<String, String>,
91
92 #[serde(default)]
96 pub safety: SafetyConfig,
97
98 #[serde(default)]
100 pub memory: MemoryConfig,
101
102 #[serde(default)]
104 pub compaction: CompactionConfig,
105
106 #[serde(default)]
108 pub computer_use: ComputerUseConfig,
109
110 #[serde(default)]
113 pub agents: AgentsConfig,
114
115 #[serde(skip)]
119 pub prompt: PromptConfig,
120}
121
122#[derive(Debug, Clone, Default)]
123pub struct PromptConfig {
124 pub system_prompt: Option<String>,
125 pub append_system_prompt: Vec<String>,
126}
127
128impl PromptConfig {
129 pub fn render_system_prompt(&self, default_prompt: &str) -> String {
130 let mut rendered = self
131 .system_prompt
132 .as_deref()
133 .unwrap_or(default_prompt)
134 .trim_end()
135 .to_string();
136
137 for extra in &self.append_system_prompt {
138 let extra = extra.trim();
139 if extra.is_empty() {
140 continue;
141 }
142 if !rendered.is_empty() {
143 rendered.push_str("\n\n");
144 }
145 rendered.push_str(extra);
146 }
147
148 rendered
149 }
150
151 pub fn is_customized(&self) -> bool {
152 self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default)]
158pub struct SafetyConfig {
159 pub mode: SafetyMode,
160 pub checkpoint_on_mutation: bool,
161 #[serde(default)]
162 pub overrides: Vec<PolicyOverride>,
163 #[serde(default)]
167 pub auto_classifier_model: Option<String>,
168 #[serde(default)]
174 pub allow_untrusted_headless_tools: bool,
175}
176
177impl Default for SafetyConfig {
178 fn default() -> Self {
179 Self {
180 mode: SafetyMode::Ask,
184 checkpoint_on_mutation: true,
185 overrides: Vec::new(),
186 auto_classifier_model: None,
187 allow_untrusted_headless_tools: false,
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(default)]
195pub struct MemoryConfig {
196 pub enabled: bool,
199 pub index_cap_bytes: usize,
201}
202
203impl Default for MemoryConfig {
204 fn default() -> Self {
205 Self {
206 enabled: true,
207 index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(default)]
215pub struct CompactionConfig {
216 pub max_truncation_recoveries: u8,
228}
229
230impl Default for CompactionConfig {
231 fn default() -> Self {
232 Self {
233 max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
234 }
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(default)]
241pub struct ComputerUseConfig {
242 pub auto_screenshot: bool,
248}
249
250impl Default for ComputerUseConfig {
251 fn default() -> Self {
252 Self {
253 auto_screenshot: true,
254 }
255 }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
260#[serde(default)]
261pub struct AgentsConfig {
262 pub timeout_secs: u64,
265 pub types: HashMap<String, AgentTypeConfig>,
276}
277
278impl Default for AgentsConfig {
279 fn default() -> Self {
280 Self {
281 timeout_secs: 1200,
282 types: HashMap::new(),
283 }
284 }
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290#[serde(default)]
291pub struct AgentTypeConfig {
292 pub tools: Option<Vec<String>>,
297 pub safety: Option<String>,
301 pub preamble: Option<String>,
304 pub model: Option<String>,
307}
308
309#[derive(Clone, Default, Serialize, Deserialize)]
314pub struct UserProviderConfig {
315 #[serde(default)]
318 pub base_url: Option<String>,
319 #[serde(default)]
323 pub api_key_env: Option<String>,
324 #[serde(default)]
326 pub extra_headers: HashMap<String, String>,
327 #[serde(default)]
333 pub compat: Option<String>,
334 #[serde(default)]
338 pub default_model: Option<String>,
339}
340
341#[derive(Clone, Serialize, Deserialize)]
343pub struct McpServerConfig {
344 pub command: String,
346 #[serde(default)]
348 pub args: Vec<String>,
349 #[serde(default)]
351 pub env: HashMap<String, String>,
352}
353
354fn debug_masked_map(
358 map: &HashMap<String, String>,
359) -> std::collections::BTreeMap<&str, &'static str> {
360 map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
361}
362
363impl std::fmt::Debug for McpServerConfig {
368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 f.debug_struct("McpServerConfig")
370 .field("command", &self.command)
371 .field(
373 "args",
374 &self
375 .args
376 .iter()
377 .map(|a| crate::utils::redact_secrets(a))
378 .collect::<Vec<_>>(),
379 )
380 .field("env", &debug_masked_map(&self.env))
381 .finish()
382 }
383}
384
385impl std::fmt::Debug for UserProviderConfig {
386 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387 f.debug_struct("UserProviderConfig")
388 .field("base_url", &self.base_url)
389 .field("api_key_env", &self.api_key_env)
390 .field("extra_headers", &debug_masked_map(&self.extra_headers))
391 .field("compat", &self.compat)
392 .field("default_model", &self.default_model)
393 .finish()
394 }
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
399#[serde(default)]
400pub struct ModelSettings {
401 pub provider: String,
403 pub name: String,
405 pub temperature: f32,
407 pub max_tokens: usize,
409 pub reasoning: ReasoningLevel,
413}
414
415impl Default for ModelSettings {
416 fn default() -> Self {
417 Self {
418 provider: String::new(),
419 name: String::new(),
420 temperature: DEFAULT_TEMPERATURE,
421 max_tokens: DEFAULT_MAX_TOKENS,
422 reasoning: ReasoningLevel::default(),
423 }
424 }
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(default)]
430pub struct OllamaConfig {
431 pub host: String,
433 pub port: u16,
435 pub num_gpu: Option<i32>,
438 pub num_thread: Option<i32>,
441 pub num_ctx: Option<i32>,
444 pub numa: Option<bool>,
446 pub allow_ram_offload: bool,
452 pub max_auto_num_ctx: Option<usize>,
456 pub auto_start: bool,
461}
462
463impl Default for OllamaConfig {
464 fn default() -> Self {
465 Self {
466 host: String::from("localhost"),
467 port: DEFAULT_OLLAMA_PORT,
468 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, auto_start: true, }
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
481#[serde(rename_all = "lowercase")]
482pub enum FetchBackend {
483 #[default]
486 Native,
487 Ollama,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
493#[serde(rename_all = "lowercase")]
494pub enum SearchBackend {
495 #[default]
499 Auto,
500 Ollama,
502 Searxng,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
515#[serde(default)]
516pub struct WebConfig {
517 pub fetch_backend: FetchBackend,
520 pub search_backend: SearchBackend,
525 pub searxng_url: String,
530}
531
532impl Default for WebConfig {
533 fn default() -> Self {
534 Self {
535 fetch_backend: FetchBackend::Native,
536 search_backend: SearchBackend::Auto,
537 searxng_url: String::from("http://localhost:8080"),
538 }
539 }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(default)]
545pub struct NonInteractiveConfig {
546 pub output_format: String,
548 pub max_tokens: usize,
550 pub no_execute: bool,
552}
553
554impl Default for NonInteractiveConfig {
555 fn default() -> Self {
556 Self {
557 output_format: String::from("text"),
558 max_tokens: DEFAULT_MAX_TOKENS,
559 no_execute: false,
560 }
561 }
562}
563
564pub fn load_config() -> Result<Config> {
567 let config_path = get_config_path()?;
568
569 if config_path.exists() {
570 let toml_str = std::fs::read_to_string(&config_path)
571 .with_context(|| format!("Failed to read {}", config_path.display()))?;
572 let config: Config = toml::from_str(&toml_str).with_context(|| {
573 format!(
574 "Failed to parse {}. Run 'mermaid init' to regenerate.",
575 config_path.display()
576 )
577 })?;
578 Ok(config)
579 } else {
580 Ok(Config::default())
581 }
582}
583
584pub fn load_config_or_warn() -> Config {
590 match load_config() {
591 Ok(config) => config,
592 Err(e) => {
593 eprintln!(
597 "mermaid: {}",
598 crate::utils::redact_secrets(&format!("{e:#}"))
599 );
600 Config::default()
601 },
602 }
603}
604
605pub fn get_config_path() -> Result<PathBuf> {
607 Ok(get_config_dir()?.join("config.toml"))
608}
609
610pub fn get_config_dir() -> Result<PathBuf> {
612 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
613 let config_dir = proj_dirs.config_dir();
614 std::fs::create_dir_all(config_dir)?;
615 Ok(config_dir.to_path_buf())
616 } else {
617 let home = std::env::var("HOME")
619 .or_else(|_| std::env::var("USERPROFILE"))
620 .context("Could not determine home directory")?;
621 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
622 std::fs::create_dir_all(&config_dir)?;
623 Ok(config_dir)
624 }
625}
626
627pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
629 let path = if let Some(p) = path {
630 p
631 } else {
632 get_config_dir()?.join("config.toml")
633 };
634
635 let toml_string = toml::to_string_pretty(config)?;
636
637 #[cfg(unix)]
644 {
645 use std::io::Write;
646 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
647 let mut file = std::fs::OpenOptions::new()
648 .write(true)
649 .create(true)
650 .truncate(true)
651 .mode(0o600)
652 .open(&path)
653 .with_context(|| format!("Failed to write config to {}", path.display()))?;
654 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
656 file.write_all(toml_string.as_bytes())
657 .with_context(|| format!("Failed to write config to {}", path.display()))?;
658 }
659 #[cfg(not(unix))]
660 {
661 std::fs::write(&path, toml_string)
662 .with_context(|| format!("Failed to write config to {}", path.display()))?;
663 }
664
665 Ok(())
666}
667
668pub fn init_config() -> Result<()> {
670 let config_file = get_config_path()?;
671
672 if config_file.exists() {
673 println!("Configuration already exists at: {}", config_file.display());
674 } else {
675 let default_config = Config::default();
676 save_config(&default_config, Some(config_file.clone()))?;
677 println!("Created configuration at: {}", config_file.display());
678 }
679
680 Ok(())
681}
682
683static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
689
690fn update_config(mutate: impl FnOnce(&mut Config)) -> Result<()> {
695 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
696 let mut config = load_config()?;
697 mutate(&mut config);
698 save_config(&config, None)
699}
700
701pub fn persist_last_model(model: &str) -> Result<()> {
703 update_config(|config| config.last_used_model = Some(model.to_string()))
704}
705
706pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
710 update_config(|config| config.default_model.reasoning = level)
711}
712
713pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
719 update_config(|config| {
720 config
721 .reasoning_per_model
722 .insert(model_id.to_string(), level);
723 })
724}
725
726pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
729 update_config(|config| match num_ctx {
730 Some(n) => {
731 config
732 .ollama_num_ctx_per_model
733 .insert(model_id.to_string(), n);
734 },
735 None => {
736 config.ollama_num_ctx_per_model.remove(model_id);
737 },
738 })
739}
740
741pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
743 update_config(|config| config.ollama.allow_ram_offload = enabled)
744}
745
746pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
748 if let Some(model) = cli_model {
749 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
750 return Ok(resolved);
751 }
752 return Ok(model.to_string());
753 }
754 if let Some(last_model) = &config.last_used_model {
755 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
756 return Ok(resolved);
757 }
758 return Ok(last_model.clone());
759 }
760 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
761 return Ok(format!(
762 "{}/{}",
763 config.default_model.provider, config.default_model.name
764 ));
765 }
766 let available = crate::ollama::require_any_model(config).await?;
767 let first = available
771 .first()
772 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
773 Ok(format!("ollama/{}", first))
774}
775
776fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
777 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
778 if let Some(model) = config.model_profiles.get(profile) {
779 anyhow::ensure!(
780 !model.trim().is_empty(),
781 "model profile `{}` is configured with an empty model id",
782 profile
783 );
784 return Ok(Some(model.clone()));
785 }
786 if requested.starts_with("profile:") {
787 anyhow::bail!(
788 "model profile `{}` is not configured; add it under [model_profiles]",
789 profile
790 );
791 }
792 Ok(None)
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798
799 #[test]
804 fn model_settings_deserializes_without_reasoning_field() {
805 let toml_blob = r#"
806 provider = "ollama"
807 name = "qwen3-coder:30b"
808 temperature = 0.7
809 max_tokens = 4096
810 "#;
811 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
812 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
813 assert_eq!(settings.provider, "ollama");
814 }
815
816 #[test]
817 fn model_settings_round_trips_reasoning_high() {
818 let original = ModelSettings {
819 provider: "anthropic".to_string(),
820 name: "claude-sonnet-4-6".to_string(),
821 temperature: 0.5,
822 max_tokens: 8192,
823 reasoning: ReasoningLevel::High,
824 };
825 let toml_blob = toml::to_string(&original).expect("serialize");
826 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
827 assert_eq!(back.reasoning, ReasoningLevel::High);
828 assert_eq!(back.name, "claude-sonnet-4-6");
829 }
830
831 #[test]
832 fn agents_config_defaults_and_parses_custom_types() {
833 let config: Config = toml::from_str("").expect("empty config parses");
835 assert_eq!(config.agents.timeout_secs, 1200);
836 assert!(config.agents.types.is_empty());
837
838 let config: Config = toml::from_str(
839 r#"
840[agents]
841timeout_secs = 300
842
843[agents.types.scout]
844tools = ["read_file", "execute_command"]
845safety = "read_only"
846preamble = "You are a scout."
847model = "ollama/qwen3:8b"
848"#,
849 )
850 .expect("agents section parses");
851 assert_eq!(config.agents.timeout_secs, 300);
852 let scout = &config.agents.types["scout"];
853 assert_eq!(
854 scout.tools.as_deref(),
855 Some(&["read_file".to_string(), "execute_command".to_string()][..])
856 );
857 assert_eq!(scout.safety.as_deref(), Some("read_only"));
858 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
859 }
860
861 #[test]
862 fn configured_model_profile_resolves_explicit_alias() {
863 let mut config = Config::default();
864 config
865 .model_profiles
866 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
867 assert_eq!(
868 resolve_model_profile_alias("fast", &config).unwrap(),
869 Some("ollama/qwen3-coder:14b".to_string())
870 );
871 assert_eq!(
872 resolve_model_profile_alias("profile:fast", &config).unwrap(),
873 Some("ollama/qwen3-coder:14b".to_string())
874 );
875 }
876
877 #[test]
878 fn profile_prefix_requires_configuration() {
879 let config = Config::default();
880 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
881 assert_eq!(
882 resolve_model_profile_alias("vision", &config).unwrap(),
883 None
884 );
885 }
886
887 #[test]
894 fn save_and_reload_preserves_reasoning_field() {
895 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
896 std::fs::create_dir_all(&dir).expect("create temp dir");
897 let path = dir.join("config.toml");
898
899 let mut cfg = Config::default();
900 cfg.default_model.provider = "ollama".to_string();
901 cfg.default_model.name = "qwen3-coder:30b".to_string();
902 cfg.default_model.reasoning = ReasoningLevel::Low;
903
904 save_config(&cfg, Some(path.clone())).expect("save");
905
906 let blob = std::fs::read_to_string(&path).expect("read");
907 let loaded: Config = toml::from_str(&blob).expect("parse back");
908 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
909
910 let _ = std::fs::remove_dir_all(&dir);
911 }
912
913 #[test]
918 fn save_and_reload_preserves_reasoning_per_model_table() {
919 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
920 std::fs::create_dir_all(&dir).expect("create temp dir");
921 let path = dir.join("config.toml");
922
923 let mut cfg = Config::default();
924 cfg.reasoning_per_model.insert(
925 "anthropic/claude-sonnet-4-6".to_string(),
926 ReasoningLevel::High,
927 );
928 cfg.reasoning_per_model
929 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
930
931 save_config(&cfg, Some(path.clone())).expect("save");
932
933 let blob = std::fs::read_to_string(&path).expect("read");
934 let loaded: Config = toml::from_str(&blob).expect("parse back");
935 assert_eq!(
936 loaded
937 .reasoning_per_model
938 .get("anthropic/claude-sonnet-4-6"),
939 Some(&ReasoningLevel::High)
940 );
941 assert_eq!(
942 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
943 Some(&ReasoningLevel::Low)
944 );
945
946 let _ = std::fs::remove_dir_all(&dir);
947 }
948
949 #[test]
952 fn save_and_reload_preserves_ollama_context_overrides() {
953 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
954 std::fs::create_dir_all(&dir).expect("create temp dir");
955 let path = dir.join("config.toml");
956
957 let mut cfg = Config::default();
958 cfg.ollama_num_ctx_per_model
959 .insert("ollama/ornith:9b".to_string(), 131_072);
960 cfg.ollama.allow_ram_offload = true;
961 cfg.ollama.max_auto_num_ctx = Some(65_536);
962
963 save_config(&cfg, Some(path.clone())).expect("save");
964 let blob = std::fs::read_to_string(&path).expect("read");
965 let loaded: Config = toml::from_str(&blob).expect("parse back");
966
967 assert_eq!(
968 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
969 Some(&131_072)
970 );
971 assert!(loaded.ollama.allow_ram_offload);
972 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
973
974 let _ = std::fs::remove_dir_all(&dir);
975 }
976
977 #[test]
980 fn config_deserializes_without_ollama_context_keys() {
981 let toml_blob = r#"
982[ollama]
983host = "localhost"
984port = 11434
985"#;
986 let cfg: Config = toml::from_str(toml_blob).expect("parse");
987 assert!(cfg.ollama_num_ctx_per_model.is_empty());
988 assert!(!cfg.ollama.allow_ram_offload);
989 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
990 assert!(cfg.ollama.auto_start);
993 }
994
995 #[test]
999 fn config_deserializes_without_reasoning_per_model() {
1000 let toml_blob = r#"
1001 last_used_model = "ollama/qwen3-coder:30b"
1002
1003 [default_model]
1004 provider = "ollama"
1005 name = "qwen3-coder:30b"
1006 temperature = 0.7
1007 max_tokens = 4096
1008 "#;
1009 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
1010 assert!(cfg.reasoning_per_model.is_empty());
1011 assert!(!cfg.prompt.is_customized());
1012 }
1013
1014 #[cfg(unix)]
1018 #[test]
1019 fn save_config_writes_owner_only_perms() {
1020 use std::os::unix::fs::PermissionsExt;
1021 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
1022 std::fs::create_dir_all(&dir).expect("create temp dir");
1023 let path = dir.join("config.toml");
1024 std::fs::write(&path, "stale").expect("seed");
1026 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
1027
1028 save_config(&Config::default(), Some(path.clone())).expect("save");
1029 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1030 assert_eq!(mode, 0o600, "config must be written owner-only");
1031
1032 let _ = std::fs::remove_dir_all(&dir);
1033 }
1034
1035 #[test]
1036 fn config_defaults_computer_use_auto_screenshot_on() {
1037 let cfg: Config = toml::from_str("").expect("empty config");
1039 assert!(cfg.computer_use.auto_screenshot);
1040 }
1041
1042 #[test]
1043 fn prompt_config_replaces_and_appends_without_persisting() {
1044 let mut cfg = Config::default();
1045 cfg.prompt.system_prompt = Some("base".to_string());
1046 cfg.prompt
1047 .append_system_prompt
1048 .push("extra instructions".to_string());
1049
1050 assert_eq!(
1051 cfg.prompt.render_system_prompt("default"),
1052 "base\n\nextra instructions"
1053 );
1054
1055 let blob = toml::to_string(&cfg).expect("serialize");
1056 assert!(!blob.contains("extra instructions"));
1057 let loaded: Config = toml::from_str(&blob).expect("deserialize");
1058 assert!(!loaded.prompt.is_customized());
1059 }
1060}