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, Copy, PartialEq, Eq, Default)]
112pub enum ContextManagementMode {
113 #[default]
114 Off,
115 Auto,
116}
117
118impl ContextManagementMode {
119 pub fn parse(value: &str) -> Option<Self> {
120 match value.trim().to_ascii_lowercase().as_str() {
121 "off" => Some(Self::Off),
122 "auto" => Some(Self::Auto),
123 _ => None,
124 }
125 }
126
127 pub fn as_str(self) -> &'static str {
128 match self {
129 Self::Off => "off",
130 Self::Auto => "auto",
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct ContextManagementConfig {
143 pub mode: ContextManagementMode,
144 pub pressure_tokens: Option<u64>,
146 pub rollover_tokens: Option<u64>,
148 pub reserve_tokens: u64,
150 pub finish_rounds: u32,
153}
154
155impl Default for ContextManagementConfig {
156 fn default() -> Self {
157 Self {
158 mode: ContextManagementMode::Off,
159 pressure_tokens: None,
160 rollover_tokens: None,
161 reserve_tokens: 16_000,
162 finish_rounds: 2,
163 }
164 }
165}
166
167impl ContextManagementConfig {
168 pub const MAX_FINISH_ROUNDS: u32 = 8;
169 pub const MAX_RESERVE_TOKENS: u64 = 1_000_000;
170
171 pub fn validate(&self) -> Result<(), &'static str> {
174 if self.pressure_tokens == Some(0) {
175 return Err("pressure_tokens must be positive");
176 }
177 if self.rollover_tokens.is_some_and(|v| v < 2) {
178 return Err("rollover_tokens must be at least 2");
179 }
180 if let (Some(pressure), Some(rollover)) = (self.pressure_tokens, self.rollover_tokens) {
181 if pressure >= rollover {
182 return Err("pressure_tokens must be below rollover_tokens");
183 }
184 }
185 if !(1..=Self::MAX_RESERVE_TOKENS).contains(&self.reserve_tokens) {
186 return Err("reserve_tokens must be between 1 and 1000000");
187 }
188 if self.finish_rounds > Self::MAX_FINISH_ROUNDS {
189 return Err("finish_rounds must be between 0 and 8");
190 }
191 Ok(())
192 }
193
194 pub fn validate_for_window(&self, context_window_tokens: u64) -> Result<(), &'static str> {
197 self.validate()?;
198 if context_window_tokens < 2 {
199 return Err("context window must contain at least two tokens");
200 }
201 if self
202 .pressure_tokens
203 .is_some_and(|v| v >= context_window_tokens)
204 {
205 return Err("pressure_tokens must be below the context window");
206 }
207 if self
208 .rollover_tokens
209 .is_some_and(|v| v > context_window_tokens)
210 {
211 return Err("rollover_tokens must not exceed the context window");
212 }
213 Ok(())
214 }
215}
216
217fn parse_context_management_config_key(
220 config: &mut ContextManagementConfig,
221 key: &str,
222 value: &str,
223) -> Result<(), &'static str> {
224 match key {
225 "context_management.mode" => {
226 config.mode = ContextManagementMode::parse(value).ok_or("expected off or auto")?;
227 }
228 "context_management.pressure_tokens" | "context_management.rollover_tokens" => {
229 let tokens = if value.eq_ignore_ascii_case("auto") {
230 None
231 } else {
232 Some(
233 value
234 .parse::<u64>()
235 .map_err(|_| "expected auto or an unsigned token count")?,
236 )
237 };
238 if key == "context_management.pressure_tokens" {
239 config.pressure_tokens = tokens;
240 } else {
241 config.rollover_tokens = tokens;
242 }
243 }
244 "context_management.reserve_tokens" => {
245 config.reserve_tokens = value
246 .parse()
247 .map_err(|_| "expected an unsigned token count")?;
248 }
249 "context_management.finish_rounds" => {
250 config.finish_rounds = value
251 .parse()
252 .map_err(|_| "expected an unsigned round count")?;
253 }
254 _ => return Err("unknown context management key"),
255 }
256 Ok(())
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub struct StartupConfig {
263 pub quick_start: bool,
267 pub extensions_ready_timeout_secs: u64,
269}
270
271impl Default for StartupConfig {
272 fn default() -> Self {
273 Self {
274 quick_start: true,
275 extensions_ready_timeout_secs: 30,
276 }
277 }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct DaemonConfig {
284 pub idle_exit_secs: u64,
288 pub prompt_abandon_secs: u64,
292 pub parked_evict_secs: u64,
295}
296
297impl Default for DaemonConfig {
298 fn default() -> Self {
299 Self {
300 idle_exit_secs: 10,
301 prompt_abandon_secs: 3600,
302 parked_evict_secs: 3600,
303 }
304 }
305}
306
307fn parse_startup_config_key(
309 config: &mut StartupConfig,
310 key: &str,
311 value: &str,
312) -> Result<(), &'static str> {
313 match key {
314 "startup.quick_start" => {
315 config.quick_start = match value.trim() {
316 "true" | "1" | "on" | "yes" => true,
317 "false" | "0" | "off" | "no" => false,
318 _ => return Err("expected on or off"),
319 };
320 }
321 "startup.extensions_ready_timeout_secs" => {
322 config.extensions_ready_timeout_secs =
323 value.trim().parse().map_err(|_| "expected an unsigned second count")?;
324 }
325 _ => return Err("unknown startup key"),
326 }
327 Ok(())
328}
329
330fn parse_daemon_config_key(
332 config: &mut DaemonConfig,
333 key: &str,
334 value: &str,
335) -> Result<(), &'static str> {
336 let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
337 match key {
338 "daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
339 "daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
340 "daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
341 _ => return Err("unknown daemon key"),
342 }
343 Ok(())
344}
345
346#[derive(Debug, Clone, Default)]
348pub struct ServerConfig {
349 pub allowed_origins: Vec<String>,
351 pub token: Option<String>,
354 pub auto_approve_confirms: bool,
356 pub max_message_size: Option<usize>,
359}
360
361#[derive(Debug, Clone)]
367pub struct BridgeConfig {
368 pub uds_path: Option<PathBuf>,
371 pub heartbeat_mirror: bool,
374 pub heartbeat_timeout_ms: u64,
376}
377
378impl Default for BridgeConfig {
379 fn default() -> Self {
380 Self {
381 uds_path: None,
382 heartbeat_mirror: false,
383 heartbeat_timeout_ms: 250,
384 }
385 }
386}
387
388impl BridgeConfig {
389 pub fn resolved_uds_path(&self) -> PathBuf {
391 self.uds_path
392 .clone()
393 .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
394 }
395}
396
397#[derive(Debug, Clone, Default)]
401pub struct AuthConfig {
402 pub remote_endpoint: Option<String>,
406 pub machine_token: Option<String>,
409}
410
411impl AuthConfig {
412 pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
416 let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
417 .ok()
418 .filter(|s| !s.trim().is_empty())
419 .or_else(|| self.remote_endpoint.clone());
420 let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
421 .ok()
422 .filter(|s| !s.trim().is_empty())
423 .or_else(|| self.machine_token.clone());
424 crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
425 }
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
438pub enum CacheTtl {
439 #[default]
440 FiveMinutes,
441 OneHour,
442 Hybrid,
443}
444
445impl CacheTtl {
446 pub fn parse(val: &str) -> Option<CacheTtl> {
449 match val.to_ascii_lowercase().as_str() {
450 "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
451 "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
452 "hybrid" => Some(CacheTtl::Hybrid),
453 _ => None,
454 }
455 }
456}
457
458#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
461pub enum ActivationConfirm {
462 #[default]
464 Auto,
465 Prompt,
468 Deny,
470}
471
472impl ActivationConfirm {
473 pub fn parse(val: &str) -> Option<Self> {
474 match val.trim().to_ascii_lowercase().as_str() {
475 "auto" => Some(Self::Auto),
476 "prompt" => Some(Self::Prompt),
477 "deny" => Some(Self::Deny),
478 _ => None,
479 }
480 }
481
482 pub fn as_str(self) -> &'static str {
483 match self {
484 Self::Auto => "auto",
485 Self::Prompt => "prompt",
486 Self::Deny => "deny",
487 }
488 }
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
500pub enum ThemeTransitionMode {
501 #[default]
502 On,
503 Off,
504 Ms(u64),
505}
506
507impl ThemeTransitionMode {
508 pub const DEFAULT_MS: u64 = 350;
511 pub const MAX_MS: u64 = 2000;
513
514 pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
517 match val.to_ascii_lowercase().as_str() {
518 "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
519 "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
520 other => other
521 .parse::<u64>()
522 .ok()
523 .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
524 }
525 }
526
527 pub fn duration_ms(self) -> u64 {
529 match self {
530 ThemeTransitionMode::On => Self::DEFAULT_MS,
531 ThemeTransitionMode::Off => 0,
532 ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
533 }
534 }
535}
536
537#[derive(Debug, Clone)]
542pub struct EventsConfig {
543 pub auto_turn: bool,
549 pub auto_turn_cap: u32,
556}
557
558pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
560
561impl Default for EventsConfig {
562 fn default() -> Self {
563 Self {
564 auto_turn: true,
565 auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
566 }
567 }
568}
569
570pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
575 let normalised = val.trim().to_lowercase();
576 match normalised.as_str() {
577 "unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
578 n => n.parse::<u32>().ok(),
579 }
580}
581
582fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
584 if key == "events.auto_turn" {
585 let normalised = val.trim().to_lowercase();
586 cfg.auto_turn = match normalised.as_str() {
587 "true" | "1" | "yes" | "on" => true,
589 "false" | "0" | "no" | "off" => false,
591 other => {
593 eprintln!(
594 "warning: config: unrecognised value for events.auto_turn = {:?}; \
595 expected true/false/yes/no/on/off/1/0 — defaulting to false",
596 other
597 );
598 false
599 }
600 };
601 } else if key == "events.auto_turn_cap" {
602 match parse_auto_turn_cap(val) {
603 Some(cap) => cfg.auto_turn_cap = cap,
604 None => {
605 eprintln!(
606 "warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
607 expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
608 — keeping default {}",
609 val.trim(),
610 DEFAULT_AUTO_TURN_CAP
611 );
612 }
613 }
614 } }
616
617#[derive(Debug, Clone, Default, PartialEq)]
621pub struct TurnBudgetOverrides {
622 pub max_provider_rounds: Option<u32>,
623 pub max_tool_calls: Option<u32>,
624 pub max_elapsed_secs: Option<u64>,
625 pub max_accumulated_tool_result_bytes: Option<usize>,
626 pub max_context_tokens: Option<u64>,
627 pub max_cost_usd: Option<f64>,
628 pub max_round_renewals: Option<u32>,
633}
634
635#[derive(Debug, Clone, Default, PartialEq)]
637pub struct TurnBudgetsConfig {
638 pub foreground: TurnBudgetOverrides,
639 pub autonomous: TurnBudgetOverrides,
640 pub worker: TurnBudgetOverrides,
641}
642
643fn parse_turn_budget_config_key(budgets: &mut TurnBudgetsConfig, key: &str, val: &str) {
646 let Some(rest) = key.strip_prefix("turn_budget.") else {
647 return;
648 };
649 let Some((role, field)) = rest.split_once('.') else {
650 eprintln!("Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)");
651 return;
652 };
653 let overrides = match role {
654 "foreground" => &mut budgets.foreground,
655 "autonomous" => &mut budgets.autonomous,
656 "worker" => &mut budgets.worker,
657 other => {
658 eprintln!("Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)");
659 return;
660 }
661 };
662 macro_rules! set {
663 ($slot:expr, $ty:ty) => {
664 match val.parse::<$ty>() {
665 Ok(parsed) => $slot = Some(parsed),
666 Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
667 }
668 };
669 }
670 match field {
671 "max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
672 "max_tool_calls" => set!(overrides.max_tool_calls, u32),
673 "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
674 "max_accumulated_tool_result_bytes" => {
675 set!(overrides.max_accumulated_tool_result_bytes, usize)
676 }
677 "max_context_tokens" => set!(overrides.max_context_tokens, u64),
678 "max_cost_usd" => set!(overrides.max_cost_usd, f64),
679 "max_round_renewals" => set!(overrides.max_round_renewals, u32),
680 other => eprintln!("Warning: unknown turn_budget field '{other}'"),
681 }
682}
683
684#[derive(Debug, Clone, PartialEq)]
693pub struct MemoryConfig {
694 pub default_mode: String,
700 pub default_mode_confirmed: bool,
704 pub recall_max_records: u32,
706 pub recall_max_tokens: u32,
708 pub recall_timeout_ms: u64,
710 pub capture_tools: String,
712 pub capture_assistant: bool,
714 pub capture_user: bool,
716 pub auto_consolidate: String,
718 pub local_embeddings: String,
720 pub gliner: String,
722}
723
724impl Default for MemoryConfig {
725 fn default() -> Self {
726 Self {
727 default_mode: "off".to_string(),
728 default_mode_confirmed: false,
729 recall_max_records: 8,
730 recall_max_tokens: 4096,
731 recall_timeout_ms: 150,
732 capture_tools: "summary_only".to_string(),
733 capture_assistant: true,
734 capture_user: true,
735 auto_consolidate: "off".to_string(),
736 local_embeddings: "off".to_string(),
737 gliner: "off".to_string(),
738 }
739 }
740}
741
742const KNOWN_MEMORY_MODES: &[&str] = &[
747 "off",
748 "recall_once",
749 "recall_each_prompt",
750 "capture_only",
751 "capture_and_recall",
752];
753
754fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) {
757 let Some(field) = key.strip_prefix("memory.") else {
758 return;
759 };
760 fn parse_bool(slot: &mut bool, key: &str, val: &str) {
763 match val {
764 "true" | "1" | "yes" | "on" => *slot = true,
765 "false" | "0" | "no" | "off" => *slot = false,
766 other => eprintln!(
767 "Warning: invalid value for {key}: '{other}', expected true/false — using default"
768 ),
769 }
770 }
771 fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) {
774 if allowed.contains(&val) {
775 *slot = val.to_string();
776 } else {
777 eprintln!(
778 "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
779 allowed.join("|")
780 );
781 }
782 }
783 macro_rules! set_num {
784 ($slot:expr, $ty:ty) => {
785 match val.parse::<$ty>() {
786 Ok(parsed) => $slot = parsed,
787 Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
788 }
789 };
790 }
791 match field {
792 "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
793 "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
794 "recall_max_records" => set_num!(memory.recall_max_records, u32),
795 "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
796 "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
797 "capture_tools" => parse_enum(
798 &mut memory.capture_tools,
799 key,
800 val,
801 &["off", "summary_only"],
802 ),
803 "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
804 "capture_user" => parse_bool(&mut memory.capture_user, key, val),
805 "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
806 "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
807 "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
808 other => eprintln!("Warning: unknown memory field '{other}'"),
809 }
810}
811
812#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
814pub enum MemoryBackendKind {
815 #[default]
816 Legacy,
817 Axel,
818 Unavailable,
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Default)]
823pub struct MemoryBackendConfig {
824 pub kind: MemoryBackendKind,
825 pub executable: Option<PathBuf>,
827 pub brain: Option<PathBuf>,
829 pub user_scope: bool,
831}
832
833#[derive(Debug, Clone)]
835pub struct SynapsConfig {
836 pub model: Option<String>,
837 pub thinking_budget: Option<u32>,
838 pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
842 pub context_window: Option<u64>, pub context_management: ContextManagementConfig,
844 pub startup: StartupConfig,
846 pub daemon: DaemonConfig,
848 pub compaction_model: Option<String>, pub compaction_mode: crate::core::compaction::CompactionMode,
852 pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
854 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,
864 pub max_fps: u32,
868 pub scroll_lines: Option<u16>,
872 pub theme: Option<String>,
873 pub theme_transition: ThemeTransitionMode,
876 pub tui_background_opaque: bool,
878 pub tui_streaming_glow: bool,
880 pub agent_name: Option<String>,
881 pub identity: Option<String>,
882 pub disabled_plugins: Vec<String>,
883 pub favorite_models: Vec<String>,
884 pub disabled_skills: Vec<String>,
885 pub progressive_tool_disclosure: bool,
890 pub tools_activation_confirm: ActivationConfirm,
896 pub session_persistence: crate::core::session_journal::SessionPersistence,
901 pub disabled_tools: Vec<String>,
904 pub shell: ShellConfig,
905 pub server: ServerConfig,
906 pub bridge: BridgeConfig,
907 pub auth: AuthConfig,
908 pub events: EventsConfig,
909 pub provider_keys: BTreeMap<String, String>,
910 pub keybinds: std::collections::HashMap<String, String>,
911 pub turn_budgets: TurnBudgetsConfig,
913 pub memory: MemoryConfig,
915 pub memory_backend: MemoryBackendConfig,
917 pub warnings: Vec<String>,
920}
921
922impl Default for SynapsConfig {
923 fn default() -> Self {
924 Self {
925 model: None,
926 thinking_budget: None,
927 thinking_level: None,
928 context_window: None,
929 context_management: ContextManagementConfig::default(),
930 startup: StartupConfig::default(),
931 daemon: DaemonConfig::default(),
932 compaction_model: None,
933 compaction_mode: crate::core::compaction::CompactionMode::default(),
934 compaction_exclude: Vec::new(),
935 max_tool_output: 30000,
936 bash_timeout: 30,
937 bash_max_timeout: 300,
938 subagent_timeout: 300,
939 api_retries: 3,
940 refusal_retries: 2,
941 telemetry: "off".to_string(),
942 cache_diagnostics: false,
943 cache_ttl: CacheTtl::default(),
944 max_fps: 60,
945 scroll_lines: None,
946 theme: None,
947 theme_transition: ThemeTransitionMode::default(),
948 tui_background_opaque: true,
949 tui_streaming_glow: false,
950 agent_name: None,
951 identity: None,
952 disabled_plugins: Vec::new(),
953 favorite_models: Vec::new(),
954 disabled_skills: Vec::new(),
955 progressive_tool_disclosure: false,
956 tools_activation_confirm: ActivationConfirm::default(),
957 session_persistence: crate::core::session_journal::SessionPersistence::default(),
958 disabled_tools: Vec::new(),
959 shell: ShellConfig::default(),
960 server: ServerConfig::default(),
961 bridge: BridgeConfig::default(),
962 auth: AuthConfig::default(),
963 events: EventsConfig::default(),
964 provider_keys: BTreeMap::new(),
965 keybinds: std::collections::HashMap::new(),
966 turn_budgets: TurnBudgetsConfig::default(),
967 memory: MemoryConfig::default(),
968 memory_backend: MemoryBackendConfig::default(),
969 warnings: Vec::new(),
970 }
971 }
972}
973
974const KNOWN_CONFIG_KEYS: &[&str] = &[
976 "model",
977 "thinking",
978 "compaction_model",
979 "context_window",
980 "context_management.mode",
981 "context_management.pressure_tokens",
982 "context_management.rollover_tokens",
983 "context_management.reserve_tokens",
984 "context_management.finish_rounds",
985 "startup.quick_start",
986 "startup.extensions_ready_timeout_secs",
987 "daemon.idle_exit_secs",
988 "daemon.prompt_abandon_secs",
989 "daemon.parked_evict_secs",
990 "max_tool_output",
991 "bash_timeout",
992 "bash_max_timeout",
993 "subagent_timeout",
994 "api_retries",
995 "refusal_retries",
996 "telemetry",
997 "cache_diagnostics",
998 "cache_ttl",
999 "max_fps",
1000 "scroll_lines",
1001 "theme",
1002 "theme_transition",
1003 "tui_background_opaque",
1004 "tui_streaming_glow",
1005 "agent_name",
1006 "identity",
1007 "disabled_plugins",
1008 "favorite_models",
1009 "disabled_skills",
1010 "disabled_tools",
1011 "progressive_tool_disclosure",
1012 "tools.activation_confirm",
1013 "session_persistence",
1014];
1015
1016fn levenshtein(a: &str, b: &str) -> usize {
1018 let a: Vec<char> = a.chars().collect();
1019 let b: Vec<char> = b.chars().collect();
1020 let mut prev: Vec<usize> = (0..=b.len()).collect();
1021 let mut cur = vec![0; b.len() + 1];
1022 for (i, ca) in a.iter().enumerate() {
1023 cur[0] = i + 1;
1024 for (j, cb) in b.iter().enumerate() {
1025 let cost = if ca == cb { 0 } else { 1 };
1026 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1027 }
1028 std::mem::swap(&mut prev, &mut cur);
1029 }
1030 prev[b.len()]
1031}
1032
1033fn did_you_mean(key: &str) -> Option<&'static str> {
1035 KNOWN_CONFIG_KEYS
1036 .iter()
1037 .map(|k| (*k, levenshtein(key, k)))
1038 .filter(|(_, d)| *d <= 2)
1039 .min_by_key(|(_, d)| *d)
1040 .map(|(k, _)| k)
1041}
1042
1043fn parse_comma_list(val: &str) -> Vec<String> {
1046 val.split(',')
1047 .map(|s| s.trim().to_string())
1048 .filter(|s| !s.is_empty())
1049 .collect()
1050}
1051
1052fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1053 write_config_value_locked(key, &values.join(", "))
1054}
1055
1056fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
1058 match key {
1059 "shell.max_sessions" => {
1060 if let Ok(sessions) = val.parse::<usize>() {
1061 shell_config.max_sessions = sessions;
1062 } else {
1063 eprintln!(
1064 "Warning: invalid value for shell.max_sessions: '{}', using default",
1065 val
1066 );
1067 }
1068 }
1069 "shell.idle_timeout" => {
1070 if let Ok(timeout) = val.parse::<u64>() {
1071 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1072 } else {
1073 eprintln!(
1074 "Warning: invalid value for shell.idle_timeout: '{}', using default",
1075 val
1076 );
1077 }
1078 }
1079 "shell.readiness_timeout_ms" => {
1080 if let Ok(timeout) = val.parse::<u64>() {
1081 shell_config.readiness_timeout_ms = timeout;
1082 } else {
1083 eprintln!(
1084 "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
1085 val
1086 );
1087 }
1088 }
1089 "shell.max_readiness_timeout_ms" => {
1090 if let Ok(timeout) = val.parse::<u64>() {
1091 shell_config.max_readiness_timeout_ms = timeout;
1092 } else {
1093 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
1094 }
1095 }
1096 "shell.default_rows" => {
1097 if let Ok(rows) = val.parse::<u16>() {
1098 shell_config.default_rows = rows;
1099 } else {
1100 eprintln!(
1101 "Warning: invalid value for shell.default_rows: '{}', using default",
1102 val
1103 );
1104 }
1105 }
1106 "shell.default_cols" => {
1107 if let Ok(cols) = val.parse::<u16>() {
1108 shell_config.default_cols = cols;
1109 } else {
1110 eprintln!(
1111 "Warning: invalid value for shell.default_cols: '{}', using default",
1112 val
1113 );
1114 }
1115 }
1116 "shell.readiness_strategy" => {
1117 let val_lower = val.to_lowercase();
1118 match val_lower.as_str() {
1119 "timeout" | "prompt" | "hybrid" => {
1120 shell_config.readiness_strategy = val.to_string();
1121 }
1122 _ => {
1123 eprintln!(
1124 "Warning: invalid value for shell.readiness_strategy: '{}', using default",
1125 val
1126 );
1127 }
1128 }
1129 }
1130 "shell.max_output" => {
1131 if let Ok(max_output) = val.parse::<usize>() {
1132 shell_config.max_output = max_output;
1133 } else {
1134 eprintln!(
1135 "Warning: invalid value for shell.max_output: '{}', using default",
1136 val
1137 );
1138 }
1139 }
1140 _ => {
1141 }
1143 }
1144}
1145
1146#[allow(clippy::collapsible_match)]
1148fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1149 match key {
1150 "server.allowed_origins" => {
1151 server_config.allowed_origins = parse_comma_list(val);
1152 }
1153 "server.token" => {
1154 if !val.is_empty() {
1155 server_config.token = Some(val.to_string());
1156 }
1157 }
1158 "server.auto_approve_confirms" => {
1159 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1160 }
1161 "server.max_message_size" => {
1162 if let Ok(size) = val.parse::<usize>() {
1163 server_config.max_message_size = Some(size);
1164 } else {
1165 eprintln!(
1166 "Warning: invalid value for server.max_message_size: '{}', ignored",
1167 val
1168 );
1169 }
1170 }
1171 _ => {
1172 }
1174 }
1175}
1176
1177fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1179 match key {
1180 "bridge.uds_path" => {
1181 if val.is_empty() {
1182 bridge_config.uds_path = None;
1183 } else {
1184 bridge_config.uds_path = Some(PathBuf::from(val));
1185 }
1186 }
1187 "bridge.heartbeat_mirror" => {
1188 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
1189 }
1190 "bridge.heartbeat_timeout_ms" => {
1191 if let Ok(ms) = val.parse::<u64>() {
1192 bridge_config.heartbeat_timeout_ms = ms;
1193 } else {
1194 eprintln!(
1195 "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
1196 val
1197 );
1198 }
1199 }
1200 _ => {
1201 }
1203 }
1204}
1205
1206fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1208 let v = val.trim();
1209 match key {
1210 "auth.remote_endpoint" => {
1211 auth_config.remote_endpoint = if v.is_empty() {
1212 None
1213 } else {
1214 Some(v.to_string())
1215 };
1216 }
1217 "auth.machine_token" => {
1218 auth_config.machine_token = if v.is_empty() {
1219 None
1220 } else {
1221 Some(v.to_string())
1222 };
1223 }
1224 _ => {
1225 }
1227 }
1228}
1229
1230pub fn load_config_from_str(content: &str) -> SynapsConfig {
1233 let mut config = SynapsConfig::default();
1234 apply_config_content(&mut config, content);
1235 config
1236}
1237
1238pub fn load_config() -> SynapsConfig {
1241 let path = resolve_read_path("config");
1242 let mut config = SynapsConfig::default();
1243
1244 let Ok(content) = std::fs::read_to_string(&path) else {
1245 return config;
1246 };
1247
1248 apply_config_content(&mut config, &content);
1249
1250 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1253
1254 let identity_val = config
1256 .identity
1257 .clone()
1258 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1259 let _ = IDENTITY.set(identity_val);
1260
1261 config
1262}
1263
1264fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1267 let mut invalid_context_management = false;
1268 let mut invalid_memory_backend = false;
1269 for line in content.lines() {
1270 let line = line.trim();
1271 if line.is_empty() || line.starts_with('#') {
1272 continue;
1273 }
1274 let Some((key, val)) = line.split_once('=') else {
1275 continue;
1276 };
1277 let key = key.trim();
1278 let val = val.trim();
1279 match key {
1280 "model" => config.model = Some(val.to_string()),
1281 "thinking" => {
1282 use crate::core::reasoning::ThinkingSpec;
1283 match ThinkingSpec::parse(val) {
1284 Some(ThinkingSpec::Named(level)) => {
1285 config.thinking_level = Some(level);
1286 config.thinking_budget = level.to_legacy_budget();
1287 }
1288 Some(ThinkingSpec::Budget(budget)) => {
1289 config.thinking_level = None;
1292 config.thinking_budget = Some(budget);
1293 }
1294 None => {
1295 config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
1296 }
1297 }
1298 }
1299 "compaction_model" => config.compaction_model = Some(val.to_string()),
1300 "compaction_mode" => match val {
1301 "remote" => {
1302 config.compaction_mode = crate::core::compaction::CompactionMode::Remote
1303 }
1304 "local" | "local_only" | "local-only" => {
1305 config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
1306 }
1307 _ => {
1308 config.warnings.push(format!(
1309 "compaction_mode = {val} — expected remote or local; ignored"
1310 ));
1311 }
1312 },
1313 "compaction_exclude" => {
1314 let mut classes = Vec::new();
1315 for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1316 match crate::core::compaction::ContentClass::parse(part) {
1317 Some(class) => {
1318 if !classes.contains(&class) {
1319 classes.push(class);
1320 }
1321 }
1322 None => config.warnings.push(format!(
1323 "compaction_exclude — unknown content class '{part}'; \
1324 expected one of: user_text, assistant_text, thinking, \
1325 tool_calls, tool_results, file_paths, event_data"
1326 )),
1327 }
1328 }
1329 config.compaction_exclude = classes;
1330 }
1331 "context_window" => {
1332 let parsed = match val {
1333 "200k" | "200K" => Some(200_000),
1334 "1m" | "1M" => Some(1_000_000),
1335 _ => val.parse::<u64>().ok(),
1336 };
1337 if parsed.is_none() {
1338 config.warnings.push(format!(
1339 "context_window = {val} — expected 200k, 1m, or a token count; ignored"
1340 ));
1341 }
1342 config.context_window = parsed;
1343 }
1344 "max_tool_output" => match val.parse::<usize>() {
1345 Ok(size) => config.max_tool_output = size,
1346 Err(_) => config.warnings.push(format!(
1347 "max_tool_output = {val} — not a number; using {}",
1348 config.max_tool_output
1349 )),
1350 },
1351 "bash_timeout" => match val.parse::<u64>() {
1352 Ok(t) if t >= 1 => config.bash_timeout = t,
1353 Ok(_) => config.warnings.push(format!(
1354 "bash_timeout = {val} — below minimum (1s); using {}",
1355 config.bash_timeout
1356 )),
1357 Err(_) => config.warnings.push(format!(
1358 "bash_timeout = {val} — not a number; using {}",
1359 config.bash_timeout
1360 )),
1361 },
1362 "bash_max_timeout" => {
1363 if let Ok(timeout) = val.parse::<u64>() {
1364 config.bash_max_timeout = timeout;
1365 }
1366 }
1367 "subagent_timeout" => {
1368 if let Ok(timeout) = val.parse::<u64>() {
1369 config.subagent_timeout = timeout;
1370 }
1371 }
1372 "api_retries" => {
1373 if let Ok(retries) = val.parse::<u32>() {
1374 config.api_retries = retries;
1375 }
1376 }
1377 "refusal_retries" => {
1378 if let Ok(retries) = val.parse::<u32>() {
1379 config.refusal_retries = retries;
1380 }
1381 }
1382 "telemetry" => config.telemetry = val.to_string(),
1383 "cache_diagnostics" => {
1384 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1385 }
1386 "cache_ttl" => match CacheTtl::parse(val) {
1387 Some(ttl) => config.cache_ttl = ttl,
1388 None => config.warnings.push(format!(
1389 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1390 )),
1391 },
1392 "max_fps" => match val.parse::<u32>() {
1393 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1394 Ok(_) => config.warnings.push(format!(
1395 "max_fps = {val} — expected 1–1000; using {}",
1396 config.max_fps
1397 )),
1398 Err(_) => config.warnings.push(format!(
1399 "max_fps = {val} — not a number; using {}",
1400 config.max_fps
1401 )),
1402 },
1403 "scroll_lines" => match val.parse::<u16>() {
1404 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1405 Ok(_) => config
1406 .warnings
1407 .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1408 Err(_) => config
1409 .warnings
1410 .push(format!("scroll_lines = {val} — not a number; ignoring")),
1411 },
1412 "theme" => config.theme = Some(val.to_string()),
1413 "theme_transition" => match ThemeTransitionMode::parse(val) {
1414 Some(mode) => config.theme_transition = mode,
1415 None => config.warnings.push(format!(
1416 "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1417 )),
1418 },
1419 "tui_background_opaque" => match val {
1420 "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1421 "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1422 _ => config.warnings.push(format!(
1423 "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1424 if config.tui_background_opaque {
1425 "opaque"
1426 } else {
1427 "invisible"
1428 }
1429 )),
1430 },
1431 "tui_streaming_glow" => match val {
1432 "true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
1433 "false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
1434 _ => config.warnings.push(format!(
1435 "tui_streaming_glow = {val} — expected on or off; using off"
1436 )),
1437 },
1438 "agent_name" => config.agent_name = Some(val.to_string()),
1439 "identity" => config.identity = Some(val.to_string()),
1440 "disabled_plugins" => {
1441 config.disabled_plugins = parse_comma_list(val);
1442 }
1443 "favorite_models" => {
1444 config.favorite_models = parse_comma_list(val);
1445 }
1446 "disabled_skills" => {
1447 config.disabled_skills = parse_comma_list(val);
1448 }
1449 "progressive_tool_disclosure" => {
1450 config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1451 }
1452 "tools.activation_confirm" => match ActivationConfirm::parse(val) {
1453 Some(mode) => config.tools_activation_confirm = mode,
1454 None => config.warnings.push(format!(
1455 "tools.activation_confirm = {val} — expected auto, prompt or deny; \
1456 keeping the default (auto)"
1457 )),
1458 },
1459 "session_persistence" => {
1460 match crate::core::session_journal::SessionPersistence::parse(val) {
1461 Some(mode) => config.session_persistence = mode,
1462 None => config.warnings.push(format!(
1463 "session_persistence = {val} — expected json or journal; \
1464 keeping the default (json)"
1465 )),
1466 }
1467 }
1468 "disabled_tools" => {
1469 config.disabled_tools = parse_comma_list(val);
1470 }
1471 "memory.backend" => {
1472 config.memory_backend.kind = match val {
1473 "legacy" => MemoryBackendKind::Legacy,
1474 "axel" => MemoryBackendKind::Axel,
1475 _ => {
1476 invalid_memory_backend = true;
1477 config.warnings.push(format!(
1478 "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
1479 ));
1480 MemoryBackendKind::Unavailable
1481 }
1482 };
1483 }
1484 "memory.user_scope" => match val {
1485 "true" => config.memory_backend.user_scope = true,
1486 "false" => config.memory_backend.user_scope = false,
1487 _ => {
1488 invalid_memory_backend = true;
1489 config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
1490 }
1491 },
1492 "memory.axel.executable" | "memory.axel.brain" => {
1493 let path = PathBuf::from(val);
1494 if !path.is_absolute() {
1495 invalid_memory_backend = true;
1496 config.warnings.push(format!(
1497 "{key} — expected an absolute path; memory backend unavailable"
1498 ));
1499 }
1500 if key == "memory.axel.executable" {
1503 config.memory_backend.executable = Some(path);
1504 } else {
1505 config.memory_backend.brain = Some(path);
1506 }
1507 }
1508 _ => {
1509 if key.starts_with("shell.") {
1511 parse_shell_config_key(&mut config.shell, key, val);
1512 } else if key.starts_with("server.") {
1513 parse_server_config_key(&mut config.server, key, val);
1514 } else if key.starts_with("bridge.") {
1515 parse_bridge_config_key(&mut config.bridge, key, val);
1516 } else if key.starts_with("auth.") {
1517 parse_auth_config_key(&mut config.auth, key, val);
1518 } else if key.starts_with("events.") {
1519 parse_events_config_key(&mut config.events, key, val);
1520 } else if key.starts_with("turn_budget.") {
1521 parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1522 } else if key.starts_with("memory.") {
1523 parse_memory_config_key(&mut config.memory, key, val);
1524 } else if key.starts_with("startup.") {
1525 if let Err(reason) =
1526 parse_startup_config_key(&mut config.startup, key, val)
1527 {
1528 config.warnings.push(format!("{key} — {reason}"));
1529 }
1530 } else if key.starts_with("daemon.") {
1531 if let Err(reason) =
1532 parse_daemon_config_key(&mut config.daemon, key, val)
1533 {
1534 config.warnings.push(format!("{key} — {reason}"));
1535 }
1536 } else if key.starts_with("context_management.") {
1537 if let Err(reason) = parse_context_management_config_key(
1538 &mut config.context_management, key, val,
1539 ) {
1540 invalid_context_management = true;
1541 config.warnings.push(format!("{key} — {reason}"));
1542 }
1543 } else if let Some(provider_key) = key.strip_prefix("provider.") {
1544 config
1545 .provider_keys
1546 .insert(provider_key.to_string(), val.to_string());
1547 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1548 config
1549 .keybinds
1550 .insert(keybind_key.to_string(), val.to_string());
1551 } else if key.contains('.') {
1552 } else {
1556 match did_you_mean(key) {
1558 Some(suggestion) => config.warnings.push(format!(
1559 "unknown key '{key}' (did you mean '{suggestion}'?)"
1560 )),
1561 None => config
1562 .warnings
1563 .push(format!("unknown key '{key}' — ignored")),
1564 }
1565 }
1566 }
1567 }
1568 }
1569
1570 let context_validation = match config.context_window {
1573 Some(window) => config.context_management.validate_for_window(window),
1574 None => config.context_management.validate(),
1575 };
1576 if let Err(reason) = context_validation {
1577 invalid_context_management = true;
1578 config
1579 .warnings
1580 .push(format!("context_management — {reason}"));
1581 }
1582 if invalid_context_management {
1583 config.context_management = ContextManagementConfig::default();
1584 config.warnings.push(
1585 "invalid context_management configuration — using defaults (mode off)".to_string(),
1586 );
1587 }
1588
1589 if invalid_memory_backend {
1592 config.memory_backend.kind = MemoryBackendKind::Unavailable;
1593 }
1594
1595 if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1600 config.warnings.push(format!(
1601 "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1602 in the same config; reverting to off",
1603 config.memory.default_mode
1604 ));
1605 config.memory.default_mode = "off".to_string();
1606 }
1607
1608 if config.server.max_message_size.is_none() {
1611 if let Some(ctx_tokens) = config.context_window {
1612 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1613 }
1614 }
1615}
1616
1617pub fn read_config_value(key: &str) -> Option<String> {
1619 let path = resolve_read_path("config");
1620 let content = std::fs::read_to_string(&path).ok()?;
1621 for line in content.lines() {
1622 let line = line.trim();
1623 if line.is_empty() || line.starts_with('#') {
1624 continue;
1625 }
1626 let Some((k, v)) = line.split_once('=') else {
1627 continue;
1628 };
1629 if k.trim() == key.trim() {
1630 return Some(v.trim().to_string());
1631 }
1632 }
1633 None
1634}
1635
1636struct ConfigLock {
1648 _file: Option<std::fs::File>,
1649}
1650
1651impl ConfigLock {
1652 fn acquire() -> std::io::Result<Self> {
1653 if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
1654 return Ok(Self { _file: None });
1655 }
1656 use fs4::fs_std::FileExt;
1657 let lock_path = resolve_write_path("config").with_extension("lock");
1658 if let Some(parent) = lock_path.parent() {
1659 std::fs::create_dir_all(parent)?;
1660 }
1661 let mut open = std::fs::OpenOptions::new();
1662 open.create(true).write(true).truncate(false);
1663 #[cfg(unix)]
1664 {
1665 use std::os::unix::fs::OpenOptionsExt;
1666 open.mode(0o600);
1667 }
1668 let file = open.open(&lock_path)?;
1669 FileExt::lock_exclusive(&file)?;
1670 Ok(Self { _file: Some(file) })
1671 }
1672}
1673
1674pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1678 let _guard = ConfigLock::acquire()?;
1679 write_config_value_locked(key, value)
1680}
1681
1682fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
1684 let path = resolve_write_path("config");
1685 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1686
1687 let key_trimmed = key.trim();
1688 let replacement = format!("{} = {}", key_trimmed, value);
1689
1690 let mut found = false;
1691 let mut new_lines: Vec<String> = existing
1692 .lines()
1693 .map(|line| {
1694 if found {
1695 return line.to_string();
1696 }
1697 let t = line.trim_start();
1698 if t.starts_with('#') || t.is_empty() {
1699 return line.to_string();
1700 }
1701 if let Some((k, _)) = t.split_once('=') {
1702 if k.trim() == key_trimmed {
1703 found = true;
1704 return replacement.clone();
1705 }
1706 }
1707 line.to_string()
1708 })
1709 .collect();
1710
1711 if !found {
1712 new_lines.push(replacement);
1713 }
1714
1715 let mut out = new_lines.join("\n");
1716 if !out.ends_with('\n') {
1717 out.push('\n');
1718 }
1719
1720 let tmp = path.with_extension("tmp");
1721 std::fs::write(&tmp, out)?;
1722 #[cfg(unix)]
1724 {
1725 use std::os::unix::fs::PermissionsExt;
1726 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1727 }
1728 std::fs::rename(&tmp, &path)?;
1729 Ok(())
1730}
1731
1732pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1734 let trimmed = id.trim();
1735 if trimmed.is_empty() {
1736 return Ok(());
1737 }
1738 let _guard = ConfigLock::acquire()?;
1739 let mut values = load_config().favorite_models;
1740 if !values.iter().any(|v| v == trimmed) {
1741 values.push(trimmed.to_string());
1742 values.sort();
1743 }
1744 write_comma_list_locked("favorite_models", &values)
1745}
1746
1747pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1749 let _guard = ConfigLock::acquire()?;
1750 let mut values = load_config().favorite_models;
1751 values.retain(|v| v != id.trim());
1752 write_comma_list_locked("favorite_models", &values)
1753}
1754
1755pub fn is_favorite_model(id: &str) -> bool {
1757 load_config().favorite_models.iter().any(|v| v == id.trim())
1758}
1759
1760pub fn is_secret_key(key: &str) -> bool {
1764 let upper = key.to_ascii_uppercase();
1765 upper.ends_with("_KEY")
1766 || upper.ends_with("_TOKEN")
1767 || upper.ends_with("_CREDENTIALS")
1768 || upper.ends_with("_API_KEY")
1769 || upper.contains("SECRET")
1770 || upper.contains("PASSWORD")
1771 || upper.contains("PASSWD")
1772 || upper.ends_with("_PAT")
1773 || upper.ends_with("_DSN")
1774}
1775
1776pub fn is_secret_env_value(value: &str) -> bool {
1784 let mut rest = value;
1785 while let Some(i) = rest.find("://") {
1786 let scheme_ok = rest[..i]
1787 .chars()
1788 .next_back()
1789 .is_some_and(|c| c.is_ascii_alphanumeric());
1790 let after = &rest[i + 3..];
1791 let end = after
1794 .find(|c: char| {
1795 matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
1796 })
1797 .unwrap_or(after.len());
1798 if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
1799 return true;
1800 }
1801 rest = after;
1802 }
1803 false
1804}
1805
1806pub fn is_secret_env(key: &str, value: &str) -> bool {
1809 is_secret_key(key) || is_secret_env_value(value)
1810}
1811
1812pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1815 #[cfg(windows)]
1816 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
1817 You have access to powershell, bash, read, and write tools. \
1818 Prefer the powershell tool for system commands and filesystem operations — it is native \
1819 to Windows and avoids quoting/path translation issues. \
1820 Use bash only when the task specifically requires WSL or a Unix environment. \
1821 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1822 #[cfg(not(windows))]
1823 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1824 You have access to bash, read, and write tools. \
1825 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1826
1827 if let Some(val) = explicit {
1828 let path = std::path::Path::new(val);
1829 if path.exists() && path.is_file() {
1830 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1831 }
1832 return val.to_string();
1833 }
1834
1835 let system_path = resolve_read_path("system.md");
1836 if system_path.exists() {
1837 return std::fs::read_to_string(&system_path).unwrap_or_default();
1838 }
1839
1840 DEFAULT_PROMPT.to_string()
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845 #[test]
1846 fn secret_env_value_detects_credential_urls() {
1847 use super::is_secret_env_value as v;
1848 assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
1850 assert!(v("redis://:pw@cache:6379/0"));
1851 assert!(v("http://user:pass@proxy.corp:3128"));
1852 assert!(v(
1853 "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
1854 ));
1855 assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
1857 assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
1859 assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
1860 assert!(!v("https://api.example.com/v1/items?q=a@b"));
1862 assert!(!v("http://host/path/with@sign"));
1863 assert!(!v("https://@host/empty-userinfo"));
1864 assert!(!v("git@github.com:org/repo.git"));
1865 assert!(!v("mailto:someone@example.com"));
1866 assert!(!v("someone@example.com"));
1867 assert!(!v("/usr/local/bin:/usr/bin"));
1868 assert!(!v(""));
1869 }
1870
1871 #[test]
1872 fn secret_key_covers_pat_and_dsn() {
1873 assert!(super::is_secret_key("GITHUB_PAT"));
1874 assert!(super::is_secret_key("gh_pat"));
1875 assert!(super::is_secret_key("SENTRY_DSN"));
1876 assert!(!super::is_secret_key("PATH"));
1877 assert!(!super::is_secret_key("PATTERN_DIR"));
1878 }
1879
1880 #[test]
1881 fn secret_env_is_key_or_value() {
1882 assert!(super::is_secret_env(
1883 "DATABASE_URL",
1884 "postgres://app:hunter2@db/prod"
1885 ));
1886 assert!(super::is_secret_env("GH_TOKEN", "anything"));
1887 assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
1888 assert!(!super::is_secret_env("HOME", "/home/u"));
1889 }
1890 #[test]
1891 fn startup_and_daemon_defaults() {
1892 let c = super::load_config_from_str("");
1893 assert!(c.startup.quick_start, "quick_start defaults ON");
1894 assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
1895 assert_eq!(c.daemon.idle_exit_secs, 10);
1896 assert_eq!(c.daemon.prompt_abandon_secs, 3600);
1897 assert_eq!(c.daemon.parked_evict_secs, 3600);
1898 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
1900 }
1901
1902 #[test]
1903 fn startup_and_daemon_parse_all_keys() {
1904 let c = super::load_config_from_str(concat!(
1905 "startup.quick_start = off\n",
1906 "startup.extensions_ready_timeout_secs = 45\n",
1907 "daemon.idle_exit_secs = 20\n",
1908 "daemon.prompt_abandon_secs = 0\n",
1909 "daemon.parked_evict_secs = 120\n",
1910 ));
1911 assert!(!c.startup.quick_start);
1912 assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
1913 assert_eq!(c.daemon.idle_exit_secs, 20);
1914 assert_eq!(c.daemon.prompt_abandon_secs, 0);
1915 assert_eq!(c.daemon.parked_evict_secs, 120);
1916 assert!(
1917 !c.warnings.iter().any(|w| w.contains("unknown")),
1918 "no unknown-key warning: {:?}",
1919 c.warnings
1920 );
1921 }
1922
1923 #[test]
1924 fn startup_quick_start_accepts_on() {
1925 let c = super::load_config_from_str("startup.quick_start = on\n");
1926 assert!(c.startup.quick_start);
1927 }
1928
1929 #[test]
1930 fn startup_and_daemon_keys_are_known() {
1931 for k in [
1932 "startup.quick_start",
1933 "startup.extensions_ready_timeout_secs",
1934 "daemon.idle_exit_secs",
1935 "daemon.prompt_abandon_secs",
1936 "daemon.parked_evict_secs",
1937 ] {
1938 assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
1939 }
1940 }
1941
1942 #[test]
1943 fn startup_daemon_bad_values_warn_and_keep_defaults() {
1944 let c = super::load_config_from_str(concat!(
1945 "startup.quick_start = maybe\n",
1946 "daemon.idle_exit_secs = soon\n",
1947 ));
1948 assert!(c.startup.quick_start, "invalid bool keeps default");
1949 assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
1950 assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
1951 assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
1952 }
1953
1954 #[test]
1955 fn context_management_defaults_off_and_parses_all_keys() {
1956 use super::{ContextManagementConfig, ContextManagementMode};
1957 assert_eq!(
1958 super::load_config_from_str("").context_management,
1959 ContextManagementConfig::default()
1960 );
1961 let parsed = super::load_config_from_str(
1962 "context_management.mode = AUTO\n\
1963 context_management.pressure_tokens = 250000\n\
1964 context_management.rollover_tokens = 400000\n\
1965 context_management.reserve_tokens = 24000\n\
1966 context_management.finish_rounds = 3\n\
1967 context_window = 1m\n",
1968 );
1969 assert_eq!(
1970 parsed.context_management,
1971 ContextManagementConfig {
1972 mode: ContextManagementMode::Auto,
1973 pressure_tokens: Some(250_000),
1974 rollover_tokens: Some(400_000),
1975 reserve_tokens: 24_000,
1976 finish_rounds: 3,
1977 }
1978 );
1979 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
1980 assert!(parsed.provider_keys.is_empty());
1981 assert!(super::load_config_from_str("context_management.mode = off")
1982 .warnings
1983 .is_empty());
1984 }
1985
1986 #[test]
1987 fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
1988 for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
1989 let parsed = super::load_config_from_str(&format!(
1990 "context_management.mode = auto\n\
1991 context_management.pressure_tokens = 300000\n\
1992 context_management.rollover_tokens = 450000\n\
1993 context_management.pressure_tokens = auto\n\
1994 context_management.rollover_tokens = AUTO\n\
1995 context_management.finish_rounds = {rounds}\n"
1996 ));
1997 assert_eq!(parsed.context_management.pressure_tokens, None);
1998 assert_eq!(parsed.context_management.rollover_tokens, None);
1999 assert_eq!(parsed.context_management.finish_rounds, rounds);
2000 assert!(parsed.warnings.is_empty());
2001 }
2002 }
2003
2004 #[test]
2005 fn context_management_invalid_values_reset_entire_policy() {
2006 for (key, value) in [
2007 ("mode", "on"),
2008 ("pressure_tokens", "0"),
2009 ("pressure_tokens", "-1"),
2010 ("pressure_tokens", "250k"),
2011 ("pressure_tokens", "18446744073709551616"),
2012 ("rollover_tokens", "1"),
2013 ("rollover_tokens", "nope"),
2014 ("reserve_tokens", "0"),
2015 ("reserve_tokens", "1000001"),
2016 ("reserve_tokens", "auto"),
2017 ("finish_rounds", "9"),
2018 ("finish_rounds", "-1"),
2019 ("finish_rounds", "4294967296"),
2020 ("finish_rounds", ""),
2021 ("mod", "auto"),
2022 ] {
2023 let parsed = super::load_config_from_str(&format!(
2024 "context_management.mode = auto\ncontext_management.{key} = {value}\n"
2025 ));
2026 assert_eq!(
2027 parsed.context_management,
2028 super::ContextManagementConfig::default(),
2029 "{key}={value}"
2030 );
2031 assert!(!parsed.warnings.is_empty(), "{key}={value}");
2032 assert!(parsed.provider_keys.is_empty());
2033 }
2034 let duplicate = super::load_config_from_str(
2035 "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
2036 );
2037 assert_eq!(
2038 duplicate.context_management,
2039 super::ContextManagementConfig::default()
2040 );
2041 }
2042
2043 #[test]
2044 fn context_management_validates_final_threshold_order_and_window() {
2045 for fields in [
2046 "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
2047 "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
2048 "pressure_tokens = 1000000",
2049 "rollover_tokens = 1000001",
2050 ] {
2051 for window_first in [false, true] {
2052 let policy =
2053 format!("context_management.mode = auto\ncontext_management.{fields}\n");
2054 let content = if window_first {
2055 format!("context_window = 1m\n{policy}")
2056 } else {
2057 format!("{policy}context_window = 1m\n")
2058 };
2059 let parsed = super::load_config_from_str(&content);
2060 assert_eq!(
2061 parsed.context_management,
2062 super::ContextManagementConfig::default()
2063 );
2064 assert!(!parsed.warnings.is_empty());
2065 }
2066 }
2067 let parsed = super::load_config_from_str(
2069 "context_management.mode = auto\n\
2070 context_management.rollover_tokens = 100000\n\
2071 context_management.pressure_tokens = 300000\n\
2072 context_management.rollover_tokens = 450000\ncontext_window = 1m",
2073 );
2074 assert!(parsed.warnings.is_empty());
2075 assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
2076 }
2077
2078 #[test]
2079 fn context_management_parsed_finish_override_bounds_rollover_extension() {
2080 use crate::core::context_policy::{
2081 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2082 };
2083 let config = super::load_config_from_str(
2084 "context_management.mode = auto\n\
2085 context_management.finish_rounds = 1\ncontext_window = 1m",
2086 );
2087 let budget = ContextBudget {
2088 context_window_tokens: 1_000_000,
2089 used_tokens: 400_000,
2090 hard_remaining_tokens: 600_000,
2091 required_next_round_tokens: 8_000,
2092 };
2093 let mut state = ContextState::default();
2094 state.report_phase(WorkPhase::Plan);
2095 let first = assess_context(&config.context_management, &state, budget);
2096 assert_eq!(
2097 first.action,
2098 ContextAction::FinishBounded {
2099 rounds_remaining: 0
2100 }
2101 );
2102 state = first.next_state;
2103 state.report_phase(WorkPhase::WrapUp);
2104 assert_eq!(
2105 assess_context(&config.context_management, &state, budget).action,
2106 ContextAction::Rollover
2107 );
2108 }
2109
2110 #[test]
2111 fn context_management_keys_are_known_for_suggestions() {
2112 for key in [
2113 "mode",
2114 "pressure_tokens",
2115 "rollover_tokens",
2116 "reserve_tokens",
2117 "finish_rounds",
2118 ] {
2119 let full = format!("context_management.{key}");
2120 assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
2121 assert_eq!(
2122 super::did_you_mean(&format!("{full}x")),
2123 Some(full.as_str())
2124 );
2125 }
2126 }
2127
2128 #[test]
2129 fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
2130 use crate::core::context_policy::{
2131 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2132 };
2133 let config =
2134 super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
2135 let budget = ContextBudget {
2136 context_window_tokens: config.context_window.unwrap(),
2137 used_tokens: 350_000,
2138 hard_remaining_tokens: 650_000,
2139 required_next_round_tokens: 8_000,
2140 };
2141 let mut state = ContextState::default();
2142 state.report_phase(WorkPhase::Plan);
2143 let plan = assess_context(&config.context_management, &state, budget);
2144 assert_eq!(plan.action, ContextAction::Advisory);
2145 state = plan.next_state;
2146 state.report_phase(WorkPhase::Execute);
2147 assert_eq!(
2148 assess_context(&config.context_management, &state, budget).action,
2149 ContextAction::Rollover
2150 );
2151 }
2152
2153 #[test]
2154 fn tui_streaming_glow_defaults_off_and_parses() {
2155 assert!(!super::load_config_from_str("").tui_streaming_glow);
2156 assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
2157 assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
2158 let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
2159 assert!(
2160 !invalid.tui_streaming_glow,
2161 "invalid keeps the default (off)"
2162 );
2163 assert!(invalid
2164 .warnings
2165 .iter()
2166 .any(|w| w.contains("tui_streaming_glow")));
2167 }
2168
2169 #[test]
2170 fn tui_background_opaque_defaults_and_parses_supported_values() {
2171 assert!(super::load_config_from_str("").tui_background_opaque);
2172 assert!(
2173 super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
2174 );
2175 assert!(
2176 !super::load_config_from_str("tui_background_opaque = invisible\n")
2177 .tui_background_opaque
2178 );
2179 let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
2180 assert!(invalid.tui_background_opaque);
2181 assert!(invalid
2182 .warnings
2183 .iter()
2184 .any(|w| w.contains("tui_background_opaque")));
2185 }
2186
2187 #[test]
2188 fn turn_budget_keys_parse_typed_per_role() {
2189 let config = super::load_config_from_str(
2190 "turn_budget.worker.max_provider_rounds = 3\n\
2191 turn_budget.worker.max_cost_usd = 0.25\n\
2192 turn_budget.autonomous.max_elapsed_secs = 90\n\
2193 turn_budget.foreground.max_tool_calls = 7\n\
2194 turn_budget.foreground.max_round_renewals = 12\n\
2195 turn_budget.worker.max_provider_rounds_bogus = 1\n\
2196 turn_budget.nosuchrole.max_tool_calls = 1\n",
2197 );
2198 assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
2199 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
2200 assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
2201 assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
2202 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
2203 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
2205 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
2207 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
2208 }
2209
2210 #[test]
2211 fn memory_keys_all_parse_with_consent() {
2212 let config = super::load_config_from_str(
2213 "memory.default_mode = capture_and_recall\n\
2214 memory.default_mode_confirmed = true\n\
2215 memory.recall_max_records = 12\n\
2216 memory.recall_max_tokens = 2048\n\
2217 memory.recall_timeout_ms = 250\n\
2218 memory.capture_tools = off\n\
2219 memory.capture_assistant = false\n\
2220 memory.capture_user = false\n\
2221 memory.auto_consolidate = on\n\
2222 memory.local_embeddings = on\n\
2223 memory.gliner = on\n",
2224 );
2225 assert_eq!(config.memory.default_mode, "capture_and_recall");
2226 assert!(config.memory.default_mode_confirmed);
2227 assert_eq!(config.memory.recall_max_records, 12);
2228 assert_eq!(config.memory.recall_max_tokens, 2048);
2229 assert_eq!(config.memory.recall_timeout_ms, 250);
2230 assert_eq!(config.memory.capture_tools, "off");
2231 assert!(!config.memory.capture_assistant);
2232 assert!(!config.memory.capture_user);
2233 assert_eq!(config.memory.auto_consolidate, "on");
2234 assert_eq!(config.memory.local_embeddings, "on");
2235 assert_eq!(config.memory.gliner, "on");
2236 }
2237
2238 #[test]
2239 fn memory_defaults_match_spec_12() {
2240 let config = super::load_config_from_str("");
2241 assert_eq!(config.memory, super::MemoryConfig::default());
2242 assert_eq!(config.memory.default_mode, "off");
2243 assert!(!config.memory.default_mode_confirmed);
2244 assert_eq!(config.memory.recall_max_records, 8);
2245 assert_eq!(config.memory.recall_max_tokens, 4096);
2246 assert_eq!(config.memory.recall_timeout_ms, 150);
2247 assert_eq!(config.memory.capture_tools, "summary_only");
2248 assert!(config.memory.capture_assistant);
2249 assert!(config.memory.capture_user);
2250 assert_eq!(config.memory.auto_consolidate, "off");
2251 assert_eq!(config.memory.local_embeddings, "off");
2252 assert_eq!(config.memory.gliner, "off");
2253 }
2254
2255 #[test]
2256 fn memory_unknown_values_fail_closed_to_defaults() {
2257 let config = super::load_config_from_str(
2258 "memory.default_mode = recall_everything_forever\n\
2259 memory.default_mode_confirmed = true\n\
2260 memory.recall_max_records = lots\n\
2261 memory.recall_max_tokens = -5\n\
2262 memory.recall_timeout_ms = fast\n\
2263 memory.capture_tools = full\n\
2264 memory.capture_assistant = maybe\n\
2265 memory.capture_user = sometimes\n\
2266 memory.auto_consolidate = aggressive\n\
2267 memory.local_embeddings = auto\n\
2268 memory.gliner = download\n\
2269 memory.nosuchfield = 1\n",
2270 );
2271 assert_eq!(config.memory.default_mode, "off");
2273 assert_eq!(config.memory.recall_max_records, 8);
2274 assert_eq!(config.memory.recall_max_tokens, 4096);
2275 assert_eq!(config.memory.recall_timeout_ms, 150);
2276 assert_eq!(config.memory.capture_tools, "summary_only");
2277 assert!(config.memory.capture_assistant);
2278 assert!(config.memory.capture_user);
2279 assert_eq!(config.memory.auto_consolidate, "off");
2280 assert_eq!(config.memory.local_embeddings, "off");
2281 assert_eq!(config.memory.gliner, "off");
2282 }
2283
2284 #[test]
2285 fn memory_default_mode_without_consent_reverts_to_off() {
2286 let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
2287 assert_eq!(config.memory.default_mode, "off");
2288 assert!(
2289 config
2290 .warnings
2291 .iter()
2292 .any(|w| w.contains("memory.default_mode_confirmed")),
2293 "reversion must surface a warning; got: {:?}",
2294 config.warnings
2295 );
2296
2297 let denied = super::load_config_from_str(
2299 "memory.default_mode = capture_only\n\
2300 memory.default_mode_confirmed = false\n",
2301 );
2302 assert_eq!(denied.memory.default_mode, "off");
2303
2304 let garbage = super::load_config_from_str(
2306 "memory.default_mode = capture_only\n\
2307 memory.default_mode_confirmed = definitely\n",
2308 );
2309 assert_eq!(garbage.memory.default_mode, "off");
2310 }
2311
2312 #[test]
2313 fn memory_consent_is_order_independent() {
2314 let before = super::load_config_from_str(
2316 "memory.default_mode_confirmed = true\n\
2317 memory.default_mode = recall_once\n",
2318 );
2319 assert_eq!(before.memory.default_mode, "recall_once");
2320 let after = super::load_config_from_str(
2322 "memory.default_mode = recall_once\n\
2323 memory.default_mode_confirmed = true\n",
2324 );
2325 assert_eq!(after.memory.default_mode, "recall_once");
2326 }
2327
2328 #[test]
2329 fn memory_backend_defaults_are_independent_of_consent() {
2330 let config = super::load_config_from_str("");
2331 assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
2332 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
2333 let axel = super::load_config_from_str("memory.backend = axel\n");
2334 assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
2335 assert_eq!(axel.memory, config.memory);
2336 assert!(axel.warnings.is_empty());
2337 }
2338
2339 #[test]
2340 fn user_wide_notes_require_explicit_valid_opt_in() {
2341 let default = super::load_config_from_str("memory.backend = axel\n");
2342 assert!(!default.memory_backend.user_scope);
2343 let enabled =
2344 super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
2345 assert!(enabled.memory_backend.user_scope);
2346 assert_eq!(enabled.memory, default.memory);
2347 for bad in ["yes", "", "1", "TRUE"] {
2348 let config = super::load_config_from_str(&format!(
2349 "memory.user_scope = {bad}\nmemory.backend = axel\n"
2350 ));
2351 assert_eq!(
2352 config.memory_backend.kind,
2353 super::MemoryBackendKind::Unavailable
2354 );
2355 }
2356 }
2357
2358 #[test]
2359 fn memory_backend_invalid_selector_fails_closed() {
2360 for content in [
2361 "memory.backend = typo\n",
2362 "memory.backend = \n",
2363 "memory.backend = unavailable\n",
2364 "memory.backend = axel\nmemory.backend = typo\n",
2365 "memory.backend = typo\nmemory.backend = legacy\n",
2366 ] {
2367 let config = super::load_config_from_str(content);
2368 assert_eq!(
2369 config.memory_backend.kind,
2370 super::MemoryBackendKind::Unavailable
2371 );
2372 assert!(config
2373 .warnings
2374 .iter()
2375 .any(|w| w.contains("memory backend unavailable")));
2376 }
2377 }
2378
2379 #[test]
2380 fn memory_backend_absolute_paths_are_preserved() {
2381 let root = std::env::current_dir().unwrap();
2382 let executable = root.join("fixture bin/axel");
2383 let brain = root.join("fixture brain");
2384 let config = super::load_config_from_str(&format!(
2385 "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
2386 brain.display(),
2387 executable.display()
2388 ));
2389 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
2390 assert_eq!(config.memory_backend.executable, Some(executable));
2391 assert_eq!(config.memory_backend.brain, Some(brain));
2392 assert!(config.warnings.is_empty());
2393 }
2394
2395 #[test]
2396 fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
2397 for key in ["memory.axel.executable", "memory.axel.brain"] {
2398 for path in ["relative/path", "~/brain", ""] {
2399 for content in [
2400 format!("memory.backend = axel\n{key} = {path}\n"),
2401 format!("{key} = {path}\nmemory.backend = legacy\n"),
2402 ] {
2403 let config = super::load_config_from_str(&content);
2404 assert_eq!(
2405 config.memory_backend.kind,
2406 super::MemoryBackendKind::Unavailable
2407 );
2408 assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
2409 }
2410 }
2411 }
2412 }
2413
2414 #[test]
2415 fn memory_config_surface_is_closed_by_construction() {
2416 let _closed_surface = super::MemoryConfig {
2424 default_mode: "off".to_string(),
2425 default_mode_confirmed: false,
2426 recall_max_records: 8,
2427 recall_max_tokens: 4096,
2428 recall_timeout_ms: 150,
2429 capture_tools: "summary_only".to_string(),
2430 capture_assistant: true,
2431 capture_user: true,
2432 auto_consolidate: "off".to_string(),
2433 local_embeddings: "off".to_string(),
2434 gliner: "off".to_string(),
2435 };
2436 }
2437
2438 use super::*;
2439 use serial_test::serial;
2440
2441 #[test]
2442 fn test_levenshtein_basics() {
2443 assert_eq!(levenshtein("model", "model"), 0);
2444 assert_eq!(levenshtein("modle", "model"), 2);
2445 assert_eq!(levenshtein("them", "theme"), 1);
2446 }
2447
2448 #[test]
2449 fn test_did_you_mean_close_typos() {
2450 assert_eq!(did_you_mean("modle"), Some("model"));
2451 assert_eq!(did_you_mean("them"), Some("theme"));
2452 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
2453 assert_eq!(did_you_mean("completely_unrelated_key"), None);
2454 }
2455
2456 #[test]
2457 #[serial]
2458 fn test_config_warnings_unknown_key_and_bad_values() {
2459 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
2460 let dir = home.join(".synaps-cli");
2461 std::fs::create_dir_all(&dir).unwrap();
2462 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();
2463
2464 with_home(&home, || {
2465 let config = load_config();
2466 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
2468 assert!(
2469 !config.warnings.iter().any(|w| w.contains("knowledge")),
2470 "{:?}",
2471 config.warnings
2472 );
2473 assert!(
2474 config
2475 .warnings
2476 .iter()
2477 .any(|w| w.contains("did you mean 'model'")),
2478 "{:?}",
2479 config.warnings
2480 );
2481 assert!(
2482 config.warnings.iter().any(|w| w.contains("thinking")),
2483 "{:?}",
2484 config.warnings
2485 );
2486 assert!(
2487 config.warnings.iter().any(|w| w.contains("below minimum")),
2488 "{:?}",
2489 config.warnings
2490 );
2491 assert_eq!(config.bash_timeout, 30);
2493 assert_eq!(config.thinking_budget, None);
2494 });
2495 let _ = std::fs::remove_dir_all(&home);
2496 }
2497
2498 #[test]
2501 fn test_cache_ttl_parse_table() {
2502 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
2504 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
2505 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
2506 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
2508 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
2509 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
2510 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
2512 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
2514 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
2515 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
2516 assert_eq!(CacheTtl::parse("2h"), None);
2518 assert_eq!(CacheTtl::parse(""), None);
2519 assert_eq!(CacheTtl::parse("forever"), None);
2520 }
2521
2522 #[test]
2523 fn test_cache_ttl_default_is_five_minutes() {
2524 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
2525 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
2526 }
2527
2528 #[test]
2529 fn test_max_fps_default_is_60() {
2530 assert_eq!(SynapsConfig::default().max_fps, 60);
2531 }
2532
2533 #[test]
2536 fn test_theme_transition_parse_table() {
2537 use ThemeTransitionMode as M;
2538 assert_eq!(M::parse("on"), Some(M::On));
2539 assert_eq!(M::parse("ON"), Some(M::On));
2540 assert_eq!(M::parse("off"), Some(M::Off));
2541 assert_eq!(M::parse("Off"), Some(M::Off));
2542 assert_eq!(M::parse("500"), Some(M::Ms(500)));
2544 assert_eq!(M::parse("0"), Some(M::Ms(0)));
2545 assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
2546 assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
2547 assert_eq!(M::parse("fast"), None);
2549 assert_eq!(M::parse(""), None);
2550 assert_eq!(M::parse("-5"), None);
2551 assert_eq!(M::parse("1.5"), None);
2552 }
2553
2554 #[test]
2555 fn test_theme_transition_duration_ms() {
2556 use ThemeTransitionMode as M;
2557 assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
2558 assert_eq!(M::DEFAULT_MS, 350);
2559 assert_eq!(M::Off.duration_ms(), 0);
2560 assert_eq!(M::Ms(120).duration_ms(), 120);
2561 assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
2562 assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
2563 }
2564
2565 #[test]
2566 fn test_theme_transition_config_key_parses_and_defaults() {
2567 use ThemeTransitionMode as M;
2568 assert_eq!(SynapsConfig::default().theme_transition, M::On);
2569 assert_eq!(
2570 super::load_config_from_str("theme_transition = off\n").theme_transition,
2571 M::Off
2572 );
2573 assert_eq!(
2574 super::load_config_from_str("theme_transition = 750\n").theme_transition,
2575 M::Ms(750)
2576 );
2577 let bad = super::load_config_from_str("theme_transition = warp\n");
2578 assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
2579 assert!(
2580 bad.warnings.iter().any(|w| w.contains("theme_transition")),
2581 "{:?}",
2582 bad.warnings
2583 );
2584 }
2585
2586 #[test]
2587 #[serial]
2588 fn test_max_fps_config_parse_and_validation() {
2589 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
2590 let dir = home.join(".synaps-cli");
2591 std::fs::create_dir_all(&dir).unwrap();
2592
2593 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
2595 with_home(&home, || {
2596 let config = load_config();
2597 assert_eq!(config.max_fps, 144);
2598 assert!(
2599 config.warnings.is_empty(),
2600 "warnings: {:?}",
2601 config.warnings
2602 );
2603 });
2604
2605 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
2607 with_home(&home, || {
2608 let config = load_config();
2609 assert_eq!(config.max_fps, 60);
2610 assert!(
2611 config.warnings.iter().any(|w| w.contains("max_fps")),
2612 "warnings: {:?}",
2613 config.warnings
2614 );
2615 });
2616
2617 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
2619 with_home(&home, || {
2620 let config = load_config();
2621 assert_eq!(config.max_fps, 60);
2622 assert!(
2623 config.warnings.iter().any(|w| w.contains("max_fps")),
2624 "warnings: {:?}",
2625 config.warnings
2626 );
2627 });
2628
2629 let _ = std::fs::remove_dir_all(&home);
2630 }
2631
2632 #[test]
2633 #[serial]
2634 fn test_cache_ttl_config_parse_and_garbage_warning() {
2635 let home =
2636 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
2637 let dir = home.join(".synaps-cli");
2638 std::fs::create_dir_all(&dir).unwrap();
2639
2640 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
2642 with_home(&home, || {
2643 let config = load_config();
2644 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
2645 assert!(
2646 config.warnings.is_empty(),
2647 "warnings: {:?}",
2648 config.warnings
2649 );
2650 });
2651
2652 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
2654 with_home(&home, || {
2655 let config = load_config();
2656 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
2657 assert!(
2658 config.warnings.iter().any(|w| w.contains("cache_ttl")),
2659 "warnings: {:?}",
2660 config.warnings
2661 );
2662 });
2663
2664 let _ = std::fs::remove_dir_all(&home);
2665 }
2666
2667 #[test]
2668 fn test_parse_thinking_budget() {
2669 use crate::core::reasoning::ThinkingSpec;
2670 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
2672 assert_eq!(
2673 ThinkingSpec::parse("medium").unwrap().to_budget(),
2674 Some(4096)
2675 );
2676 assert_eq!(
2677 ThinkingSpec::parse("high").unwrap().to_budget(),
2678 Some(16384)
2679 );
2680 assert_eq!(
2681 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
2682 Some(32768)
2683 );
2684 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
2685 let config = load_config_from_str("thinking = 8192\n");
2686 assert_eq!(config.thinking_level, None);
2687 assert_eq!(config.thinking_budget, Some(8192));
2688 assert_eq!(ThinkingSpec::parse("invalid"), None);
2689 }
2690
2691 #[test]
2692 fn test_base_dir() {
2693 let path = base_dir();
2694 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
2695 }
2696
2697 #[test]
2698 fn test_resolve_system_prompt_explicit() {
2699 let result = resolve_system_prompt(Some("test prompt"));
2700 assert_eq!(result, "test prompt");
2701 }
2702
2703 #[test]
2704 fn test_resolve_system_prompt_none() {
2705 let result = resolve_system_prompt(None);
2706 assert!(result.contains("helpful AI agent"));
2707 }
2708
2709 #[test]
2714 fn test_synaps_config_default() {
2715 let config = SynapsConfig::default();
2716 assert_eq!(config.model, None);
2717 assert_eq!(config.thinking_budget, None);
2718 assert_eq!(config.max_tool_output, 30000);
2719 assert_eq!(config.bash_timeout, 30);
2720 assert_eq!(config.bash_max_timeout, 300);
2721 assert_eq!(config.subagent_timeout, 300);
2722 assert_eq!(config.api_retries, 3);
2723 assert_eq!(config.theme, None);
2724 assert!(config.disabled_plugins.is_empty());
2725 assert!(config.favorite_models.is_empty());
2726 assert!(config.disabled_skills.is_empty());
2727 assert!(!config.progressive_tool_disclosure);
2728 assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
2729 assert_eq!(config.shell.max_sessions, 5);
2730 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
2731 assert!(config.server.allowed_origins.is_empty());
2733 assert_eq!(config.server.token, None);
2734 assert!(!config.server.auto_approve_confirms);
2735 assert_eq!(config.server.max_message_size, None);
2736 assert!(config.bridge.uds_path.is_none());
2738 assert!(!config.bridge.heartbeat_mirror);
2739 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2740 }
2741
2742 #[test]
2743 #[serial]
2744 fn test_load_config_tools_activation_confirm_modes() {
2745 for (raw, expected, warns) in [
2746 ("prompt", ActivationConfirm::Prompt, false),
2747 ("deny", ActivationConfirm::Deny, false),
2748 ("AUTO", ActivationConfirm::Auto, false),
2749 ("garbage", ActivationConfirm::Auto, true),
2750 ] {
2751 let home = make_test_home(&format!("activation-confirm-{raw}"));
2752 let cfg = home.join(".synaps-cli/config");
2753 std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
2754 with_home(&home, || {
2755 let config = load_config();
2756 assert_eq!(config.tools_activation_confirm, expected, "{raw}");
2757 assert_eq!(
2758 config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
2759 warns,
2760 "{raw}: {:?}",
2761 config.warnings
2762 );
2763 });
2764 let _ = std::fs::remove_dir_all(&home);
2765 }
2766 }
2767
2768 #[test]
2769 #[serial]
2770 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
2771 let home = make_test_home("progressive-tool-disclosure");
2772 let cfg = home.join(".synaps-cli/config");
2773 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
2774
2775 with_home(&home, || {
2776 let config = load_config();
2777 assert!(config.progressive_tool_disclosure);
2778 assert!(config.warnings.is_empty());
2779 });
2780
2781 let _ = std::fs::remove_dir_all(&home);
2782 }
2783
2784 #[test]
2785 #[serial]
2786 fn test_load_config_bridge_keys() {
2787 let home = make_test_home("bridge-keys");
2788 let cfg = home.join(".synaps-cli/config");
2789 std::fs::write(
2790 &cfg,
2791 "\
2792bridge.uds_path = /tmp/some/control.sock\n\
2793bridge.heartbeat_mirror = true\n\
2794bridge.heartbeat_timeout_ms = 750\n\
2795",
2796 )
2797 .unwrap();
2798
2799 with_home(&home, || {
2800 let config = load_config();
2801 assert_eq!(
2802 config.bridge.uds_path,
2803 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
2804 );
2805 assert!(config.bridge.heartbeat_mirror);
2806 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
2807 assert_eq!(
2808 config.bridge.resolved_uds_path(),
2809 std::path::PathBuf::from("/tmp/some/control.sock"),
2810 );
2811 });
2812
2813 let _ = std::fs::remove_dir_all(&home);
2814 }
2815
2816 #[test]
2817 fn test_bridge_config_defaults() {
2818 let cfg = BridgeConfig::default();
2819 assert!(cfg.uds_path.is_none());
2820 assert!(!cfg.heartbeat_mirror);
2821 assert_eq!(cfg.heartbeat_timeout_ms, 250);
2822 let resolved = cfg.resolved_uds_path();
2824 assert!(resolved.ends_with("bridge/control.sock"));
2825 }
2826
2827 #[test]
2828 #[serial]
2829 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
2830 let home = make_test_home("bridge-default-off");
2831 let cfg = home.join(".synaps-cli/config");
2832 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
2833
2834 with_home(&home, || {
2835 let config = load_config();
2836 assert!(!config.bridge.heartbeat_mirror);
2837 assert!(config.bridge.uds_path.is_none());
2838 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2839 });
2840
2841 let _ = std::fs::remove_dir_all(&home);
2842 }
2843
2844 #[test]
2845 fn compaction_disclosure_keys_parse_with_typed_warnings() {
2846 let config = load_config_from_str(
2847 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
2848 );
2849 assert_eq!(
2850 config.compaction_mode,
2851 crate::core::compaction::CompactionMode::LocalOnly
2852 );
2853 assert_eq!(
2854 config.compaction_exclude,
2855 vec![
2856 crate::core::compaction::ContentClass::Thinking,
2857 crate::core::compaction::ContentClass::ToolResults,
2858 ]
2859 );
2860 assert!(
2861 config.warnings.iter().any(|w| w.contains("bogus")),
2862 "unknown class must warn: {:?}",
2863 config.warnings
2864 );
2865
2866 let defaults = load_config_from_str("");
2867 assert_eq!(
2868 defaults.compaction_mode,
2869 crate::core::compaction::CompactionMode::Remote
2870 );
2871 assert!(defaults.compaction_exclude.is_empty());
2872
2873 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
2874 assert_eq!(
2875 bad_mode.compaction_mode,
2876 crate::core::compaction::CompactionMode::Remote
2877 );
2878 assert!(bad_mode
2879 .warnings
2880 .iter()
2881 .any(|w| w.contains("compaction_mode")));
2882 }
2883
2884 #[test]
2885 #[serial]
2886 fn test_load_config_server_keys() {
2887 let home = make_test_home("server-keys");
2888 let cfg = home.join(".synaps-cli/config");
2889 std::fs::write(
2890 &cfg,
2891 "\
2892server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
2893server.token = my-secret-token\n\
2894server.auto_approve_confirms = true\n\
2895server.max_message_size = 65536\n\
2896context_window = 200k\n\
2897",
2898 )
2899 .unwrap();
2900
2901 with_home(&home, || {
2902 let config = load_config();
2903 assert_eq!(
2904 config.server.allowed_origins,
2905 vec![
2906 "http://localhost:3000".to_string(),
2907 "http://localhost:5193".to_string(),
2908 ]
2909 );
2910 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
2911 assert!(config.server.auto_approve_confirms);
2912 assert_eq!(config.server.max_message_size, Some(65536));
2914 });
2915
2916 let _ = std::fs::remove_dir_all(&home);
2917 }
2918
2919 #[test]
2920 #[serial]
2921 fn test_server_max_message_size_derived_from_context_window() {
2922 let home = make_test_home("server-derive");
2923 let cfg = home.join(".synaps-cli/config");
2924 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
2925
2926 with_home(&home, || {
2927 let config = load_config();
2928 assert_eq!(config.server.max_message_size, Some(800_000));
2930 });
2931
2932 let _ = std::fs::remove_dir_all(&home);
2933 }
2934
2935 fn make_test_home(subdir: &str) -> std::path::PathBuf {
2936 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
2937 let _ = std::fs::remove_dir_all(&dir);
2938 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
2939 dir
2940 }
2941
2942 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
2943 let original = std::env::var("HOME").ok();
2946 let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
2947 std::env::set_var("HOME", home);
2948 std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
2949 f();
2950 if let Some(h) = original {
2951 std::env::set_var("HOME", h);
2952 } else {
2953 std::env::remove_var("HOME");
2954 }
2955 if let Some(b) = original_base {
2956 std::env::set_var("SYNAPS_BASE_DIR", b);
2957 } else {
2958 std::env::remove_var("SYNAPS_BASE_DIR");
2959 }
2960 }
2961
2962 #[test]
2963 #[serial]
2964 fn write_config_value_replaces_existing_key() {
2965 let home = make_test_home("replace");
2966 let cfg = home.join(".synaps-cli/config");
2967 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
2968
2969 with_home(&home, || {
2970 write_config_value("model", "claude-sonnet-4-6").unwrap();
2971 });
2972
2973 let contents = std::fs::read_to_string(&cfg).unwrap();
2974 assert!(contents.contains("model = claude-sonnet-4-6"));
2975 assert!(contents.contains("thinking = low"));
2976 let _ = std::fs::remove_dir_all(&home);
2977 }
2978
2979 #[test]
2980 #[serial]
2981 fn write_config_value_appends_when_missing() {
2982 let home = make_test_home("append");
2983 let cfg = home.join(".synaps-cli/config");
2984 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
2985
2986 with_home(&home, || {
2987 write_config_value("theme", "dracula").unwrap();
2988 });
2989
2990 let contents = std::fs::read_to_string(&cfg).unwrap();
2991 assert!(contents.contains("model = claude-opus-4-6"));
2992 assert!(contents.contains("theme = dracula"));
2993 let _ = std::fs::remove_dir_all(&home);
2994 }
2995
2996 #[test]
2997 #[serial]
2998 fn write_config_value_concurrent_writers_lose_nothing() {
2999 let home = make_test_home("concurrent");
3000 let cfg = home.join(".synaps-cli/config");
3001 std::fs::write(&cfg, "# keep\n").unwrap();
3002
3003 with_home(&home, || {
3005 let a = std::thread::spawn(|| {
3006 for i in 0..200 {
3007 write_config_value("a_key", &i.to_string()).unwrap();
3008 }
3009 });
3010 let b = std::thread::spawn(|| {
3011 for i in 0..200 {
3012 write_config_value("b_key", &i.to_string()).unwrap();
3013 }
3014 });
3015 a.join().unwrap();
3016 b.join().unwrap();
3017 });
3018
3019 let contents = std::fs::read_to_string(&cfg).unwrap();
3020 assert!(contents.contains("# keep"), "{contents}");
3021 assert!(contents.contains("a_key = 199"), "{contents}");
3022 assert!(contents.contains("b_key = 199"), "{contents}");
3023 assert_eq!(contents.matches("a_key").count(), 1);
3024 assert_eq!(contents.matches("b_key").count(), 1);
3025
3026 #[cfg(unix)]
3027 {
3028 use std::os::unix::fs::PermissionsExt;
3029 let lock = home.join(".synaps-cli/config.lock");
3030 let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
3031 assert_eq!(mode, 0o600);
3032 }
3033 let _ = std::fs::remove_dir_all(&home);
3034 }
3035
3036 #[test]
3037 #[serial]
3038 fn config_lock_kill_switch_still_writes() {
3039 let home = make_test_home("lock-off");
3040 let cfg = home.join(".synaps-cli/config");
3041 std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
3042 with_home(&home, || {
3043 write_config_value("model", "claude-sonnet-4-6").unwrap();
3044 });
3045 std::env::remove_var("SYNAPS_CONFIG_LOCK");
3046 let contents = std::fs::read_to_string(&cfg).unwrap();
3047 assert!(contents.contains("model = claude-sonnet-4-6"));
3048 assert!(!home.join(".synaps-cli/config.lock").exists());
3049 let _ = std::fs::remove_dir_all(&home);
3050 }
3051
3052 #[test]
3053 #[serial]
3054 fn add_favorite_model_end_to_end_does_not_deadlock() {
3055 let home = make_test_home("favorite-lock");
3056 let cfg = home.join(".synaps-cli/config");
3057 with_home(&home, || {
3058 add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3059 add_favorite_model("openai/gpt-5").unwrap();
3060 remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3061 });
3062 let contents = std::fs::read_to_string(&cfg).unwrap();
3063 assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
3064 let _ = std::fs::remove_dir_all(&home);
3065 }
3066
3067 #[test]
3068 #[serial]
3069 fn write_config_value_preserves_comments() {
3070 let home = make_test_home("comments");
3071 let cfg = home.join(".synaps-cli/config");
3072 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
3073
3074 with_home(&home, || {
3075 write_config_value("model", "claude-sonnet-4-6").unwrap();
3076 });
3077
3078 let contents = std::fs::read_to_string(&cfg).unwrap();
3079 assert!(contents.contains("# user comment"));
3080 assert!(contents.contains("# another"));
3081 assert!(contents.contains("model = claude-sonnet-4-6"));
3082 let _ = std::fs::remove_dir_all(&home);
3083 }
3084
3085 #[test]
3086 #[serial]
3087 fn write_config_value_preserves_unknown_keys() {
3088 let home = make_test_home("unknown");
3089 let cfg = home.join(".synaps-cli/config");
3090 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
3091
3092 with_home(&home, || {
3093 write_config_value("model", "claude-sonnet-4-6").unwrap();
3094 });
3095
3096 let contents = std::fs::read_to_string(&cfg).unwrap();
3097 assert!(contents.contains("custom_thing = 42"));
3098 let _ = std::fs::remove_dir_all(&home);
3099 }
3100
3101 #[test]
3102 #[serial]
3103 fn write_config_value_creates_file_if_absent() {
3104 let home = make_test_home("create");
3105 let cfg = home.join(".synaps-cli/config");
3106 assert!(!cfg.exists());
3107
3108 with_home(&home, || {
3109 write_config_value("model", "claude-sonnet-4-6").unwrap();
3110 });
3111
3112 let contents = std::fs::read_to_string(&cfg).unwrap();
3113 assert!(contents.contains("model = claude-sonnet-4-6"));
3114 let _ = std::fs::remove_dir_all(&home);
3115 }
3116
3117 #[test]
3118 #[serial]
3119 fn load_config_parses_theme_key() {
3120 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
3121 let _ = std::fs::create_dir_all(&dir);
3122 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
3123
3124 let original_home = std::env::var("HOME").ok();
3125 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
3126
3127 let config = load_config();
3128
3129 if let Some(home) = original_home {
3130 std::env::set_var("HOME", home);
3131 } else {
3132 std::env::remove_var("HOME");
3133 }
3134 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
3135
3136 assert_eq!(config.theme.as_deref(), Some("dracula"));
3137 }
3138
3139 #[test]
3140 #[serial]
3141 fn test_load_config_disable_lists() {
3142 let test_dir =
3143 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
3144 let _ = std::fs::create_dir_all(&test_dir);
3145 let config_path = test_dir.join("config");
3146
3147 let config_content = r#"
3148# Test config with disable lists
3149favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
3150
3151disabled_plugins = foo, bar
3152disabled_skills = baz, plug:qual
3153"#;
3154 std::fs::write(&config_path, config_content).unwrap();
3155
3156 let original_home = std::env::var("HOME").ok();
3157 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
3158
3159 let config = load_config();
3160
3161 if let Some(home) = original_home {
3162 std::env::set_var("HOME", home);
3163 } else {
3164 std::env::remove_var("HOME");
3165 }
3166
3167 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
3168
3169 assert_eq!(
3170 config.disabled_plugins,
3171 vec!["foo".to_string(), "bar".to_string()]
3172 );
3173 assert_eq!(
3174 config.favorite_models,
3175 vec![
3176 "claude/claude-opus-4-7".to_string(),
3177 "groq/llama-3.3-70b-versatile".to_string(),
3178 ]
3179 );
3180 assert_eq!(
3181 config.disabled_skills,
3182 vec!["baz".to_string(), "plug:qual".to_string()]
3183 );
3184 }
3185
3186 #[test]
3187 #[serial]
3188 fn favorite_model_helpers_round_trip_through_config_file() {
3189 let home = make_test_home("favorite-models");
3190 let cfg = home.join(".synaps-cli/config");
3191 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
3192
3193 with_home(&home, || {
3194 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3195 add_favorite_model("claude/claude-opus-4-7").unwrap();
3196 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3197 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
3198 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3199 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
3200 assert!(is_favorite_model("claude/claude-opus-4-7"));
3201 });
3202
3203 let contents = std::fs::read_to_string(&cfg).unwrap();
3204 assert!(contents.contains("model = claude-opus-4-7"));
3205 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
3206 let _ = std::fs::remove_dir_all(&home);
3207 }
3208
3209 #[test]
3210 #[serial]
3211 fn test_load_config_new_keys() {
3212 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
3214 let _ = std::fs::create_dir_all(&test_dir);
3215 let config_path = test_dir.join("config");
3216
3217 let config_content = r#"
3218# Test config with new keys
3219model = claude-haiku
3220thinking = medium
3221max_tool_output = 50000
3222bash_timeout = 45
3223bash_max_timeout = 600
3224subagent_timeout = 120
3225api_retries = 5
3226"#;
3227 std::fs::write(&config_path, config_content).unwrap();
3228
3229 let original_home = std::env::var("HOME").ok();
3231 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
3232
3233 let config = load_config();
3234
3235 if let Some(home) = original_home {
3237 std::env::set_var("HOME", home);
3238 } else {
3239 std::env::remove_var("HOME");
3240 }
3241
3242 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
3244
3245 assert_eq!(config.model, Some("claude-haiku".to_string()));
3246 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
3248 assert_eq!(config.bash_timeout, 45);
3249 assert_eq!(config.bash_max_timeout, 600);
3250 assert_eq!(config.subagent_timeout, 120);
3251 assert_eq!(config.api_retries, 5);
3252 }
3253
3254 #[test]
3259 fn events_auto_turn_explicit_true_values() {
3260 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
3261 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3262 assert!(
3263 cfg.events.auto_turn,
3264 "expected true for events.auto_turn = {val}"
3265 );
3266 }
3267 }
3268
3269 #[test]
3270 fn events_auto_turn_explicit_false_values() {
3271 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
3272 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3273 assert!(
3274 !cfg.events.auto_turn,
3275 "expected false for events.auto_turn = {val}"
3276 );
3277 }
3278 }
3279
3280 #[test]
3281 fn events_auto_turn_typo_fails_safe_false() {
3282 let cfg = load_config_from_str("events.auto_turn = fales");
3284 assert!(
3285 !cfg.events.auto_turn,
3286 "typo 'fales' must fail safe to false"
3287 );
3288 }
3289
3290 #[test]
3293 fn events_auto_turn_cap_default_is_five() {
3294 let cfg = load_config_from_str("");
3295 assert_eq!(cfg.events.auto_turn_cap, 5);
3296 assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
3297 }
3298
3299 #[test]
3300 fn events_auto_turn_cap_zero_is_unlimited() {
3301 let cfg = load_config_from_str("events.auto_turn_cap = 0");
3302 assert_eq!(cfg.events.auto_turn_cap, 0);
3303 }
3304
3305 #[test]
3306 fn events_auto_turn_cap_unlimited_aliases() {
3307 for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
3308 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3309 assert_eq!(
3310 cfg.events.auto_turn_cap, 0,
3311 "expected 0 for events.auto_turn_cap = {val:?}"
3312 );
3313 }
3314 }
3315
3316 #[test]
3317 fn events_auto_turn_cap_explicit_number() {
3318 let cfg = load_config_from_str("events.auto_turn_cap = 12");
3319 assert_eq!(cfg.events.auto_turn_cap, 12);
3320 }
3321
3322 #[test]
3323 fn events_auto_turn_cap_garbage_keeps_default() {
3324 for val in ["banana", "-3", "5.5", "", "true"] {
3325 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3326 assert_eq!(
3327 cfg.events.auto_turn_cap, 5,
3328 "garbage {val:?} must keep default 5"
3329 );
3330 }
3331 }
3332
3333 #[test]
3334 fn events_auto_turn_cap_independent_of_auto_turn() {
3335 let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
3336 assert!(!cfg.events.auto_turn);
3337 assert_eq!(cfg.events.auto_turn_cap, 0);
3338 }
3339
3340 #[test]
3341 fn events_auto_turn_default_is_true() {
3342 let cfg = load_config_from_str("model = claude-haiku");
3343 assert!(cfg.events.auto_turn, "default must be true when key absent");
3344 }
3345
3346 #[test]
3347 #[serial]
3348 fn test_auth_config_parses_endpoint_and_token() {
3349 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
3350 let dir = home.join(".synaps-cli");
3351 std::fs::create_dir_all(&dir).unwrap();
3352 std::fs::write(
3353 dir.join("config"),
3354 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
3355 )
3356 .unwrap();
3357 with_home(&home, || {
3358 let config = load_config();
3359 assert_eq!(
3360 config.auth.remote_endpoint.as_deref(),
3361 Some("https://jade.jade:8181")
3362 );
3363 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
3364 assert!(
3366 !config.warnings.iter().any(|w| w.contains("auth.")),
3367 "{:?}",
3368 config.warnings
3369 );
3370 });
3371 let _ = std::fs::remove_dir_all(&home);
3372 }
3373
3374 #[test]
3375 #[serial]
3376 fn test_credential_source_local_by_default() {
3377 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3378 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3379 assert!(!AuthConfig::default().credential_source().is_remote());
3380 }
3381
3382 #[test]
3383 #[serial]
3384 fn test_credential_source_remote_from_config() {
3385 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3386 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3387 let auth = AuthConfig {
3388 remote_endpoint: Some("https://b".into()),
3389 machine_token: Some("m".into()),
3390 };
3391 assert_eq!(
3392 auth.credential_source(),
3393 crate::core::auth::CredentialSource::Remote {
3394 endpoint: "https://b".into(),
3395 machine_token: "m".into()
3396 }
3397 );
3398 }
3399
3400 #[test]
3401 #[serial]
3402 fn test_credential_source_env_overrides_config() {
3403 let auth = AuthConfig {
3404 remote_endpoint: Some("https://config-host".into()),
3405 machine_token: Some("config-tok".into()),
3406 };
3407 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
3408 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
3409 let src = auth.credential_source();
3410 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3411 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3412 assert_eq!(
3413 src,
3414 crate::core::auth::CredentialSource::Remote {
3415 endpoint: "https://env-host".into(),
3416 machine_token: "env-tok".into()
3417 }
3418 );
3419 }
3420 #[test]
3421 fn config_parses_ultracode_as_distinct_canonical_level() {
3422 let cfg = load_config_from_str("thinking = ultracode\n");
3423 assert_eq!(
3424 cfg.thinking_level,
3425 Some(crate::reasoning::ReasoningLevel::UltraCode)
3426 );
3427 assert_eq!(cfg.thinking_budget, None);
3428 for other in [
3429 crate::reasoning::ReasoningLevel::Ultra,
3430 crate::reasoning::ReasoningLevel::Max,
3431 crate::reasoning::ReasoningLevel::XHigh,
3432 ] {
3433 assert_ne!(cfg.thinking_level, Some(other));
3434 }
3435 }
3436}