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)]
115pub struct BridgeConfig {
116 pub uds_path: Option<PathBuf>,
119 pub heartbeat_mirror: bool,
122 pub heartbeat_timeout_ms: u64,
124}
125
126impl Default for BridgeConfig {
127 fn default() -> Self {
128 Self {
129 uds_path: None,
130 heartbeat_mirror: false,
131 heartbeat_timeout_ms: 250,
132 }
133 }
134}
135
136impl BridgeConfig {
137 pub fn resolved_uds_path(&self) -> PathBuf {
139 self.uds_path
140 .clone()
141 .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
142 }
143}
144
145#[derive(Debug, Clone)]
147pub struct SynapsConfig {
148 pub model: Option<String>,
149 pub thinking_budget: Option<u32>,
150 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>,
158 pub agent_name: Option<String>,
159 pub disabled_plugins: Vec<String>,
160 pub favorite_models: Vec<String>,
161 pub disabled_skills: Vec<String>,
162 pub shell: ShellConfig,
163 pub server: ServerConfig,
164 pub bridge: BridgeConfig,
165 pub provider_keys: BTreeMap<String, String>,
166 pub keybinds: std::collections::HashMap<String, String>,
167}
168
169impl Default for SynapsConfig {
170 fn default() -> Self {
171 Self {
172 model: None,
173 thinking_budget: None,
174 context_window: None,
175 compaction_model: None,
176 max_tool_output: 30000,
177 bash_timeout: 30,
178 bash_max_timeout: 300,
179 subagent_timeout: 300,
180 api_retries: 3,
181 theme: None,
182 agent_name: None,
183 disabled_plugins: Vec::new(),
184 favorite_models: Vec::new(),
185 disabled_skills: Vec::new(),
186 shell: ShellConfig::default(),
187 server: ServerConfig::default(),
188 bridge: BridgeConfig::default(),
189 provider_keys: BTreeMap::new(),
190 keybinds: std::collections::HashMap::new(),
191 }
192 }
193}
194
195
196fn parse_thinking_budget(val: &str) -> Option<u32> {
197 match val {
198 "low" => Some(2048),
199 "medium" => Some(4096),
200 "high" => Some(16384),
201 "xhigh" => Some(32768),
202 "adaptive" => Some(0), _ => val.parse::<u32>().ok(),
204 }
205}
206
207fn parse_comma_list(val: &str) -> Vec<String> {
208 val.split(',')
209 .map(|s| s.trim().to_string())
210 .filter(|s| !s.is_empty())
211 .collect()
212}
213
214fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
215 write_config_value(key, &values.join(", "))
216}
217
218fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
220 match key {
221 "shell.max_sessions" => {
222 if let Ok(sessions) = val.parse::<usize>() {
223 shell_config.max_sessions = sessions;
224 } else {
225 eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
226 }
227 }
228 "shell.idle_timeout" => {
229 if let Ok(timeout) = val.parse::<u64>() {
230 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
231 } else {
232 eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
233 }
234 }
235 "shell.readiness_timeout_ms" => {
236 if let Ok(timeout) = val.parse::<u64>() {
237 shell_config.readiness_timeout_ms = timeout;
238 } else {
239 eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
240 }
241 }
242 "shell.max_readiness_timeout_ms" => {
243 if let Ok(timeout) = val.parse::<u64>() {
244 shell_config.max_readiness_timeout_ms = timeout;
245 } else {
246 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
247 }
248 }
249 "shell.default_rows" => {
250 if let Ok(rows) = val.parse::<u16>() {
251 shell_config.default_rows = rows;
252 } else {
253 eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
254 }
255 }
256 "shell.default_cols" => {
257 if let Ok(cols) = val.parse::<u16>() {
258 shell_config.default_cols = cols;
259 } else {
260 eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
261 }
262 }
263 "shell.readiness_strategy" => {
264 let val_lower = val.to_lowercase();
265 match val_lower.as_str() {
266 "timeout" | "prompt" | "hybrid" => {
267 shell_config.readiness_strategy = val.to_string();
268 }
269 _ => {
270 eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
271 }
272 }
273 }
274 "shell.max_output" => {
275 if let Ok(max_output) = val.parse::<usize>() {
276 shell_config.max_output = max_output;
277 } else {
278 eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
279 }
280 }
281 _ => {
282 }
284 }
285}
286
287#[allow(clippy::collapsible_match)]
289fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
290 match key {
291 "server.allowed_origins" => {
292 server_config.allowed_origins = parse_comma_list(val);
293 }
294 "server.token" => {
295 if !val.is_empty() {
296 server_config.token = Some(val.to_string());
297 }
298 }
299 "server.auto_approve_confirms" => {
300 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
301 }
302 "server.max_message_size" => {
303 if let Ok(size) = val.parse::<usize>() {
304 server_config.max_message_size = Some(size);
305 } else {
306 eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
307 }
308 }
309 _ => {
310 }
312 }
313}
314
315fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
317 match key {
318 "bridge.uds_path" => {
319 if val.is_empty() {
320 bridge_config.uds_path = None;
321 } else {
322 bridge_config.uds_path = Some(PathBuf::from(val));
323 }
324 }
325 "bridge.heartbeat_mirror" => {
326 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
327 }
328 "bridge.heartbeat_timeout_ms" => {
329 if let Ok(ms) = val.parse::<u64>() {
330 bridge_config.heartbeat_timeout_ms = ms;
331 } else {
332 eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
333 }
334 }
335 _ => {
336 }
338 }
339}
340
341pub fn load_config() -> SynapsConfig {
344 let path = resolve_read_path("config");
345 let mut config = SynapsConfig::default();
346
347 let Ok(content) = std::fs::read_to_string(&path) else {
348 return config;
349 };
350
351 for line in content.lines() {
352 let line = line.trim();
353 if line.is_empty() || line.starts_with('#') { continue; }
354 let Some((key, val)) = line.split_once('=') else { continue };
355 let key = key.trim();
356 let val = val.trim();
357 match key {
358 "model" => config.model = Some(val.to_string()),
359 "thinking" => config.thinking_budget = parse_thinking_budget(val),
360 "compaction_model" => config.compaction_model = Some(val.to_string()),
361 "context_window" => {
362 let parsed = match val {
363 "200k" | "200K" => Some(200_000),
364 "1m" | "1M" => Some(1_000_000),
365 _ => val.parse::<u64>().ok(),
366 };
367 config.context_window = parsed;
368 }
369 "max_tool_output" => {
370 if let Ok(size) = val.parse::<usize>() {
371 config.max_tool_output = size;
372 }
373 }
374 "bash_timeout" => {
375 if let Ok(timeout) = val.parse::<u64>() {
376 config.bash_timeout = timeout;
377 }
378 }
379 "bash_max_timeout" => {
380 if let Ok(timeout) = val.parse::<u64>() {
381 config.bash_max_timeout = timeout;
382 }
383 }
384 "subagent_timeout" => {
385 if let Ok(timeout) = val.parse::<u64>() {
386 config.subagent_timeout = timeout;
387 }
388 }
389 "api_retries" => {
390 if let Ok(retries) = val.parse::<u32>() {
391 config.api_retries = retries;
392 }
393 }
394 "theme" => config.theme = Some(val.to_string()),
395 "agent_name" => config.agent_name = Some(val.to_string()),
396 "disabled_plugins" => {
397 config.disabled_plugins = parse_comma_list(val);
398 }
399 "favorite_models" => {
400 config.favorite_models = parse_comma_list(val);
401 }
402 "disabled_skills" => {
403 config.disabled_skills = parse_comma_list(val);
404 }
405 _ => {
406 if key.starts_with("shell.") {
408 parse_shell_config_key(&mut config.shell, key, val);
409 } else if key.starts_with("server.") {
410 parse_server_config_key(&mut config.server, key, val);
411 } else if key.starts_with("bridge.") {
412 parse_bridge_config_key(&mut config.bridge, key, val);
413 } else if let Some(provider_key) = key.strip_prefix("provider.") {
414 config.provider_keys.insert(provider_key.to_string(), val.to_string());
415 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
416 config.keybinds.insert(keybind_key.to_string(), val.to_string());
417 }
418 }
420 }
421 }
422
423 if config.server.max_message_size.is_none() {
426 if let Some(ctx_tokens) = config.context_window {
427 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
428 }
429 }
430
431 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
434
435 config
436}
437
438pub fn read_config_value(key: &str) -> Option<String> {
440 let path = resolve_read_path("config");
441 let content = std::fs::read_to_string(&path).ok()?;
442 for line in content.lines() {
443 let line = line.trim();
444 if line.is_empty() || line.starts_with('#') { continue; }
445 let Some((k, v)) = line.split_once('=') else { continue };
446 if k.trim() == key.trim() {
447 return Some(v.trim().to_string());
448 }
449 }
450 None
451}
452
453pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
457 let path = resolve_write_path("config");
458 let existing = std::fs::read_to_string(&path).unwrap_or_default();
459
460 let key_trimmed = key.trim();
461 let replacement = format!("{} = {}", key_trimmed, value);
462
463 let mut found = false;
464 let mut new_lines: Vec<String> = existing.lines().map(|line| {
465 if found { return line.to_string(); }
466 let t = line.trim_start();
467 if t.starts_with('#') || t.is_empty() { return line.to_string(); }
468 if let Some((k, _)) = t.split_once('=') {
469 if k.trim() == key_trimmed {
470 found = true;
471 return replacement.clone();
472 }
473 }
474 line.to_string()
475 }).collect();
476
477 if !found {
478 new_lines.push(replacement);
479 }
480
481 let mut out = new_lines.join("\n");
482 if !out.ends_with('\n') { out.push('\n'); }
483
484 let tmp = path.with_extension("tmp");
485 std::fs::write(&tmp, out)?;
486 #[cfg(unix)]
488 {
489 use std::os::unix::fs::PermissionsExt;
490 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
491 }
492 std::fs::rename(&tmp, &path)?;
493 Ok(())
494}
495
496pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
498 let trimmed = id.trim();
499 if trimmed.is_empty() {
500 return Ok(());
501 }
502 let mut values = load_config().favorite_models;
503 if !values.iter().any(|v| v == trimmed) {
504 values.push(trimmed.to_string());
505 values.sort();
506 }
507 write_comma_list("favorite_models", &values)
508}
509
510pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
512 let mut values = load_config().favorite_models;
513 values.retain(|v| v != id.trim());
514 write_comma_list("favorite_models", &values)
515}
516
517pub fn is_favorite_model(id: &str) -> bool {
519 load_config().favorite_models.iter().any(|v| v == id.trim())
520}
521
522pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
525 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
526 You have access to bash, read, and write tools. \
527 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
528
529 if let Some(val) = explicit {
530 let path = std::path::Path::new(val);
531 if path.exists() && path.is_file() {
532 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
533 }
534 return val.to_string();
535 }
536
537 let system_path = resolve_read_path("system.md");
538 if system_path.exists() {
539 return std::fs::read_to_string(&system_path).unwrap_or_default();
540 }
541
542 DEFAULT_PROMPT.to_string()
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use serial_test::serial;
549
550 #[test]
551 fn test_parse_thinking_budget() {
552 assert_eq!(parse_thinking_budget("low"), Some(2048));
553 assert_eq!(parse_thinking_budget("medium"), Some(4096));
554 assert_eq!(parse_thinking_budget("high"), Some(16384));
555 assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
556 assert_eq!(parse_thinking_budget("8192"), Some(8192));
557 assert_eq!(parse_thinking_budget("invalid"), None);
558 }
559
560 #[test]
561 fn test_base_dir() {
562 let path = base_dir();
563 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
564 }
565
566 #[test]
567 fn test_resolve_system_prompt_explicit() {
568 let result = resolve_system_prompt(Some("test prompt"));
569 assert_eq!(result, "test prompt");
570 }
571
572 #[test]
573 fn test_resolve_system_prompt_none() {
574 let result = resolve_system_prompt(None);
575 assert!(result.contains("helpful AI agent"));
576 }
577
578 #[test]
583 fn test_synaps_config_default() {
584 let config = SynapsConfig::default();
585 assert_eq!(config.model, None);
586 assert_eq!(config.thinking_budget, None);
587 assert_eq!(config.max_tool_output, 30000);
588 assert_eq!(config.bash_timeout, 30);
589 assert_eq!(config.bash_max_timeout, 300);
590 assert_eq!(config.subagent_timeout, 300);
591 assert_eq!(config.api_retries, 3);
592 assert_eq!(config.theme, None);
593 assert!(config.disabled_plugins.is_empty());
594 assert!(config.favorite_models.is_empty());
595 assert!(config.disabled_skills.is_empty());
596 assert_eq!(config.shell.max_sessions, 5);
597 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
598 assert!(config.server.allowed_origins.is_empty());
600 assert_eq!(config.server.token, None);
601 assert!(!config.server.auto_approve_confirms);
602 assert_eq!(config.server.max_message_size, None);
603 assert!(config.bridge.uds_path.is_none());
605 assert!(!config.bridge.heartbeat_mirror);
606 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
607 }
608
609 #[test]
610 #[serial]
611 fn test_load_config_bridge_keys() {
612 let home = make_test_home("bridge-keys");
613 let cfg = home.join(".synaps-cli/config");
614 std::fs::write(&cfg, "\
615bridge.uds_path = /tmp/some/control.sock\n\
616bridge.heartbeat_mirror = true\n\
617bridge.heartbeat_timeout_ms = 750\n\
618").unwrap();
619
620 with_home(&home, || {
621 let config = load_config();
622 assert_eq!(
623 config.bridge.uds_path,
624 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
625 );
626 assert!(config.bridge.heartbeat_mirror);
627 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
628 assert_eq!(
629 config.bridge.resolved_uds_path(),
630 std::path::PathBuf::from("/tmp/some/control.sock"),
631 );
632 });
633
634 let _ = std::fs::remove_dir_all(&home);
635 }
636
637 #[test]
638 fn test_bridge_config_defaults() {
639 let cfg = BridgeConfig::default();
640 assert!(cfg.uds_path.is_none());
641 assert!(!cfg.heartbeat_mirror);
642 assert_eq!(cfg.heartbeat_timeout_ms, 250);
643 let resolved = cfg.resolved_uds_path();
645 assert!(resolved.ends_with("bridge/control.sock"));
646 }
647
648 #[test]
649 #[serial]
650 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
651 let home = make_test_home("bridge-default-off");
652 let cfg = home.join(".synaps-cli/config");
653 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
654
655 with_home(&home, || {
656 let config = load_config();
657 assert!(!config.bridge.heartbeat_mirror);
658 assert!(config.bridge.uds_path.is_none());
659 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
660 });
661
662 let _ = std::fs::remove_dir_all(&home);
663 }
664
665 #[test]
666 #[serial]
667 fn test_load_config_server_keys() {
668 let home = make_test_home("server-keys");
669 let cfg = home.join(".synaps-cli/config");
670 std::fs::write(&cfg, "\
671server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
672server.token = my-secret-token\n\
673server.auto_approve_confirms = true\n\
674server.max_message_size = 65536\n\
675context_window = 200k\n\
676").unwrap();
677
678 with_home(&home, || {
679 let config = load_config();
680 assert_eq!(config.server.allowed_origins, vec![
681 "http://localhost:3000".to_string(),
682 "http://localhost:5193".to_string(),
683 ]);
684 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
685 assert!(config.server.auto_approve_confirms);
686 assert_eq!(config.server.max_message_size, Some(65536));
688 });
689
690 let _ = std::fs::remove_dir_all(&home);
691 }
692
693 #[test]
694 #[serial]
695 fn test_server_max_message_size_derived_from_context_window() {
696 let home = make_test_home("server-derive");
697 let cfg = home.join(".synaps-cli/config");
698 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
699
700 with_home(&home, || {
701 let config = load_config();
702 assert_eq!(config.server.max_message_size, Some(800_000));
704 });
705
706 let _ = std::fs::remove_dir_all(&home);
707 }
708
709 fn make_test_home(subdir: &str) -> std::path::PathBuf {
710 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
711 let _ = std::fs::remove_dir_all(&dir);
712 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
713 dir
714 }
715
716 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
717 let original = std::env::var("HOME").ok();
718 std::env::set_var("HOME", home);
719 f();
720 if let Some(h) = original {
721 std::env::set_var("HOME", h);
722 } else {
723 std::env::remove_var("HOME");
724 }
725 }
726
727 #[test]
728 #[serial]
729 fn write_config_value_replaces_existing_key() {
730 let home = make_test_home("replace");
731 let cfg = home.join(".synaps-cli/config");
732 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
733
734 with_home(&home, || {
735 write_config_value("model", "claude-sonnet-4-6").unwrap();
736 });
737
738 let contents = std::fs::read_to_string(&cfg).unwrap();
739 assert!(contents.contains("model = claude-sonnet-4-6"));
740 assert!(contents.contains("thinking = low"));
741 let _ = std::fs::remove_dir_all(&home);
742 }
743
744 #[test]
745 #[serial]
746 fn write_config_value_appends_when_missing() {
747 let home = make_test_home("append");
748 let cfg = home.join(".synaps-cli/config");
749 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
750
751 with_home(&home, || {
752 write_config_value("theme", "dracula").unwrap();
753 });
754
755 let contents = std::fs::read_to_string(&cfg).unwrap();
756 assert!(contents.contains("model = claude-opus-4-6"));
757 assert!(contents.contains("theme = dracula"));
758 let _ = std::fs::remove_dir_all(&home);
759 }
760
761 #[test]
762 #[serial]
763 fn write_config_value_preserves_comments() {
764 let home = make_test_home("comments");
765 let cfg = home.join(".synaps-cli/config");
766 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
767
768 with_home(&home, || {
769 write_config_value("model", "claude-sonnet-4-6").unwrap();
770 });
771
772 let contents = std::fs::read_to_string(&cfg).unwrap();
773 assert!(contents.contains("# user comment"));
774 assert!(contents.contains("# another"));
775 assert!(contents.contains("model = claude-sonnet-4-6"));
776 let _ = std::fs::remove_dir_all(&home);
777 }
778
779 #[test]
780 #[serial]
781 fn write_config_value_preserves_unknown_keys() {
782 let home = make_test_home("unknown");
783 let cfg = home.join(".synaps-cli/config");
784 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
785
786 with_home(&home, || {
787 write_config_value("model", "claude-sonnet-4-6").unwrap();
788 });
789
790 let contents = std::fs::read_to_string(&cfg).unwrap();
791 assert!(contents.contains("custom_thing = 42"));
792 let _ = std::fs::remove_dir_all(&home);
793 }
794
795 #[test]
796 #[serial]
797 fn write_config_value_creates_file_if_absent() {
798 let home = make_test_home("create");
799 let cfg = home.join(".synaps-cli/config");
800 assert!(!cfg.exists());
801
802 with_home(&home, || {
803 write_config_value("model", "claude-sonnet-4-6").unwrap();
804 });
805
806 let contents = std::fs::read_to_string(&cfg).unwrap();
807 assert!(contents.contains("model = claude-sonnet-4-6"));
808 let _ = std::fs::remove_dir_all(&home);
809 }
810
811 #[test]
812 #[serial]
813 fn load_config_parses_theme_key() {
814 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
815 let _ = std::fs::create_dir_all(&dir);
816 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
817
818 let original_home = std::env::var("HOME").ok();
819 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
820
821 let config = load_config();
822
823 if let Some(home) = original_home {
824 std::env::set_var("HOME", home);
825 } else {
826 std::env::remove_var("HOME");
827 }
828 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
829
830 assert_eq!(config.theme.as_deref(), Some("dracula"));
831 }
832
833 #[test]
834 #[serial]
835 fn test_load_config_disable_lists() {
836 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
837 let _ = std::fs::create_dir_all(&test_dir);
838 let config_path = test_dir.join("config");
839
840 let config_content = r#"
841# Test config with disable lists
842favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
843
844disabled_plugins = foo, bar
845disabled_skills = baz, plug:qual
846"#;
847 std::fs::write(&config_path, config_content).unwrap();
848
849 let original_home = std::env::var("HOME").ok();
850 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
851
852 let config = load_config();
853
854 if let Some(home) = original_home {
855 std::env::set_var("HOME", home);
856 } else {
857 std::env::remove_var("HOME");
858 }
859
860 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
861
862 assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
863 assert_eq!(config.favorite_models, vec![
864 "claude/claude-opus-4-7".to_string(),
865 "groq/llama-3.3-70b-versatile".to_string(),
866 ]);
867 assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
868 }
869
870 #[test]
871 #[serial]
872 fn favorite_model_helpers_round_trip_through_config_file() {
873 let home = make_test_home("favorite-models");
874 let cfg = home.join(".synaps-cli/config");
875 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
876
877 with_home(&home, || {
878 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
879 add_favorite_model("claude/claude-opus-4-7").unwrap();
880 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
881 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
882 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
883 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
884 assert!(is_favorite_model("claude/claude-opus-4-7"));
885 });
886
887 let contents = std::fs::read_to_string(&cfg).unwrap();
888 assert!(contents.contains("model = claude-opus-4-7"));
889 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
890 let _ = std::fs::remove_dir_all(&home);
891 }
892
893 #[test]
894 #[serial]
895 fn test_load_config_new_keys() {
896 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
898 let _ = std::fs::create_dir_all(&test_dir);
899 let config_path = test_dir.join("config");
900
901 let config_content = r#"
902# Test config with new keys
903model = claude-haiku
904thinking = medium
905max_tool_output = 50000
906bash_timeout = 45
907bash_max_timeout = 600
908subagent_timeout = 120
909api_retries = 5
910"#;
911 std::fs::write(&config_path, config_content).unwrap();
912
913 let original_home = std::env::var("HOME").ok();
915 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
916
917 let config = load_config();
918
919 if let Some(home) = original_home {
921 std::env::set_var("HOME", home);
922 } else {
923 std::env::remove_var("HOME");
924 }
925
926 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
928
929 assert_eq!(config.model, Some("claude-haiku".to_string()));
930 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
932 assert_eq!(config.bash_timeout, 45);
933 assert_eq!(config.bash_max_timeout, 600);
934 assert_eq!(config.subagent_timeout, 120);
935 assert_eq!(config.api_retries, 5);
936 }
937}