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