1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4use crate::tools::shell::config::ShellConfig;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8
9pub fn get_provider_keys() -> BTreeMap<String, String> {
13 PROVIDER_KEYS.get().cloned().unwrap_or_default()
14}
15
16pub fn get_profile() -> Option<String> {
19 PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
20}
21
22pub fn set_profile(name: Option<String>) {
26 let _ = PROFILE_NAME.set(name);
27}
28
29pub fn base_dir() -> PathBuf {
30 if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
31 return PathBuf::from(path);
32 }
33 let home = std::env::var("HOME")
34 .or_else(|_| std::env::var("USERPROFILE"))
35 .unwrap_or_else(|_| ".".to_string());
36 PathBuf::from(home).join(".synaps-cli")
37}
38
39#[doc(hidden)]
41pub fn set_base_dir_for_tests(path: PathBuf) {
42 std::env::set_var("SYNAPS_BASE_DIR", path);
43}
44
45pub fn resolve_read_path(filename: &str) -> PathBuf {
47 let base = base_dir();
48
49 if let Some(profile) = get_profile() {
50 let profile_path = base.join(&profile).join(filename);
51 if profile_path.exists() {
52 return profile_path;
53 }
54 }
55
56 base.join(filename)
57}
58
59pub fn resolve_read_path_extended(path: &str) -> PathBuf {
61 let base = base_dir();
62
63 if let Some(profile) = get_profile() {
64 let profile_path = base.join(&profile).join(path);
65 if profile_path.exists() {
66 return profile_path;
67 }
68 }
69
70 base.join(path)
71}
72
73pub fn resolve_write_path(filename: &str) -> PathBuf {
75 let mut base = base_dir();
76
77 if let Some(profile) = get_profile() {
78 base.push(profile);
79 }
80
81 let _ = std::fs::create_dir_all(&base);
82 base.join(filename)
83}
84
85pub fn get_active_config_dir() -> PathBuf {
87 let mut base = base_dir();
88 if let Some(profile) = get_profile() {
89 base.push(profile);
90 }
91 base
92}
93
94#[derive(Debug, Clone, Default)]
96pub struct ServerConfig {
97 pub allowed_origins: Vec<String>,
99 pub token: Option<String>,
102 pub auto_approve_confirms: bool,
104 pub max_message_size: Option<usize>,
107}
108
109#[derive(Debug, Clone)]
111pub struct SynapsConfig {
112 pub model: Option<String>,
113 pub thinking_budget: Option<u32>,
114 pub context_window: Option<u64>, pub compaction_model: Option<String>, pub max_tool_output: usize, pub bash_timeout: u64, pub bash_max_timeout: u64, pub subagent_timeout: u64, pub api_retries: u32, pub theme: Option<String>,
122 pub agent_name: Option<String>,
123 pub disabled_plugins: Vec<String>,
124 pub favorite_models: Vec<String>,
125 pub disabled_skills: Vec<String>,
126 pub shell: ShellConfig,
127 pub server: ServerConfig,
128 pub provider_keys: BTreeMap<String, String>,
129 pub keybinds: std::collections::HashMap<String, String>,
130}
131
132impl Default for SynapsConfig {
133 fn default() -> Self {
134 Self {
135 model: None,
136 thinking_budget: None,
137 context_window: None,
138 compaction_model: None,
139 max_tool_output: 30000,
140 bash_timeout: 30,
141 bash_max_timeout: 300,
142 subagent_timeout: 300,
143 api_retries: 3,
144 theme: None,
145 agent_name: None,
146 disabled_plugins: Vec::new(),
147 favorite_models: Vec::new(),
148 disabled_skills: Vec::new(),
149 shell: ShellConfig::default(),
150 server: ServerConfig::default(),
151 provider_keys: BTreeMap::new(),
152 keybinds: std::collections::HashMap::new(),
153 }
154 }
155}
156
157
158fn parse_thinking_budget(val: &str) -> Option<u32> {
159 match val {
160 "low" => Some(2048),
161 "medium" => Some(4096),
162 "high" => Some(16384),
163 "xhigh" => Some(32768),
164 "adaptive" => Some(0), _ => val.parse::<u32>().ok(),
166 }
167}
168
169fn parse_comma_list(val: &str) -> Vec<String> {
170 val.split(',')
171 .map(|s| s.trim().to_string())
172 .filter(|s| !s.is_empty())
173 .collect()
174}
175
176fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
177 write_config_value(key, &values.join(", "))
178}
179
180fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
182 match key {
183 "shell.max_sessions" => {
184 if let Ok(sessions) = val.parse::<usize>() {
185 shell_config.max_sessions = sessions;
186 } else {
187 eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
188 }
189 }
190 "shell.idle_timeout" => {
191 if let Ok(timeout) = val.parse::<u64>() {
192 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
193 } else {
194 eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
195 }
196 }
197 "shell.readiness_timeout_ms" => {
198 if let Ok(timeout) = val.parse::<u64>() {
199 shell_config.readiness_timeout_ms = timeout;
200 } else {
201 eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
202 }
203 }
204 "shell.max_readiness_timeout_ms" => {
205 if let Ok(timeout) = val.parse::<u64>() {
206 shell_config.max_readiness_timeout_ms = timeout;
207 } else {
208 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
209 }
210 }
211 "shell.default_rows" => {
212 if let Ok(rows) = val.parse::<u16>() {
213 shell_config.default_rows = rows;
214 } else {
215 eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
216 }
217 }
218 "shell.default_cols" => {
219 if let Ok(cols) = val.parse::<u16>() {
220 shell_config.default_cols = cols;
221 } else {
222 eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
223 }
224 }
225 "shell.readiness_strategy" => {
226 let val_lower = val.to_lowercase();
227 match val_lower.as_str() {
228 "timeout" | "prompt" | "hybrid" => {
229 shell_config.readiness_strategy = val.to_string();
230 }
231 _ => {
232 eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
233 }
234 }
235 }
236 "shell.max_output" => {
237 if let Ok(max_output) = val.parse::<usize>() {
238 shell_config.max_output = max_output;
239 } else {
240 eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
241 }
242 }
243 _ => {
244 }
246 }
247}
248
249#[allow(clippy::collapsible_match)]
251fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
252 match key {
253 "server.allowed_origins" => {
254 server_config.allowed_origins = parse_comma_list(val);
255 }
256 "server.token" => {
257 if !val.is_empty() {
258 server_config.token = Some(val.to_string());
259 }
260 }
261 "server.auto_approve_confirms" => {
262 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
263 }
264 "server.max_message_size" => {
265 if let Ok(size) = val.parse::<usize>() {
266 server_config.max_message_size = Some(size);
267 } else {
268 eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
269 }
270 }
271 _ => {
272 }
274 }
275}
276
277pub fn load_config() -> SynapsConfig {
280 let path = resolve_read_path("config");
281 let mut config = SynapsConfig::default();
282
283 let Ok(content) = std::fs::read_to_string(&path) else {
284 return config;
285 };
286
287 for line in content.lines() {
288 let line = line.trim();
289 if line.is_empty() || line.starts_with('#') { continue; }
290 let Some((key, val)) = line.split_once('=') else { continue };
291 let key = key.trim();
292 let val = val.trim();
293 match key {
294 "model" => config.model = Some(val.to_string()),
295 "thinking" => config.thinking_budget = parse_thinking_budget(val),
296 "compaction_model" => config.compaction_model = Some(val.to_string()),
297 "context_window" => {
298 let parsed = match val {
299 "200k" | "200K" => Some(200_000),
300 "1m" | "1M" => Some(1_000_000),
301 _ => val.parse::<u64>().ok(),
302 };
303 config.context_window = parsed;
304 }
305 "max_tool_output" => {
306 if let Ok(size) = val.parse::<usize>() {
307 config.max_tool_output = size;
308 }
309 }
310 "bash_timeout" => {
311 if let Ok(timeout) = val.parse::<u64>() {
312 config.bash_timeout = timeout;
313 }
314 }
315 "bash_max_timeout" => {
316 if let Ok(timeout) = val.parse::<u64>() {
317 config.bash_max_timeout = timeout;
318 }
319 }
320 "subagent_timeout" => {
321 if let Ok(timeout) = val.parse::<u64>() {
322 config.subagent_timeout = timeout;
323 }
324 }
325 "api_retries" => {
326 if let Ok(retries) = val.parse::<u32>() {
327 config.api_retries = retries;
328 }
329 }
330 "theme" => config.theme = Some(val.to_string()),
331 "agent_name" => config.agent_name = Some(val.to_string()),
332 "disabled_plugins" => {
333 config.disabled_plugins = parse_comma_list(val);
334 }
335 "favorite_models" => {
336 config.favorite_models = parse_comma_list(val);
337 }
338 "disabled_skills" => {
339 config.disabled_skills = parse_comma_list(val);
340 }
341 _ => {
342 if key.starts_with("shell.") {
344 parse_shell_config_key(&mut config.shell, key, val);
345 } else if key.starts_with("server.") {
346 parse_server_config_key(&mut config.server, key, val);
347 } else if let Some(provider_key) = key.strip_prefix("provider.") {
348 config.provider_keys.insert(provider_key.to_string(), val.to_string());
349 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
350 config.keybinds.insert(keybind_key.to_string(), val.to_string());
351 }
352 }
354 }
355 }
356
357 if config.server.max_message_size.is_none() {
360 if let Some(ctx_tokens) = config.context_window {
361 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
362 }
363 }
364
365 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
368
369 config
370}
371
372pub fn read_config_value(key: &str) -> Option<String> {
374 let path = resolve_read_path("config");
375 let content = std::fs::read_to_string(&path).ok()?;
376 for line in content.lines() {
377 let line = line.trim();
378 if line.is_empty() || line.starts_with('#') { continue; }
379 let Some((k, v)) = line.split_once('=') else { continue };
380 if k.trim() == key.trim() {
381 return Some(v.trim().to_string());
382 }
383 }
384 None
385}
386
387pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
391 let path = resolve_write_path("config");
392 let existing = std::fs::read_to_string(&path).unwrap_or_default();
393
394 let key_trimmed = key.trim();
395 let replacement = format!("{} = {}", key_trimmed, value);
396
397 let mut found = false;
398 let mut new_lines: Vec<String> = existing.lines().map(|line| {
399 if found { return line.to_string(); }
400 let t = line.trim_start();
401 if t.starts_with('#') || t.is_empty() { return line.to_string(); }
402 if let Some((k, _)) = t.split_once('=') {
403 if k.trim() == key_trimmed {
404 found = true;
405 return replacement.clone();
406 }
407 }
408 line.to_string()
409 }).collect();
410
411 if !found {
412 new_lines.push(replacement);
413 }
414
415 let mut out = new_lines.join("\n");
416 if !out.ends_with('\n') { out.push('\n'); }
417
418 let tmp = path.with_extension("tmp");
419 std::fs::write(&tmp, out)?;
420 #[cfg(unix)]
422 {
423 use std::os::unix::fs::PermissionsExt;
424 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
425 }
426 std::fs::rename(&tmp, &path)?;
427 Ok(())
428}
429
430pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
432 let trimmed = id.trim();
433 if trimmed.is_empty() {
434 return Ok(());
435 }
436 let mut values = load_config().favorite_models;
437 if !values.iter().any(|v| v == trimmed) {
438 values.push(trimmed.to_string());
439 values.sort();
440 }
441 write_comma_list("favorite_models", &values)
442}
443
444pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
446 let mut values = load_config().favorite_models;
447 values.retain(|v| v != id.trim());
448 write_comma_list("favorite_models", &values)
449}
450
451pub fn is_favorite_model(id: &str) -> bool {
453 load_config().favorite_models.iter().any(|v| v == id.trim())
454}
455
456pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
459 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
460 You have access to bash, read, and write tools. \
461 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
462
463 if let Some(val) = explicit {
464 let path = std::path::Path::new(val);
465 if path.exists() && path.is_file() {
466 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
467 }
468 return val.to_string();
469 }
470
471 let system_path = resolve_read_path("system.md");
472 if system_path.exists() {
473 return std::fs::read_to_string(&system_path).unwrap_or_default();
474 }
475
476 DEFAULT_PROMPT.to_string()
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use serial_test::serial;
483
484 #[test]
485 fn test_parse_thinking_budget() {
486 assert_eq!(parse_thinking_budget("low"), Some(2048));
487 assert_eq!(parse_thinking_budget("medium"), Some(4096));
488 assert_eq!(parse_thinking_budget("high"), Some(16384));
489 assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
490 assert_eq!(parse_thinking_budget("8192"), Some(8192));
491 assert_eq!(parse_thinking_budget("invalid"), None);
492 }
493
494 #[test]
495 fn test_base_dir() {
496 let path = base_dir();
497 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
498 }
499
500 #[test]
501 fn test_resolve_system_prompt_explicit() {
502 let result = resolve_system_prompt(Some("test prompt"));
503 assert_eq!(result, "test prompt");
504 }
505
506 #[test]
507 fn test_resolve_system_prompt_none() {
508 let result = resolve_system_prompt(None);
509 assert!(result.contains("helpful AI agent"));
510 }
511
512 #[test]
517 fn test_synaps_config_default() {
518 let config = SynapsConfig::default();
519 assert_eq!(config.model, None);
520 assert_eq!(config.thinking_budget, None);
521 assert_eq!(config.max_tool_output, 30000);
522 assert_eq!(config.bash_timeout, 30);
523 assert_eq!(config.bash_max_timeout, 300);
524 assert_eq!(config.subagent_timeout, 300);
525 assert_eq!(config.api_retries, 3);
526 assert_eq!(config.theme, None);
527 assert!(config.disabled_plugins.is_empty());
528 assert!(config.favorite_models.is_empty());
529 assert!(config.disabled_skills.is_empty());
530 assert_eq!(config.shell.max_sessions, 5);
531 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
532 assert!(config.server.allowed_origins.is_empty());
534 assert_eq!(config.server.token, None);
535 assert!(!config.server.auto_approve_confirms);
536 assert_eq!(config.server.max_message_size, None);
537 }
538
539 #[test]
540 #[serial]
541 fn test_load_config_server_keys() {
542 let home = make_test_home("server-keys");
543 let cfg = home.join(".synaps-cli/config");
544 std::fs::write(&cfg, "\
545server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
546server.token = my-secret-token\n\
547server.auto_approve_confirms = true\n\
548server.max_message_size = 65536\n\
549context_window = 200k\n\
550").unwrap();
551
552 with_home(&home, || {
553 let config = load_config();
554 assert_eq!(config.server.allowed_origins, vec![
555 "http://localhost:3000".to_string(),
556 "http://localhost:5193".to_string(),
557 ]);
558 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
559 assert!(config.server.auto_approve_confirms);
560 assert_eq!(config.server.max_message_size, Some(65536));
562 });
563
564 let _ = std::fs::remove_dir_all(&home);
565 }
566
567 #[test]
568 #[serial]
569 fn test_server_max_message_size_derived_from_context_window() {
570 let home = make_test_home("server-derive");
571 let cfg = home.join(".synaps-cli/config");
572 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
573
574 with_home(&home, || {
575 let config = load_config();
576 assert_eq!(config.server.max_message_size, Some(800_000));
578 });
579
580 let _ = std::fs::remove_dir_all(&home);
581 }
582
583 fn make_test_home(subdir: &str) -> std::path::PathBuf {
584 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
585 let _ = std::fs::remove_dir_all(&dir);
586 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
587 dir
588 }
589
590 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
591 let original = std::env::var("HOME").ok();
592 std::env::set_var("HOME", home);
593 f();
594 if let Some(h) = original {
595 std::env::set_var("HOME", h);
596 } else {
597 std::env::remove_var("HOME");
598 }
599 }
600
601 #[test]
602 #[serial]
603 fn write_config_value_replaces_existing_key() {
604 let home = make_test_home("replace");
605 let cfg = home.join(".synaps-cli/config");
606 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
607
608 with_home(&home, || {
609 write_config_value("model", "claude-sonnet-4-6").unwrap();
610 });
611
612 let contents = std::fs::read_to_string(&cfg).unwrap();
613 assert!(contents.contains("model = claude-sonnet-4-6"));
614 assert!(contents.contains("thinking = low"));
615 let _ = std::fs::remove_dir_all(&home);
616 }
617
618 #[test]
619 #[serial]
620 fn write_config_value_appends_when_missing() {
621 let home = make_test_home("append");
622 let cfg = home.join(".synaps-cli/config");
623 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
624
625 with_home(&home, || {
626 write_config_value("theme", "dracula").unwrap();
627 });
628
629 let contents = std::fs::read_to_string(&cfg).unwrap();
630 assert!(contents.contains("model = claude-opus-4-6"));
631 assert!(contents.contains("theme = dracula"));
632 let _ = std::fs::remove_dir_all(&home);
633 }
634
635 #[test]
636 #[serial]
637 fn write_config_value_preserves_comments() {
638 let home = make_test_home("comments");
639 let cfg = home.join(".synaps-cli/config");
640 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
641
642 with_home(&home, || {
643 write_config_value("model", "claude-sonnet-4-6").unwrap();
644 });
645
646 let contents = std::fs::read_to_string(&cfg).unwrap();
647 assert!(contents.contains("# user comment"));
648 assert!(contents.contains("# another"));
649 assert!(contents.contains("model = claude-sonnet-4-6"));
650 let _ = std::fs::remove_dir_all(&home);
651 }
652
653 #[test]
654 #[serial]
655 fn write_config_value_preserves_unknown_keys() {
656 let home = make_test_home("unknown");
657 let cfg = home.join(".synaps-cli/config");
658 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
659
660 with_home(&home, || {
661 write_config_value("model", "claude-sonnet-4-6").unwrap();
662 });
663
664 let contents = std::fs::read_to_string(&cfg).unwrap();
665 assert!(contents.contains("custom_thing = 42"));
666 let _ = std::fs::remove_dir_all(&home);
667 }
668
669 #[test]
670 #[serial]
671 fn write_config_value_creates_file_if_absent() {
672 let home = make_test_home("create");
673 let cfg = home.join(".synaps-cli/config");
674 assert!(!cfg.exists());
675
676 with_home(&home, || {
677 write_config_value("model", "claude-sonnet-4-6").unwrap();
678 });
679
680 let contents = std::fs::read_to_string(&cfg).unwrap();
681 assert!(contents.contains("model = claude-sonnet-4-6"));
682 let _ = std::fs::remove_dir_all(&home);
683 }
684
685 #[test]
686 #[serial]
687 fn load_config_parses_theme_key() {
688 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
689 let _ = std::fs::create_dir_all(&dir);
690 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
691
692 let original_home = std::env::var("HOME").ok();
693 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
694
695 let config = load_config();
696
697 if let Some(home) = original_home {
698 std::env::set_var("HOME", home);
699 } else {
700 std::env::remove_var("HOME");
701 }
702 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
703
704 assert_eq!(config.theme.as_deref(), Some("dracula"));
705 }
706
707 #[test]
708 #[serial]
709 fn test_load_config_disable_lists() {
710 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
711 let _ = std::fs::create_dir_all(&test_dir);
712 let config_path = test_dir.join("config");
713
714 let config_content = r#"
715# Test config with disable lists
716favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
717
718disabled_plugins = foo, bar
719disabled_skills = baz, plug:qual
720"#;
721 std::fs::write(&config_path, config_content).unwrap();
722
723 let original_home = std::env::var("HOME").ok();
724 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
725
726 let config = load_config();
727
728 if let Some(home) = original_home {
729 std::env::set_var("HOME", home);
730 } else {
731 std::env::remove_var("HOME");
732 }
733
734 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
735
736 assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
737 assert_eq!(config.favorite_models, vec![
738 "claude/claude-opus-4-7".to_string(),
739 "groq/llama-3.3-70b-versatile".to_string(),
740 ]);
741 assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
742 }
743
744 #[test]
745 #[serial]
746 fn favorite_model_helpers_round_trip_through_config_file() {
747 let home = make_test_home("favorite-models");
748 let cfg = home.join(".synaps-cli/config");
749 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
750
751 with_home(&home, || {
752 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
753 add_favorite_model("claude/claude-opus-4-7").unwrap();
754 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
755 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
756 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
757 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
758 assert!(is_favorite_model("claude/claude-opus-4-7"));
759 });
760
761 let contents = std::fs::read_to_string(&cfg).unwrap();
762 assert!(contents.contains("model = claude-opus-4-7"));
763 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
764 let _ = std::fs::remove_dir_all(&home);
765 }
766
767 #[test]
768 #[serial]
769 fn test_load_config_new_keys() {
770 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
772 let _ = std::fs::create_dir_all(&test_dir);
773 let config_path = test_dir.join("config");
774
775 let config_content = r#"
776# Test config with new keys
777model = claude-haiku
778thinking = medium
779max_tool_output = 50000
780bash_timeout = 45
781bash_max_timeout = 600
782subagent_timeout = 120
783api_retries = 5
784"#;
785 std::fs::write(&config_path, config_content).unwrap();
786
787 let original_home = std::env::var("HOME").ok();
789 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
790
791 let config = load_config();
792
793 if let Some(home) = original_home {
795 std::env::set_var("HOME", home);
796 } else {
797 std::env::remove_var("HOME");
798 }
799
800 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
802
803 assert_eq!(config.model, Some("claude-haiku".to_string()));
804 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
806 assert_eq!(config.bash_timeout, 45);
807 assert_eq!(config.bash_max_timeout, 600);
808 assert_eq!(config.subagent_timeout, 120);
809 assert_eq!(config.api_retries, 5);
810 }
811}