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