1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4use crate::core::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, Copy, PartialEq, Eq, Default)]
165pub enum CacheTtl {
166 #[default]
167 FiveMinutes,
168 OneHour,
169 Hybrid,
170}
171
172impl CacheTtl {
173 pub fn parse(val: &str) -> Option<CacheTtl> {
176 match val.to_ascii_lowercase().as_str() {
177 "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
178 "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
179 "hybrid" => Some(CacheTtl::Hybrid),
180 _ => None,
181 }
182 }
183}
184
185#[derive(Debug, Clone)]
187pub struct SynapsConfig {
188 pub model: Option<String>,
189 pub thinking_budget: Option<u32>,
190 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 cache_ttl: CacheTtl,
201 pub theme: Option<String>,
202 pub agent_name: Option<String>,
203 pub identity: Option<String>,
204 pub disabled_plugins: Vec<String>,
205 pub favorite_models: Vec<String>,
206 pub disabled_skills: Vec<String>,
207 pub shell: ShellConfig,
208 pub server: ServerConfig,
209 pub bridge: BridgeConfig,
210 pub provider_keys: BTreeMap<String, String>,
211 pub keybinds: std::collections::HashMap<String, String>,
212 pub warnings: Vec<String>,
215}
216
217impl Default for SynapsConfig {
218 fn default() -> Self {
219 Self {
220 model: None,
221 thinking_budget: None,
222 context_window: None,
223 compaction_model: None,
224 max_tool_output: 30000,
225 bash_timeout: 30,
226 bash_max_timeout: 300,
227 subagent_timeout: 300,
228 api_retries: 3,
229 telemetry: "off".to_string(),
230 cache_diagnostics: false,
231 cache_ttl: CacheTtl::default(),
232 theme: None,
233 agent_name: None,
234 identity: None,
235 disabled_plugins: Vec::new(),
236 favorite_models: Vec::new(),
237 disabled_skills: Vec::new(),
238 shell: ShellConfig::default(),
239 server: ServerConfig::default(),
240 bridge: BridgeConfig::default(),
241 provider_keys: BTreeMap::new(),
242 keybinds: std::collections::HashMap::new(),
243 warnings: Vec::new(),
244 }
245 }
246}
247
248const KNOWN_CONFIG_KEYS: &[&str] = &[
250 "model", "thinking", "compaction_model", "context_window", "max_tool_output",
251 "bash_timeout", "bash_max_timeout", "subagent_timeout", "api_retries",
252 "telemetry", "cache_diagnostics", "cache_ttl", "theme", "agent_name", "identity",
253 "disabled_plugins", "favorite_models", "disabled_skills",
254];
255
256fn levenshtein(a: &str, b: &str) -> usize {
258 let a: Vec<char> = a.chars().collect();
259 let b: Vec<char> = b.chars().collect();
260 let mut prev: Vec<usize> = (0..=b.len()).collect();
261 let mut cur = vec![0; b.len() + 1];
262 for (i, ca) in a.iter().enumerate() {
263 cur[0] = i + 1;
264 for (j, cb) in b.iter().enumerate() {
265 let cost = if ca == cb { 0 } else { 1 };
266 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
267 }
268 std::mem::swap(&mut prev, &mut cur);
269 }
270 prev[b.len()]
271}
272
273fn did_you_mean(key: &str) -> Option<&'static str> {
275 KNOWN_CONFIG_KEYS
276 .iter()
277 .map(|k| (*k, levenshtein(key, k)))
278 .filter(|(_, d)| *d <= 2)
279 .min_by_key(|(_, d)| *d)
280 .map(|(k, _)| k)
281}
282
283
284fn parse_thinking_budget(val: &str) -> Option<u32> {
285 match val {
286 "low" => Some(2048),
287 "medium" => Some(4096),
288 "high" => Some(16384),
289 "xhigh" => Some(32768),
290 "adaptive" => Some(0), _ => val.parse::<u32>().ok(),
292 }
293}
294
295fn parse_comma_list(val: &str) -> Vec<String> {
296 val.split(',')
297 .map(|s| s.trim().to_string())
298 .filter(|s| !s.is_empty())
299 .collect()
300}
301
302fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
303 write_config_value(key, &values.join(", "))
304}
305
306fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
308 match key {
309 "shell.max_sessions" => {
310 if let Ok(sessions) = val.parse::<usize>() {
311 shell_config.max_sessions = sessions;
312 } else {
313 eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
314 }
315 }
316 "shell.idle_timeout" => {
317 if let Ok(timeout) = val.parse::<u64>() {
318 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
319 } else {
320 eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
321 }
322 }
323 "shell.readiness_timeout_ms" => {
324 if let Ok(timeout) = val.parse::<u64>() {
325 shell_config.readiness_timeout_ms = timeout;
326 } else {
327 eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
328 }
329 }
330 "shell.max_readiness_timeout_ms" => {
331 if let Ok(timeout) = val.parse::<u64>() {
332 shell_config.max_readiness_timeout_ms = timeout;
333 } else {
334 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
335 }
336 }
337 "shell.default_rows" => {
338 if let Ok(rows) = val.parse::<u16>() {
339 shell_config.default_rows = rows;
340 } else {
341 eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
342 }
343 }
344 "shell.default_cols" => {
345 if let Ok(cols) = val.parse::<u16>() {
346 shell_config.default_cols = cols;
347 } else {
348 eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
349 }
350 }
351 "shell.readiness_strategy" => {
352 let val_lower = val.to_lowercase();
353 match val_lower.as_str() {
354 "timeout" | "prompt" | "hybrid" => {
355 shell_config.readiness_strategy = val.to_string();
356 }
357 _ => {
358 eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
359 }
360 }
361 }
362 "shell.max_output" => {
363 if let Ok(max_output) = val.parse::<usize>() {
364 shell_config.max_output = max_output;
365 } else {
366 eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
367 }
368 }
369 _ => {
370 }
372 }
373}
374
375#[allow(clippy::collapsible_match)]
377fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
378 match key {
379 "server.allowed_origins" => {
380 server_config.allowed_origins = parse_comma_list(val);
381 }
382 "server.token" => {
383 if !val.is_empty() {
384 server_config.token = Some(val.to_string());
385 }
386 }
387 "server.auto_approve_confirms" => {
388 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
389 }
390 "server.max_message_size" => {
391 if let Ok(size) = val.parse::<usize>() {
392 server_config.max_message_size = Some(size);
393 } else {
394 eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
395 }
396 }
397 _ => {
398 }
400 }
401}
402
403fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
405 match key {
406 "bridge.uds_path" => {
407 if val.is_empty() {
408 bridge_config.uds_path = None;
409 } else {
410 bridge_config.uds_path = Some(PathBuf::from(val));
411 }
412 }
413 "bridge.heartbeat_mirror" => {
414 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
415 }
416 "bridge.heartbeat_timeout_ms" => {
417 if let Ok(ms) = val.parse::<u64>() {
418 bridge_config.heartbeat_timeout_ms = ms;
419 } else {
420 eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
421 }
422 }
423 _ => {
424 }
426 }
427}
428
429pub fn load_config() -> SynapsConfig {
432 let path = resolve_read_path("config");
433 let mut config = SynapsConfig::default();
434
435 let Ok(content) = std::fs::read_to_string(&path) else {
436 return config;
437 };
438
439 for line in content.lines() {
440 let line = line.trim();
441 if line.is_empty() || line.starts_with('#') { continue; }
442 let Some((key, val)) = line.split_once('=') else { continue };
443 let key = key.trim();
444 let val = val.trim();
445 match key {
446 "model" => config.model = Some(val.to_string()),
447 "thinking" => {
448 config.thinking_budget = parse_thinking_budget(val);
449 if config.thinking_budget.is_none() {
450 config.warnings.push(format!("thinking = {val} — expected low|medium|high|xhigh|adaptive or a token count; thinking disabled"));
451 }
452 }
453 "compaction_model" => config.compaction_model = Some(val.to_string()),
454 "context_window" => {
455 let parsed = match val {
456 "200k" | "200K" => Some(200_000),
457 "1m" | "1M" => Some(1_000_000),
458 _ => val.parse::<u64>().ok(),
459 };
460 if parsed.is_none() {
461 config.warnings.push(format!("context_window = {val} — expected 200k, 1m, or a token count; ignored"));
462 }
463 config.context_window = parsed;
464 }
465 "max_tool_output" => {
466 match val.parse::<usize>() {
467 Ok(size) => config.max_tool_output = size,
468 Err(_) => config.warnings.push(format!("max_tool_output = {val} — not a number; using {}", config.max_tool_output)),
469 }
470 }
471 "bash_timeout" => {
472 match val.parse::<u64>() {
473 Ok(t) if t >= 1 => config.bash_timeout = t,
474 Ok(_) => config.warnings.push(format!("bash_timeout = {val} — below minimum (1s); using {}", config.bash_timeout)),
475 Err(_) => config.warnings.push(format!("bash_timeout = {val} — not a number; using {}", config.bash_timeout)),
476 }
477 }
478 "bash_max_timeout" => {
479 if let Ok(timeout) = val.parse::<u64>() {
480 config.bash_max_timeout = timeout;
481 }
482 }
483 "subagent_timeout" => {
484 if let Ok(timeout) = val.parse::<u64>() {
485 config.subagent_timeout = timeout;
486 }
487 }
488 "api_retries" => {
489 if let Ok(retries) = val.parse::<u32>() {
490 config.api_retries = retries;
491 }
492 }
493 "telemetry" => config.telemetry = val.to_string(),
494 "cache_diagnostics" => {
495 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
496 }
497 "cache_ttl" => {
498 match CacheTtl::parse(val) {
499 Some(ttl) => config.cache_ttl = ttl,
500 None => config.warnings.push(format!(
501 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
502 )),
503 }
504 }
505 "theme" => config.theme = Some(val.to_string()),
506 "agent_name" => config.agent_name = Some(val.to_string()),
507 "identity" => config.identity = Some(val.to_string()),
508 "disabled_plugins" => {
509 config.disabled_plugins = parse_comma_list(val);
510 }
511 "favorite_models" => {
512 config.favorite_models = parse_comma_list(val);
513 }
514 "disabled_skills" => {
515 config.disabled_skills = parse_comma_list(val);
516 }
517 _ => {
518 if key.starts_with("shell.") {
520 parse_shell_config_key(&mut config.shell, key, val);
521 } else if key.starts_with("server.") {
522 parse_server_config_key(&mut config.server, key, val);
523 } else if key.starts_with("bridge.") {
524 parse_bridge_config_key(&mut config.bridge, key, val);
525 } else if let Some(provider_key) = key.strip_prefix("provider.") {
526 config.provider_keys.insert(provider_key.to_string(), val.to_string());
527 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
528 config.keybinds.insert(keybind_key.to_string(), val.to_string());
529 } else if key.contains('.') {
530 } else {
534 match did_you_mean(key) {
536 Some(suggestion) => config.warnings.push(format!("unknown key '{key}' (did you mean '{suggestion}'?)")),
537 None => config.warnings.push(format!("unknown key '{key}' — ignored")),
538 }
539 }
540 }
541 }
542 }
543
544 if config.server.max_message_size.is_none() {
547 if let Some(ctx_tokens) = config.context_window {
548 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
549 }
550 }
551
552 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
555
556 let identity_val = config.identity.clone().unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
558 let _ = IDENTITY.set(identity_val);
559
560 config
561}
562
563pub fn read_config_value(key: &str) -> Option<String> {
565 let path = resolve_read_path("config");
566 let content = std::fs::read_to_string(&path).ok()?;
567 for line in content.lines() {
568 let line = line.trim();
569 if line.is_empty() || line.starts_with('#') { continue; }
570 let Some((k, v)) = line.split_once('=') else { continue };
571 if k.trim() == key.trim() {
572 return Some(v.trim().to_string());
573 }
574 }
575 None
576}
577
578pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
582 let path = resolve_write_path("config");
583 let existing = std::fs::read_to_string(&path).unwrap_or_default();
584
585 let key_trimmed = key.trim();
586 let replacement = format!("{} = {}", key_trimmed, value);
587
588 let mut found = false;
589 let mut new_lines: Vec<String> = existing.lines().map(|line| {
590 if found { return line.to_string(); }
591 let t = line.trim_start();
592 if t.starts_with('#') || t.is_empty() { return line.to_string(); }
593 if let Some((k, _)) = t.split_once('=') {
594 if k.trim() == key_trimmed {
595 found = true;
596 return replacement.clone();
597 }
598 }
599 line.to_string()
600 }).collect();
601
602 if !found {
603 new_lines.push(replacement);
604 }
605
606 let mut out = new_lines.join("\n");
607 if !out.ends_with('\n') { out.push('\n'); }
608
609 let tmp = path.with_extension("tmp");
610 std::fs::write(&tmp, out)?;
611 #[cfg(unix)]
613 {
614 use std::os::unix::fs::PermissionsExt;
615 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
616 }
617 std::fs::rename(&tmp, &path)?;
618 Ok(())
619}
620
621pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
623 let trimmed = id.trim();
624 if trimmed.is_empty() {
625 return Ok(());
626 }
627 let mut values = load_config().favorite_models;
628 if !values.iter().any(|v| v == trimmed) {
629 values.push(trimmed.to_string());
630 values.sort();
631 }
632 write_comma_list("favorite_models", &values)
633}
634
635pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
637 let mut values = load_config().favorite_models;
638 values.retain(|v| v != id.trim());
639 write_comma_list("favorite_models", &values)
640}
641
642pub fn is_favorite_model(id: &str) -> bool {
644 load_config().favorite_models.iter().any(|v| v == id.trim())
645}
646
647pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
650 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
651 You have access to bash, read, and write tools. \
652 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
653
654 if let Some(val) = explicit {
655 let path = std::path::Path::new(val);
656 if path.exists() && path.is_file() {
657 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
658 }
659 return val.to_string();
660 }
661
662 let system_path = resolve_read_path("system.md");
663 if system_path.exists() {
664 return std::fs::read_to_string(&system_path).unwrap_or_default();
665 }
666
667 DEFAULT_PROMPT.to_string()
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use serial_test::serial;
674
675 #[test]
676 fn test_levenshtein_basics() {
677 assert_eq!(levenshtein("model", "model"), 0);
678 assert_eq!(levenshtein("modle", "model"), 2);
679 assert_eq!(levenshtein("them", "theme"), 1);
680 }
681
682 #[test]
683 fn test_did_you_mean_close_typos() {
684 assert_eq!(did_you_mean("modle"), Some("model"));
685 assert_eq!(did_you_mean("them"), Some("theme"));
686 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
687 assert_eq!(did_you_mean("completely_unrelated_key"), None);
688 }
689
690 #[test]
691 #[serial]
692 fn test_config_warnings_unknown_key_and_bad_values() {
693 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
694 let dir = home.join(".synaps-cli");
695 std::fs::create_dir_all(&dir).unwrap();
696 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();
697
698 with_home(&home, || {
699 let config = load_config();
700 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
702 assert!(!config.warnings.iter().any(|w| w.contains("knowledge")), "{:?}", config.warnings);
703 assert!(config.warnings.iter().any(|w| w.contains("did you mean 'model'")), "{:?}", config.warnings);
704 assert!(config.warnings.iter().any(|w| w.contains("thinking")), "{:?}", config.warnings);
705 assert!(config.warnings.iter().any(|w| w.contains("below minimum")), "{:?}", config.warnings);
706 assert_eq!(config.bash_timeout, 30);
708 assert_eq!(config.thinking_budget, None);
709 });
710 let _ = std::fs::remove_dir_all(&home);
711 }
712
713 #[test]
716 fn test_cache_ttl_parse_table() {
717 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
719 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
720 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
721 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
723 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
724 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
725 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
727 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
729 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
730 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
731 assert_eq!(CacheTtl::parse("2h"), None);
733 assert_eq!(CacheTtl::parse(""), None);
734 assert_eq!(CacheTtl::parse("forever"), None);
735 }
736
737 #[test]
738 fn test_cache_ttl_default_is_five_minutes() {
739 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
740 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
741 }
742
743 #[test]
744 #[serial]
745 fn test_cache_ttl_config_parse_and_garbage_warning() {
746 let home = std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
747 let dir = home.join(".synaps-cli");
748 std::fs::create_dir_all(&dir).unwrap();
749
750 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
752 with_home(&home, || {
753 let config = load_config();
754 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
755 assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
756 });
757
758 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
760 with_home(&home, || {
761 let config = load_config();
762 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
763 assert!(
764 config.warnings.iter().any(|w| w.contains("cache_ttl")),
765 "warnings: {:?}", config.warnings
766 );
767 });
768
769 let _ = std::fs::remove_dir_all(&home);
770 }
771
772 #[test]
773 fn test_parse_thinking_budget() {
774 assert_eq!(parse_thinking_budget("low"), Some(2048));
775 assert_eq!(parse_thinking_budget("medium"), Some(4096));
776 assert_eq!(parse_thinking_budget("high"), Some(16384));
777 assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
778 assert_eq!(parse_thinking_budget("8192"), Some(8192));
779 assert_eq!(parse_thinking_budget("invalid"), None);
780 }
781
782 #[test]
783 fn test_base_dir() {
784 let path = base_dir();
785 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
786 }
787
788 #[test]
789 fn test_resolve_system_prompt_explicit() {
790 let result = resolve_system_prompt(Some("test prompt"));
791 assert_eq!(result, "test prompt");
792 }
793
794 #[test]
795 fn test_resolve_system_prompt_none() {
796 let result = resolve_system_prompt(None);
797 assert!(result.contains("helpful AI agent"));
798 }
799
800 #[test]
805 fn test_synaps_config_default() {
806 let config = SynapsConfig::default();
807 assert_eq!(config.model, None);
808 assert_eq!(config.thinking_budget, None);
809 assert_eq!(config.max_tool_output, 30000);
810 assert_eq!(config.bash_timeout, 30);
811 assert_eq!(config.bash_max_timeout, 300);
812 assert_eq!(config.subagent_timeout, 300);
813 assert_eq!(config.api_retries, 3);
814 assert_eq!(config.theme, None);
815 assert!(config.disabled_plugins.is_empty());
816 assert!(config.favorite_models.is_empty());
817 assert!(config.disabled_skills.is_empty());
818 assert_eq!(config.shell.max_sessions, 5);
819 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
820 assert!(config.server.allowed_origins.is_empty());
822 assert_eq!(config.server.token, None);
823 assert!(!config.server.auto_approve_confirms);
824 assert_eq!(config.server.max_message_size, None);
825 assert!(config.bridge.uds_path.is_none());
827 assert!(!config.bridge.heartbeat_mirror);
828 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
829 }
830
831 #[test]
832 #[serial]
833 fn test_load_config_bridge_keys() {
834 let home = make_test_home("bridge-keys");
835 let cfg = home.join(".synaps-cli/config");
836 std::fs::write(&cfg, "\
837bridge.uds_path = /tmp/some/control.sock\n\
838bridge.heartbeat_mirror = true\n\
839bridge.heartbeat_timeout_ms = 750\n\
840").unwrap();
841
842 with_home(&home, || {
843 let config = load_config();
844 assert_eq!(
845 config.bridge.uds_path,
846 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
847 );
848 assert!(config.bridge.heartbeat_mirror);
849 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
850 assert_eq!(
851 config.bridge.resolved_uds_path(),
852 std::path::PathBuf::from("/tmp/some/control.sock"),
853 );
854 });
855
856 let _ = std::fs::remove_dir_all(&home);
857 }
858
859 #[test]
860 fn test_bridge_config_defaults() {
861 let cfg = BridgeConfig::default();
862 assert!(cfg.uds_path.is_none());
863 assert!(!cfg.heartbeat_mirror);
864 assert_eq!(cfg.heartbeat_timeout_ms, 250);
865 let resolved = cfg.resolved_uds_path();
867 assert!(resolved.ends_with("bridge/control.sock"));
868 }
869
870 #[test]
871 #[serial]
872 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
873 let home = make_test_home("bridge-default-off");
874 let cfg = home.join(".synaps-cli/config");
875 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
876
877 with_home(&home, || {
878 let config = load_config();
879 assert!(!config.bridge.heartbeat_mirror);
880 assert!(config.bridge.uds_path.is_none());
881 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
882 });
883
884 let _ = std::fs::remove_dir_all(&home);
885 }
886
887 #[test]
888 #[serial]
889 fn test_load_config_server_keys() {
890 let home = make_test_home("server-keys");
891 let cfg = home.join(".synaps-cli/config");
892 std::fs::write(&cfg, "\
893server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
894server.token = my-secret-token\n\
895server.auto_approve_confirms = true\n\
896server.max_message_size = 65536\n\
897context_window = 200k\n\
898").unwrap();
899
900 with_home(&home, || {
901 let config = load_config();
902 assert_eq!(config.server.allowed_origins, vec![
903 "http://localhost:3000".to_string(),
904 "http://localhost:5193".to_string(),
905 ]);
906 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
907 assert!(config.server.auto_approve_confirms);
908 assert_eq!(config.server.max_message_size, Some(65536));
910 });
911
912 let _ = std::fs::remove_dir_all(&home);
913 }
914
915 #[test]
916 #[serial]
917 fn test_server_max_message_size_derived_from_context_window() {
918 let home = make_test_home("server-derive");
919 let cfg = home.join(".synaps-cli/config");
920 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
921
922 with_home(&home, || {
923 let config = load_config();
924 assert_eq!(config.server.max_message_size, Some(800_000));
926 });
927
928 let _ = std::fs::remove_dir_all(&home);
929 }
930
931 fn make_test_home(subdir: &str) -> std::path::PathBuf {
932 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
933 let _ = std::fs::remove_dir_all(&dir);
934 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
935 dir
936 }
937
938 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
939 let original = std::env::var("HOME").ok();
940 std::env::set_var("HOME", home);
941 f();
942 if let Some(h) = original {
943 std::env::set_var("HOME", h);
944 } else {
945 std::env::remove_var("HOME");
946 }
947 }
948
949 #[test]
950 #[serial]
951 fn write_config_value_replaces_existing_key() {
952 let home = make_test_home("replace");
953 let cfg = home.join(".synaps-cli/config");
954 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
955
956 with_home(&home, || {
957 write_config_value("model", "claude-sonnet-4-6").unwrap();
958 });
959
960 let contents = std::fs::read_to_string(&cfg).unwrap();
961 assert!(contents.contains("model = claude-sonnet-4-6"));
962 assert!(contents.contains("thinking = low"));
963 let _ = std::fs::remove_dir_all(&home);
964 }
965
966 #[test]
967 #[serial]
968 fn write_config_value_appends_when_missing() {
969 let home = make_test_home("append");
970 let cfg = home.join(".synaps-cli/config");
971 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
972
973 with_home(&home, || {
974 write_config_value("theme", "dracula").unwrap();
975 });
976
977 let contents = std::fs::read_to_string(&cfg).unwrap();
978 assert!(contents.contains("model = claude-opus-4-6"));
979 assert!(contents.contains("theme = dracula"));
980 let _ = std::fs::remove_dir_all(&home);
981 }
982
983 #[test]
984 #[serial]
985 fn write_config_value_preserves_comments() {
986 let home = make_test_home("comments");
987 let cfg = home.join(".synaps-cli/config");
988 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
989
990 with_home(&home, || {
991 write_config_value("model", "claude-sonnet-4-6").unwrap();
992 });
993
994 let contents = std::fs::read_to_string(&cfg).unwrap();
995 assert!(contents.contains("# user comment"));
996 assert!(contents.contains("# another"));
997 assert!(contents.contains("model = claude-sonnet-4-6"));
998 let _ = std::fs::remove_dir_all(&home);
999 }
1000
1001 #[test]
1002 #[serial]
1003 fn write_config_value_preserves_unknown_keys() {
1004 let home = make_test_home("unknown");
1005 let cfg = home.join(".synaps-cli/config");
1006 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1007
1008 with_home(&home, || {
1009 write_config_value("model", "claude-sonnet-4-6").unwrap();
1010 });
1011
1012 let contents = std::fs::read_to_string(&cfg).unwrap();
1013 assert!(contents.contains("custom_thing = 42"));
1014 let _ = std::fs::remove_dir_all(&home);
1015 }
1016
1017 #[test]
1018 #[serial]
1019 fn write_config_value_creates_file_if_absent() {
1020 let home = make_test_home("create");
1021 let cfg = home.join(".synaps-cli/config");
1022 assert!(!cfg.exists());
1023
1024 with_home(&home, || {
1025 write_config_value("model", "claude-sonnet-4-6").unwrap();
1026 });
1027
1028 let contents = std::fs::read_to_string(&cfg).unwrap();
1029 assert!(contents.contains("model = claude-sonnet-4-6"));
1030 let _ = std::fs::remove_dir_all(&home);
1031 }
1032
1033 #[test]
1034 #[serial]
1035 fn load_config_parses_theme_key() {
1036 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1037 let _ = std::fs::create_dir_all(&dir);
1038 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1039
1040 let original_home = std::env::var("HOME").ok();
1041 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1042
1043 let config = load_config();
1044
1045 if let Some(home) = original_home {
1046 std::env::set_var("HOME", home);
1047 } else {
1048 std::env::remove_var("HOME");
1049 }
1050 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1051
1052 assert_eq!(config.theme.as_deref(), Some("dracula"));
1053 }
1054
1055 #[test]
1056 #[serial]
1057 fn test_load_config_disable_lists() {
1058 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1059 let _ = std::fs::create_dir_all(&test_dir);
1060 let config_path = test_dir.join("config");
1061
1062 let config_content = r#"
1063# Test config with disable lists
1064favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1065
1066disabled_plugins = foo, bar
1067disabled_skills = baz, plug:qual
1068"#;
1069 std::fs::write(&config_path, config_content).unwrap();
1070
1071 let original_home = std::env::var("HOME").ok();
1072 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1073
1074 let config = load_config();
1075
1076 if let Some(home) = original_home {
1077 std::env::set_var("HOME", home);
1078 } else {
1079 std::env::remove_var("HOME");
1080 }
1081
1082 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1083
1084 assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
1085 assert_eq!(config.favorite_models, vec![
1086 "claude/claude-opus-4-7".to_string(),
1087 "groq/llama-3.3-70b-versatile".to_string(),
1088 ]);
1089 assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
1090 }
1091
1092 #[test]
1093 #[serial]
1094 fn favorite_model_helpers_round_trip_through_config_file() {
1095 let home = make_test_home("favorite-models");
1096 let cfg = home.join(".synaps-cli/config");
1097 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
1098
1099 with_home(&home, || {
1100 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1101 add_favorite_model("claude/claude-opus-4-7").unwrap();
1102 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1103 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
1104 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1105 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
1106 assert!(is_favorite_model("claude/claude-opus-4-7"));
1107 });
1108
1109 let contents = std::fs::read_to_string(&cfg).unwrap();
1110 assert!(contents.contains("model = claude-opus-4-7"));
1111 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
1112 let _ = std::fs::remove_dir_all(&home);
1113 }
1114
1115 #[test]
1116 #[serial]
1117 fn test_load_config_new_keys() {
1118 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
1120 let _ = std::fs::create_dir_all(&test_dir);
1121 let config_path = test_dir.join("config");
1122
1123 let config_content = r#"
1124# Test config with new keys
1125model = claude-haiku
1126thinking = medium
1127max_tool_output = 50000
1128bash_timeout = 45
1129bash_max_timeout = 600
1130subagent_timeout = 120
1131api_retries = 5
1132"#;
1133 std::fs::write(&config_path, config_content).unwrap();
1134
1135 let original_home = std::env::var("HOME").ok();
1137 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
1138
1139 let config = load_config();
1140
1141 if let Some(home) = original_home {
1143 std::env::set_var("HOME", home);
1144 } else {
1145 std::env::remove_var("HOME");
1146 }
1147
1148 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
1150
1151 assert_eq!(config.model, Some("claude-haiku".to_string()));
1152 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
1154 assert_eq!(config.bash_timeout, 45);
1155 assert_eq!(config.bash_max_timeout, 600);
1156 assert_eq!(config.subagent_timeout, 120);
1157 assert_eq!(config.api_retries, 5);
1158 }
1159}