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}
457
458impl Default for OllamaConfig {
459 fn default() -> Self {
460 Self {
461 host: String::from("localhost"),
462 port: DEFAULT_OLLAMA_PORT,
463 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, }
470 }
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
475#[serde(rename_all = "lowercase")]
476pub enum FetchBackend {
477 #[default]
480 Native,
481 Ollama,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
487#[serde(rename_all = "lowercase")]
488pub enum SearchBackend {
489 #[default]
493 Auto,
494 Ollama,
496 Searxng,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
509#[serde(default)]
510pub struct WebConfig {
511 pub fetch_backend: FetchBackend,
514 pub search_backend: SearchBackend,
519 pub searxng_url: String,
524}
525
526impl Default for WebConfig {
527 fn default() -> Self {
528 Self {
529 fetch_backend: FetchBackend::Native,
530 search_backend: SearchBackend::Auto,
531 searxng_url: String::from("http://localhost:8080"),
532 }
533 }
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
538#[serde(default)]
539pub struct NonInteractiveConfig {
540 pub output_format: String,
542 pub max_tokens: usize,
544 pub no_execute: bool,
546}
547
548impl Default for NonInteractiveConfig {
549 fn default() -> Self {
550 Self {
551 output_format: String::from("text"),
552 max_tokens: DEFAULT_MAX_TOKENS,
553 no_execute: false,
554 }
555 }
556}
557
558pub fn load_config() -> Result<Config> {
561 let config_path = get_config_path()?;
562
563 if config_path.exists() {
564 let toml_str = std::fs::read_to_string(&config_path)
565 .with_context(|| format!("Failed to read {}", config_path.display()))?;
566 let config: Config = toml::from_str(&toml_str).with_context(|| {
567 format!(
568 "Failed to parse {}. Run 'mermaid init' to regenerate.",
569 config_path.display()
570 )
571 })?;
572 Ok(config)
573 } else {
574 Ok(Config::default())
575 }
576}
577
578pub fn load_config_or_warn() -> Config {
584 match load_config() {
585 Ok(config) => config,
586 Err(e) => {
587 eprintln!(
591 "mermaid: {}",
592 crate::utils::redact_secrets(&format!("{e:#}"))
593 );
594 Config::default()
595 },
596 }
597}
598
599pub fn get_config_path() -> Result<PathBuf> {
601 Ok(get_config_dir()?.join("config.toml"))
602}
603
604pub fn get_config_dir() -> Result<PathBuf> {
606 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
607 let config_dir = proj_dirs.config_dir();
608 std::fs::create_dir_all(config_dir)?;
609 Ok(config_dir.to_path_buf())
610 } else {
611 let home = std::env::var("HOME")
613 .or_else(|_| std::env::var("USERPROFILE"))
614 .context("Could not determine home directory")?;
615 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
616 std::fs::create_dir_all(&config_dir)?;
617 Ok(config_dir)
618 }
619}
620
621pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
623 let path = if let Some(p) = path {
624 p
625 } else {
626 get_config_dir()?.join("config.toml")
627 };
628
629 let toml_string = toml::to_string_pretty(config)?;
630
631 #[cfg(unix)]
638 {
639 use std::io::Write;
640 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
641 let mut file = std::fs::OpenOptions::new()
642 .write(true)
643 .create(true)
644 .truncate(true)
645 .mode(0o600)
646 .open(&path)
647 .with_context(|| format!("Failed to write config to {}", path.display()))?;
648 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
650 file.write_all(toml_string.as_bytes())
651 .with_context(|| format!("Failed to write config to {}", path.display()))?;
652 }
653 #[cfg(not(unix))]
654 {
655 std::fs::write(&path, toml_string)
656 .with_context(|| format!("Failed to write config to {}", path.display()))?;
657 }
658
659 Ok(())
660}
661
662pub fn init_config() -> Result<()> {
664 let config_file = get_config_path()?;
665
666 if config_file.exists() {
667 println!("Configuration already exists at: {}", config_file.display());
668 } else {
669 let default_config = Config::default();
670 save_config(&default_config, Some(config_file.clone()))?;
671 println!("Created configuration at: {}", config_file.display());
672 }
673
674 Ok(())
675}
676
677static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
683
684fn update_config(mutate: impl FnOnce(&mut Config)) -> Result<()> {
689 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
690 let mut config = load_config()?;
691 mutate(&mut config);
692 save_config(&config, None)
693}
694
695pub fn persist_last_model(model: &str) -> Result<()> {
697 update_config(|config| config.last_used_model = Some(model.to_string()))
698}
699
700pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
704 update_config(|config| config.default_model.reasoning = level)
705}
706
707pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
713 update_config(|config| {
714 config
715 .reasoning_per_model
716 .insert(model_id.to_string(), level);
717 })
718}
719
720pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
723 update_config(|config| match num_ctx {
724 Some(n) => {
725 config
726 .ollama_num_ctx_per_model
727 .insert(model_id.to_string(), n);
728 },
729 None => {
730 config.ollama_num_ctx_per_model.remove(model_id);
731 },
732 })
733}
734
735pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
737 update_config(|config| config.ollama.allow_ram_offload = enabled)
738}
739
740pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
742 if let Some(model) = cli_model {
743 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
744 return Ok(resolved);
745 }
746 return Ok(model.to_string());
747 }
748 if let Some(last_model) = &config.last_used_model {
749 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
750 return Ok(resolved);
751 }
752 return Ok(last_model.clone());
753 }
754 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
755 return Ok(format!(
756 "{}/{}",
757 config.default_model.provider, config.default_model.name
758 ));
759 }
760 let available = crate::ollama::require_any_model(config).await?;
761 let first = available
765 .first()
766 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
767 Ok(format!("ollama/{}", first))
768}
769
770fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
771 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
772 if let Some(model) = config.model_profiles.get(profile) {
773 anyhow::ensure!(
774 !model.trim().is_empty(),
775 "model profile `{}` is configured with an empty model id",
776 profile
777 );
778 return Ok(Some(model.clone()));
779 }
780 if requested.starts_with("profile:") {
781 anyhow::bail!(
782 "model profile `{}` is not configured; add it under [model_profiles]",
783 profile
784 );
785 }
786 Ok(None)
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
798 fn model_settings_deserializes_without_reasoning_field() {
799 let toml_blob = r#"
800 provider = "ollama"
801 name = "qwen3-coder:30b"
802 temperature = 0.7
803 max_tokens = 4096
804 "#;
805 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
806 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
807 assert_eq!(settings.provider, "ollama");
808 }
809
810 #[test]
811 fn model_settings_round_trips_reasoning_high() {
812 let original = ModelSettings {
813 provider: "anthropic".to_string(),
814 name: "claude-sonnet-4-6".to_string(),
815 temperature: 0.5,
816 max_tokens: 8192,
817 reasoning: ReasoningLevel::High,
818 };
819 let toml_blob = toml::to_string(&original).expect("serialize");
820 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
821 assert_eq!(back.reasoning, ReasoningLevel::High);
822 assert_eq!(back.name, "claude-sonnet-4-6");
823 }
824
825 #[test]
826 fn agents_config_defaults_and_parses_custom_types() {
827 let config: Config = toml::from_str("").expect("empty config parses");
829 assert_eq!(config.agents.timeout_secs, 1200);
830 assert!(config.agents.types.is_empty());
831
832 let config: Config = toml::from_str(
833 r#"
834[agents]
835timeout_secs = 300
836
837[agents.types.scout]
838tools = ["read_file", "execute_command"]
839safety = "read_only"
840preamble = "You are a scout."
841model = "ollama/qwen3:8b"
842"#,
843 )
844 .expect("agents section parses");
845 assert_eq!(config.agents.timeout_secs, 300);
846 let scout = &config.agents.types["scout"];
847 assert_eq!(
848 scout.tools.as_deref(),
849 Some(&["read_file".to_string(), "execute_command".to_string()][..])
850 );
851 assert_eq!(scout.safety.as_deref(), Some("read_only"));
852 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
853 }
854
855 #[test]
856 fn configured_model_profile_resolves_explicit_alias() {
857 let mut config = Config::default();
858 config
859 .model_profiles
860 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
861 assert_eq!(
862 resolve_model_profile_alias("fast", &config).unwrap(),
863 Some("ollama/qwen3-coder:14b".to_string())
864 );
865 assert_eq!(
866 resolve_model_profile_alias("profile:fast", &config).unwrap(),
867 Some("ollama/qwen3-coder:14b".to_string())
868 );
869 }
870
871 #[test]
872 fn profile_prefix_requires_configuration() {
873 let config = Config::default();
874 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
875 assert_eq!(
876 resolve_model_profile_alias("vision", &config).unwrap(),
877 None
878 );
879 }
880
881 #[test]
888 fn save_and_reload_preserves_reasoning_field() {
889 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
890 std::fs::create_dir_all(&dir).expect("create temp dir");
891 let path = dir.join("config.toml");
892
893 let mut cfg = Config::default();
894 cfg.default_model.provider = "ollama".to_string();
895 cfg.default_model.name = "qwen3-coder:30b".to_string();
896 cfg.default_model.reasoning = ReasoningLevel::Low;
897
898 save_config(&cfg, Some(path.clone())).expect("save");
899
900 let blob = std::fs::read_to_string(&path).expect("read");
901 let loaded: Config = toml::from_str(&blob).expect("parse back");
902 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
903
904 let _ = std::fs::remove_dir_all(&dir);
905 }
906
907 #[test]
912 fn save_and_reload_preserves_reasoning_per_model_table() {
913 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
914 std::fs::create_dir_all(&dir).expect("create temp dir");
915 let path = dir.join("config.toml");
916
917 let mut cfg = Config::default();
918 cfg.reasoning_per_model.insert(
919 "anthropic/claude-sonnet-4-6".to_string(),
920 ReasoningLevel::High,
921 );
922 cfg.reasoning_per_model
923 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
924
925 save_config(&cfg, Some(path.clone())).expect("save");
926
927 let blob = std::fs::read_to_string(&path).expect("read");
928 let loaded: Config = toml::from_str(&blob).expect("parse back");
929 assert_eq!(
930 loaded
931 .reasoning_per_model
932 .get("anthropic/claude-sonnet-4-6"),
933 Some(&ReasoningLevel::High)
934 );
935 assert_eq!(
936 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
937 Some(&ReasoningLevel::Low)
938 );
939
940 let _ = std::fs::remove_dir_all(&dir);
941 }
942
943 #[test]
946 fn save_and_reload_preserves_ollama_context_overrides() {
947 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
948 std::fs::create_dir_all(&dir).expect("create temp dir");
949 let path = dir.join("config.toml");
950
951 let mut cfg = Config::default();
952 cfg.ollama_num_ctx_per_model
953 .insert("ollama/ornith:9b".to_string(), 131_072);
954 cfg.ollama.allow_ram_offload = true;
955 cfg.ollama.max_auto_num_ctx = Some(65_536);
956
957 save_config(&cfg, Some(path.clone())).expect("save");
958 let blob = std::fs::read_to_string(&path).expect("read");
959 let loaded: Config = toml::from_str(&blob).expect("parse back");
960
961 assert_eq!(
962 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
963 Some(&131_072)
964 );
965 assert!(loaded.ollama.allow_ram_offload);
966 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
967
968 let _ = std::fs::remove_dir_all(&dir);
969 }
970
971 #[test]
974 fn config_deserializes_without_ollama_context_keys() {
975 let toml_blob = r#"
976[ollama]
977host = "localhost"
978port = 11434
979"#;
980 let cfg: Config = toml::from_str(toml_blob).expect("parse");
981 assert!(cfg.ollama_num_ctx_per_model.is_empty());
982 assert!(!cfg.ollama.allow_ram_offload);
983 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
984 }
985
986 #[test]
990 fn config_deserializes_without_reasoning_per_model() {
991 let toml_blob = r#"
992 last_used_model = "ollama/qwen3-coder:30b"
993
994 [default_model]
995 provider = "ollama"
996 name = "qwen3-coder:30b"
997 temperature = 0.7
998 max_tokens = 4096
999 "#;
1000 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
1001 assert!(cfg.reasoning_per_model.is_empty());
1002 assert!(!cfg.prompt.is_customized());
1003 }
1004
1005 #[cfg(unix)]
1009 #[test]
1010 fn save_config_writes_owner_only_perms() {
1011 use std::os::unix::fs::PermissionsExt;
1012 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
1013 std::fs::create_dir_all(&dir).expect("create temp dir");
1014 let path = dir.join("config.toml");
1015 std::fs::write(&path, "stale").expect("seed");
1017 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
1018
1019 save_config(&Config::default(), Some(path.clone())).expect("save");
1020 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1021 assert_eq!(mode, 0o600, "config must be written owner-only");
1022
1023 let _ = std::fs::remove_dir_all(&dir);
1024 }
1025
1026 #[test]
1027 fn config_defaults_computer_use_auto_screenshot_on() {
1028 let cfg: Config = toml::from_str("").expect("empty config");
1030 assert!(cfg.computer_use.auto_screenshot);
1031 }
1032
1033 #[test]
1034 fn prompt_config_replaces_and_appends_without_persisting() {
1035 let mut cfg = Config::default();
1036 cfg.prompt.system_prompt = Some("base".to_string());
1037 cfg.prompt
1038 .append_system_prompt
1039 .push("extra instructions".to_string());
1040
1041 assert_eq!(
1042 cfg.prompt.render_system_prompt("default"),
1043 "base\n\nextra instructions"
1044 );
1045
1046 let blob = toml::to_string(&cfg).expect("serialize");
1047 assert!(!blob.contains("extra instructions"));
1048 let loaded: Config = toml::from_str(&blob).expect("deserialize");
1049 assert!(!loaded.prompt.is_customized());
1050 }
1051}