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