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 agent_name: Option<String>,
494 pub identity: Option<String>,
495 pub disabled_plugins: Vec<String>,
496 pub favorite_models: Vec<String>,
497 pub disabled_skills: Vec<String>,
498 pub progressive_tool_disclosure: bool,
503 pub session_persistence: crate::core::session_journal::SessionPersistence,
508 pub disabled_tools: Vec<String>,
511 pub shell: ShellConfig,
512 pub server: ServerConfig,
513 pub bridge: BridgeConfig,
514 pub auth: AuthConfig,
515 pub events: EventsConfig,
516 pub provider_keys: BTreeMap<String, String>,
517 pub keybinds: std::collections::HashMap<String, String>,
518 pub turn_budgets: TurnBudgetsConfig,
520 pub memory: MemoryConfig,
522 pub warnings: Vec<String>,
525}
526
527impl Default for SynapsConfig {
528 fn default() -> Self {
529 Self {
530 model: None,
531 thinking_budget: None,
532 thinking_level: None,
533 context_window: None,
534 compaction_model: None,
535 compaction_mode: crate::core::compaction::CompactionMode::default(),
536 compaction_exclude: Vec::new(),
537 max_tool_output: 30000,
538 bash_timeout: 30,
539 bash_max_timeout: 300,
540 subagent_timeout: 300,
541 api_retries: 3,
542 refusal_retries: 2,
543 telemetry: "off".to_string(),
544 cache_diagnostics: false,
545 cache_ttl: CacheTtl::default(),
546 max_fps: 60,
547 scroll_lines: None,
548 theme: None,
549 agent_name: None,
550 identity: None,
551 disabled_plugins: Vec::new(),
552 favorite_models: Vec::new(),
553 disabled_skills: Vec::new(),
554 progressive_tool_disclosure: false,
555 session_persistence: crate::core::session_journal::SessionPersistence::default(),
556 disabled_tools: Vec::new(),
557 shell: ShellConfig::default(),
558 server: ServerConfig::default(),
559 bridge: BridgeConfig::default(),
560 auth: AuthConfig::default(),
561 events: EventsConfig::default(),
562 provider_keys: BTreeMap::new(),
563 keybinds: std::collections::HashMap::new(),
564 turn_budgets: TurnBudgetsConfig::default(),
565 memory: MemoryConfig::default(),
566 warnings: Vec::new(),
567 }
568 }
569}
570
571const KNOWN_CONFIG_KEYS: &[&str] = &[
573 "model",
574 "thinking",
575 "compaction_model",
576 "context_window",
577 "max_tool_output",
578 "bash_timeout",
579 "bash_max_timeout",
580 "subagent_timeout",
581 "api_retries",
582 "refusal_retries",
583 "telemetry",
584 "cache_diagnostics",
585 "cache_ttl",
586 "max_fps",
587 "scroll_lines",
588 "theme",
589 "agent_name",
590 "identity",
591 "disabled_plugins",
592 "favorite_models",
593 "disabled_skills",
594 "disabled_tools",
595 "progressive_tool_disclosure",
596 "session_persistence",
597];
598
599fn levenshtein(a: &str, b: &str) -> usize {
601 let a: Vec<char> = a.chars().collect();
602 let b: Vec<char> = b.chars().collect();
603 let mut prev: Vec<usize> = (0..=b.len()).collect();
604 let mut cur = vec![0; b.len() + 1];
605 for (i, ca) in a.iter().enumerate() {
606 cur[0] = i + 1;
607 for (j, cb) in b.iter().enumerate() {
608 let cost = if ca == cb { 0 } else { 1 };
609 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
610 }
611 std::mem::swap(&mut prev, &mut cur);
612 }
613 prev[b.len()]
614}
615
616fn did_you_mean(key: &str) -> Option<&'static str> {
618 KNOWN_CONFIG_KEYS
619 .iter()
620 .map(|k| (*k, levenshtein(key, k)))
621 .filter(|(_, d)| *d <= 2)
622 .min_by_key(|(_, d)| *d)
623 .map(|(k, _)| k)
624}
625
626fn parse_comma_list(val: &str) -> Vec<String> {
629 val.split(',')
630 .map(|s| s.trim().to_string())
631 .filter(|s| !s.is_empty())
632 .collect()
633}
634
635fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
636 write_config_value(key, &values.join(", "))
637}
638
639fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
641 match key {
642 "shell.max_sessions" => {
643 if let Ok(sessions) = val.parse::<usize>() {
644 shell_config.max_sessions = sessions;
645 } else {
646 eprintln!(
647 "Warning: invalid value for shell.max_sessions: '{}', using default",
648 val
649 );
650 }
651 }
652 "shell.idle_timeout" => {
653 if let Ok(timeout) = val.parse::<u64>() {
654 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
655 } else {
656 eprintln!(
657 "Warning: invalid value for shell.idle_timeout: '{}', using default",
658 val
659 );
660 }
661 }
662 "shell.readiness_timeout_ms" => {
663 if let Ok(timeout) = val.parse::<u64>() {
664 shell_config.readiness_timeout_ms = timeout;
665 } else {
666 eprintln!(
667 "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
668 val
669 );
670 }
671 }
672 "shell.max_readiness_timeout_ms" => {
673 if let Ok(timeout) = val.parse::<u64>() {
674 shell_config.max_readiness_timeout_ms = timeout;
675 } else {
676 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
677 }
678 }
679 "shell.default_rows" => {
680 if let Ok(rows) = val.parse::<u16>() {
681 shell_config.default_rows = rows;
682 } else {
683 eprintln!(
684 "Warning: invalid value for shell.default_rows: '{}', using default",
685 val
686 );
687 }
688 }
689 "shell.default_cols" => {
690 if let Ok(cols) = val.parse::<u16>() {
691 shell_config.default_cols = cols;
692 } else {
693 eprintln!(
694 "Warning: invalid value for shell.default_cols: '{}', using default",
695 val
696 );
697 }
698 }
699 "shell.readiness_strategy" => {
700 let val_lower = val.to_lowercase();
701 match val_lower.as_str() {
702 "timeout" | "prompt" | "hybrid" => {
703 shell_config.readiness_strategy = val.to_string();
704 }
705 _ => {
706 eprintln!(
707 "Warning: invalid value for shell.readiness_strategy: '{}', using default",
708 val
709 );
710 }
711 }
712 }
713 "shell.max_output" => {
714 if let Ok(max_output) = val.parse::<usize>() {
715 shell_config.max_output = max_output;
716 } else {
717 eprintln!(
718 "Warning: invalid value for shell.max_output: '{}', using default",
719 val
720 );
721 }
722 }
723 _ => {
724 }
726 }
727}
728
729#[allow(clippy::collapsible_match)]
731fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
732 match key {
733 "server.allowed_origins" => {
734 server_config.allowed_origins = parse_comma_list(val);
735 }
736 "server.token" => {
737 if !val.is_empty() {
738 server_config.token = Some(val.to_string());
739 }
740 }
741 "server.auto_approve_confirms" => {
742 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
743 }
744 "server.max_message_size" => {
745 if let Ok(size) = val.parse::<usize>() {
746 server_config.max_message_size = Some(size);
747 } else {
748 eprintln!(
749 "Warning: invalid value for server.max_message_size: '{}', ignored",
750 val
751 );
752 }
753 }
754 _ => {
755 }
757 }
758}
759
760fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
762 match key {
763 "bridge.uds_path" => {
764 if val.is_empty() {
765 bridge_config.uds_path = None;
766 } else {
767 bridge_config.uds_path = Some(PathBuf::from(val));
768 }
769 }
770 "bridge.heartbeat_mirror" => {
771 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
772 }
773 "bridge.heartbeat_timeout_ms" => {
774 if let Ok(ms) = val.parse::<u64>() {
775 bridge_config.heartbeat_timeout_ms = ms;
776 } else {
777 eprintln!(
778 "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
779 val
780 );
781 }
782 }
783 _ => {
784 }
786 }
787}
788
789fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
791 let v = val.trim();
792 match key {
793 "auth.remote_endpoint" => {
794 auth_config.remote_endpoint = if v.is_empty() {
795 None
796 } else {
797 Some(v.to_string())
798 };
799 }
800 "auth.machine_token" => {
801 auth_config.machine_token = if v.is_empty() {
802 None
803 } else {
804 Some(v.to_string())
805 };
806 }
807 _ => {
808 }
810 }
811}
812
813pub fn load_config_from_str(content: &str) -> SynapsConfig {
816 let mut config = SynapsConfig::default();
817 apply_config_content(&mut config, content);
818 config
819}
820
821pub fn load_config() -> SynapsConfig {
824 let path = resolve_read_path("config");
825 let mut config = SynapsConfig::default();
826
827 let Ok(content) = std::fs::read_to_string(&path) else {
828 return config;
829 };
830
831 apply_config_content(&mut config, &content);
832
833 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
836
837 let identity_val = config
839 .identity
840 .clone()
841 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
842 let _ = IDENTITY.set(identity_val);
843
844 config
845}
846
847fn apply_config_content(config: &mut SynapsConfig, content: &str) {
850 for line in content.lines() {
851 let line = line.trim();
852 if line.is_empty() || line.starts_with('#') {
853 continue;
854 }
855 let Some((key, val)) = line.split_once('=') else {
856 continue;
857 };
858 let key = key.trim();
859 let val = val.trim();
860 match key {
861 "model" => config.model = Some(val.to_string()),
862 "thinking" => {
863 use crate::core::reasoning::ThinkingSpec;
864 match ThinkingSpec::parse(val) {
865 Some(ThinkingSpec::Named(level)) => {
866 config.thinking_level = Some(level);
867 config.thinking_budget = level.to_legacy_budget();
868 }
869 Some(ThinkingSpec::Budget(budget)) => {
870 config.thinking_level = None;
873 config.thinking_budget = Some(budget);
874 }
875 None => {
876 config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
877 }
878 }
879 }
880 "compaction_model" => config.compaction_model = Some(val.to_string()),
881 "compaction_mode" => match val {
882 "remote" => {
883 config.compaction_mode = crate::core::compaction::CompactionMode::Remote
884 }
885 "local" | "local_only" | "local-only" => {
886 config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
887 }
888 _ => {
889 config.warnings.push(format!(
890 "compaction_mode = {val} — expected remote or local; ignored"
891 ));
892 }
893 },
894 "compaction_exclude" => {
895 let mut classes = Vec::new();
896 for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
897 match crate::core::compaction::ContentClass::parse(part) {
898 Some(class) => {
899 if !classes.contains(&class) {
900 classes.push(class);
901 }
902 }
903 None => config.warnings.push(format!(
904 "compaction_exclude — unknown content class '{part}'; \
905 expected one of: user_text, assistant_text, thinking, \
906 tool_calls, tool_results, file_paths, event_data"
907 )),
908 }
909 }
910 config.compaction_exclude = classes;
911 }
912 "context_window" => {
913 let parsed = match val {
914 "200k" | "200K" => Some(200_000),
915 "1m" | "1M" => Some(1_000_000),
916 _ => val.parse::<u64>().ok(),
917 };
918 if parsed.is_none() {
919 config.warnings.push(format!(
920 "context_window = {val} — expected 200k, 1m, or a token count; ignored"
921 ));
922 }
923 config.context_window = parsed;
924 }
925 "max_tool_output" => match val.parse::<usize>() {
926 Ok(size) => config.max_tool_output = size,
927 Err(_) => config.warnings.push(format!(
928 "max_tool_output = {val} — not a number; using {}",
929 config.max_tool_output
930 )),
931 },
932 "bash_timeout" => match val.parse::<u64>() {
933 Ok(t) if t >= 1 => config.bash_timeout = t,
934 Ok(_) => config.warnings.push(format!(
935 "bash_timeout = {val} — below minimum (1s); using {}",
936 config.bash_timeout
937 )),
938 Err(_) => config.warnings.push(format!(
939 "bash_timeout = {val} — not a number; using {}",
940 config.bash_timeout
941 )),
942 },
943 "bash_max_timeout" => {
944 if let Ok(timeout) = val.parse::<u64>() {
945 config.bash_max_timeout = timeout;
946 }
947 }
948 "subagent_timeout" => {
949 if let Ok(timeout) = val.parse::<u64>() {
950 config.subagent_timeout = timeout;
951 }
952 }
953 "api_retries" => {
954 if let Ok(retries) = val.parse::<u32>() {
955 config.api_retries = retries;
956 }
957 }
958 "refusal_retries" => {
959 if let Ok(retries) = val.parse::<u32>() {
960 config.refusal_retries = retries;
961 }
962 }
963 "telemetry" => config.telemetry = val.to_string(),
964 "cache_diagnostics" => {
965 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
966 }
967 "cache_ttl" => match CacheTtl::parse(val) {
968 Some(ttl) => config.cache_ttl = ttl,
969 None => config.warnings.push(format!(
970 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
971 )),
972 },
973 "max_fps" => match val.parse::<u32>() {
974 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
975 Ok(_) => config.warnings.push(format!(
976 "max_fps = {val} — expected 1–1000; using {}",
977 config.max_fps
978 )),
979 Err(_) => config.warnings.push(format!(
980 "max_fps = {val} — not a number; using {}",
981 config.max_fps
982 )),
983 },
984 "scroll_lines" => match val.parse::<u16>() {
985 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
986 Ok(_) => config
987 .warnings
988 .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
989 Err(_) => config
990 .warnings
991 .push(format!("scroll_lines = {val} — not a number; ignoring")),
992 },
993 "theme" => config.theme = Some(val.to_string()),
994 "agent_name" => config.agent_name = Some(val.to_string()),
995 "identity" => config.identity = Some(val.to_string()),
996 "disabled_plugins" => {
997 config.disabled_plugins = parse_comma_list(val);
998 }
999 "favorite_models" => {
1000 config.favorite_models = parse_comma_list(val);
1001 }
1002 "disabled_skills" => {
1003 config.disabled_skills = parse_comma_list(val);
1004 }
1005 "progressive_tool_disclosure" => {
1006 config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1007 }
1008 "session_persistence" => {
1009 match crate::core::session_journal::SessionPersistence::parse(val) {
1010 Some(mode) => config.session_persistence = mode,
1011 None => config.warnings.push(format!(
1012 "session_persistence = {val} — expected json or journal; \
1013 keeping the default (json)"
1014 )),
1015 }
1016 }
1017 "disabled_tools" => {
1018 config.disabled_tools = parse_comma_list(val);
1019 }
1020 _ => {
1021 if key.starts_with("shell.") {
1023 parse_shell_config_key(&mut config.shell, key, val);
1024 } else if key.starts_with("server.") {
1025 parse_server_config_key(&mut config.server, key, val);
1026 } else if key.starts_with("bridge.") {
1027 parse_bridge_config_key(&mut config.bridge, key, val);
1028 } else if key.starts_with("auth.") {
1029 parse_auth_config_key(&mut config.auth, key, val);
1030 } else if key.starts_with("events.") {
1031 parse_events_config_key(&mut config.events, key, val);
1032 } else if key.starts_with("turn_budget.") {
1033 parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1034 } else if key.starts_with("memory.") {
1035 parse_memory_config_key(&mut config.memory, key, val);
1036 } else if let Some(provider_key) = key.strip_prefix("provider.") {
1037 config
1038 .provider_keys
1039 .insert(provider_key.to_string(), val.to_string());
1040 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1041 config
1042 .keybinds
1043 .insert(keybind_key.to_string(), val.to_string());
1044 } else if key.contains('.') {
1045 } else {
1049 match did_you_mean(key) {
1051 Some(suggestion) => config.warnings.push(format!(
1052 "unknown key '{key}' (did you mean '{suggestion}'?)"
1053 )),
1054 None => config
1055 .warnings
1056 .push(format!("unknown key '{key}' — ignored")),
1057 }
1058 }
1059 }
1060 }
1061 }
1062
1063 if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1068 config.warnings.push(format!(
1069 "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1070 in the same config; reverting to off",
1071 config.memory.default_mode
1072 ));
1073 config.memory.default_mode = "off".to_string();
1074 }
1075
1076 if config.server.max_message_size.is_none() {
1079 if let Some(ctx_tokens) = config.context_window {
1080 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1081 }
1082 }
1083}
1084
1085pub fn read_config_value(key: &str) -> Option<String> {
1087 let path = resolve_read_path("config");
1088 let content = std::fs::read_to_string(&path).ok()?;
1089 for line in content.lines() {
1090 let line = line.trim();
1091 if line.is_empty() || line.starts_with('#') {
1092 continue;
1093 }
1094 let Some((k, v)) = line.split_once('=') else {
1095 continue;
1096 };
1097 if k.trim() == key.trim() {
1098 return Some(v.trim().to_string());
1099 }
1100 }
1101 None
1102}
1103
1104pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1108 let path = resolve_write_path("config");
1109 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1110
1111 let key_trimmed = key.trim();
1112 let replacement = format!("{} = {}", key_trimmed, value);
1113
1114 let mut found = false;
1115 let mut new_lines: Vec<String> = existing
1116 .lines()
1117 .map(|line| {
1118 if found {
1119 return line.to_string();
1120 }
1121 let t = line.trim_start();
1122 if t.starts_with('#') || t.is_empty() {
1123 return line.to_string();
1124 }
1125 if let Some((k, _)) = t.split_once('=') {
1126 if k.trim() == key_trimmed {
1127 found = true;
1128 return replacement.clone();
1129 }
1130 }
1131 line.to_string()
1132 })
1133 .collect();
1134
1135 if !found {
1136 new_lines.push(replacement);
1137 }
1138
1139 let mut out = new_lines.join("\n");
1140 if !out.ends_with('\n') {
1141 out.push('\n');
1142 }
1143
1144 let tmp = path.with_extension("tmp");
1145 std::fs::write(&tmp, out)?;
1146 #[cfg(unix)]
1148 {
1149 use std::os::unix::fs::PermissionsExt;
1150 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1151 }
1152 std::fs::rename(&tmp, &path)?;
1153 Ok(())
1154}
1155
1156pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1158 let trimmed = id.trim();
1159 if trimmed.is_empty() {
1160 return Ok(());
1161 }
1162 let mut values = load_config().favorite_models;
1163 if !values.iter().any(|v| v == trimmed) {
1164 values.push(trimmed.to_string());
1165 values.sort();
1166 }
1167 write_comma_list("favorite_models", &values)
1168}
1169
1170pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1172 let mut values = load_config().favorite_models;
1173 values.retain(|v| v != id.trim());
1174 write_comma_list("favorite_models", &values)
1175}
1176
1177pub fn is_favorite_model(id: &str) -> bool {
1179 load_config().favorite_models.iter().any(|v| v == id.trim())
1180}
1181
1182pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1185 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1186 You have access to bash, read, and write tools. \
1187 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1188
1189 if let Some(val) = explicit {
1190 let path = std::path::Path::new(val);
1191 if path.exists() && path.is_file() {
1192 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1193 }
1194 return val.to_string();
1195 }
1196
1197 let system_path = resolve_read_path("system.md");
1198 if system_path.exists() {
1199 return std::fs::read_to_string(&system_path).unwrap_or_default();
1200 }
1201
1202 DEFAULT_PROMPT.to_string()
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 #[test]
1208 fn turn_budget_keys_parse_typed_per_role() {
1209 let config = super::load_config_from_str(
1210 "turn_budget.worker.max_provider_rounds = 3\n\
1211 turn_budget.worker.max_cost_usd = 0.25\n\
1212 turn_budget.autonomous.max_elapsed_secs = 90\n\
1213 turn_budget.foreground.max_tool_calls = 7\n\
1214 turn_budget.foreground.max_round_renewals = 12\n\
1215 turn_budget.worker.max_provider_rounds_bogus = 1\n\
1216 turn_budget.nosuchrole.max_tool_calls = 1\n",
1217 );
1218 assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
1219 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
1220 assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
1221 assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
1222 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
1223 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
1225 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
1227 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
1228 }
1229
1230 #[test]
1231 fn memory_keys_all_parse_with_consent() {
1232 let config = super::load_config_from_str(
1233 "memory.default_mode = capture_and_recall\n\
1234 memory.default_mode_confirmed = true\n\
1235 memory.recall_max_records = 12\n\
1236 memory.recall_max_tokens = 2048\n\
1237 memory.recall_timeout_ms = 250\n\
1238 memory.capture_tools = off\n\
1239 memory.capture_assistant = false\n\
1240 memory.capture_user = false\n\
1241 memory.auto_consolidate = on\n\
1242 memory.local_embeddings = on\n\
1243 memory.gliner = on\n",
1244 );
1245 assert_eq!(config.memory.default_mode, "capture_and_recall");
1246 assert!(config.memory.default_mode_confirmed);
1247 assert_eq!(config.memory.recall_max_records, 12);
1248 assert_eq!(config.memory.recall_max_tokens, 2048);
1249 assert_eq!(config.memory.recall_timeout_ms, 250);
1250 assert_eq!(config.memory.capture_tools, "off");
1251 assert!(!config.memory.capture_assistant);
1252 assert!(!config.memory.capture_user);
1253 assert_eq!(config.memory.auto_consolidate, "on");
1254 assert_eq!(config.memory.local_embeddings, "on");
1255 assert_eq!(config.memory.gliner, "on");
1256 }
1257
1258 #[test]
1259 fn memory_defaults_match_spec_12() {
1260 let config = super::load_config_from_str("");
1261 assert_eq!(config.memory, super::MemoryConfig::default());
1262 assert_eq!(config.memory.default_mode, "off");
1263 assert!(!config.memory.default_mode_confirmed);
1264 assert_eq!(config.memory.recall_max_records, 8);
1265 assert_eq!(config.memory.recall_max_tokens, 4096);
1266 assert_eq!(config.memory.recall_timeout_ms, 150);
1267 assert_eq!(config.memory.capture_tools, "summary_only");
1268 assert!(config.memory.capture_assistant);
1269 assert!(config.memory.capture_user);
1270 assert_eq!(config.memory.auto_consolidate, "off");
1271 assert_eq!(config.memory.local_embeddings, "off");
1272 assert_eq!(config.memory.gliner, "off");
1273 }
1274
1275 #[test]
1276 fn memory_unknown_values_fail_closed_to_defaults() {
1277 let config = super::load_config_from_str(
1278 "memory.default_mode = recall_everything_forever\n\
1279 memory.default_mode_confirmed = true\n\
1280 memory.recall_max_records = lots\n\
1281 memory.recall_max_tokens = -5\n\
1282 memory.recall_timeout_ms = fast\n\
1283 memory.capture_tools = full\n\
1284 memory.capture_assistant = maybe\n\
1285 memory.capture_user = sometimes\n\
1286 memory.auto_consolidate = aggressive\n\
1287 memory.local_embeddings = auto\n\
1288 memory.gliner = download\n\
1289 memory.nosuchfield = 1\n",
1290 );
1291 assert_eq!(config.memory.default_mode, "off");
1293 assert_eq!(config.memory.recall_max_records, 8);
1294 assert_eq!(config.memory.recall_max_tokens, 4096);
1295 assert_eq!(config.memory.recall_timeout_ms, 150);
1296 assert_eq!(config.memory.capture_tools, "summary_only");
1297 assert!(config.memory.capture_assistant);
1298 assert!(config.memory.capture_user);
1299 assert_eq!(config.memory.auto_consolidate, "off");
1300 assert_eq!(config.memory.local_embeddings, "off");
1301 assert_eq!(config.memory.gliner, "off");
1302 }
1303
1304 #[test]
1305 fn memory_default_mode_without_consent_reverts_to_off() {
1306 let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
1307 assert_eq!(config.memory.default_mode, "off");
1308 assert!(
1309 config
1310 .warnings
1311 .iter()
1312 .any(|w| w.contains("memory.default_mode_confirmed")),
1313 "reversion must surface a warning; got: {:?}",
1314 config.warnings
1315 );
1316
1317 let denied = super::load_config_from_str(
1319 "memory.default_mode = capture_only\n\
1320 memory.default_mode_confirmed = false\n",
1321 );
1322 assert_eq!(denied.memory.default_mode, "off");
1323
1324 let garbage = super::load_config_from_str(
1326 "memory.default_mode = capture_only\n\
1327 memory.default_mode_confirmed = definitely\n",
1328 );
1329 assert_eq!(garbage.memory.default_mode, "off");
1330 }
1331
1332 #[test]
1333 fn memory_consent_is_order_independent() {
1334 let before = super::load_config_from_str(
1336 "memory.default_mode_confirmed = true\n\
1337 memory.default_mode = recall_once\n",
1338 );
1339 assert_eq!(before.memory.default_mode, "recall_once");
1340 let after = super::load_config_from_str(
1342 "memory.default_mode = recall_once\n\
1343 memory.default_mode_confirmed = true\n",
1344 );
1345 assert_eq!(after.memory.default_mode, "recall_once");
1346 }
1347
1348 #[test]
1349 fn memory_config_surface_is_closed_by_construction() {
1350 let _closed_surface = super::MemoryConfig {
1358 default_mode: "off".to_string(),
1359 default_mode_confirmed: false,
1360 recall_max_records: 8,
1361 recall_max_tokens: 4096,
1362 recall_timeout_ms: 150,
1363 capture_tools: "summary_only".to_string(),
1364 capture_assistant: true,
1365 capture_user: true,
1366 auto_consolidate: "off".to_string(),
1367 local_embeddings: "off".to_string(),
1368 gliner: "off".to_string(),
1369 };
1370 }
1371
1372 use super::*;
1373 use serial_test::serial;
1374
1375 #[test]
1376 fn test_levenshtein_basics() {
1377 assert_eq!(levenshtein("model", "model"), 0);
1378 assert_eq!(levenshtein("modle", "model"), 2);
1379 assert_eq!(levenshtein("them", "theme"), 1);
1380 }
1381
1382 #[test]
1383 fn test_did_you_mean_close_typos() {
1384 assert_eq!(did_you_mean("modle"), Some("model"));
1385 assert_eq!(did_you_mean("them"), Some("theme"));
1386 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
1387 assert_eq!(did_you_mean("completely_unrelated_key"), None);
1388 }
1389
1390 #[test]
1391 #[serial]
1392 fn test_config_warnings_unknown_key_and_bad_values() {
1393 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
1394 let dir = home.join(".synaps-cli");
1395 std::fs::create_dir_all(&dir).unwrap();
1396 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();
1397
1398 with_home(&home, || {
1399 let config = load_config();
1400 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
1402 assert!(
1403 !config.warnings.iter().any(|w| w.contains("knowledge")),
1404 "{:?}",
1405 config.warnings
1406 );
1407 assert!(
1408 config
1409 .warnings
1410 .iter()
1411 .any(|w| w.contains("did you mean 'model'")),
1412 "{:?}",
1413 config.warnings
1414 );
1415 assert!(
1416 config.warnings.iter().any(|w| w.contains("thinking")),
1417 "{:?}",
1418 config.warnings
1419 );
1420 assert!(
1421 config.warnings.iter().any(|w| w.contains("below minimum")),
1422 "{:?}",
1423 config.warnings
1424 );
1425 assert_eq!(config.bash_timeout, 30);
1427 assert_eq!(config.thinking_budget, None);
1428 });
1429 let _ = std::fs::remove_dir_all(&home);
1430 }
1431
1432 #[test]
1435 fn test_cache_ttl_parse_table() {
1436 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
1438 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
1439 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
1440 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
1442 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
1443 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
1444 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
1446 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
1448 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
1449 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
1450 assert_eq!(CacheTtl::parse("2h"), None);
1452 assert_eq!(CacheTtl::parse(""), None);
1453 assert_eq!(CacheTtl::parse("forever"), None);
1454 }
1455
1456 #[test]
1457 fn test_cache_ttl_default_is_five_minutes() {
1458 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
1459 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
1460 }
1461
1462 #[test]
1463 fn test_max_fps_default_is_60() {
1464 assert_eq!(SynapsConfig::default().max_fps, 60);
1465 }
1466
1467 #[test]
1468 #[serial]
1469 fn test_max_fps_config_parse_and_validation() {
1470 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
1471 let dir = home.join(".synaps-cli");
1472 std::fs::create_dir_all(&dir).unwrap();
1473
1474 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
1476 with_home(&home, || {
1477 let config = load_config();
1478 assert_eq!(config.max_fps, 144);
1479 assert!(
1480 config.warnings.is_empty(),
1481 "warnings: {:?}",
1482 config.warnings
1483 );
1484 });
1485
1486 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
1488 with_home(&home, || {
1489 let config = load_config();
1490 assert_eq!(config.max_fps, 60);
1491 assert!(
1492 config.warnings.iter().any(|w| w.contains("max_fps")),
1493 "warnings: {:?}",
1494 config.warnings
1495 );
1496 });
1497
1498 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
1500 with_home(&home, || {
1501 let config = load_config();
1502 assert_eq!(config.max_fps, 60);
1503 assert!(
1504 config.warnings.iter().any(|w| w.contains("max_fps")),
1505 "warnings: {:?}",
1506 config.warnings
1507 );
1508 });
1509
1510 let _ = std::fs::remove_dir_all(&home);
1511 }
1512
1513 #[test]
1514 #[serial]
1515 fn test_cache_ttl_config_parse_and_garbage_warning() {
1516 let home =
1517 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
1518 let dir = home.join(".synaps-cli");
1519 std::fs::create_dir_all(&dir).unwrap();
1520
1521 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
1523 with_home(&home, || {
1524 let config = load_config();
1525 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
1526 assert!(
1527 config.warnings.is_empty(),
1528 "warnings: {:?}",
1529 config.warnings
1530 );
1531 });
1532
1533 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
1535 with_home(&home, || {
1536 let config = load_config();
1537 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
1538 assert!(
1539 config.warnings.iter().any(|w| w.contains("cache_ttl")),
1540 "warnings: {:?}",
1541 config.warnings
1542 );
1543 });
1544
1545 let _ = std::fs::remove_dir_all(&home);
1546 }
1547
1548 #[test]
1549 fn test_parse_thinking_budget() {
1550 use crate::core::reasoning::ThinkingSpec;
1551 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
1553 assert_eq!(
1554 ThinkingSpec::parse("medium").unwrap().to_budget(),
1555 Some(4096)
1556 );
1557 assert_eq!(
1558 ThinkingSpec::parse("high").unwrap().to_budget(),
1559 Some(16384)
1560 );
1561 assert_eq!(
1562 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
1563 Some(32768)
1564 );
1565 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
1566 let config = load_config_from_str("thinking = 8192\n");
1567 assert_eq!(config.thinking_level, None);
1568 assert_eq!(config.thinking_budget, Some(8192));
1569 assert_eq!(ThinkingSpec::parse("invalid"), None);
1570 }
1571
1572 #[test]
1573 fn test_base_dir() {
1574 let path = base_dir();
1575 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
1576 }
1577
1578 #[test]
1579 fn test_resolve_system_prompt_explicit() {
1580 let result = resolve_system_prompt(Some("test prompt"));
1581 assert_eq!(result, "test prompt");
1582 }
1583
1584 #[test]
1585 fn test_resolve_system_prompt_none() {
1586 let result = resolve_system_prompt(None);
1587 assert!(result.contains("helpful AI agent"));
1588 }
1589
1590 #[test]
1595 fn test_synaps_config_default() {
1596 let config = SynapsConfig::default();
1597 assert_eq!(config.model, None);
1598 assert_eq!(config.thinking_budget, None);
1599 assert_eq!(config.max_tool_output, 30000);
1600 assert_eq!(config.bash_timeout, 30);
1601 assert_eq!(config.bash_max_timeout, 300);
1602 assert_eq!(config.subagent_timeout, 300);
1603 assert_eq!(config.api_retries, 3);
1604 assert_eq!(config.theme, None);
1605 assert!(config.disabled_plugins.is_empty());
1606 assert!(config.favorite_models.is_empty());
1607 assert!(config.disabled_skills.is_empty());
1608 assert!(!config.progressive_tool_disclosure);
1609 assert_eq!(config.shell.max_sessions, 5);
1610 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
1611 assert!(config.server.allowed_origins.is_empty());
1613 assert_eq!(config.server.token, None);
1614 assert!(!config.server.auto_approve_confirms);
1615 assert_eq!(config.server.max_message_size, None);
1616 assert!(config.bridge.uds_path.is_none());
1618 assert!(!config.bridge.heartbeat_mirror);
1619 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1620 }
1621
1622 #[test]
1623 #[serial]
1624 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
1625 let home = make_test_home("progressive-tool-disclosure");
1626 let cfg = home.join(".synaps-cli/config");
1627 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
1628
1629 with_home(&home, || {
1630 let config = load_config();
1631 assert!(config.progressive_tool_disclosure);
1632 assert!(config.warnings.is_empty());
1633 });
1634
1635 let _ = std::fs::remove_dir_all(&home);
1636 }
1637
1638 #[test]
1639 #[serial]
1640 fn test_load_config_bridge_keys() {
1641 let home = make_test_home("bridge-keys");
1642 let cfg = home.join(".synaps-cli/config");
1643 std::fs::write(
1644 &cfg,
1645 "\
1646bridge.uds_path = /tmp/some/control.sock\n\
1647bridge.heartbeat_mirror = true\n\
1648bridge.heartbeat_timeout_ms = 750\n\
1649",
1650 )
1651 .unwrap();
1652
1653 with_home(&home, || {
1654 let config = load_config();
1655 assert_eq!(
1656 config.bridge.uds_path,
1657 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
1658 );
1659 assert!(config.bridge.heartbeat_mirror);
1660 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
1661 assert_eq!(
1662 config.bridge.resolved_uds_path(),
1663 std::path::PathBuf::from("/tmp/some/control.sock"),
1664 );
1665 });
1666
1667 let _ = std::fs::remove_dir_all(&home);
1668 }
1669
1670 #[test]
1671 fn test_bridge_config_defaults() {
1672 let cfg = BridgeConfig::default();
1673 assert!(cfg.uds_path.is_none());
1674 assert!(!cfg.heartbeat_mirror);
1675 assert_eq!(cfg.heartbeat_timeout_ms, 250);
1676 let resolved = cfg.resolved_uds_path();
1678 assert!(resolved.ends_with("bridge/control.sock"));
1679 }
1680
1681 #[test]
1682 #[serial]
1683 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
1684 let home = make_test_home("bridge-default-off");
1685 let cfg = home.join(".synaps-cli/config");
1686 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1687
1688 with_home(&home, || {
1689 let config = load_config();
1690 assert!(!config.bridge.heartbeat_mirror);
1691 assert!(config.bridge.uds_path.is_none());
1692 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1693 });
1694
1695 let _ = std::fs::remove_dir_all(&home);
1696 }
1697
1698 #[test]
1699 fn compaction_disclosure_keys_parse_with_typed_warnings() {
1700 let config = load_config_from_str(
1701 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
1702 );
1703 assert_eq!(
1704 config.compaction_mode,
1705 crate::core::compaction::CompactionMode::LocalOnly
1706 );
1707 assert_eq!(
1708 config.compaction_exclude,
1709 vec![
1710 crate::core::compaction::ContentClass::Thinking,
1711 crate::core::compaction::ContentClass::ToolResults,
1712 ]
1713 );
1714 assert!(
1715 config.warnings.iter().any(|w| w.contains("bogus")),
1716 "unknown class must warn: {:?}",
1717 config.warnings
1718 );
1719
1720 let defaults = load_config_from_str("");
1721 assert_eq!(
1722 defaults.compaction_mode,
1723 crate::core::compaction::CompactionMode::Remote
1724 );
1725 assert!(defaults.compaction_exclude.is_empty());
1726
1727 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
1728 assert_eq!(
1729 bad_mode.compaction_mode,
1730 crate::core::compaction::CompactionMode::Remote
1731 );
1732 assert!(bad_mode
1733 .warnings
1734 .iter()
1735 .any(|w| w.contains("compaction_mode")));
1736 }
1737
1738 #[test]
1739 #[serial]
1740 fn test_load_config_server_keys() {
1741 let home = make_test_home("server-keys");
1742 let cfg = home.join(".synaps-cli/config");
1743 std::fs::write(
1744 &cfg,
1745 "\
1746server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1747server.token = my-secret-token\n\
1748server.auto_approve_confirms = true\n\
1749server.max_message_size = 65536\n\
1750context_window = 200k\n\
1751",
1752 )
1753 .unwrap();
1754
1755 with_home(&home, || {
1756 let config = load_config();
1757 assert_eq!(
1758 config.server.allowed_origins,
1759 vec![
1760 "http://localhost:3000".to_string(),
1761 "http://localhost:5193".to_string(),
1762 ]
1763 );
1764 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1765 assert!(config.server.auto_approve_confirms);
1766 assert_eq!(config.server.max_message_size, Some(65536));
1768 });
1769
1770 let _ = std::fs::remove_dir_all(&home);
1771 }
1772
1773 #[test]
1774 #[serial]
1775 fn test_server_max_message_size_derived_from_context_window() {
1776 let home = make_test_home("server-derive");
1777 let cfg = home.join(".synaps-cli/config");
1778 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1779
1780 with_home(&home, || {
1781 let config = load_config();
1782 assert_eq!(config.server.max_message_size, Some(800_000));
1784 });
1785
1786 let _ = std::fs::remove_dir_all(&home);
1787 }
1788
1789 fn make_test_home(subdir: &str) -> std::path::PathBuf {
1790 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1791 let _ = std::fs::remove_dir_all(&dir);
1792 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1793 dir
1794 }
1795
1796 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1797 let original = std::env::var("HOME").ok();
1798 std::env::set_var("HOME", home);
1799 f();
1800 if let Some(h) = original {
1801 std::env::set_var("HOME", h);
1802 } else {
1803 std::env::remove_var("HOME");
1804 }
1805 }
1806
1807 #[test]
1808 #[serial]
1809 fn write_config_value_replaces_existing_key() {
1810 let home = make_test_home("replace");
1811 let cfg = home.join(".synaps-cli/config");
1812 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1813
1814 with_home(&home, || {
1815 write_config_value("model", "claude-sonnet-4-6").unwrap();
1816 });
1817
1818 let contents = std::fs::read_to_string(&cfg).unwrap();
1819 assert!(contents.contains("model = claude-sonnet-4-6"));
1820 assert!(contents.contains("thinking = low"));
1821 let _ = std::fs::remove_dir_all(&home);
1822 }
1823
1824 #[test]
1825 #[serial]
1826 fn write_config_value_appends_when_missing() {
1827 let home = make_test_home("append");
1828 let cfg = home.join(".synaps-cli/config");
1829 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1830
1831 with_home(&home, || {
1832 write_config_value("theme", "dracula").unwrap();
1833 });
1834
1835 let contents = std::fs::read_to_string(&cfg).unwrap();
1836 assert!(contents.contains("model = claude-opus-4-6"));
1837 assert!(contents.contains("theme = dracula"));
1838 let _ = std::fs::remove_dir_all(&home);
1839 }
1840
1841 #[test]
1842 #[serial]
1843 fn write_config_value_preserves_comments() {
1844 let home = make_test_home("comments");
1845 let cfg = home.join(".synaps-cli/config");
1846 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\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("# user comment"));
1854 assert!(contents.contains("# another"));
1855 assert!(contents.contains("model = claude-sonnet-4-6"));
1856 let _ = std::fs::remove_dir_all(&home);
1857 }
1858
1859 #[test]
1860 #[serial]
1861 fn write_config_value_preserves_unknown_keys() {
1862 let home = make_test_home("unknown");
1863 let cfg = home.join(".synaps-cli/config");
1864 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1865
1866 with_home(&home, || {
1867 write_config_value("model", "claude-sonnet-4-6").unwrap();
1868 });
1869
1870 let contents = std::fs::read_to_string(&cfg).unwrap();
1871 assert!(contents.contains("custom_thing = 42"));
1872 let _ = std::fs::remove_dir_all(&home);
1873 }
1874
1875 #[test]
1876 #[serial]
1877 fn write_config_value_creates_file_if_absent() {
1878 let home = make_test_home("create");
1879 let cfg = home.join(".synaps-cli/config");
1880 assert!(!cfg.exists());
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("model = claude-sonnet-4-6"));
1888 let _ = std::fs::remove_dir_all(&home);
1889 }
1890
1891 #[test]
1892 #[serial]
1893 fn load_config_parses_theme_key() {
1894 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1895 let _ = std::fs::create_dir_all(&dir);
1896 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1897
1898 let original_home = std::env::var("HOME").ok();
1899 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1900
1901 let config = load_config();
1902
1903 if let Some(home) = original_home {
1904 std::env::set_var("HOME", home);
1905 } else {
1906 std::env::remove_var("HOME");
1907 }
1908 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1909
1910 assert_eq!(config.theme.as_deref(), Some("dracula"));
1911 }
1912
1913 #[test]
1914 #[serial]
1915 fn test_load_config_disable_lists() {
1916 let test_dir =
1917 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1918 let _ = std::fs::create_dir_all(&test_dir);
1919 let config_path = test_dir.join("config");
1920
1921 let config_content = r#"
1922# Test config with disable lists
1923favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1924
1925disabled_plugins = foo, bar
1926disabled_skills = baz, plug:qual
1927"#;
1928 std::fs::write(&config_path, config_content).unwrap();
1929
1930 let original_home = std::env::var("HOME").ok();
1931 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1932
1933 let config = load_config();
1934
1935 if let Some(home) = original_home {
1936 std::env::set_var("HOME", home);
1937 } else {
1938 std::env::remove_var("HOME");
1939 }
1940
1941 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1942
1943 assert_eq!(
1944 config.disabled_plugins,
1945 vec!["foo".to_string(), "bar".to_string()]
1946 );
1947 assert_eq!(
1948 config.favorite_models,
1949 vec![
1950 "claude/claude-opus-4-7".to_string(),
1951 "groq/llama-3.3-70b-versatile".to_string(),
1952 ]
1953 );
1954 assert_eq!(
1955 config.disabled_skills,
1956 vec!["baz".to_string(), "plug:qual".to_string()]
1957 );
1958 }
1959
1960 #[test]
1961 #[serial]
1962 fn favorite_model_helpers_round_trip_through_config_file() {
1963 let home = make_test_home("favorite-models");
1964 let cfg = home.join(".synaps-cli/config");
1965 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
1966
1967 with_home(&home, || {
1968 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1969 add_favorite_model("claude/claude-opus-4-7").unwrap();
1970 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1971 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
1972 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1973 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
1974 assert!(is_favorite_model("claude/claude-opus-4-7"));
1975 });
1976
1977 let contents = std::fs::read_to_string(&cfg).unwrap();
1978 assert!(contents.contains("model = claude-opus-4-7"));
1979 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
1980 let _ = std::fs::remove_dir_all(&home);
1981 }
1982
1983 #[test]
1984 #[serial]
1985 fn test_load_config_new_keys() {
1986 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
1988 let _ = std::fs::create_dir_all(&test_dir);
1989 let config_path = test_dir.join("config");
1990
1991 let config_content = r#"
1992# Test config with new keys
1993model = claude-haiku
1994thinking = medium
1995max_tool_output = 50000
1996bash_timeout = 45
1997bash_max_timeout = 600
1998subagent_timeout = 120
1999api_retries = 5
2000"#;
2001 std::fs::write(&config_path, config_content).unwrap();
2002
2003 let original_home = std::env::var("HOME").ok();
2005 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
2006
2007 let config = load_config();
2008
2009 if let Some(home) = original_home {
2011 std::env::set_var("HOME", home);
2012 } else {
2013 std::env::remove_var("HOME");
2014 }
2015
2016 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
2018
2019 assert_eq!(config.model, Some("claude-haiku".to_string()));
2020 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
2022 assert_eq!(config.bash_timeout, 45);
2023 assert_eq!(config.bash_max_timeout, 600);
2024 assert_eq!(config.subagent_timeout, 120);
2025 assert_eq!(config.api_retries, 5);
2026 }
2027
2028 #[test]
2033 fn events_auto_turn_explicit_true_values() {
2034 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
2035 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2036 assert!(
2037 cfg.events.auto_turn,
2038 "expected true for events.auto_turn = {val}"
2039 );
2040 }
2041 }
2042
2043 #[test]
2044 fn events_auto_turn_explicit_false_values() {
2045 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
2046 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2047 assert!(
2048 !cfg.events.auto_turn,
2049 "expected false for events.auto_turn = {val}"
2050 );
2051 }
2052 }
2053
2054 #[test]
2055 fn events_auto_turn_typo_fails_safe_false() {
2056 let cfg = load_config_from_str("events.auto_turn = fales");
2058 assert!(
2059 !cfg.events.auto_turn,
2060 "typo 'fales' must fail safe to false"
2061 );
2062 }
2063
2064 #[test]
2065 fn events_auto_turn_default_is_true() {
2066 let cfg = load_config_from_str("model = claude-haiku");
2067 assert!(cfg.events.auto_turn, "default must be true when key absent");
2068 }
2069
2070 #[test]
2071 #[serial]
2072 fn test_auth_config_parses_endpoint_and_token() {
2073 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
2074 let dir = home.join(".synaps-cli");
2075 std::fs::create_dir_all(&dir).unwrap();
2076 std::fs::write(
2077 dir.join("config"),
2078 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
2079 )
2080 .unwrap();
2081 with_home(&home, || {
2082 let config = load_config();
2083 assert_eq!(
2084 config.auth.remote_endpoint.as_deref(),
2085 Some("https://jade.jade:8181")
2086 );
2087 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
2088 assert!(
2090 !config.warnings.iter().any(|w| w.contains("auth.")),
2091 "{:?}",
2092 config.warnings
2093 );
2094 });
2095 let _ = std::fs::remove_dir_all(&home);
2096 }
2097
2098 #[test]
2099 #[serial]
2100 fn test_credential_source_local_by_default() {
2101 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2102 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2103 assert!(!AuthConfig::default().credential_source().is_remote());
2104 }
2105
2106 #[test]
2107 #[serial]
2108 fn test_credential_source_remote_from_config() {
2109 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2110 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2111 let auth = AuthConfig {
2112 remote_endpoint: Some("https://b".into()),
2113 machine_token: Some("m".into()),
2114 };
2115 assert_eq!(
2116 auth.credential_source(),
2117 crate::core::auth::CredentialSource::Remote {
2118 endpoint: "https://b".into(),
2119 machine_token: "m".into()
2120 }
2121 );
2122 }
2123
2124 #[test]
2125 #[serial]
2126 fn test_credential_source_env_overrides_config() {
2127 let auth = AuthConfig {
2128 remote_endpoint: Some("https://config-host".into()),
2129 machine_token: Some("config-tok".into()),
2130 };
2131 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
2132 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
2133 let src = auth.credential_source();
2134 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2135 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2136 assert_eq!(
2137 src,
2138 crate::core::auth::CredentialSource::Remote {
2139 endpoint: "https://env-host".into(),
2140 machine_token: "env-tok".into()
2141 }
2142 );
2143 }
2144 #[test]
2145 fn config_parses_ultracode_as_distinct_canonical_level() {
2146 let cfg = load_config_from_str("thinking = ultracode\n");
2147 assert_eq!(
2148 cfg.thinking_level,
2149 Some(crate::reasoning::ReasoningLevel::UltraCode)
2150 );
2151 assert_eq!(cfg.thinking_budget, None);
2152 for other in [
2153 crate::reasoning::ReasoningLevel::Ultra,
2154 crate::reasoning::ReasoningLevel::Max,
2155 crate::reasoning::ReasoningLevel::XHigh,
2156 ] {
2157 assert_ne!(cfg.thinking_level, Some(other));
2158 }
2159 }
2160}