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