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();
8static IDENTITY: OnceLock<String> = OnceLock::new();
9
10const DEFAULT_IDENTITY: &str = "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
11
12pub fn get_identity() -> String {
16 IDENTITY.get().cloned().unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
17}
18
19pub fn get_provider_keys() -> BTreeMap<String, String> {
23 PROVIDER_KEYS.get().cloned().unwrap_or_default()
24}
25
26pub fn get_profile() -> Option<String> {
29 PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
30}
31
32pub fn set_profile(name: Option<String>) {
36 let _ = PROFILE_NAME.set(name);
37}
38
39pub fn base_dir() -> PathBuf {
40 if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
41 return PathBuf::from(path);
42 }
43 let home = std::env::var("HOME")
44 .or_else(|_| std::env::var("USERPROFILE"))
45 .unwrap_or_else(|_| ".".to_string());
46 PathBuf::from(home).join(".synaps-cli")
47}
48
49#[doc(hidden)]
51pub fn set_base_dir_for_tests(path: PathBuf) {
52 std::env::set_var("SYNAPS_BASE_DIR", path);
53}
54
55pub fn resolve_read_path(filename: &str) -> PathBuf {
57 let base = base_dir();
58
59 if let Some(profile) = get_profile() {
60 let profile_path = base.join(&profile).join(filename);
61 if profile_path.exists() {
62 return profile_path;
63 }
64 }
65
66 base.join(filename)
67}
68
69pub fn resolve_read_path_extended(path: &str) -> PathBuf {
71 let base = base_dir();
72
73 if let Some(profile) = get_profile() {
74 let profile_path = base.join(&profile).join(path);
75 if profile_path.exists() {
76 return profile_path;
77 }
78 }
79
80 base.join(path)
81}
82
83pub fn resolve_write_path(filename: &str) -> PathBuf {
85 let mut base = base_dir();
86
87 if let Some(profile) = get_profile() {
88 base.push(profile);
89 }
90
91 let _ = std::fs::create_dir_all(&base);
92 base.join(filename)
93}
94
95pub fn get_active_config_dir() -> PathBuf {
97 let mut base = base_dir();
98 if let Some(profile) = get_profile() {
99 base.push(profile);
100 }
101 base
102}
103
104#[derive(Debug, Clone, Default)]
106pub struct ServerConfig {
107 pub allowed_origins: Vec<String>,
109 pub token: Option<String>,
112 pub auto_approve_confirms: bool,
114 pub max_message_size: Option<usize>,
117}
118
119#[derive(Debug, Clone)]
125pub struct BridgeConfig {
126 pub uds_path: Option<PathBuf>,
129 pub heartbeat_mirror: bool,
132 pub heartbeat_timeout_ms: u64,
134}
135
136impl Default for BridgeConfig {
137 fn default() -> Self {
138 Self {
139 uds_path: None,
140 heartbeat_mirror: false,
141 heartbeat_timeout_ms: 250,
142 }
143 }
144}
145
146impl BridgeConfig {
147 pub fn resolved_uds_path(&self) -> PathBuf {
149 self.uds_path
150 .clone()
151 .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct SynapsConfig {
158 pub model: Option<String>,
159 pub thinking_budget: Option<u32>,
160 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 telemetry: String, pub cache_diagnostics: bool, pub theme: Option<String>,
170 pub agent_name: Option<String>,
171 pub identity: Option<String>,
172 pub disabled_plugins: Vec<String>,
173 pub favorite_models: Vec<String>,
174 pub disabled_skills: Vec<String>,
175 pub shell: ShellConfig,
176 pub server: ServerConfig,
177 pub bridge: BridgeConfig,
178 pub provider_keys: BTreeMap<String, String>,
179 pub keybinds: std::collections::HashMap<String, String>,
180 pub warnings: Vec<String>,
183}
184
185impl Default for SynapsConfig {
186 fn default() -> Self {
187 Self {
188 model: None,
189 thinking_budget: None,
190 context_window: None,
191 compaction_model: None,
192 max_tool_output: 30000,
193 bash_timeout: 30,
194 bash_max_timeout: 300,
195 subagent_timeout: 300,
196 api_retries: 3,
197 telemetry: "off".to_string(),
198 cache_diagnostics: false,
199 theme: None,
200 agent_name: None,
201 identity: None,
202 disabled_plugins: Vec::new(),
203 favorite_models: Vec::new(),
204 disabled_skills: Vec::new(),
205 shell: ShellConfig::default(),
206 server: ServerConfig::default(),
207 bridge: BridgeConfig::default(),
208 provider_keys: BTreeMap::new(),
209 keybinds: std::collections::HashMap::new(),
210 warnings: Vec::new(),
211 }
212 }
213}
214
215const KNOWN_CONFIG_KEYS: &[&str] = &[
217 "model", "thinking", "compaction_model", "context_window", "max_tool_output",
218 "bash_timeout", "bash_max_timeout", "subagent_timeout", "api_retries",
219 "telemetry", "cache_diagnostics", "theme", "agent_name", "identity",
220 "disabled_plugins", "favorite_models", "disabled_skills",
221];
222
223fn levenshtein(a: &str, b: &str) -> usize {
225 let a: Vec<char> = a.chars().collect();
226 let b: Vec<char> = b.chars().collect();
227 let mut prev: Vec<usize> = (0..=b.len()).collect();
228 let mut cur = vec![0; b.len() + 1];
229 for (i, ca) in a.iter().enumerate() {
230 cur[0] = i + 1;
231 for (j, cb) in b.iter().enumerate() {
232 let cost = if ca == cb { 0 } else { 1 };
233 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
234 }
235 std::mem::swap(&mut prev, &mut cur);
236 }
237 prev[b.len()]
238}
239
240fn did_you_mean(key: &str) -> Option<&'static str> {
242 KNOWN_CONFIG_KEYS
243 .iter()
244 .map(|k| (*k, levenshtein(key, k)))
245 .filter(|(_, d)| *d <= 2)
246 .min_by_key(|(_, d)| *d)
247 .map(|(k, _)| k)
248}
249
250
251fn parse_thinking_budget(val: &str) -> Option<u32> {
252 match val {
253 "low" => Some(2048),
254 "medium" => Some(4096),
255 "high" => Some(16384),
256 "xhigh" => Some(32768),
257 "adaptive" => Some(0), _ => val.parse::<u32>().ok(),
259 }
260}
261
262fn parse_comma_list(val: &str) -> Vec<String> {
263 val.split(',')
264 .map(|s| s.trim().to_string())
265 .filter(|s| !s.is_empty())
266 .collect()
267}
268
269fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
270 write_config_value(key, &values.join(", "))
271}
272
273fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
275 match key {
276 "shell.max_sessions" => {
277 if let Ok(sessions) = val.parse::<usize>() {
278 shell_config.max_sessions = sessions;
279 } else {
280 eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
281 }
282 }
283 "shell.idle_timeout" => {
284 if let Ok(timeout) = val.parse::<u64>() {
285 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
286 } else {
287 eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
288 }
289 }
290 "shell.readiness_timeout_ms" => {
291 if let Ok(timeout) = val.parse::<u64>() {
292 shell_config.readiness_timeout_ms = timeout;
293 } else {
294 eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
295 }
296 }
297 "shell.max_readiness_timeout_ms" => {
298 if let Ok(timeout) = val.parse::<u64>() {
299 shell_config.max_readiness_timeout_ms = timeout;
300 } else {
301 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
302 }
303 }
304 "shell.default_rows" => {
305 if let Ok(rows) = val.parse::<u16>() {
306 shell_config.default_rows = rows;
307 } else {
308 eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
309 }
310 }
311 "shell.default_cols" => {
312 if let Ok(cols) = val.parse::<u16>() {
313 shell_config.default_cols = cols;
314 } else {
315 eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
316 }
317 }
318 "shell.readiness_strategy" => {
319 let val_lower = val.to_lowercase();
320 match val_lower.as_str() {
321 "timeout" | "prompt" | "hybrid" => {
322 shell_config.readiness_strategy = val.to_string();
323 }
324 _ => {
325 eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
326 }
327 }
328 }
329 "shell.max_output" => {
330 if let Ok(max_output) = val.parse::<usize>() {
331 shell_config.max_output = max_output;
332 } else {
333 eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
334 }
335 }
336 _ => {
337 }
339 }
340}
341
342#[allow(clippy::collapsible_match)]
344fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
345 match key {
346 "server.allowed_origins" => {
347 server_config.allowed_origins = parse_comma_list(val);
348 }
349 "server.token" => {
350 if !val.is_empty() {
351 server_config.token = Some(val.to_string());
352 }
353 }
354 "server.auto_approve_confirms" => {
355 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
356 }
357 "server.max_message_size" => {
358 if let Ok(size) = val.parse::<usize>() {
359 server_config.max_message_size = Some(size);
360 } else {
361 eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
362 }
363 }
364 _ => {
365 }
367 }
368}
369
370fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
372 match key {
373 "bridge.uds_path" => {
374 if val.is_empty() {
375 bridge_config.uds_path = None;
376 } else {
377 bridge_config.uds_path = Some(PathBuf::from(val));
378 }
379 }
380 "bridge.heartbeat_mirror" => {
381 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
382 }
383 "bridge.heartbeat_timeout_ms" => {
384 if let Ok(ms) = val.parse::<u64>() {
385 bridge_config.heartbeat_timeout_ms = ms;
386 } else {
387 eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
388 }
389 }
390 _ => {
391 }
393 }
394}
395
396pub fn load_config() -> SynapsConfig {
399 let path = resolve_read_path("config");
400 let mut config = SynapsConfig::default();
401
402 let Ok(content) = std::fs::read_to_string(&path) else {
403 return config;
404 };
405
406 for line in content.lines() {
407 let line = line.trim();
408 if line.is_empty() || line.starts_with('#') { continue; }
409 let Some((key, val)) = line.split_once('=') else { continue };
410 let key = key.trim();
411 let val = val.trim();
412 match key {
413 "model" => config.model = Some(val.to_string()),
414 "thinking" => {
415 config.thinking_budget = parse_thinking_budget(val);
416 if config.thinking_budget.is_none() {
417 config.warnings.push(format!("thinking = {val} — expected low|medium|high|xhigh|adaptive or a token count; thinking disabled"));
418 }
419 }
420 "compaction_model" => config.compaction_model = Some(val.to_string()),
421 "context_window" => {
422 let parsed = match val {
423 "200k" | "200K" => Some(200_000),
424 "1m" | "1M" => Some(1_000_000),
425 _ => val.parse::<u64>().ok(),
426 };
427 if parsed.is_none() {
428 config.warnings.push(format!("context_window = {val} — expected 200k, 1m, or a token count; ignored"));
429 }
430 config.context_window = parsed;
431 }
432 "max_tool_output" => {
433 match val.parse::<usize>() {
434 Ok(size) => config.max_tool_output = size,
435 Err(_) => config.warnings.push(format!("max_tool_output = {val} — not a number; using {}", config.max_tool_output)),
436 }
437 }
438 "bash_timeout" => {
439 match val.parse::<u64>() {
440 Ok(t) if t >= 1 => config.bash_timeout = t,
441 Ok(_) => config.warnings.push(format!("bash_timeout = {val} — below minimum (1s); using {}", config.bash_timeout)),
442 Err(_) => config.warnings.push(format!("bash_timeout = {val} — not a number; using {}", config.bash_timeout)),
443 }
444 }
445 "bash_max_timeout" => {
446 if let Ok(timeout) = val.parse::<u64>() {
447 config.bash_max_timeout = timeout;
448 }
449 }
450 "subagent_timeout" => {
451 if let Ok(timeout) = val.parse::<u64>() {
452 config.subagent_timeout = timeout;
453 }
454 }
455 "api_retries" => {
456 if let Ok(retries) = val.parse::<u32>() {
457 config.api_retries = retries;
458 }
459 }
460 "telemetry" => config.telemetry = val.to_string(),
461 "cache_diagnostics" => {
462 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
463 }
464 "theme" => config.theme = Some(val.to_string()),
465 "agent_name" => config.agent_name = Some(val.to_string()),
466 "identity" => config.identity = Some(val.to_string()),
467 "disabled_plugins" => {
468 config.disabled_plugins = parse_comma_list(val);
469 }
470 "favorite_models" => {
471 config.favorite_models = parse_comma_list(val);
472 }
473 "disabled_skills" => {
474 config.disabled_skills = parse_comma_list(val);
475 }
476 _ => {
477 if key.starts_with("shell.") {
479 parse_shell_config_key(&mut config.shell, key, val);
480 } else if key.starts_with("server.") {
481 parse_server_config_key(&mut config.server, key, val);
482 } else if key.starts_with("bridge.") {
483 parse_bridge_config_key(&mut config.bridge, key, val);
484 } else if let Some(provider_key) = key.strip_prefix("provider.") {
485 config.provider_keys.insert(provider_key.to_string(), val.to_string());
486 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
487 config.keybinds.insert(keybind_key.to_string(), val.to_string());
488 } else if key.contains('.') {
489 } else {
493 match did_you_mean(key) {
495 Some(suggestion) => config.warnings.push(format!("unknown key '{key}' (did you mean '{suggestion}'?)")),
496 None => config.warnings.push(format!("unknown key '{key}' — ignored")),
497 }
498 }
499 }
500 }
501 }
502
503 if config.server.max_message_size.is_none() {
506 if let Some(ctx_tokens) = config.context_window {
507 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
508 }
509 }
510
511 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
514
515 let identity_val = config.identity.clone().unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
517 let _ = IDENTITY.set(identity_val);
518
519 config
520}
521
522pub fn read_config_value(key: &str) -> Option<String> {
524 let path = resolve_read_path("config");
525 let content = std::fs::read_to_string(&path).ok()?;
526 for line in content.lines() {
527 let line = line.trim();
528 if line.is_empty() || line.starts_with('#') { continue; }
529 let Some((k, v)) = line.split_once('=') else { continue };
530 if k.trim() == key.trim() {
531 return Some(v.trim().to_string());
532 }
533 }
534 None
535}
536
537pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
541 let path = resolve_write_path("config");
542 let existing = std::fs::read_to_string(&path).unwrap_or_default();
543
544 let key_trimmed = key.trim();
545 let replacement = format!("{} = {}", key_trimmed, value);
546
547 let mut found = false;
548 let mut new_lines: Vec<String> = existing.lines().map(|line| {
549 if found { return line.to_string(); }
550 let t = line.trim_start();
551 if t.starts_with('#') || t.is_empty() { return line.to_string(); }
552 if let Some((k, _)) = t.split_once('=') {
553 if k.trim() == key_trimmed {
554 found = true;
555 return replacement.clone();
556 }
557 }
558 line.to_string()
559 }).collect();
560
561 if !found {
562 new_lines.push(replacement);
563 }
564
565 let mut out = new_lines.join("\n");
566 if !out.ends_with('\n') { out.push('\n'); }
567
568 let tmp = path.with_extension("tmp");
569 std::fs::write(&tmp, out)?;
570 #[cfg(unix)]
572 {
573 use std::os::unix::fs::PermissionsExt;
574 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
575 }
576 std::fs::rename(&tmp, &path)?;
577 Ok(())
578}
579
580pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
582 let trimmed = id.trim();
583 if trimmed.is_empty() {
584 return Ok(());
585 }
586 let mut values = load_config().favorite_models;
587 if !values.iter().any(|v| v == trimmed) {
588 values.push(trimmed.to_string());
589 values.sort();
590 }
591 write_comma_list("favorite_models", &values)
592}
593
594pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
596 let mut values = load_config().favorite_models;
597 values.retain(|v| v != id.trim());
598 write_comma_list("favorite_models", &values)
599}
600
601pub fn is_favorite_model(id: &str) -> bool {
603 load_config().favorite_models.iter().any(|v| v == id.trim())
604}
605
606pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
609 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
610 You have access to bash, read, and write tools. \
611 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
612
613 if let Some(val) = explicit {
614 let path = std::path::Path::new(val);
615 if path.exists() && path.is_file() {
616 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
617 }
618 return val.to_string();
619 }
620
621 let system_path = resolve_read_path("system.md");
622 if system_path.exists() {
623 return std::fs::read_to_string(&system_path).unwrap_or_default();
624 }
625
626 DEFAULT_PROMPT.to_string()
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use serial_test::serial;
633
634 #[test]
635 fn test_levenshtein_basics() {
636 assert_eq!(levenshtein("model", "model"), 0);
637 assert_eq!(levenshtein("modle", "model"), 2);
638 assert_eq!(levenshtein("them", "theme"), 1);
639 }
640
641 #[test]
642 fn test_did_you_mean_close_typos() {
643 assert_eq!(did_you_mean("modle"), Some("model"));
644 assert_eq!(did_you_mean("them"), Some("theme"));
645 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
646 assert_eq!(did_you_mean("completely_unrelated_key"), None);
647 }
648
649 #[test]
650 #[serial]
651 fn test_config_warnings_unknown_key_and_bad_values() {
652 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
653 let dir = home.join(".synaps-cli");
654 std::fs::create_dir_all(&dir).unwrap();
655 std::fs::write(dir.join("config"), "modle = claude-opus-4-6\nthinking = hgih\nbash_timeout = 0\nknowledge.jawz_notes = ~/Jawz/notes\ncustom.plugin.key = 42\n").unwrap();
656
657 with_home(&home, || {
658 let config = load_config();
659 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
661 assert!(!config.warnings.iter().any(|w| w.contains("knowledge")), "{:?}", config.warnings);
662 assert!(config.warnings.iter().any(|w| w.contains("did you mean 'model'")), "{:?}", config.warnings);
663 assert!(config.warnings.iter().any(|w| w.contains("thinking")), "{:?}", config.warnings);
664 assert!(config.warnings.iter().any(|w| w.contains("below minimum")), "{:?}", config.warnings);
665 assert_eq!(config.bash_timeout, 30);
667 assert_eq!(config.thinking_budget, None);
668 });
669 let _ = std::fs::remove_dir_all(&home);
670 }
671
672 #[test]
673 fn test_parse_thinking_budget() {
674 assert_eq!(parse_thinking_budget("low"), Some(2048));
675 assert_eq!(parse_thinking_budget("medium"), Some(4096));
676 assert_eq!(parse_thinking_budget("high"), Some(16384));
677 assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
678 assert_eq!(parse_thinking_budget("8192"), Some(8192));
679 assert_eq!(parse_thinking_budget("invalid"), None);
680 }
681
682 #[test]
683 fn test_base_dir() {
684 let path = base_dir();
685 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
686 }
687
688 #[test]
689 fn test_resolve_system_prompt_explicit() {
690 let result = resolve_system_prompt(Some("test prompt"));
691 assert_eq!(result, "test prompt");
692 }
693
694 #[test]
695 fn test_resolve_system_prompt_none() {
696 let result = resolve_system_prompt(None);
697 assert!(result.contains("helpful AI agent"));
698 }
699
700 #[test]
705 fn test_synaps_config_default() {
706 let config = SynapsConfig::default();
707 assert_eq!(config.model, None);
708 assert_eq!(config.thinking_budget, None);
709 assert_eq!(config.max_tool_output, 30000);
710 assert_eq!(config.bash_timeout, 30);
711 assert_eq!(config.bash_max_timeout, 300);
712 assert_eq!(config.subagent_timeout, 300);
713 assert_eq!(config.api_retries, 3);
714 assert_eq!(config.theme, None);
715 assert!(config.disabled_plugins.is_empty());
716 assert!(config.favorite_models.is_empty());
717 assert!(config.disabled_skills.is_empty());
718 assert_eq!(config.shell.max_sessions, 5);
719 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
720 assert!(config.server.allowed_origins.is_empty());
722 assert_eq!(config.server.token, None);
723 assert!(!config.server.auto_approve_confirms);
724 assert_eq!(config.server.max_message_size, None);
725 assert!(config.bridge.uds_path.is_none());
727 assert!(!config.bridge.heartbeat_mirror);
728 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
729 }
730
731 #[test]
732 #[serial]
733 fn test_load_config_bridge_keys() {
734 let home = make_test_home("bridge-keys");
735 let cfg = home.join(".synaps-cli/config");
736 std::fs::write(&cfg, "\
737bridge.uds_path = /tmp/some/control.sock\n\
738bridge.heartbeat_mirror = true\n\
739bridge.heartbeat_timeout_ms = 750\n\
740").unwrap();
741
742 with_home(&home, || {
743 let config = load_config();
744 assert_eq!(
745 config.bridge.uds_path,
746 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
747 );
748 assert!(config.bridge.heartbeat_mirror);
749 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
750 assert_eq!(
751 config.bridge.resolved_uds_path(),
752 std::path::PathBuf::from("/tmp/some/control.sock"),
753 );
754 });
755
756 let _ = std::fs::remove_dir_all(&home);
757 }
758
759 #[test]
760 fn test_bridge_config_defaults() {
761 let cfg = BridgeConfig::default();
762 assert!(cfg.uds_path.is_none());
763 assert!(!cfg.heartbeat_mirror);
764 assert_eq!(cfg.heartbeat_timeout_ms, 250);
765 let resolved = cfg.resolved_uds_path();
767 assert!(resolved.ends_with("bridge/control.sock"));
768 }
769
770 #[test]
771 #[serial]
772 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
773 let home = make_test_home("bridge-default-off");
774 let cfg = home.join(".synaps-cli/config");
775 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
776
777 with_home(&home, || {
778 let config = load_config();
779 assert!(!config.bridge.heartbeat_mirror);
780 assert!(config.bridge.uds_path.is_none());
781 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
782 });
783
784 let _ = std::fs::remove_dir_all(&home);
785 }
786
787 #[test]
788 #[serial]
789 fn test_load_config_server_keys() {
790 let home = make_test_home("server-keys");
791 let cfg = home.join(".synaps-cli/config");
792 std::fs::write(&cfg, "\
793server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
794server.token = my-secret-token\n\
795server.auto_approve_confirms = true\n\
796server.max_message_size = 65536\n\
797context_window = 200k\n\
798").unwrap();
799
800 with_home(&home, || {
801 let config = load_config();
802 assert_eq!(config.server.allowed_origins, vec![
803 "http://localhost:3000".to_string(),
804 "http://localhost:5193".to_string(),
805 ]);
806 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
807 assert!(config.server.auto_approve_confirms);
808 assert_eq!(config.server.max_message_size, Some(65536));
810 });
811
812 let _ = std::fs::remove_dir_all(&home);
813 }
814
815 #[test]
816 #[serial]
817 fn test_server_max_message_size_derived_from_context_window() {
818 let home = make_test_home("server-derive");
819 let cfg = home.join(".synaps-cli/config");
820 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
821
822 with_home(&home, || {
823 let config = load_config();
824 assert_eq!(config.server.max_message_size, Some(800_000));
826 });
827
828 let _ = std::fs::remove_dir_all(&home);
829 }
830
831 fn make_test_home(subdir: &str) -> std::path::PathBuf {
832 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
833 let _ = std::fs::remove_dir_all(&dir);
834 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
835 dir
836 }
837
838 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
839 let original = std::env::var("HOME").ok();
840 std::env::set_var("HOME", home);
841 f();
842 if let Some(h) = original {
843 std::env::set_var("HOME", h);
844 } else {
845 std::env::remove_var("HOME");
846 }
847 }
848
849 #[test]
850 #[serial]
851 fn write_config_value_replaces_existing_key() {
852 let home = make_test_home("replace");
853 let cfg = home.join(".synaps-cli/config");
854 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
855
856 with_home(&home, || {
857 write_config_value("model", "claude-sonnet-4-6").unwrap();
858 });
859
860 let contents = std::fs::read_to_string(&cfg).unwrap();
861 assert!(contents.contains("model = claude-sonnet-4-6"));
862 assert!(contents.contains("thinking = low"));
863 let _ = std::fs::remove_dir_all(&home);
864 }
865
866 #[test]
867 #[serial]
868 fn write_config_value_appends_when_missing() {
869 let home = make_test_home("append");
870 let cfg = home.join(".synaps-cli/config");
871 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
872
873 with_home(&home, || {
874 write_config_value("theme", "dracula").unwrap();
875 });
876
877 let contents = std::fs::read_to_string(&cfg).unwrap();
878 assert!(contents.contains("model = claude-opus-4-6"));
879 assert!(contents.contains("theme = dracula"));
880 let _ = std::fs::remove_dir_all(&home);
881 }
882
883 #[test]
884 #[serial]
885 fn write_config_value_preserves_comments() {
886 let home = make_test_home("comments");
887 let cfg = home.join(".synaps-cli/config");
888 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
889
890 with_home(&home, || {
891 write_config_value("model", "claude-sonnet-4-6").unwrap();
892 });
893
894 let contents = std::fs::read_to_string(&cfg).unwrap();
895 assert!(contents.contains("# user comment"));
896 assert!(contents.contains("# another"));
897 assert!(contents.contains("model = claude-sonnet-4-6"));
898 let _ = std::fs::remove_dir_all(&home);
899 }
900
901 #[test]
902 #[serial]
903 fn write_config_value_preserves_unknown_keys() {
904 let home = make_test_home("unknown");
905 let cfg = home.join(".synaps-cli/config");
906 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
907
908 with_home(&home, || {
909 write_config_value("model", "claude-sonnet-4-6").unwrap();
910 });
911
912 let contents = std::fs::read_to_string(&cfg).unwrap();
913 assert!(contents.contains("custom_thing = 42"));
914 let _ = std::fs::remove_dir_all(&home);
915 }
916
917 #[test]
918 #[serial]
919 fn write_config_value_creates_file_if_absent() {
920 let home = make_test_home("create");
921 let cfg = home.join(".synaps-cli/config");
922 assert!(!cfg.exists());
923
924 with_home(&home, || {
925 write_config_value("model", "claude-sonnet-4-6").unwrap();
926 });
927
928 let contents = std::fs::read_to_string(&cfg).unwrap();
929 assert!(contents.contains("model = claude-sonnet-4-6"));
930 let _ = std::fs::remove_dir_all(&home);
931 }
932
933 #[test]
934 #[serial]
935 fn load_config_parses_theme_key() {
936 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
937 let _ = std::fs::create_dir_all(&dir);
938 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
939
940 let original_home = std::env::var("HOME").ok();
941 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
942
943 let config = load_config();
944
945 if let Some(home) = original_home {
946 std::env::set_var("HOME", home);
947 } else {
948 std::env::remove_var("HOME");
949 }
950 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
951
952 assert_eq!(config.theme.as_deref(), Some("dracula"));
953 }
954
955 #[test]
956 #[serial]
957 fn test_load_config_disable_lists() {
958 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
959 let _ = std::fs::create_dir_all(&test_dir);
960 let config_path = test_dir.join("config");
961
962 let config_content = r#"
963# Test config with disable lists
964favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
965
966disabled_plugins = foo, bar
967disabled_skills = baz, plug:qual
968"#;
969 std::fs::write(&config_path, config_content).unwrap();
970
971 let original_home = std::env::var("HOME").ok();
972 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
973
974 let config = load_config();
975
976 if let Some(home) = original_home {
977 std::env::set_var("HOME", home);
978 } else {
979 std::env::remove_var("HOME");
980 }
981
982 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
983
984 assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
985 assert_eq!(config.favorite_models, vec![
986 "claude/claude-opus-4-7".to_string(),
987 "groq/llama-3.3-70b-versatile".to_string(),
988 ]);
989 assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
990 }
991
992 #[test]
993 #[serial]
994 fn favorite_model_helpers_round_trip_through_config_file() {
995 let home = make_test_home("favorite-models");
996 let cfg = home.join(".synaps-cli/config");
997 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
998
999 with_home(&home, || {
1000 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1001 add_favorite_model("claude/claude-opus-4-7").unwrap();
1002 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1003 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
1004 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1005 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
1006 assert!(is_favorite_model("claude/claude-opus-4-7"));
1007 });
1008
1009 let contents = std::fs::read_to_string(&cfg).unwrap();
1010 assert!(contents.contains("model = claude-opus-4-7"));
1011 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
1012 let _ = std::fs::remove_dir_all(&home);
1013 }
1014
1015 #[test]
1016 #[serial]
1017 fn test_load_config_new_keys() {
1018 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
1020 let _ = std::fs::create_dir_all(&test_dir);
1021 let config_path = test_dir.join("config");
1022
1023 let config_content = r#"
1024# Test config with new keys
1025model = claude-haiku
1026thinking = medium
1027max_tool_output = 50000
1028bash_timeout = 45
1029bash_max_timeout = 600
1030subagent_timeout = 120
1031api_retries = 5
1032"#;
1033 std::fs::write(&config_path, config_content).unwrap();
1034
1035 let original_home = std::env::var("HOME").ok();
1037 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
1038
1039 let config = load_config();
1040
1041 if let Some(home) = original_home {
1043 std::env::set_var("HOME", home);
1044 } else {
1045 std::env::remove_var("HOME");
1046 }
1047
1048 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
1050
1051 assert_eq!(config.model, Some("claude-haiku".to_string()));
1052 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
1054 assert_eq!(config.bash_timeout, 45);
1055 assert_eq!(config.bash_max_timeout, 600);
1056 assert_eq!(config.subagent_timeout, 120);
1057 assert_eq!(config.api_retries, 5);
1058 }
1059}