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