1use crate::core::shell_config::ShellConfig;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::sync::OnceLock;
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 =
11 "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
12
13pub fn get_identity() -> String {
17 IDENTITY
18 .get()
19 .cloned()
20 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
21}
22
23pub fn get_provider_keys() -> BTreeMap<String, String> {
27 PROVIDER_KEYS.get().cloned().unwrap_or_default()
28}
29
30pub fn get_profile() -> Option<String> {
33 PROFILE_NAME
34 .get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
35 .clone()
36}
37
38pub fn set_profile(name: Option<String>) {
42 let _ = PROFILE_NAME.set(name);
43}
44
45pub fn base_dir() -> PathBuf {
46 if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
47 return PathBuf::from(path);
48 }
49 let home = std::env::var("HOME")
50 .or_else(|_| std::env::var("USERPROFILE"))
51 .unwrap_or_else(|_| ".".to_string());
52 PathBuf::from(home).join(".synaps-cli")
53}
54
55#[doc(hidden)]
57pub fn set_base_dir_for_tests(path: PathBuf) {
58 std::env::set_var("SYNAPS_BASE_DIR", path);
59}
60
61pub fn resolve_read_path(filename: &str) -> PathBuf {
63 let base = base_dir();
64
65 if let Some(profile) = get_profile() {
66 let profile_path = base.join(&profile).join(filename);
67 if profile_path.exists() {
68 return profile_path;
69 }
70 }
71
72 base.join(filename)
73}
74
75pub fn resolve_read_path_extended(path: &str) -> PathBuf {
77 let base = base_dir();
78
79 if let Some(profile) = get_profile() {
80 let profile_path = base.join(&profile).join(path);
81 if profile_path.exists() {
82 return profile_path;
83 }
84 }
85
86 base.join(path)
87}
88
89pub fn resolve_write_path(filename: &str) -> PathBuf {
91 let mut base = base_dir();
92
93 if let Some(profile) = get_profile() {
94 base.push(profile);
95 }
96
97 let _ = std::fs::create_dir_all(&base);
98 base.join(filename)
99}
100
101pub fn get_active_config_dir() -> PathBuf {
103 let mut base = base_dir();
104 if let Some(profile) = get_profile() {
105 base.push(profile);
106 }
107 base
108}
109
110#[derive(Debug, Clone, Default)]
112pub struct ServerConfig {
113 pub allowed_origins: Vec<String>,
115 pub token: Option<String>,
118 pub auto_approve_confirms: bool,
120 pub max_message_size: Option<usize>,
123}
124
125#[derive(Debug, Clone)]
131pub struct BridgeConfig {
132 pub uds_path: Option<PathBuf>,
135 pub heartbeat_mirror: bool,
138 pub heartbeat_timeout_ms: u64,
140}
141
142impl Default for BridgeConfig {
143 fn default() -> Self {
144 Self {
145 uds_path: None,
146 heartbeat_mirror: false,
147 heartbeat_timeout_ms: 250,
148 }
149 }
150}
151
152impl BridgeConfig {
153 pub fn resolved_uds_path(&self) -> PathBuf {
155 self.uds_path
156 .clone()
157 .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
158 }
159}
160
161#[derive(Debug, Clone, Default)]
165pub struct AuthConfig {
166 pub remote_endpoint: Option<String>,
170 pub machine_token: Option<String>,
173}
174
175impl AuthConfig {
176 pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
180 let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
181 .ok()
182 .filter(|s| !s.trim().is_empty())
183 .or_else(|| self.remote_endpoint.clone());
184 let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
185 .ok()
186 .filter(|s| !s.trim().is_empty())
187 .or_else(|| self.machine_token.clone());
188 crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum CacheTtl {
203 #[default]
204 FiveMinutes,
205 OneHour,
206 Hybrid,
207}
208
209impl CacheTtl {
210 pub fn parse(val: &str) -> Option<CacheTtl> {
213 match val.to_ascii_lowercase().as_str() {
214 "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
215 "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
216 "hybrid" => Some(CacheTtl::Hybrid),
217 _ => None,
218 }
219 }
220}
221
222#[derive(Debug, Clone)]
227pub struct EventsConfig {
228 pub auto_turn: bool,
234}
235
236impl Default for EventsConfig {
237 fn default() -> Self {
238 Self { auto_turn: true }
239 }
240}
241
242fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
244 if key == "events.auto_turn" {
245 let normalised = val.trim().to_lowercase();
246 cfg.auto_turn = match normalised.as_str() {
247 "true" | "1" | "yes" | "on" => true,
249 "false" | "0" | "no" | "off" => false,
251 other => {
253 eprintln!(
254 "warning: config: unrecognised value for events.auto_turn = {:?}; \
255 expected true/false/yes/no/on/off/1/0 — defaulting to false",
256 other
257 );
258 false
259 }
260 };
261 } }
263
264#[derive(Debug, Clone, Default, PartialEq)]
268pub struct TurnBudgetOverrides {
269 pub max_provider_rounds: Option<u32>,
270 pub max_tool_calls: Option<u32>,
271 pub max_elapsed_secs: Option<u64>,
272 pub max_accumulated_tool_result_bytes: Option<usize>,
273 pub max_context_tokens: Option<u64>,
274 pub max_cost_usd: Option<f64>,
275 pub max_round_renewals: Option<u32>,
280}
281
282#[derive(Debug, Clone, Default, PartialEq)]
284pub struct TurnBudgetsConfig {
285 pub foreground: TurnBudgetOverrides,
286 pub autonomous: TurnBudgetOverrides,
287 pub worker: TurnBudgetOverrides,
288}
289
290fn parse_turn_budget_config_key(budgets: &mut TurnBudgetsConfig, key: &str, val: &str) {
293 let Some(rest) = key.strip_prefix("turn_budget.") else {
294 return;
295 };
296 let Some((role, field)) = rest.split_once('.') else {
297 eprintln!("Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)");
298 return;
299 };
300 let overrides = match role {
301 "foreground" => &mut budgets.foreground,
302 "autonomous" => &mut budgets.autonomous,
303 "worker" => &mut budgets.worker,
304 other => {
305 eprintln!("Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)");
306 return;
307 }
308 };
309 macro_rules! set {
310 ($slot:expr, $ty:ty) => {
311 match val.parse::<$ty>() {
312 Ok(parsed) => $slot = Some(parsed),
313 Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
314 }
315 };
316 }
317 match field {
318 "max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
319 "max_tool_calls" => set!(overrides.max_tool_calls, u32),
320 "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
321 "max_accumulated_tool_result_bytes" => {
322 set!(overrides.max_accumulated_tool_result_bytes, usize)
323 }
324 "max_context_tokens" => set!(overrides.max_context_tokens, u64),
325 "max_cost_usd" => set!(overrides.max_cost_usd, f64),
326 "max_round_renewals" => set!(overrides.max_round_renewals, u32),
327 other => eprintln!("Warning: unknown turn_budget field '{other}'"),
328 }
329}
330
331#[derive(Debug, Clone, PartialEq)]
339pub struct MemoryConfig {
340 pub default_mode: String,
346 pub default_mode_confirmed: bool,
350 pub recall_max_records: u32,
352 pub recall_max_tokens: u32,
354 pub recall_timeout_ms: u64,
356 pub capture_tools: String,
358 pub capture_assistant: bool,
360 pub capture_user: bool,
362 pub auto_consolidate: String,
364 pub local_embeddings: String,
366 pub gliner: String,
368}
369
370impl Default for MemoryConfig {
371 fn default() -> Self {
372 Self {
373 default_mode: "off".to_string(),
374 default_mode_confirmed: false,
375 recall_max_records: 8,
376 recall_max_tokens: 4096,
377 recall_timeout_ms: 150,
378 capture_tools: "summary_only".to_string(),
379 capture_assistant: true,
380 capture_user: true,
381 auto_consolidate: "off".to_string(),
382 local_embeddings: "off".to_string(),
383 gliner: "off".to_string(),
384 }
385 }
386}
387
388const KNOWN_MEMORY_MODES: &[&str] = &[
393 "off",
394 "recall_once",
395 "recall_each_prompt",
396 "capture_only",
397 "capture_and_recall",
398];
399
400fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) {
403 let Some(field) = key.strip_prefix("memory.") else {
404 return;
405 };
406 fn parse_bool(slot: &mut bool, key: &str, val: &str) {
409 match val {
410 "true" | "1" | "yes" | "on" => *slot = true,
411 "false" | "0" | "no" | "off" => *slot = false,
412 other => eprintln!(
413 "Warning: invalid value for {key}: '{other}', expected true/false — using default"
414 ),
415 }
416 }
417 fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) {
420 if allowed.contains(&val) {
421 *slot = val.to_string();
422 } else {
423 eprintln!(
424 "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
425 allowed.join("|")
426 );
427 }
428 }
429 macro_rules! set_num {
430 ($slot:expr, $ty:ty) => {
431 match val.parse::<$ty>() {
432 Ok(parsed) => $slot = parsed,
433 Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
434 }
435 };
436 }
437 match field {
438 "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
439 "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
440 "recall_max_records" => set_num!(memory.recall_max_records, u32),
441 "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
442 "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
443 "capture_tools" => parse_enum(
444 &mut memory.capture_tools,
445 key,
446 val,
447 &["off", "summary_only"],
448 ),
449 "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
450 "capture_user" => parse_bool(&mut memory.capture_user, key, val),
451 "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
452 "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
453 "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
454 other => eprintln!("Warning: unknown memory field '{other}'"),
455 }
456}
457
458#[derive(Debug, Clone)]
460pub struct SynapsConfig {
461 pub model: Option<String>,
462 pub thinking_budget: Option<u32>,
463 pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
467 pub context_window: Option<u64>, pub compaction_model: Option<String>, pub compaction_mode: crate::core::compaction::CompactionMode,
472 pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
474 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,
484 pub max_fps: u32,
488 pub scroll_lines: Option<u16>,
492 pub theme: Option<String>,
493 pub tui_background_opaque: bool,
495 pub agent_name: Option<String>,
496 pub identity: Option<String>,
497 pub disabled_plugins: Vec<String>,
498 pub favorite_models: Vec<String>,
499 pub disabled_skills: Vec<String>,
500 pub progressive_tool_disclosure: bool,
505 pub session_persistence: crate::core::session_journal::SessionPersistence,
510 pub disabled_tools: Vec<String>,
513 pub shell: ShellConfig,
514 pub server: ServerConfig,
515 pub bridge: BridgeConfig,
516 pub auth: AuthConfig,
517 pub events: EventsConfig,
518 pub provider_keys: BTreeMap<String, String>,
519 pub keybinds: std::collections::HashMap<String, String>,
520 pub turn_budgets: TurnBudgetsConfig,
522 pub memory: MemoryConfig,
524 pub warnings: Vec<String>,
527}
528
529impl Default for SynapsConfig {
530 fn default() -> Self {
531 Self {
532 model: None,
533 thinking_budget: None,
534 thinking_level: None,
535 context_window: None,
536 compaction_model: None,
537 compaction_mode: crate::core::compaction::CompactionMode::default(),
538 compaction_exclude: Vec::new(),
539 max_tool_output: 30000,
540 bash_timeout: 30,
541 bash_max_timeout: 300,
542 subagent_timeout: 300,
543 api_retries: 3,
544 refusal_retries: 2,
545 telemetry: "off".to_string(),
546 cache_diagnostics: false,
547 cache_ttl: CacheTtl::default(),
548 max_fps: 60,
549 scroll_lines: None,
550 theme: None,
551 tui_background_opaque: true,
552 agent_name: None,
553 identity: None,
554 disabled_plugins: Vec::new(),
555 favorite_models: Vec::new(),
556 disabled_skills: Vec::new(),
557 progressive_tool_disclosure: false,
558 session_persistence: crate::core::session_journal::SessionPersistence::default(),
559 disabled_tools: Vec::new(),
560 shell: ShellConfig::default(),
561 server: ServerConfig::default(),
562 bridge: BridgeConfig::default(),
563 auth: AuthConfig::default(),
564 events: EventsConfig::default(),
565 provider_keys: BTreeMap::new(),
566 keybinds: std::collections::HashMap::new(),
567 turn_budgets: TurnBudgetsConfig::default(),
568 memory: MemoryConfig::default(),
569 warnings: Vec::new(),
570 }
571 }
572}
573
574const KNOWN_CONFIG_KEYS: &[&str] = &[
576 "model",
577 "thinking",
578 "compaction_model",
579 "context_window",
580 "max_tool_output",
581 "bash_timeout",
582 "bash_max_timeout",
583 "subagent_timeout",
584 "api_retries",
585 "refusal_retries",
586 "telemetry",
587 "cache_diagnostics",
588 "cache_ttl",
589 "max_fps",
590 "scroll_lines",
591 "theme",
592 "tui_background_opaque",
593 "agent_name",
594 "identity",
595 "disabled_plugins",
596 "favorite_models",
597 "disabled_skills",
598 "disabled_tools",
599 "progressive_tool_disclosure",
600 "session_persistence",
601];
602
603fn levenshtein(a: &str, b: &str) -> usize {
605 let a: Vec<char> = a.chars().collect();
606 let b: Vec<char> = b.chars().collect();
607 let mut prev: Vec<usize> = (0..=b.len()).collect();
608 let mut cur = vec![0; b.len() + 1];
609 for (i, ca) in a.iter().enumerate() {
610 cur[0] = i + 1;
611 for (j, cb) in b.iter().enumerate() {
612 let cost = if ca == cb { 0 } else { 1 };
613 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
614 }
615 std::mem::swap(&mut prev, &mut cur);
616 }
617 prev[b.len()]
618}
619
620fn did_you_mean(key: &str) -> Option<&'static str> {
622 KNOWN_CONFIG_KEYS
623 .iter()
624 .map(|k| (*k, levenshtein(key, k)))
625 .filter(|(_, d)| *d <= 2)
626 .min_by_key(|(_, d)| *d)
627 .map(|(k, _)| k)
628}
629
630fn parse_comma_list(val: &str) -> Vec<String> {
633 val.split(',')
634 .map(|s| s.trim().to_string())
635 .filter(|s| !s.is_empty())
636 .collect()
637}
638
639fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
640 write_config_value(key, &values.join(", "))
641}
642
643fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
645 match key {
646 "shell.max_sessions" => {
647 if let Ok(sessions) = val.parse::<usize>() {
648 shell_config.max_sessions = sessions;
649 } else {
650 eprintln!(
651 "Warning: invalid value for shell.max_sessions: '{}', using default",
652 val
653 );
654 }
655 }
656 "shell.idle_timeout" => {
657 if let Ok(timeout) = val.parse::<u64>() {
658 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
659 } else {
660 eprintln!(
661 "Warning: invalid value for shell.idle_timeout: '{}', using default",
662 val
663 );
664 }
665 }
666 "shell.readiness_timeout_ms" => {
667 if let Ok(timeout) = val.parse::<u64>() {
668 shell_config.readiness_timeout_ms = timeout;
669 } else {
670 eprintln!(
671 "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
672 val
673 );
674 }
675 }
676 "shell.max_readiness_timeout_ms" => {
677 if let Ok(timeout) = val.parse::<u64>() {
678 shell_config.max_readiness_timeout_ms = timeout;
679 } else {
680 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
681 }
682 }
683 "shell.default_rows" => {
684 if let Ok(rows) = val.parse::<u16>() {
685 shell_config.default_rows = rows;
686 } else {
687 eprintln!(
688 "Warning: invalid value for shell.default_rows: '{}', using default",
689 val
690 );
691 }
692 }
693 "shell.default_cols" => {
694 if let Ok(cols) = val.parse::<u16>() {
695 shell_config.default_cols = cols;
696 } else {
697 eprintln!(
698 "Warning: invalid value for shell.default_cols: '{}', using default",
699 val
700 );
701 }
702 }
703 "shell.readiness_strategy" => {
704 let val_lower = val.to_lowercase();
705 match val_lower.as_str() {
706 "timeout" | "prompt" | "hybrid" => {
707 shell_config.readiness_strategy = val.to_string();
708 }
709 _ => {
710 eprintln!(
711 "Warning: invalid value for shell.readiness_strategy: '{}', using default",
712 val
713 );
714 }
715 }
716 }
717 "shell.max_output" => {
718 if let Ok(max_output) = val.parse::<usize>() {
719 shell_config.max_output = max_output;
720 } else {
721 eprintln!(
722 "Warning: invalid value for shell.max_output: '{}', using default",
723 val
724 );
725 }
726 }
727 _ => {
728 }
730 }
731}
732
733#[allow(clippy::collapsible_match)]
735fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
736 match key {
737 "server.allowed_origins" => {
738 server_config.allowed_origins = parse_comma_list(val);
739 }
740 "server.token" => {
741 if !val.is_empty() {
742 server_config.token = Some(val.to_string());
743 }
744 }
745 "server.auto_approve_confirms" => {
746 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
747 }
748 "server.max_message_size" => {
749 if let Ok(size) = val.parse::<usize>() {
750 server_config.max_message_size = Some(size);
751 } else {
752 eprintln!(
753 "Warning: invalid value for server.max_message_size: '{}', ignored",
754 val
755 );
756 }
757 }
758 _ => {
759 }
761 }
762}
763
764fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
766 match key {
767 "bridge.uds_path" => {
768 if val.is_empty() {
769 bridge_config.uds_path = None;
770 } else {
771 bridge_config.uds_path = Some(PathBuf::from(val));
772 }
773 }
774 "bridge.heartbeat_mirror" => {
775 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
776 }
777 "bridge.heartbeat_timeout_ms" => {
778 if let Ok(ms) = val.parse::<u64>() {
779 bridge_config.heartbeat_timeout_ms = ms;
780 } else {
781 eprintln!(
782 "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
783 val
784 );
785 }
786 }
787 _ => {
788 }
790 }
791}
792
793fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
795 let v = val.trim();
796 match key {
797 "auth.remote_endpoint" => {
798 auth_config.remote_endpoint = if v.is_empty() {
799 None
800 } else {
801 Some(v.to_string())
802 };
803 }
804 "auth.machine_token" => {
805 auth_config.machine_token = if v.is_empty() {
806 None
807 } else {
808 Some(v.to_string())
809 };
810 }
811 _ => {
812 }
814 }
815}
816
817pub fn load_config_from_str(content: &str) -> SynapsConfig {
820 let mut config = SynapsConfig::default();
821 apply_config_content(&mut config, content);
822 config
823}
824
825pub fn load_config() -> SynapsConfig {
828 let path = resolve_read_path("config");
829 let mut config = SynapsConfig::default();
830
831 let Ok(content) = std::fs::read_to_string(&path) else {
832 return config;
833 };
834
835 apply_config_content(&mut config, &content);
836
837 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
840
841 let identity_val = config
843 .identity
844 .clone()
845 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
846 let _ = IDENTITY.set(identity_val);
847
848 config
849}
850
851fn apply_config_content(config: &mut SynapsConfig, content: &str) {
854 for line in content.lines() {
855 let line = line.trim();
856 if line.is_empty() || line.starts_with('#') {
857 continue;
858 }
859 let Some((key, val)) = line.split_once('=') else {
860 continue;
861 };
862 let key = key.trim();
863 let val = val.trim();
864 match key {
865 "model" => config.model = Some(val.to_string()),
866 "thinking" => {
867 use crate::core::reasoning::ThinkingSpec;
868 match ThinkingSpec::parse(val) {
869 Some(ThinkingSpec::Named(level)) => {
870 config.thinking_level = Some(level);
871 config.thinking_budget = level.to_legacy_budget();
872 }
873 Some(ThinkingSpec::Budget(budget)) => {
874 config.thinking_level = None;
877 config.thinking_budget = Some(budget);
878 }
879 None => {
880 config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
881 }
882 }
883 }
884 "compaction_model" => config.compaction_model = Some(val.to_string()),
885 "compaction_mode" => match val {
886 "remote" => {
887 config.compaction_mode = crate::core::compaction::CompactionMode::Remote
888 }
889 "local" | "local_only" | "local-only" => {
890 config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
891 }
892 _ => {
893 config.warnings.push(format!(
894 "compaction_mode = {val} — expected remote or local; ignored"
895 ));
896 }
897 },
898 "compaction_exclude" => {
899 let mut classes = Vec::new();
900 for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
901 match crate::core::compaction::ContentClass::parse(part) {
902 Some(class) => {
903 if !classes.contains(&class) {
904 classes.push(class);
905 }
906 }
907 None => config.warnings.push(format!(
908 "compaction_exclude — unknown content class '{part}'; \
909 expected one of: user_text, assistant_text, thinking, \
910 tool_calls, tool_results, file_paths, event_data"
911 )),
912 }
913 }
914 config.compaction_exclude = classes;
915 }
916 "context_window" => {
917 let parsed = match val {
918 "200k" | "200K" => Some(200_000),
919 "1m" | "1M" => Some(1_000_000),
920 _ => val.parse::<u64>().ok(),
921 };
922 if parsed.is_none() {
923 config.warnings.push(format!(
924 "context_window = {val} — expected 200k, 1m, or a token count; ignored"
925 ));
926 }
927 config.context_window = parsed;
928 }
929 "max_tool_output" => match val.parse::<usize>() {
930 Ok(size) => config.max_tool_output = size,
931 Err(_) => config.warnings.push(format!(
932 "max_tool_output = {val} — not a number; using {}",
933 config.max_tool_output
934 )),
935 },
936 "bash_timeout" => match val.parse::<u64>() {
937 Ok(t) if t >= 1 => config.bash_timeout = t,
938 Ok(_) => config.warnings.push(format!(
939 "bash_timeout = {val} — below minimum (1s); using {}",
940 config.bash_timeout
941 )),
942 Err(_) => config.warnings.push(format!(
943 "bash_timeout = {val} — not a number; using {}",
944 config.bash_timeout
945 )),
946 },
947 "bash_max_timeout" => {
948 if let Ok(timeout) = val.parse::<u64>() {
949 config.bash_max_timeout = timeout;
950 }
951 }
952 "subagent_timeout" => {
953 if let Ok(timeout) = val.parse::<u64>() {
954 config.subagent_timeout = timeout;
955 }
956 }
957 "api_retries" => {
958 if let Ok(retries) = val.parse::<u32>() {
959 config.api_retries = retries;
960 }
961 }
962 "refusal_retries" => {
963 if let Ok(retries) = val.parse::<u32>() {
964 config.refusal_retries = retries;
965 }
966 }
967 "telemetry" => config.telemetry = val.to_string(),
968 "cache_diagnostics" => {
969 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
970 }
971 "cache_ttl" => match CacheTtl::parse(val) {
972 Some(ttl) => config.cache_ttl = ttl,
973 None => config.warnings.push(format!(
974 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
975 )),
976 },
977 "max_fps" => match val.parse::<u32>() {
978 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
979 Ok(_) => config.warnings.push(format!(
980 "max_fps = {val} — expected 1–1000; using {}",
981 config.max_fps
982 )),
983 Err(_) => config.warnings.push(format!(
984 "max_fps = {val} — not a number; using {}",
985 config.max_fps
986 )),
987 },
988 "scroll_lines" => match val.parse::<u16>() {
989 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
990 Ok(_) => config
991 .warnings
992 .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
993 Err(_) => config
994 .warnings
995 .push(format!("scroll_lines = {val} — not a number; ignoring")),
996 },
997 "theme" => config.theme = Some(val.to_string()),
998 "tui_background_opaque" => match val {
999 "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1000 "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1001 _ => config.warnings.push(format!(
1002 "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1003 if config.tui_background_opaque {
1004 "opaque"
1005 } else {
1006 "invisible"
1007 }
1008 )),
1009 },
1010 "agent_name" => config.agent_name = Some(val.to_string()),
1011 "identity" => config.identity = Some(val.to_string()),
1012 "disabled_plugins" => {
1013 config.disabled_plugins = parse_comma_list(val);
1014 }
1015 "favorite_models" => {
1016 config.favorite_models = parse_comma_list(val);
1017 }
1018 "disabled_skills" => {
1019 config.disabled_skills = parse_comma_list(val);
1020 }
1021 "progressive_tool_disclosure" => {
1022 config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1023 }
1024 "session_persistence" => {
1025 match crate::core::session_journal::SessionPersistence::parse(val) {
1026 Some(mode) => config.session_persistence = mode,
1027 None => config.warnings.push(format!(
1028 "session_persistence = {val} — expected json or journal; \
1029 keeping the default (json)"
1030 )),
1031 }
1032 }
1033 "disabled_tools" => {
1034 config.disabled_tools = parse_comma_list(val);
1035 }
1036 _ => {
1037 if key.starts_with("shell.") {
1039 parse_shell_config_key(&mut config.shell, key, val);
1040 } else if key.starts_with("server.") {
1041 parse_server_config_key(&mut config.server, key, val);
1042 } else if key.starts_with("bridge.") {
1043 parse_bridge_config_key(&mut config.bridge, key, val);
1044 } else if key.starts_with("auth.") {
1045 parse_auth_config_key(&mut config.auth, key, val);
1046 } else if key.starts_with("events.") {
1047 parse_events_config_key(&mut config.events, key, val);
1048 } else if key.starts_with("turn_budget.") {
1049 parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1050 } else if key.starts_with("memory.") {
1051 parse_memory_config_key(&mut config.memory, key, val);
1052 } else if let Some(provider_key) = key.strip_prefix("provider.") {
1053 config
1054 .provider_keys
1055 .insert(provider_key.to_string(), val.to_string());
1056 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1057 config
1058 .keybinds
1059 .insert(keybind_key.to_string(), val.to_string());
1060 } else if key.contains('.') {
1061 } else {
1065 match did_you_mean(key) {
1067 Some(suggestion) => config.warnings.push(format!(
1068 "unknown key '{key}' (did you mean '{suggestion}'?)"
1069 )),
1070 None => config
1071 .warnings
1072 .push(format!("unknown key '{key}' — ignored")),
1073 }
1074 }
1075 }
1076 }
1077 }
1078
1079 if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1084 config.warnings.push(format!(
1085 "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1086 in the same config; reverting to off",
1087 config.memory.default_mode
1088 ));
1089 config.memory.default_mode = "off".to_string();
1090 }
1091
1092 if config.server.max_message_size.is_none() {
1095 if let Some(ctx_tokens) = config.context_window {
1096 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1097 }
1098 }
1099}
1100
1101pub fn read_config_value(key: &str) -> Option<String> {
1103 let path = resolve_read_path("config");
1104 let content = std::fs::read_to_string(&path).ok()?;
1105 for line in content.lines() {
1106 let line = line.trim();
1107 if line.is_empty() || line.starts_with('#') {
1108 continue;
1109 }
1110 let Some((k, v)) = line.split_once('=') else {
1111 continue;
1112 };
1113 if k.trim() == key.trim() {
1114 return Some(v.trim().to_string());
1115 }
1116 }
1117 None
1118}
1119
1120pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1124 let path = resolve_write_path("config");
1125 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1126
1127 let key_trimmed = key.trim();
1128 let replacement = format!("{} = {}", key_trimmed, value);
1129
1130 let mut found = false;
1131 let mut new_lines: Vec<String> = existing
1132 .lines()
1133 .map(|line| {
1134 if found {
1135 return line.to_string();
1136 }
1137 let t = line.trim_start();
1138 if t.starts_with('#') || t.is_empty() {
1139 return line.to_string();
1140 }
1141 if let Some((k, _)) = t.split_once('=') {
1142 if k.trim() == key_trimmed {
1143 found = true;
1144 return replacement.clone();
1145 }
1146 }
1147 line.to_string()
1148 })
1149 .collect();
1150
1151 if !found {
1152 new_lines.push(replacement);
1153 }
1154
1155 let mut out = new_lines.join("\n");
1156 if !out.ends_with('\n') {
1157 out.push('\n');
1158 }
1159
1160 let tmp = path.with_extension("tmp");
1161 std::fs::write(&tmp, out)?;
1162 #[cfg(unix)]
1164 {
1165 use std::os::unix::fs::PermissionsExt;
1166 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1167 }
1168 std::fs::rename(&tmp, &path)?;
1169 Ok(())
1170}
1171
1172pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1174 let trimmed = id.trim();
1175 if trimmed.is_empty() {
1176 return Ok(());
1177 }
1178 let mut values = load_config().favorite_models;
1179 if !values.iter().any(|v| v == trimmed) {
1180 values.push(trimmed.to_string());
1181 values.sort();
1182 }
1183 write_comma_list("favorite_models", &values)
1184}
1185
1186pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1188 let mut values = load_config().favorite_models;
1189 values.retain(|v| v != id.trim());
1190 write_comma_list("favorite_models", &values)
1191}
1192
1193pub fn is_favorite_model(id: &str) -> bool {
1195 load_config().favorite_models.iter().any(|v| v == id.trim())
1196}
1197
1198pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1201 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1202 You have access to bash, read, and write tools. \
1203 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1204
1205 if let Some(val) = explicit {
1206 let path = std::path::Path::new(val);
1207 if path.exists() && path.is_file() {
1208 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1209 }
1210 return val.to_string();
1211 }
1212
1213 let system_path = resolve_read_path("system.md");
1214 if system_path.exists() {
1215 return std::fs::read_to_string(&system_path).unwrap_or_default();
1216 }
1217
1218 DEFAULT_PROMPT.to_string()
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 #[test]
1224 fn tui_background_opaque_defaults_and_parses_supported_values() {
1225 assert!(super::load_config_from_str("").tui_background_opaque);
1226 assert!(
1227 super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
1228 );
1229 assert!(
1230 !super::load_config_from_str("tui_background_opaque = invisible\n")
1231 .tui_background_opaque
1232 );
1233 let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
1234 assert!(invalid.tui_background_opaque);
1235 assert!(invalid
1236 .warnings
1237 .iter()
1238 .any(|w| w.contains("tui_background_opaque")));
1239 }
1240
1241 #[test]
1242 fn turn_budget_keys_parse_typed_per_role() {
1243 let config = super::load_config_from_str(
1244 "turn_budget.worker.max_provider_rounds = 3\n\
1245 turn_budget.worker.max_cost_usd = 0.25\n\
1246 turn_budget.autonomous.max_elapsed_secs = 90\n\
1247 turn_budget.foreground.max_tool_calls = 7\n\
1248 turn_budget.foreground.max_round_renewals = 12\n\
1249 turn_budget.worker.max_provider_rounds_bogus = 1\n\
1250 turn_budget.nosuchrole.max_tool_calls = 1\n",
1251 );
1252 assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
1253 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
1254 assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
1255 assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
1256 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
1257 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
1259 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
1261 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
1262 }
1263
1264 #[test]
1265 fn memory_keys_all_parse_with_consent() {
1266 let config = super::load_config_from_str(
1267 "memory.default_mode = capture_and_recall\n\
1268 memory.default_mode_confirmed = true\n\
1269 memory.recall_max_records = 12\n\
1270 memory.recall_max_tokens = 2048\n\
1271 memory.recall_timeout_ms = 250\n\
1272 memory.capture_tools = off\n\
1273 memory.capture_assistant = false\n\
1274 memory.capture_user = false\n\
1275 memory.auto_consolidate = on\n\
1276 memory.local_embeddings = on\n\
1277 memory.gliner = on\n",
1278 );
1279 assert_eq!(config.memory.default_mode, "capture_and_recall");
1280 assert!(config.memory.default_mode_confirmed);
1281 assert_eq!(config.memory.recall_max_records, 12);
1282 assert_eq!(config.memory.recall_max_tokens, 2048);
1283 assert_eq!(config.memory.recall_timeout_ms, 250);
1284 assert_eq!(config.memory.capture_tools, "off");
1285 assert!(!config.memory.capture_assistant);
1286 assert!(!config.memory.capture_user);
1287 assert_eq!(config.memory.auto_consolidate, "on");
1288 assert_eq!(config.memory.local_embeddings, "on");
1289 assert_eq!(config.memory.gliner, "on");
1290 }
1291
1292 #[test]
1293 fn memory_defaults_match_spec_12() {
1294 let config = super::load_config_from_str("");
1295 assert_eq!(config.memory, super::MemoryConfig::default());
1296 assert_eq!(config.memory.default_mode, "off");
1297 assert!(!config.memory.default_mode_confirmed);
1298 assert_eq!(config.memory.recall_max_records, 8);
1299 assert_eq!(config.memory.recall_max_tokens, 4096);
1300 assert_eq!(config.memory.recall_timeout_ms, 150);
1301 assert_eq!(config.memory.capture_tools, "summary_only");
1302 assert!(config.memory.capture_assistant);
1303 assert!(config.memory.capture_user);
1304 assert_eq!(config.memory.auto_consolidate, "off");
1305 assert_eq!(config.memory.local_embeddings, "off");
1306 assert_eq!(config.memory.gliner, "off");
1307 }
1308
1309 #[test]
1310 fn memory_unknown_values_fail_closed_to_defaults() {
1311 let config = super::load_config_from_str(
1312 "memory.default_mode = recall_everything_forever\n\
1313 memory.default_mode_confirmed = true\n\
1314 memory.recall_max_records = lots\n\
1315 memory.recall_max_tokens = -5\n\
1316 memory.recall_timeout_ms = fast\n\
1317 memory.capture_tools = full\n\
1318 memory.capture_assistant = maybe\n\
1319 memory.capture_user = sometimes\n\
1320 memory.auto_consolidate = aggressive\n\
1321 memory.local_embeddings = auto\n\
1322 memory.gliner = download\n\
1323 memory.nosuchfield = 1\n",
1324 );
1325 assert_eq!(config.memory.default_mode, "off");
1327 assert_eq!(config.memory.recall_max_records, 8);
1328 assert_eq!(config.memory.recall_max_tokens, 4096);
1329 assert_eq!(config.memory.recall_timeout_ms, 150);
1330 assert_eq!(config.memory.capture_tools, "summary_only");
1331 assert!(config.memory.capture_assistant);
1332 assert!(config.memory.capture_user);
1333 assert_eq!(config.memory.auto_consolidate, "off");
1334 assert_eq!(config.memory.local_embeddings, "off");
1335 assert_eq!(config.memory.gliner, "off");
1336 }
1337
1338 #[test]
1339 fn memory_default_mode_without_consent_reverts_to_off() {
1340 let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
1341 assert_eq!(config.memory.default_mode, "off");
1342 assert!(
1343 config
1344 .warnings
1345 .iter()
1346 .any(|w| w.contains("memory.default_mode_confirmed")),
1347 "reversion must surface a warning; got: {:?}",
1348 config.warnings
1349 );
1350
1351 let denied = super::load_config_from_str(
1353 "memory.default_mode = capture_only\n\
1354 memory.default_mode_confirmed = false\n",
1355 );
1356 assert_eq!(denied.memory.default_mode, "off");
1357
1358 let garbage = super::load_config_from_str(
1360 "memory.default_mode = capture_only\n\
1361 memory.default_mode_confirmed = definitely\n",
1362 );
1363 assert_eq!(garbage.memory.default_mode, "off");
1364 }
1365
1366 #[test]
1367 fn memory_consent_is_order_independent() {
1368 let before = super::load_config_from_str(
1370 "memory.default_mode_confirmed = true\n\
1371 memory.default_mode = recall_once\n",
1372 );
1373 assert_eq!(before.memory.default_mode, "recall_once");
1374 let after = super::load_config_from_str(
1376 "memory.default_mode = recall_once\n\
1377 memory.default_mode_confirmed = true\n",
1378 );
1379 assert_eq!(after.memory.default_mode, "recall_once");
1380 }
1381
1382 #[test]
1383 fn memory_config_surface_is_closed_by_construction() {
1384 let _closed_surface = super::MemoryConfig {
1392 default_mode: "off".to_string(),
1393 default_mode_confirmed: false,
1394 recall_max_records: 8,
1395 recall_max_tokens: 4096,
1396 recall_timeout_ms: 150,
1397 capture_tools: "summary_only".to_string(),
1398 capture_assistant: true,
1399 capture_user: true,
1400 auto_consolidate: "off".to_string(),
1401 local_embeddings: "off".to_string(),
1402 gliner: "off".to_string(),
1403 };
1404 }
1405
1406 use super::*;
1407 use serial_test::serial;
1408
1409 #[test]
1410 fn test_levenshtein_basics() {
1411 assert_eq!(levenshtein("model", "model"), 0);
1412 assert_eq!(levenshtein("modle", "model"), 2);
1413 assert_eq!(levenshtein("them", "theme"), 1);
1414 }
1415
1416 #[test]
1417 fn test_did_you_mean_close_typos() {
1418 assert_eq!(did_you_mean("modle"), Some("model"));
1419 assert_eq!(did_you_mean("them"), Some("theme"));
1420 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
1421 assert_eq!(did_you_mean("completely_unrelated_key"), None);
1422 }
1423
1424 #[test]
1425 #[serial]
1426 fn test_config_warnings_unknown_key_and_bad_values() {
1427 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
1428 let dir = home.join(".synaps-cli");
1429 std::fs::create_dir_all(&dir).unwrap();
1430 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();
1431
1432 with_home(&home, || {
1433 let config = load_config();
1434 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
1436 assert!(
1437 !config.warnings.iter().any(|w| w.contains("knowledge")),
1438 "{:?}",
1439 config.warnings
1440 );
1441 assert!(
1442 config
1443 .warnings
1444 .iter()
1445 .any(|w| w.contains("did you mean 'model'")),
1446 "{:?}",
1447 config.warnings
1448 );
1449 assert!(
1450 config.warnings.iter().any(|w| w.contains("thinking")),
1451 "{:?}",
1452 config.warnings
1453 );
1454 assert!(
1455 config.warnings.iter().any(|w| w.contains("below minimum")),
1456 "{:?}",
1457 config.warnings
1458 );
1459 assert_eq!(config.bash_timeout, 30);
1461 assert_eq!(config.thinking_budget, None);
1462 });
1463 let _ = std::fs::remove_dir_all(&home);
1464 }
1465
1466 #[test]
1469 fn test_cache_ttl_parse_table() {
1470 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
1472 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
1473 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
1474 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
1476 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
1477 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
1478 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
1480 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
1482 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
1483 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
1484 assert_eq!(CacheTtl::parse("2h"), None);
1486 assert_eq!(CacheTtl::parse(""), None);
1487 assert_eq!(CacheTtl::parse("forever"), None);
1488 }
1489
1490 #[test]
1491 fn test_cache_ttl_default_is_five_minutes() {
1492 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
1493 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
1494 }
1495
1496 #[test]
1497 fn test_max_fps_default_is_60() {
1498 assert_eq!(SynapsConfig::default().max_fps, 60);
1499 }
1500
1501 #[test]
1502 #[serial]
1503 fn test_max_fps_config_parse_and_validation() {
1504 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
1505 let dir = home.join(".synaps-cli");
1506 std::fs::create_dir_all(&dir).unwrap();
1507
1508 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
1510 with_home(&home, || {
1511 let config = load_config();
1512 assert_eq!(config.max_fps, 144);
1513 assert!(
1514 config.warnings.is_empty(),
1515 "warnings: {:?}",
1516 config.warnings
1517 );
1518 });
1519
1520 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
1522 with_home(&home, || {
1523 let config = load_config();
1524 assert_eq!(config.max_fps, 60);
1525 assert!(
1526 config.warnings.iter().any(|w| w.contains("max_fps")),
1527 "warnings: {:?}",
1528 config.warnings
1529 );
1530 });
1531
1532 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
1534 with_home(&home, || {
1535 let config = load_config();
1536 assert_eq!(config.max_fps, 60);
1537 assert!(
1538 config.warnings.iter().any(|w| w.contains("max_fps")),
1539 "warnings: {:?}",
1540 config.warnings
1541 );
1542 });
1543
1544 let _ = std::fs::remove_dir_all(&home);
1545 }
1546
1547 #[test]
1548 #[serial]
1549 fn test_cache_ttl_config_parse_and_garbage_warning() {
1550 let home =
1551 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
1552 let dir = home.join(".synaps-cli");
1553 std::fs::create_dir_all(&dir).unwrap();
1554
1555 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
1557 with_home(&home, || {
1558 let config = load_config();
1559 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
1560 assert!(
1561 config.warnings.is_empty(),
1562 "warnings: {:?}",
1563 config.warnings
1564 );
1565 });
1566
1567 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
1569 with_home(&home, || {
1570 let config = load_config();
1571 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
1572 assert!(
1573 config.warnings.iter().any(|w| w.contains("cache_ttl")),
1574 "warnings: {:?}",
1575 config.warnings
1576 );
1577 });
1578
1579 let _ = std::fs::remove_dir_all(&home);
1580 }
1581
1582 #[test]
1583 fn test_parse_thinking_budget() {
1584 use crate::core::reasoning::ThinkingSpec;
1585 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
1587 assert_eq!(
1588 ThinkingSpec::parse("medium").unwrap().to_budget(),
1589 Some(4096)
1590 );
1591 assert_eq!(
1592 ThinkingSpec::parse("high").unwrap().to_budget(),
1593 Some(16384)
1594 );
1595 assert_eq!(
1596 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
1597 Some(32768)
1598 );
1599 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
1600 let config = load_config_from_str("thinking = 8192\n");
1601 assert_eq!(config.thinking_level, None);
1602 assert_eq!(config.thinking_budget, Some(8192));
1603 assert_eq!(ThinkingSpec::parse("invalid"), None);
1604 }
1605
1606 #[test]
1607 fn test_base_dir() {
1608 let path = base_dir();
1609 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
1610 }
1611
1612 #[test]
1613 fn test_resolve_system_prompt_explicit() {
1614 let result = resolve_system_prompt(Some("test prompt"));
1615 assert_eq!(result, "test prompt");
1616 }
1617
1618 #[test]
1619 fn test_resolve_system_prompt_none() {
1620 let result = resolve_system_prompt(None);
1621 assert!(result.contains("helpful AI agent"));
1622 }
1623
1624 #[test]
1629 fn test_synaps_config_default() {
1630 let config = SynapsConfig::default();
1631 assert_eq!(config.model, None);
1632 assert_eq!(config.thinking_budget, None);
1633 assert_eq!(config.max_tool_output, 30000);
1634 assert_eq!(config.bash_timeout, 30);
1635 assert_eq!(config.bash_max_timeout, 300);
1636 assert_eq!(config.subagent_timeout, 300);
1637 assert_eq!(config.api_retries, 3);
1638 assert_eq!(config.theme, None);
1639 assert!(config.disabled_plugins.is_empty());
1640 assert!(config.favorite_models.is_empty());
1641 assert!(config.disabled_skills.is_empty());
1642 assert!(!config.progressive_tool_disclosure);
1643 assert_eq!(config.shell.max_sessions, 5);
1644 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
1645 assert!(config.server.allowed_origins.is_empty());
1647 assert_eq!(config.server.token, None);
1648 assert!(!config.server.auto_approve_confirms);
1649 assert_eq!(config.server.max_message_size, None);
1650 assert!(config.bridge.uds_path.is_none());
1652 assert!(!config.bridge.heartbeat_mirror);
1653 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1654 }
1655
1656 #[test]
1657 #[serial]
1658 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
1659 let home = make_test_home("progressive-tool-disclosure");
1660 let cfg = home.join(".synaps-cli/config");
1661 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
1662
1663 with_home(&home, || {
1664 let config = load_config();
1665 assert!(config.progressive_tool_disclosure);
1666 assert!(config.warnings.is_empty());
1667 });
1668
1669 let _ = std::fs::remove_dir_all(&home);
1670 }
1671
1672 #[test]
1673 #[serial]
1674 fn test_load_config_bridge_keys() {
1675 let home = make_test_home("bridge-keys");
1676 let cfg = home.join(".synaps-cli/config");
1677 std::fs::write(
1678 &cfg,
1679 "\
1680bridge.uds_path = /tmp/some/control.sock\n\
1681bridge.heartbeat_mirror = true\n\
1682bridge.heartbeat_timeout_ms = 750\n\
1683",
1684 )
1685 .unwrap();
1686
1687 with_home(&home, || {
1688 let config = load_config();
1689 assert_eq!(
1690 config.bridge.uds_path,
1691 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
1692 );
1693 assert!(config.bridge.heartbeat_mirror);
1694 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
1695 assert_eq!(
1696 config.bridge.resolved_uds_path(),
1697 std::path::PathBuf::from("/tmp/some/control.sock"),
1698 );
1699 });
1700
1701 let _ = std::fs::remove_dir_all(&home);
1702 }
1703
1704 #[test]
1705 fn test_bridge_config_defaults() {
1706 let cfg = BridgeConfig::default();
1707 assert!(cfg.uds_path.is_none());
1708 assert!(!cfg.heartbeat_mirror);
1709 assert_eq!(cfg.heartbeat_timeout_ms, 250);
1710 let resolved = cfg.resolved_uds_path();
1712 assert!(resolved.ends_with("bridge/control.sock"));
1713 }
1714
1715 #[test]
1716 #[serial]
1717 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
1718 let home = make_test_home("bridge-default-off");
1719 let cfg = home.join(".synaps-cli/config");
1720 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1721
1722 with_home(&home, || {
1723 let config = load_config();
1724 assert!(!config.bridge.heartbeat_mirror);
1725 assert!(config.bridge.uds_path.is_none());
1726 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1727 });
1728
1729 let _ = std::fs::remove_dir_all(&home);
1730 }
1731
1732 #[test]
1733 fn compaction_disclosure_keys_parse_with_typed_warnings() {
1734 let config = load_config_from_str(
1735 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
1736 );
1737 assert_eq!(
1738 config.compaction_mode,
1739 crate::core::compaction::CompactionMode::LocalOnly
1740 );
1741 assert_eq!(
1742 config.compaction_exclude,
1743 vec![
1744 crate::core::compaction::ContentClass::Thinking,
1745 crate::core::compaction::ContentClass::ToolResults,
1746 ]
1747 );
1748 assert!(
1749 config.warnings.iter().any(|w| w.contains("bogus")),
1750 "unknown class must warn: {:?}",
1751 config.warnings
1752 );
1753
1754 let defaults = load_config_from_str("");
1755 assert_eq!(
1756 defaults.compaction_mode,
1757 crate::core::compaction::CompactionMode::Remote
1758 );
1759 assert!(defaults.compaction_exclude.is_empty());
1760
1761 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
1762 assert_eq!(
1763 bad_mode.compaction_mode,
1764 crate::core::compaction::CompactionMode::Remote
1765 );
1766 assert!(bad_mode
1767 .warnings
1768 .iter()
1769 .any(|w| w.contains("compaction_mode")));
1770 }
1771
1772 #[test]
1773 #[serial]
1774 fn test_load_config_server_keys() {
1775 let home = make_test_home("server-keys");
1776 let cfg = home.join(".synaps-cli/config");
1777 std::fs::write(
1778 &cfg,
1779 "\
1780server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1781server.token = my-secret-token\n\
1782server.auto_approve_confirms = true\n\
1783server.max_message_size = 65536\n\
1784context_window = 200k\n\
1785",
1786 )
1787 .unwrap();
1788
1789 with_home(&home, || {
1790 let config = load_config();
1791 assert_eq!(
1792 config.server.allowed_origins,
1793 vec![
1794 "http://localhost:3000".to_string(),
1795 "http://localhost:5193".to_string(),
1796 ]
1797 );
1798 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1799 assert!(config.server.auto_approve_confirms);
1800 assert_eq!(config.server.max_message_size, Some(65536));
1802 });
1803
1804 let _ = std::fs::remove_dir_all(&home);
1805 }
1806
1807 #[test]
1808 #[serial]
1809 fn test_server_max_message_size_derived_from_context_window() {
1810 let home = make_test_home("server-derive");
1811 let cfg = home.join(".synaps-cli/config");
1812 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1813
1814 with_home(&home, || {
1815 let config = load_config();
1816 assert_eq!(config.server.max_message_size, Some(800_000));
1818 });
1819
1820 let _ = std::fs::remove_dir_all(&home);
1821 }
1822
1823 fn make_test_home(subdir: &str) -> std::path::PathBuf {
1824 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1825 let _ = std::fs::remove_dir_all(&dir);
1826 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1827 dir
1828 }
1829
1830 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1831 let original = std::env::var("HOME").ok();
1832 std::env::set_var("HOME", home);
1833 f();
1834 if let Some(h) = original {
1835 std::env::set_var("HOME", h);
1836 } else {
1837 std::env::remove_var("HOME");
1838 }
1839 }
1840
1841 #[test]
1842 #[serial]
1843 fn write_config_value_replaces_existing_key() {
1844 let home = make_test_home("replace");
1845 let cfg = home.join(".synaps-cli/config");
1846 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1847
1848 with_home(&home, || {
1849 write_config_value("model", "claude-sonnet-4-6").unwrap();
1850 });
1851
1852 let contents = std::fs::read_to_string(&cfg).unwrap();
1853 assert!(contents.contains("model = claude-sonnet-4-6"));
1854 assert!(contents.contains("thinking = low"));
1855 let _ = std::fs::remove_dir_all(&home);
1856 }
1857
1858 #[test]
1859 #[serial]
1860 fn write_config_value_appends_when_missing() {
1861 let home = make_test_home("append");
1862 let cfg = home.join(".synaps-cli/config");
1863 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1864
1865 with_home(&home, || {
1866 write_config_value("theme", "dracula").unwrap();
1867 });
1868
1869 let contents = std::fs::read_to_string(&cfg).unwrap();
1870 assert!(contents.contains("model = claude-opus-4-6"));
1871 assert!(contents.contains("theme = dracula"));
1872 let _ = std::fs::remove_dir_all(&home);
1873 }
1874
1875 #[test]
1876 #[serial]
1877 fn write_config_value_preserves_comments() {
1878 let home = make_test_home("comments");
1879 let cfg = home.join(".synaps-cli/config");
1880 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
1881
1882 with_home(&home, || {
1883 write_config_value("model", "claude-sonnet-4-6").unwrap();
1884 });
1885
1886 let contents = std::fs::read_to_string(&cfg).unwrap();
1887 assert!(contents.contains("# user comment"));
1888 assert!(contents.contains("# another"));
1889 assert!(contents.contains("model = claude-sonnet-4-6"));
1890 let _ = std::fs::remove_dir_all(&home);
1891 }
1892
1893 #[test]
1894 #[serial]
1895 fn write_config_value_preserves_unknown_keys() {
1896 let home = make_test_home("unknown");
1897 let cfg = home.join(".synaps-cli/config");
1898 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1899
1900 with_home(&home, || {
1901 write_config_value("model", "claude-sonnet-4-6").unwrap();
1902 });
1903
1904 let contents = std::fs::read_to_string(&cfg).unwrap();
1905 assert!(contents.contains("custom_thing = 42"));
1906 let _ = std::fs::remove_dir_all(&home);
1907 }
1908
1909 #[test]
1910 #[serial]
1911 fn write_config_value_creates_file_if_absent() {
1912 let home = make_test_home("create");
1913 let cfg = home.join(".synaps-cli/config");
1914 assert!(!cfg.exists());
1915
1916 with_home(&home, || {
1917 write_config_value("model", "claude-sonnet-4-6").unwrap();
1918 });
1919
1920 let contents = std::fs::read_to_string(&cfg).unwrap();
1921 assert!(contents.contains("model = claude-sonnet-4-6"));
1922 let _ = std::fs::remove_dir_all(&home);
1923 }
1924
1925 #[test]
1926 #[serial]
1927 fn load_config_parses_theme_key() {
1928 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1929 let _ = std::fs::create_dir_all(&dir);
1930 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1931
1932 let original_home = std::env::var("HOME").ok();
1933 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1934
1935 let config = load_config();
1936
1937 if let Some(home) = original_home {
1938 std::env::set_var("HOME", home);
1939 } else {
1940 std::env::remove_var("HOME");
1941 }
1942 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1943
1944 assert_eq!(config.theme.as_deref(), Some("dracula"));
1945 }
1946
1947 #[test]
1948 #[serial]
1949 fn test_load_config_disable_lists() {
1950 let test_dir =
1951 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1952 let _ = std::fs::create_dir_all(&test_dir);
1953 let config_path = test_dir.join("config");
1954
1955 let config_content = r#"
1956# Test config with disable lists
1957favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1958
1959disabled_plugins = foo, bar
1960disabled_skills = baz, plug:qual
1961"#;
1962 std::fs::write(&config_path, config_content).unwrap();
1963
1964 let original_home = std::env::var("HOME").ok();
1965 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1966
1967 let config = load_config();
1968
1969 if let Some(home) = original_home {
1970 std::env::set_var("HOME", home);
1971 } else {
1972 std::env::remove_var("HOME");
1973 }
1974
1975 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1976
1977 assert_eq!(
1978 config.disabled_plugins,
1979 vec!["foo".to_string(), "bar".to_string()]
1980 );
1981 assert_eq!(
1982 config.favorite_models,
1983 vec![
1984 "claude/claude-opus-4-7".to_string(),
1985 "groq/llama-3.3-70b-versatile".to_string(),
1986 ]
1987 );
1988 assert_eq!(
1989 config.disabled_skills,
1990 vec!["baz".to_string(), "plug:qual".to_string()]
1991 );
1992 }
1993
1994 #[test]
1995 #[serial]
1996 fn favorite_model_helpers_round_trip_through_config_file() {
1997 let home = make_test_home("favorite-models");
1998 let cfg = home.join(".synaps-cli/config");
1999 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
2000
2001 with_home(&home, || {
2002 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2003 add_favorite_model("claude/claude-opus-4-7").unwrap();
2004 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2005 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
2006 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2007 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
2008 assert!(is_favorite_model("claude/claude-opus-4-7"));
2009 });
2010
2011 let contents = std::fs::read_to_string(&cfg).unwrap();
2012 assert!(contents.contains("model = claude-opus-4-7"));
2013 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
2014 let _ = std::fs::remove_dir_all(&home);
2015 }
2016
2017 #[test]
2018 #[serial]
2019 fn test_load_config_new_keys() {
2020 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
2022 let _ = std::fs::create_dir_all(&test_dir);
2023 let config_path = test_dir.join("config");
2024
2025 let config_content = r#"
2026# Test config with new keys
2027model = claude-haiku
2028thinking = medium
2029max_tool_output = 50000
2030bash_timeout = 45
2031bash_max_timeout = 600
2032subagent_timeout = 120
2033api_retries = 5
2034"#;
2035 std::fs::write(&config_path, config_content).unwrap();
2036
2037 let original_home = std::env::var("HOME").ok();
2039 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
2040
2041 let config = load_config();
2042
2043 if let Some(home) = original_home {
2045 std::env::set_var("HOME", home);
2046 } else {
2047 std::env::remove_var("HOME");
2048 }
2049
2050 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
2052
2053 assert_eq!(config.model, Some("claude-haiku".to_string()));
2054 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
2056 assert_eq!(config.bash_timeout, 45);
2057 assert_eq!(config.bash_max_timeout, 600);
2058 assert_eq!(config.subagent_timeout, 120);
2059 assert_eq!(config.api_retries, 5);
2060 }
2061
2062 #[test]
2067 fn events_auto_turn_explicit_true_values() {
2068 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
2069 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2070 assert!(
2071 cfg.events.auto_turn,
2072 "expected true for events.auto_turn = {val}"
2073 );
2074 }
2075 }
2076
2077 #[test]
2078 fn events_auto_turn_explicit_false_values() {
2079 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
2080 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2081 assert!(
2082 !cfg.events.auto_turn,
2083 "expected false for events.auto_turn = {val}"
2084 );
2085 }
2086 }
2087
2088 #[test]
2089 fn events_auto_turn_typo_fails_safe_false() {
2090 let cfg = load_config_from_str("events.auto_turn = fales");
2092 assert!(
2093 !cfg.events.auto_turn,
2094 "typo 'fales' must fail safe to false"
2095 );
2096 }
2097
2098 #[test]
2099 fn events_auto_turn_default_is_true() {
2100 let cfg = load_config_from_str("model = claude-haiku");
2101 assert!(cfg.events.auto_turn, "default must be true when key absent");
2102 }
2103
2104 #[test]
2105 #[serial]
2106 fn test_auth_config_parses_endpoint_and_token() {
2107 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
2108 let dir = home.join(".synaps-cli");
2109 std::fs::create_dir_all(&dir).unwrap();
2110 std::fs::write(
2111 dir.join("config"),
2112 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
2113 )
2114 .unwrap();
2115 with_home(&home, || {
2116 let config = load_config();
2117 assert_eq!(
2118 config.auth.remote_endpoint.as_deref(),
2119 Some("https://jade.jade:8181")
2120 );
2121 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
2122 assert!(
2124 !config.warnings.iter().any(|w| w.contains("auth.")),
2125 "{:?}",
2126 config.warnings
2127 );
2128 });
2129 let _ = std::fs::remove_dir_all(&home);
2130 }
2131
2132 #[test]
2133 #[serial]
2134 fn test_credential_source_local_by_default() {
2135 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2136 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2137 assert!(!AuthConfig::default().credential_source().is_remote());
2138 }
2139
2140 #[test]
2141 #[serial]
2142 fn test_credential_source_remote_from_config() {
2143 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2144 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2145 let auth = AuthConfig {
2146 remote_endpoint: Some("https://b".into()),
2147 machine_token: Some("m".into()),
2148 };
2149 assert_eq!(
2150 auth.credential_source(),
2151 crate::core::auth::CredentialSource::Remote {
2152 endpoint: "https://b".into(),
2153 machine_token: "m".into()
2154 }
2155 );
2156 }
2157
2158 #[test]
2159 #[serial]
2160 fn test_credential_source_env_overrides_config() {
2161 let auth = AuthConfig {
2162 remote_endpoint: Some("https://config-host".into()),
2163 machine_token: Some("config-tok".into()),
2164 };
2165 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
2166 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
2167 let src = auth.credential_source();
2168 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2169 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2170 assert_eq!(
2171 src,
2172 crate::core::auth::CredentialSource::Remote {
2173 endpoint: "https://env-host".into(),
2174 machine_token: "env-tok".into()
2175 }
2176 );
2177 }
2178 #[test]
2179 fn config_parses_ultracode_as_distinct_canonical_level() {
2180 let cfg = load_config_from_str("thinking = ultracode\n");
2181 assert_eq!(
2182 cfg.thinking_level,
2183 Some(crate::reasoning::ReasoningLevel::UltraCode)
2184 );
2185 assert_eq!(cfg.thinking_budget, None);
2186 for other in [
2187 crate::reasoning::ReasoningLevel::Ultra,
2188 crate::reasoning::ReasoningLevel::Max,
2189 crate::reasoning::ReasoningLevel::XHigh,
2190 ] {
2191 assert_ne!(cfg.thinking_level, Some(other));
2192 }
2193 }
2194}