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 agent_name: Option<String>,
879 pub identity: Option<String>,
880 pub disabled_plugins: Vec<String>,
881 pub favorite_models: Vec<String>,
882 pub disabled_skills: Vec<String>,
883 pub progressive_tool_disclosure: bool,
888 pub tools_activation_confirm: ActivationConfirm,
894 pub session_persistence: crate::core::session_journal::SessionPersistence,
899 pub disabled_tools: Vec<String>,
902 pub shell: ShellConfig,
903 pub server: ServerConfig,
904 pub bridge: BridgeConfig,
905 pub auth: AuthConfig,
906 pub events: EventsConfig,
907 pub provider_keys: BTreeMap<String, String>,
908 pub keybinds: std::collections::HashMap<String, String>,
909 pub turn_budgets: TurnBudgetsConfig,
911 pub memory: MemoryConfig,
913 pub memory_backend: MemoryBackendConfig,
915 pub warnings: Vec<String>,
918}
919
920impl Default for SynapsConfig {
921 fn default() -> Self {
922 Self {
923 model: None,
924 thinking_budget: None,
925 thinking_level: None,
926 context_window: None,
927 context_management: ContextManagementConfig::default(),
928 startup: StartupConfig::default(),
929 daemon: DaemonConfig::default(),
930 compaction_model: None,
931 compaction_mode: crate::core::compaction::CompactionMode::default(),
932 compaction_exclude: Vec::new(),
933 max_tool_output: 30000,
934 bash_timeout: 30,
935 bash_max_timeout: 300,
936 subagent_timeout: 300,
937 api_retries: 3,
938 refusal_retries: 2,
939 telemetry: "off".to_string(),
940 cache_diagnostics: false,
941 cache_ttl: CacheTtl::default(),
942 max_fps: 60,
943 scroll_lines: None,
944 theme: None,
945 theme_transition: ThemeTransitionMode::default(),
946 tui_background_opaque: true,
947 agent_name: None,
948 identity: None,
949 disabled_plugins: Vec::new(),
950 favorite_models: Vec::new(),
951 disabled_skills: Vec::new(),
952 progressive_tool_disclosure: false,
953 tools_activation_confirm: ActivationConfirm::default(),
954 session_persistence: crate::core::session_journal::SessionPersistence::default(),
955 disabled_tools: Vec::new(),
956 shell: ShellConfig::default(),
957 server: ServerConfig::default(),
958 bridge: BridgeConfig::default(),
959 auth: AuthConfig::default(),
960 events: EventsConfig::default(),
961 provider_keys: BTreeMap::new(),
962 keybinds: std::collections::HashMap::new(),
963 turn_budgets: TurnBudgetsConfig::default(),
964 memory: MemoryConfig::default(),
965 memory_backend: MemoryBackendConfig::default(),
966 warnings: Vec::new(),
967 }
968 }
969}
970
971const KNOWN_CONFIG_KEYS: &[&str] = &[
973 "model",
974 "thinking",
975 "compaction_model",
976 "context_window",
977 "context_management.mode",
978 "context_management.pressure_tokens",
979 "context_management.rollover_tokens",
980 "context_management.reserve_tokens",
981 "context_management.finish_rounds",
982 "startup.quick_start",
983 "startup.extensions_ready_timeout_secs",
984 "daemon.idle_exit_secs",
985 "daemon.prompt_abandon_secs",
986 "daemon.parked_evict_secs",
987 "max_tool_output",
988 "bash_timeout",
989 "bash_max_timeout",
990 "subagent_timeout",
991 "api_retries",
992 "refusal_retries",
993 "telemetry",
994 "cache_diagnostics",
995 "cache_ttl",
996 "max_fps",
997 "scroll_lines",
998 "theme",
999 "theme_transition",
1000 "tui_background_opaque",
1001 "agent_name",
1002 "identity",
1003 "disabled_plugins",
1004 "favorite_models",
1005 "disabled_skills",
1006 "disabled_tools",
1007 "progressive_tool_disclosure",
1008 "tools.activation_confirm",
1009 "session_persistence",
1010];
1011
1012fn levenshtein(a: &str, b: &str) -> usize {
1014 let a: Vec<char> = a.chars().collect();
1015 let b: Vec<char> = b.chars().collect();
1016 let mut prev: Vec<usize> = (0..=b.len()).collect();
1017 let mut cur = vec![0; b.len() + 1];
1018 for (i, ca) in a.iter().enumerate() {
1019 cur[0] = i + 1;
1020 for (j, cb) in b.iter().enumerate() {
1021 let cost = if ca == cb { 0 } else { 1 };
1022 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1023 }
1024 std::mem::swap(&mut prev, &mut cur);
1025 }
1026 prev[b.len()]
1027}
1028
1029fn did_you_mean(key: &str) -> Option<&'static str> {
1031 KNOWN_CONFIG_KEYS
1032 .iter()
1033 .map(|k| (*k, levenshtein(key, k)))
1034 .filter(|(_, d)| *d <= 2)
1035 .min_by_key(|(_, d)| *d)
1036 .map(|(k, _)| k)
1037}
1038
1039fn parse_comma_list(val: &str) -> Vec<String> {
1042 val.split(',')
1043 .map(|s| s.trim().to_string())
1044 .filter(|s| !s.is_empty())
1045 .collect()
1046}
1047
1048fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1049 write_config_value_locked(key, &values.join(", "))
1050}
1051
1052fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
1054 match key {
1055 "shell.max_sessions" => {
1056 if let Ok(sessions) = val.parse::<usize>() {
1057 shell_config.max_sessions = sessions;
1058 } else {
1059 eprintln!(
1060 "Warning: invalid value for shell.max_sessions: '{}', using default",
1061 val
1062 );
1063 }
1064 }
1065 "shell.idle_timeout" => {
1066 if let Ok(timeout) = val.parse::<u64>() {
1067 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1068 } else {
1069 eprintln!(
1070 "Warning: invalid value for shell.idle_timeout: '{}', using default",
1071 val
1072 );
1073 }
1074 }
1075 "shell.readiness_timeout_ms" => {
1076 if let Ok(timeout) = val.parse::<u64>() {
1077 shell_config.readiness_timeout_ms = timeout;
1078 } else {
1079 eprintln!(
1080 "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
1081 val
1082 );
1083 }
1084 }
1085 "shell.max_readiness_timeout_ms" => {
1086 if let Ok(timeout) = val.parse::<u64>() {
1087 shell_config.max_readiness_timeout_ms = timeout;
1088 } else {
1089 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
1090 }
1091 }
1092 "shell.default_rows" => {
1093 if let Ok(rows) = val.parse::<u16>() {
1094 shell_config.default_rows = rows;
1095 } else {
1096 eprintln!(
1097 "Warning: invalid value for shell.default_rows: '{}', using default",
1098 val
1099 );
1100 }
1101 }
1102 "shell.default_cols" => {
1103 if let Ok(cols) = val.parse::<u16>() {
1104 shell_config.default_cols = cols;
1105 } else {
1106 eprintln!(
1107 "Warning: invalid value for shell.default_cols: '{}', using default",
1108 val
1109 );
1110 }
1111 }
1112 "shell.readiness_strategy" => {
1113 let val_lower = val.to_lowercase();
1114 match val_lower.as_str() {
1115 "timeout" | "prompt" | "hybrid" => {
1116 shell_config.readiness_strategy = val.to_string();
1117 }
1118 _ => {
1119 eprintln!(
1120 "Warning: invalid value for shell.readiness_strategy: '{}', using default",
1121 val
1122 );
1123 }
1124 }
1125 }
1126 "shell.max_output" => {
1127 if let Ok(max_output) = val.parse::<usize>() {
1128 shell_config.max_output = max_output;
1129 } else {
1130 eprintln!(
1131 "Warning: invalid value for shell.max_output: '{}', using default",
1132 val
1133 );
1134 }
1135 }
1136 _ => {
1137 }
1139 }
1140}
1141
1142#[allow(clippy::collapsible_match)]
1144fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1145 match key {
1146 "server.allowed_origins" => {
1147 server_config.allowed_origins = parse_comma_list(val);
1148 }
1149 "server.token" => {
1150 if !val.is_empty() {
1151 server_config.token = Some(val.to_string());
1152 }
1153 }
1154 "server.auto_approve_confirms" => {
1155 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1156 }
1157 "server.max_message_size" => {
1158 if let Ok(size) = val.parse::<usize>() {
1159 server_config.max_message_size = Some(size);
1160 } else {
1161 eprintln!(
1162 "Warning: invalid value for server.max_message_size: '{}', ignored",
1163 val
1164 );
1165 }
1166 }
1167 _ => {
1168 }
1170 }
1171}
1172
1173fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1175 match key {
1176 "bridge.uds_path" => {
1177 if val.is_empty() {
1178 bridge_config.uds_path = None;
1179 } else {
1180 bridge_config.uds_path = Some(PathBuf::from(val));
1181 }
1182 }
1183 "bridge.heartbeat_mirror" => {
1184 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
1185 }
1186 "bridge.heartbeat_timeout_ms" => {
1187 if let Ok(ms) = val.parse::<u64>() {
1188 bridge_config.heartbeat_timeout_ms = ms;
1189 } else {
1190 eprintln!(
1191 "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
1192 val
1193 );
1194 }
1195 }
1196 _ => {
1197 }
1199 }
1200}
1201
1202fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1204 let v = val.trim();
1205 match key {
1206 "auth.remote_endpoint" => {
1207 auth_config.remote_endpoint = if v.is_empty() {
1208 None
1209 } else {
1210 Some(v.to_string())
1211 };
1212 }
1213 "auth.machine_token" => {
1214 auth_config.machine_token = if v.is_empty() {
1215 None
1216 } else {
1217 Some(v.to_string())
1218 };
1219 }
1220 _ => {
1221 }
1223 }
1224}
1225
1226pub fn load_config_from_str(content: &str) -> SynapsConfig {
1229 let mut config = SynapsConfig::default();
1230 apply_config_content(&mut config, content);
1231 config
1232}
1233
1234pub fn load_config() -> SynapsConfig {
1237 let path = resolve_read_path("config");
1238 let mut config = SynapsConfig::default();
1239
1240 let Ok(content) = std::fs::read_to_string(&path) else {
1241 return config;
1242 };
1243
1244 apply_config_content(&mut config, &content);
1245
1246 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1249
1250 let identity_val = config
1252 .identity
1253 .clone()
1254 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1255 let _ = IDENTITY.set(identity_val);
1256
1257 config
1258}
1259
1260fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1263 let mut invalid_context_management = false;
1264 let mut invalid_memory_backend = false;
1265 for line in content.lines() {
1266 let line = line.trim();
1267 if line.is_empty() || line.starts_with('#') {
1268 continue;
1269 }
1270 let Some((key, val)) = line.split_once('=') else {
1271 continue;
1272 };
1273 let key = key.trim();
1274 let val = val.trim();
1275 match key {
1276 "model" => config.model = Some(val.to_string()),
1277 "thinking" => {
1278 use crate::core::reasoning::ThinkingSpec;
1279 match ThinkingSpec::parse(val) {
1280 Some(ThinkingSpec::Named(level)) => {
1281 config.thinking_level = Some(level);
1282 config.thinking_budget = level.to_legacy_budget();
1283 }
1284 Some(ThinkingSpec::Budget(budget)) => {
1285 config.thinking_level = None;
1288 config.thinking_budget = Some(budget);
1289 }
1290 None => {
1291 config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
1292 }
1293 }
1294 }
1295 "compaction_model" => config.compaction_model = Some(val.to_string()),
1296 "compaction_mode" => match val {
1297 "remote" => {
1298 config.compaction_mode = crate::core::compaction::CompactionMode::Remote
1299 }
1300 "local" | "local_only" | "local-only" => {
1301 config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
1302 }
1303 _ => {
1304 config.warnings.push(format!(
1305 "compaction_mode = {val} — expected remote or local; ignored"
1306 ));
1307 }
1308 },
1309 "compaction_exclude" => {
1310 let mut classes = Vec::new();
1311 for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1312 match crate::core::compaction::ContentClass::parse(part) {
1313 Some(class) => {
1314 if !classes.contains(&class) {
1315 classes.push(class);
1316 }
1317 }
1318 None => config.warnings.push(format!(
1319 "compaction_exclude — unknown content class '{part}'; \
1320 expected one of: user_text, assistant_text, thinking, \
1321 tool_calls, tool_results, file_paths, event_data"
1322 )),
1323 }
1324 }
1325 config.compaction_exclude = classes;
1326 }
1327 "context_window" => {
1328 let parsed = match val {
1329 "200k" | "200K" => Some(200_000),
1330 "1m" | "1M" => Some(1_000_000),
1331 _ => val.parse::<u64>().ok(),
1332 };
1333 if parsed.is_none() {
1334 config.warnings.push(format!(
1335 "context_window = {val} — expected 200k, 1m, or a token count; ignored"
1336 ));
1337 }
1338 config.context_window = parsed;
1339 }
1340 "max_tool_output" => match val.parse::<usize>() {
1341 Ok(size) => config.max_tool_output = size,
1342 Err(_) => config.warnings.push(format!(
1343 "max_tool_output = {val} — not a number; using {}",
1344 config.max_tool_output
1345 )),
1346 },
1347 "bash_timeout" => match val.parse::<u64>() {
1348 Ok(t) if t >= 1 => config.bash_timeout = t,
1349 Ok(_) => config.warnings.push(format!(
1350 "bash_timeout = {val} — below minimum (1s); using {}",
1351 config.bash_timeout
1352 )),
1353 Err(_) => config.warnings.push(format!(
1354 "bash_timeout = {val} — not a number; using {}",
1355 config.bash_timeout
1356 )),
1357 },
1358 "bash_max_timeout" => {
1359 if let Ok(timeout) = val.parse::<u64>() {
1360 config.bash_max_timeout = timeout;
1361 }
1362 }
1363 "subagent_timeout" => {
1364 if let Ok(timeout) = val.parse::<u64>() {
1365 config.subagent_timeout = timeout;
1366 }
1367 }
1368 "api_retries" => {
1369 if let Ok(retries) = val.parse::<u32>() {
1370 config.api_retries = retries;
1371 }
1372 }
1373 "refusal_retries" => {
1374 if let Ok(retries) = val.parse::<u32>() {
1375 config.refusal_retries = retries;
1376 }
1377 }
1378 "telemetry" => config.telemetry = val.to_string(),
1379 "cache_diagnostics" => {
1380 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1381 }
1382 "cache_ttl" => match CacheTtl::parse(val) {
1383 Some(ttl) => config.cache_ttl = ttl,
1384 None => config.warnings.push(format!(
1385 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1386 )),
1387 },
1388 "max_fps" => match val.parse::<u32>() {
1389 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1390 Ok(_) => config.warnings.push(format!(
1391 "max_fps = {val} — expected 1–1000; using {}",
1392 config.max_fps
1393 )),
1394 Err(_) => config.warnings.push(format!(
1395 "max_fps = {val} — not a number; using {}",
1396 config.max_fps
1397 )),
1398 },
1399 "scroll_lines" => match val.parse::<u16>() {
1400 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1401 Ok(_) => config
1402 .warnings
1403 .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1404 Err(_) => config
1405 .warnings
1406 .push(format!("scroll_lines = {val} — not a number; ignoring")),
1407 },
1408 "theme" => config.theme = Some(val.to_string()),
1409 "theme_transition" => match ThemeTransitionMode::parse(val) {
1410 Some(mode) => config.theme_transition = mode,
1411 None => config.warnings.push(format!(
1412 "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1413 )),
1414 },
1415 "tui_background_opaque" => match val {
1416 "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1417 "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1418 _ => config.warnings.push(format!(
1419 "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1420 if config.tui_background_opaque {
1421 "opaque"
1422 } else {
1423 "invisible"
1424 }
1425 )),
1426 },
1427 "agent_name" => config.agent_name = Some(val.to_string()),
1428 "identity" => config.identity = Some(val.to_string()),
1429 "disabled_plugins" => {
1430 config.disabled_plugins = parse_comma_list(val);
1431 }
1432 "favorite_models" => {
1433 config.favorite_models = parse_comma_list(val);
1434 }
1435 "disabled_skills" => {
1436 config.disabled_skills = parse_comma_list(val);
1437 }
1438 "progressive_tool_disclosure" => {
1439 config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1440 }
1441 "tools.activation_confirm" => match ActivationConfirm::parse(val) {
1442 Some(mode) => config.tools_activation_confirm = mode,
1443 None => config.warnings.push(format!(
1444 "tools.activation_confirm = {val} — expected auto, prompt or deny; \
1445 keeping the default (auto)"
1446 )),
1447 },
1448 "session_persistence" => {
1449 match crate::core::session_journal::SessionPersistence::parse(val) {
1450 Some(mode) => config.session_persistence = mode,
1451 None => config.warnings.push(format!(
1452 "session_persistence = {val} — expected json or journal; \
1453 keeping the default (json)"
1454 )),
1455 }
1456 }
1457 "disabled_tools" => {
1458 config.disabled_tools = parse_comma_list(val);
1459 }
1460 "memory.backend" => {
1461 config.memory_backend.kind = match val {
1462 "legacy" => MemoryBackendKind::Legacy,
1463 "axel" => MemoryBackendKind::Axel,
1464 _ => {
1465 invalid_memory_backend = true;
1466 config.warnings.push(format!(
1467 "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
1468 ));
1469 MemoryBackendKind::Unavailable
1470 }
1471 };
1472 }
1473 "memory.user_scope" => match val {
1474 "true" => config.memory_backend.user_scope = true,
1475 "false" => config.memory_backend.user_scope = false,
1476 _ => {
1477 invalid_memory_backend = true;
1478 config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
1479 }
1480 },
1481 "memory.axel.executable" | "memory.axel.brain" => {
1482 let path = PathBuf::from(val);
1483 if !path.is_absolute() {
1484 invalid_memory_backend = true;
1485 config.warnings.push(format!(
1486 "{key} — expected an absolute path; memory backend unavailable"
1487 ));
1488 }
1489 if key == "memory.axel.executable" {
1492 config.memory_backend.executable = Some(path);
1493 } else {
1494 config.memory_backend.brain = Some(path);
1495 }
1496 }
1497 _ => {
1498 if key.starts_with("shell.") {
1500 parse_shell_config_key(&mut config.shell, key, val);
1501 } else if key.starts_with("server.") {
1502 parse_server_config_key(&mut config.server, key, val);
1503 } else if key.starts_with("bridge.") {
1504 parse_bridge_config_key(&mut config.bridge, key, val);
1505 } else if key.starts_with("auth.") {
1506 parse_auth_config_key(&mut config.auth, key, val);
1507 } else if key.starts_with("events.") {
1508 parse_events_config_key(&mut config.events, key, val);
1509 } else if key.starts_with("turn_budget.") {
1510 parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1511 } else if key.starts_with("memory.") {
1512 parse_memory_config_key(&mut config.memory, key, val);
1513 } else if key.starts_with("startup.") {
1514 if let Err(reason) =
1515 parse_startup_config_key(&mut config.startup, key, val)
1516 {
1517 config.warnings.push(format!("{key} — {reason}"));
1518 }
1519 } else if key.starts_with("daemon.") {
1520 if let Err(reason) =
1521 parse_daemon_config_key(&mut config.daemon, key, val)
1522 {
1523 config.warnings.push(format!("{key} — {reason}"));
1524 }
1525 } else if key.starts_with("context_management.") {
1526 if let Err(reason) = parse_context_management_config_key(
1527 &mut config.context_management, key, val,
1528 ) {
1529 invalid_context_management = true;
1530 config.warnings.push(format!("{key} — {reason}"));
1531 }
1532 } else if let Some(provider_key) = key.strip_prefix("provider.") {
1533 config
1534 .provider_keys
1535 .insert(provider_key.to_string(), val.to_string());
1536 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1537 config
1538 .keybinds
1539 .insert(keybind_key.to_string(), val.to_string());
1540 } else if key.contains('.') {
1541 } else {
1545 match did_you_mean(key) {
1547 Some(suggestion) => config.warnings.push(format!(
1548 "unknown key '{key}' (did you mean '{suggestion}'?)"
1549 )),
1550 None => config
1551 .warnings
1552 .push(format!("unknown key '{key}' — ignored")),
1553 }
1554 }
1555 }
1556 }
1557 }
1558
1559 let context_validation = match config.context_window {
1562 Some(window) => config.context_management.validate_for_window(window),
1563 None => config.context_management.validate(),
1564 };
1565 if let Err(reason) = context_validation {
1566 invalid_context_management = true;
1567 config
1568 .warnings
1569 .push(format!("context_management — {reason}"));
1570 }
1571 if invalid_context_management {
1572 config.context_management = ContextManagementConfig::default();
1573 config.warnings.push(
1574 "invalid context_management configuration — using defaults (mode off)".to_string(),
1575 );
1576 }
1577
1578 if invalid_memory_backend {
1581 config.memory_backend.kind = MemoryBackendKind::Unavailable;
1582 }
1583
1584 if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1589 config.warnings.push(format!(
1590 "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1591 in the same config; reverting to off",
1592 config.memory.default_mode
1593 ));
1594 config.memory.default_mode = "off".to_string();
1595 }
1596
1597 if config.server.max_message_size.is_none() {
1600 if let Some(ctx_tokens) = config.context_window {
1601 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1602 }
1603 }
1604}
1605
1606pub fn read_config_value(key: &str) -> Option<String> {
1608 let path = resolve_read_path("config");
1609 let content = std::fs::read_to_string(&path).ok()?;
1610 for line in content.lines() {
1611 let line = line.trim();
1612 if line.is_empty() || line.starts_with('#') {
1613 continue;
1614 }
1615 let Some((k, v)) = line.split_once('=') else {
1616 continue;
1617 };
1618 if k.trim() == key.trim() {
1619 return Some(v.trim().to_string());
1620 }
1621 }
1622 None
1623}
1624
1625struct ConfigLock {
1637 _file: Option<std::fs::File>,
1638}
1639
1640impl ConfigLock {
1641 fn acquire() -> std::io::Result<Self> {
1642 if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
1643 return Ok(Self { _file: None });
1644 }
1645 use fs4::fs_std::FileExt;
1646 let lock_path = resolve_write_path("config").with_extension("lock");
1647 if let Some(parent) = lock_path.parent() {
1648 std::fs::create_dir_all(parent)?;
1649 }
1650 let mut open = std::fs::OpenOptions::new();
1651 open.create(true).write(true).truncate(false);
1652 #[cfg(unix)]
1653 {
1654 use std::os::unix::fs::OpenOptionsExt;
1655 open.mode(0o600);
1656 }
1657 let file = open.open(&lock_path)?;
1658 FileExt::lock_exclusive(&file)?;
1659 Ok(Self { _file: Some(file) })
1660 }
1661}
1662
1663pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1667 let _guard = ConfigLock::acquire()?;
1668 write_config_value_locked(key, value)
1669}
1670
1671fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
1673 let path = resolve_write_path("config");
1674 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1675
1676 let key_trimmed = key.trim();
1677 let replacement = format!("{} = {}", key_trimmed, value);
1678
1679 let mut found = false;
1680 let mut new_lines: Vec<String> = existing
1681 .lines()
1682 .map(|line| {
1683 if found {
1684 return line.to_string();
1685 }
1686 let t = line.trim_start();
1687 if t.starts_with('#') || t.is_empty() {
1688 return line.to_string();
1689 }
1690 if let Some((k, _)) = t.split_once('=') {
1691 if k.trim() == key_trimmed {
1692 found = true;
1693 return replacement.clone();
1694 }
1695 }
1696 line.to_string()
1697 })
1698 .collect();
1699
1700 if !found {
1701 new_lines.push(replacement);
1702 }
1703
1704 let mut out = new_lines.join("\n");
1705 if !out.ends_with('\n') {
1706 out.push('\n');
1707 }
1708
1709 let tmp = path.with_extension("tmp");
1710 std::fs::write(&tmp, out)?;
1711 #[cfg(unix)]
1713 {
1714 use std::os::unix::fs::PermissionsExt;
1715 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1716 }
1717 std::fs::rename(&tmp, &path)?;
1718 Ok(())
1719}
1720
1721pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1723 let trimmed = id.trim();
1724 if trimmed.is_empty() {
1725 return Ok(());
1726 }
1727 let _guard = ConfigLock::acquire()?;
1728 let mut values = load_config().favorite_models;
1729 if !values.iter().any(|v| v == trimmed) {
1730 values.push(trimmed.to_string());
1731 values.sort();
1732 }
1733 write_comma_list_locked("favorite_models", &values)
1734}
1735
1736pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1738 let _guard = ConfigLock::acquire()?;
1739 let mut values = load_config().favorite_models;
1740 values.retain(|v| v != id.trim());
1741 write_comma_list_locked("favorite_models", &values)
1742}
1743
1744pub fn is_favorite_model(id: &str) -> bool {
1746 load_config().favorite_models.iter().any(|v| v == id.trim())
1747}
1748
1749pub fn is_secret_key(key: &str) -> bool {
1753 let upper = key.to_ascii_uppercase();
1754 upper.ends_with("_KEY")
1755 || upper.ends_with("_TOKEN")
1756 || upper.ends_with("_CREDENTIALS")
1757 || upper.ends_with("_API_KEY")
1758 || upper.contains("SECRET")
1759 || upper.contains("PASSWORD")
1760 || upper.contains("PASSWD")
1761 || upper.ends_with("_PAT")
1762 || upper.ends_with("_DSN")
1763}
1764
1765pub fn is_secret_env_value(value: &str) -> bool {
1773 let mut rest = value;
1774 while let Some(i) = rest.find("://") {
1775 let scheme_ok = rest[..i]
1776 .chars()
1777 .next_back()
1778 .is_some_and(|c| c.is_ascii_alphanumeric());
1779 let after = &rest[i + 3..];
1780 let end = after
1783 .find(|c: char| {
1784 matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
1785 })
1786 .unwrap_or(after.len());
1787 if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
1788 return true;
1789 }
1790 rest = after;
1791 }
1792 false
1793}
1794
1795pub fn is_secret_env(key: &str, value: &str) -> bool {
1798 is_secret_key(key) || is_secret_env_value(value)
1799}
1800
1801pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1804 #[cfg(windows)]
1805 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
1806 You have access to powershell, bash, read, and write tools. \
1807 Prefer the powershell tool for system commands and filesystem operations — it is native \
1808 to Windows and avoids quoting/path translation issues. \
1809 Use bash only when the task specifically requires WSL or a Unix environment. \
1810 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1811 #[cfg(not(windows))]
1812 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1813 You have access to bash, read, and write tools. \
1814 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1815
1816 if let Some(val) = explicit {
1817 let path = std::path::Path::new(val);
1818 if path.exists() && path.is_file() {
1819 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1820 }
1821 return val.to_string();
1822 }
1823
1824 let system_path = resolve_read_path("system.md");
1825 if system_path.exists() {
1826 return std::fs::read_to_string(&system_path).unwrap_or_default();
1827 }
1828
1829 DEFAULT_PROMPT.to_string()
1830}
1831
1832#[cfg(test)]
1833mod tests {
1834 #[test]
1835 fn secret_env_value_detects_credential_urls() {
1836 use super::is_secret_env_value as v;
1837 assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
1839 assert!(v("redis://:pw@cache:6379/0"));
1840 assert!(v("http://user:pass@proxy.corp:3128"));
1841 assert!(v(
1842 "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
1843 ));
1844 assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
1846 assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
1848 assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
1849 assert!(!v("https://api.example.com/v1/items?q=a@b"));
1851 assert!(!v("http://host/path/with@sign"));
1852 assert!(!v("https://@host/empty-userinfo"));
1853 assert!(!v("git@github.com:org/repo.git"));
1854 assert!(!v("mailto:someone@example.com"));
1855 assert!(!v("someone@example.com"));
1856 assert!(!v("/usr/local/bin:/usr/bin"));
1857 assert!(!v(""));
1858 }
1859
1860 #[test]
1861 fn secret_key_covers_pat_and_dsn() {
1862 assert!(super::is_secret_key("GITHUB_PAT"));
1863 assert!(super::is_secret_key("gh_pat"));
1864 assert!(super::is_secret_key("SENTRY_DSN"));
1865 assert!(!super::is_secret_key("PATH"));
1866 assert!(!super::is_secret_key("PATTERN_DIR"));
1867 }
1868
1869 #[test]
1870 fn secret_env_is_key_or_value() {
1871 assert!(super::is_secret_env(
1872 "DATABASE_URL",
1873 "postgres://app:hunter2@db/prod"
1874 ));
1875 assert!(super::is_secret_env("GH_TOKEN", "anything"));
1876 assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
1877 assert!(!super::is_secret_env("HOME", "/home/u"));
1878 }
1879 #[test]
1880 fn startup_and_daemon_defaults() {
1881 let c = super::load_config_from_str("");
1882 assert!(c.startup.quick_start, "quick_start defaults ON");
1883 assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
1884 assert_eq!(c.daemon.idle_exit_secs, 10);
1885 assert_eq!(c.daemon.prompt_abandon_secs, 3600);
1886 assert_eq!(c.daemon.parked_evict_secs, 3600);
1887 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
1889 }
1890
1891 #[test]
1892 fn startup_and_daemon_parse_all_keys() {
1893 let c = super::load_config_from_str(concat!(
1894 "startup.quick_start = off\n",
1895 "startup.extensions_ready_timeout_secs = 45\n",
1896 "daemon.idle_exit_secs = 20\n",
1897 "daemon.prompt_abandon_secs = 0\n",
1898 "daemon.parked_evict_secs = 120\n",
1899 ));
1900 assert!(!c.startup.quick_start);
1901 assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
1902 assert_eq!(c.daemon.idle_exit_secs, 20);
1903 assert_eq!(c.daemon.prompt_abandon_secs, 0);
1904 assert_eq!(c.daemon.parked_evict_secs, 120);
1905 assert!(
1906 !c.warnings.iter().any(|w| w.contains("unknown")),
1907 "no unknown-key warning: {:?}",
1908 c.warnings
1909 );
1910 }
1911
1912 #[test]
1913 fn startup_quick_start_accepts_on() {
1914 let c = super::load_config_from_str("startup.quick_start = on\n");
1915 assert!(c.startup.quick_start);
1916 }
1917
1918 #[test]
1919 fn startup_and_daemon_keys_are_known() {
1920 for k in [
1921 "startup.quick_start",
1922 "startup.extensions_ready_timeout_secs",
1923 "daemon.idle_exit_secs",
1924 "daemon.prompt_abandon_secs",
1925 "daemon.parked_evict_secs",
1926 ] {
1927 assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
1928 }
1929 }
1930
1931 #[test]
1932 fn startup_daemon_bad_values_warn_and_keep_defaults() {
1933 let c = super::load_config_from_str(concat!(
1934 "startup.quick_start = maybe\n",
1935 "daemon.idle_exit_secs = soon\n",
1936 ));
1937 assert!(c.startup.quick_start, "invalid bool keeps default");
1938 assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
1939 assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
1940 assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
1941 }
1942
1943 #[test]
1944 fn context_management_defaults_off_and_parses_all_keys() {
1945 use super::{ContextManagementConfig, ContextManagementMode};
1946 assert_eq!(
1947 super::load_config_from_str("").context_management,
1948 ContextManagementConfig::default()
1949 );
1950 let parsed = super::load_config_from_str(
1951 "context_management.mode = AUTO\n\
1952 context_management.pressure_tokens = 250000\n\
1953 context_management.rollover_tokens = 400000\n\
1954 context_management.reserve_tokens = 24000\n\
1955 context_management.finish_rounds = 3\n\
1956 context_window = 1m\n",
1957 );
1958 assert_eq!(
1959 parsed.context_management,
1960 ContextManagementConfig {
1961 mode: ContextManagementMode::Auto,
1962 pressure_tokens: Some(250_000),
1963 rollover_tokens: Some(400_000),
1964 reserve_tokens: 24_000,
1965 finish_rounds: 3,
1966 }
1967 );
1968 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
1969 assert!(parsed.provider_keys.is_empty());
1970 assert!(super::load_config_from_str("context_management.mode = off")
1971 .warnings
1972 .is_empty());
1973 }
1974
1975 #[test]
1976 fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
1977 for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
1978 let parsed = super::load_config_from_str(&format!(
1979 "context_management.mode = auto\n\
1980 context_management.pressure_tokens = 300000\n\
1981 context_management.rollover_tokens = 450000\n\
1982 context_management.pressure_tokens = auto\n\
1983 context_management.rollover_tokens = AUTO\n\
1984 context_management.finish_rounds = {rounds}\n"
1985 ));
1986 assert_eq!(parsed.context_management.pressure_tokens, None);
1987 assert_eq!(parsed.context_management.rollover_tokens, None);
1988 assert_eq!(parsed.context_management.finish_rounds, rounds);
1989 assert!(parsed.warnings.is_empty());
1990 }
1991 }
1992
1993 #[test]
1994 fn context_management_invalid_values_reset_entire_policy() {
1995 for (key, value) in [
1996 ("mode", "on"),
1997 ("pressure_tokens", "0"),
1998 ("pressure_tokens", "-1"),
1999 ("pressure_tokens", "250k"),
2000 ("pressure_tokens", "18446744073709551616"),
2001 ("rollover_tokens", "1"),
2002 ("rollover_tokens", "nope"),
2003 ("reserve_tokens", "0"),
2004 ("reserve_tokens", "1000001"),
2005 ("reserve_tokens", "auto"),
2006 ("finish_rounds", "9"),
2007 ("finish_rounds", "-1"),
2008 ("finish_rounds", "4294967296"),
2009 ("finish_rounds", ""),
2010 ("mod", "auto"),
2011 ] {
2012 let parsed = super::load_config_from_str(&format!(
2013 "context_management.mode = auto\ncontext_management.{key} = {value}\n"
2014 ));
2015 assert_eq!(
2016 parsed.context_management,
2017 super::ContextManagementConfig::default(),
2018 "{key}={value}"
2019 );
2020 assert!(!parsed.warnings.is_empty(), "{key}={value}");
2021 assert!(parsed.provider_keys.is_empty());
2022 }
2023 let duplicate = super::load_config_from_str(
2024 "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
2025 );
2026 assert_eq!(
2027 duplicate.context_management,
2028 super::ContextManagementConfig::default()
2029 );
2030 }
2031
2032 #[test]
2033 fn context_management_validates_final_threshold_order_and_window() {
2034 for fields in [
2035 "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
2036 "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
2037 "pressure_tokens = 1000000",
2038 "rollover_tokens = 1000001",
2039 ] {
2040 for window_first in [false, true] {
2041 let policy =
2042 format!("context_management.mode = auto\ncontext_management.{fields}\n");
2043 let content = if window_first {
2044 format!("context_window = 1m\n{policy}")
2045 } else {
2046 format!("{policy}context_window = 1m\n")
2047 };
2048 let parsed = super::load_config_from_str(&content);
2049 assert_eq!(
2050 parsed.context_management,
2051 super::ContextManagementConfig::default()
2052 );
2053 assert!(!parsed.warnings.is_empty());
2054 }
2055 }
2056 let parsed = super::load_config_from_str(
2058 "context_management.mode = auto\n\
2059 context_management.rollover_tokens = 100000\n\
2060 context_management.pressure_tokens = 300000\n\
2061 context_management.rollover_tokens = 450000\ncontext_window = 1m",
2062 );
2063 assert!(parsed.warnings.is_empty());
2064 assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
2065 }
2066
2067 #[test]
2068 fn context_management_parsed_finish_override_bounds_rollover_extension() {
2069 use crate::core::context_policy::{
2070 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2071 };
2072 let config = super::load_config_from_str(
2073 "context_management.mode = auto\n\
2074 context_management.finish_rounds = 1\ncontext_window = 1m",
2075 );
2076 let budget = ContextBudget {
2077 context_window_tokens: 1_000_000,
2078 used_tokens: 400_000,
2079 hard_remaining_tokens: 600_000,
2080 required_next_round_tokens: 8_000,
2081 };
2082 let mut state = ContextState::default();
2083 state.report_phase(WorkPhase::Plan);
2084 let first = assess_context(&config.context_management, &state, budget);
2085 assert_eq!(
2086 first.action,
2087 ContextAction::FinishBounded {
2088 rounds_remaining: 0
2089 }
2090 );
2091 state = first.next_state;
2092 state.report_phase(WorkPhase::WrapUp);
2093 assert_eq!(
2094 assess_context(&config.context_management, &state, budget).action,
2095 ContextAction::Rollover
2096 );
2097 }
2098
2099 #[test]
2100 fn context_management_keys_are_known_for_suggestions() {
2101 for key in [
2102 "mode",
2103 "pressure_tokens",
2104 "rollover_tokens",
2105 "reserve_tokens",
2106 "finish_rounds",
2107 ] {
2108 let full = format!("context_management.{key}");
2109 assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
2110 assert_eq!(
2111 super::did_you_mean(&format!("{full}x")),
2112 Some(full.as_str())
2113 );
2114 }
2115 }
2116
2117 #[test]
2118 fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
2119 use crate::core::context_policy::{
2120 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2121 };
2122 let config =
2123 super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
2124 let budget = ContextBudget {
2125 context_window_tokens: config.context_window.unwrap(),
2126 used_tokens: 350_000,
2127 hard_remaining_tokens: 650_000,
2128 required_next_round_tokens: 8_000,
2129 };
2130 let mut state = ContextState::default();
2131 state.report_phase(WorkPhase::Plan);
2132 let plan = assess_context(&config.context_management, &state, budget);
2133 assert_eq!(plan.action, ContextAction::Advisory);
2134 state = plan.next_state;
2135 state.report_phase(WorkPhase::Execute);
2136 assert_eq!(
2137 assess_context(&config.context_management, &state, budget).action,
2138 ContextAction::Rollover
2139 );
2140 }
2141
2142 #[test]
2143 fn tui_background_opaque_defaults_and_parses_supported_values() {
2144 assert!(super::load_config_from_str("").tui_background_opaque);
2145 assert!(
2146 super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
2147 );
2148 assert!(
2149 !super::load_config_from_str("tui_background_opaque = invisible\n")
2150 .tui_background_opaque
2151 );
2152 let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
2153 assert!(invalid.tui_background_opaque);
2154 assert!(invalid
2155 .warnings
2156 .iter()
2157 .any(|w| w.contains("tui_background_opaque")));
2158 }
2159
2160 #[test]
2161 fn turn_budget_keys_parse_typed_per_role() {
2162 let config = super::load_config_from_str(
2163 "turn_budget.worker.max_provider_rounds = 3\n\
2164 turn_budget.worker.max_cost_usd = 0.25\n\
2165 turn_budget.autonomous.max_elapsed_secs = 90\n\
2166 turn_budget.foreground.max_tool_calls = 7\n\
2167 turn_budget.foreground.max_round_renewals = 12\n\
2168 turn_budget.worker.max_provider_rounds_bogus = 1\n\
2169 turn_budget.nosuchrole.max_tool_calls = 1\n",
2170 );
2171 assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
2172 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
2173 assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
2174 assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
2175 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
2176 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
2178 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
2180 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
2181 }
2182
2183 #[test]
2184 fn memory_keys_all_parse_with_consent() {
2185 let config = super::load_config_from_str(
2186 "memory.default_mode = capture_and_recall\n\
2187 memory.default_mode_confirmed = true\n\
2188 memory.recall_max_records = 12\n\
2189 memory.recall_max_tokens = 2048\n\
2190 memory.recall_timeout_ms = 250\n\
2191 memory.capture_tools = off\n\
2192 memory.capture_assistant = false\n\
2193 memory.capture_user = false\n\
2194 memory.auto_consolidate = on\n\
2195 memory.local_embeddings = on\n\
2196 memory.gliner = on\n",
2197 );
2198 assert_eq!(config.memory.default_mode, "capture_and_recall");
2199 assert!(config.memory.default_mode_confirmed);
2200 assert_eq!(config.memory.recall_max_records, 12);
2201 assert_eq!(config.memory.recall_max_tokens, 2048);
2202 assert_eq!(config.memory.recall_timeout_ms, 250);
2203 assert_eq!(config.memory.capture_tools, "off");
2204 assert!(!config.memory.capture_assistant);
2205 assert!(!config.memory.capture_user);
2206 assert_eq!(config.memory.auto_consolidate, "on");
2207 assert_eq!(config.memory.local_embeddings, "on");
2208 assert_eq!(config.memory.gliner, "on");
2209 }
2210
2211 #[test]
2212 fn memory_defaults_match_spec_12() {
2213 let config = super::load_config_from_str("");
2214 assert_eq!(config.memory, super::MemoryConfig::default());
2215 assert_eq!(config.memory.default_mode, "off");
2216 assert!(!config.memory.default_mode_confirmed);
2217 assert_eq!(config.memory.recall_max_records, 8);
2218 assert_eq!(config.memory.recall_max_tokens, 4096);
2219 assert_eq!(config.memory.recall_timeout_ms, 150);
2220 assert_eq!(config.memory.capture_tools, "summary_only");
2221 assert!(config.memory.capture_assistant);
2222 assert!(config.memory.capture_user);
2223 assert_eq!(config.memory.auto_consolidate, "off");
2224 assert_eq!(config.memory.local_embeddings, "off");
2225 assert_eq!(config.memory.gliner, "off");
2226 }
2227
2228 #[test]
2229 fn memory_unknown_values_fail_closed_to_defaults() {
2230 let config = super::load_config_from_str(
2231 "memory.default_mode = recall_everything_forever\n\
2232 memory.default_mode_confirmed = true\n\
2233 memory.recall_max_records = lots\n\
2234 memory.recall_max_tokens = -5\n\
2235 memory.recall_timeout_ms = fast\n\
2236 memory.capture_tools = full\n\
2237 memory.capture_assistant = maybe\n\
2238 memory.capture_user = sometimes\n\
2239 memory.auto_consolidate = aggressive\n\
2240 memory.local_embeddings = auto\n\
2241 memory.gliner = download\n\
2242 memory.nosuchfield = 1\n",
2243 );
2244 assert_eq!(config.memory.default_mode, "off");
2246 assert_eq!(config.memory.recall_max_records, 8);
2247 assert_eq!(config.memory.recall_max_tokens, 4096);
2248 assert_eq!(config.memory.recall_timeout_ms, 150);
2249 assert_eq!(config.memory.capture_tools, "summary_only");
2250 assert!(config.memory.capture_assistant);
2251 assert!(config.memory.capture_user);
2252 assert_eq!(config.memory.auto_consolidate, "off");
2253 assert_eq!(config.memory.local_embeddings, "off");
2254 assert_eq!(config.memory.gliner, "off");
2255 }
2256
2257 #[test]
2258 fn memory_default_mode_without_consent_reverts_to_off() {
2259 let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
2260 assert_eq!(config.memory.default_mode, "off");
2261 assert!(
2262 config
2263 .warnings
2264 .iter()
2265 .any(|w| w.contains("memory.default_mode_confirmed")),
2266 "reversion must surface a warning; got: {:?}",
2267 config.warnings
2268 );
2269
2270 let denied = super::load_config_from_str(
2272 "memory.default_mode = capture_only\n\
2273 memory.default_mode_confirmed = false\n",
2274 );
2275 assert_eq!(denied.memory.default_mode, "off");
2276
2277 let garbage = super::load_config_from_str(
2279 "memory.default_mode = capture_only\n\
2280 memory.default_mode_confirmed = definitely\n",
2281 );
2282 assert_eq!(garbage.memory.default_mode, "off");
2283 }
2284
2285 #[test]
2286 fn memory_consent_is_order_independent() {
2287 let before = super::load_config_from_str(
2289 "memory.default_mode_confirmed = true\n\
2290 memory.default_mode = recall_once\n",
2291 );
2292 assert_eq!(before.memory.default_mode, "recall_once");
2293 let after = super::load_config_from_str(
2295 "memory.default_mode = recall_once\n\
2296 memory.default_mode_confirmed = true\n",
2297 );
2298 assert_eq!(after.memory.default_mode, "recall_once");
2299 }
2300
2301 #[test]
2302 fn memory_backend_defaults_are_independent_of_consent() {
2303 let config = super::load_config_from_str("");
2304 assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
2305 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
2306 let axel = super::load_config_from_str("memory.backend = axel\n");
2307 assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
2308 assert_eq!(axel.memory, config.memory);
2309 assert!(axel.warnings.is_empty());
2310 }
2311
2312 #[test]
2313 fn user_wide_notes_require_explicit_valid_opt_in() {
2314 let default = super::load_config_from_str("memory.backend = axel\n");
2315 assert!(!default.memory_backend.user_scope);
2316 let enabled =
2317 super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
2318 assert!(enabled.memory_backend.user_scope);
2319 assert_eq!(enabled.memory, default.memory);
2320 for bad in ["yes", "", "1", "TRUE"] {
2321 let config = super::load_config_from_str(&format!(
2322 "memory.user_scope = {bad}\nmemory.backend = axel\n"
2323 ));
2324 assert_eq!(
2325 config.memory_backend.kind,
2326 super::MemoryBackendKind::Unavailable
2327 );
2328 }
2329 }
2330
2331 #[test]
2332 fn memory_backend_invalid_selector_fails_closed() {
2333 for content in [
2334 "memory.backend = typo\n",
2335 "memory.backend = \n",
2336 "memory.backend = unavailable\n",
2337 "memory.backend = axel\nmemory.backend = typo\n",
2338 "memory.backend = typo\nmemory.backend = legacy\n",
2339 ] {
2340 let config = super::load_config_from_str(content);
2341 assert_eq!(
2342 config.memory_backend.kind,
2343 super::MemoryBackendKind::Unavailable
2344 );
2345 assert!(config
2346 .warnings
2347 .iter()
2348 .any(|w| w.contains("memory backend unavailable")));
2349 }
2350 }
2351
2352 #[test]
2353 fn memory_backend_absolute_paths_are_preserved() {
2354 let root = std::env::current_dir().unwrap();
2355 let executable = root.join("fixture bin/axel");
2356 let brain = root.join("fixture brain");
2357 let config = super::load_config_from_str(&format!(
2358 "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
2359 brain.display(),
2360 executable.display()
2361 ));
2362 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
2363 assert_eq!(config.memory_backend.executable, Some(executable));
2364 assert_eq!(config.memory_backend.brain, Some(brain));
2365 assert!(config.warnings.is_empty());
2366 }
2367
2368 #[test]
2369 fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
2370 for key in ["memory.axel.executable", "memory.axel.brain"] {
2371 for path in ["relative/path", "~/brain", ""] {
2372 for content in [
2373 format!("memory.backend = axel\n{key} = {path}\n"),
2374 format!("{key} = {path}\nmemory.backend = legacy\n"),
2375 ] {
2376 let config = super::load_config_from_str(&content);
2377 assert_eq!(
2378 config.memory_backend.kind,
2379 super::MemoryBackendKind::Unavailable
2380 );
2381 assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
2382 }
2383 }
2384 }
2385 }
2386
2387 #[test]
2388 fn memory_config_surface_is_closed_by_construction() {
2389 let _closed_surface = super::MemoryConfig {
2397 default_mode: "off".to_string(),
2398 default_mode_confirmed: false,
2399 recall_max_records: 8,
2400 recall_max_tokens: 4096,
2401 recall_timeout_ms: 150,
2402 capture_tools: "summary_only".to_string(),
2403 capture_assistant: true,
2404 capture_user: true,
2405 auto_consolidate: "off".to_string(),
2406 local_embeddings: "off".to_string(),
2407 gliner: "off".to_string(),
2408 };
2409 }
2410
2411 use super::*;
2412 use serial_test::serial;
2413
2414 #[test]
2415 fn test_levenshtein_basics() {
2416 assert_eq!(levenshtein("model", "model"), 0);
2417 assert_eq!(levenshtein("modle", "model"), 2);
2418 assert_eq!(levenshtein("them", "theme"), 1);
2419 }
2420
2421 #[test]
2422 fn test_did_you_mean_close_typos() {
2423 assert_eq!(did_you_mean("modle"), Some("model"));
2424 assert_eq!(did_you_mean("them"), Some("theme"));
2425 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
2426 assert_eq!(did_you_mean("completely_unrelated_key"), None);
2427 }
2428
2429 #[test]
2430 #[serial]
2431 fn test_config_warnings_unknown_key_and_bad_values() {
2432 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
2433 let dir = home.join(".synaps-cli");
2434 std::fs::create_dir_all(&dir).unwrap();
2435 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();
2436
2437 with_home(&home, || {
2438 let config = load_config();
2439 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
2441 assert!(
2442 !config.warnings.iter().any(|w| w.contains("knowledge")),
2443 "{:?}",
2444 config.warnings
2445 );
2446 assert!(
2447 config
2448 .warnings
2449 .iter()
2450 .any(|w| w.contains("did you mean 'model'")),
2451 "{:?}",
2452 config.warnings
2453 );
2454 assert!(
2455 config.warnings.iter().any(|w| w.contains("thinking")),
2456 "{:?}",
2457 config.warnings
2458 );
2459 assert!(
2460 config.warnings.iter().any(|w| w.contains("below minimum")),
2461 "{:?}",
2462 config.warnings
2463 );
2464 assert_eq!(config.bash_timeout, 30);
2466 assert_eq!(config.thinking_budget, None);
2467 });
2468 let _ = std::fs::remove_dir_all(&home);
2469 }
2470
2471 #[test]
2474 fn test_cache_ttl_parse_table() {
2475 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
2477 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
2478 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
2479 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
2481 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
2482 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
2483 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
2485 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
2487 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
2488 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
2489 assert_eq!(CacheTtl::parse("2h"), None);
2491 assert_eq!(CacheTtl::parse(""), None);
2492 assert_eq!(CacheTtl::parse("forever"), None);
2493 }
2494
2495 #[test]
2496 fn test_cache_ttl_default_is_five_minutes() {
2497 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
2498 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
2499 }
2500
2501 #[test]
2502 fn test_max_fps_default_is_60() {
2503 assert_eq!(SynapsConfig::default().max_fps, 60);
2504 }
2505
2506 #[test]
2509 fn test_theme_transition_parse_table() {
2510 use ThemeTransitionMode as M;
2511 assert_eq!(M::parse("on"), Some(M::On));
2512 assert_eq!(M::parse("ON"), Some(M::On));
2513 assert_eq!(M::parse("off"), Some(M::Off));
2514 assert_eq!(M::parse("Off"), Some(M::Off));
2515 assert_eq!(M::parse("500"), Some(M::Ms(500)));
2517 assert_eq!(M::parse("0"), Some(M::Ms(0)));
2518 assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
2519 assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
2520 assert_eq!(M::parse("fast"), None);
2522 assert_eq!(M::parse(""), None);
2523 assert_eq!(M::parse("-5"), None);
2524 assert_eq!(M::parse("1.5"), None);
2525 }
2526
2527 #[test]
2528 fn test_theme_transition_duration_ms() {
2529 use ThemeTransitionMode as M;
2530 assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
2531 assert_eq!(M::DEFAULT_MS, 350);
2532 assert_eq!(M::Off.duration_ms(), 0);
2533 assert_eq!(M::Ms(120).duration_ms(), 120);
2534 assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
2535 assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
2536 }
2537
2538 #[test]
2539 fn test_theme_transition_config_key_parses_and_defaults() {
2540 use ThemeTransitionMode as M;
2541 assert_eq!(SynapsConfig::default().theme_transition, M::On);
2542 assert_eq!(
2543 super::load_config_from_str("theme_transition = off\n").theme_transition,
2544 M::Off
2545 );
2546 assert_eq!(
2547 super::load_config_from_str("theme_transition = 750\n").theme_transition,
2548 M::Ms(750)
2549 );
2550 let bad = super::load_config_from_str("theme_transition = warp\n");
2551 assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
2552 assert!(
2553 bad.warnings.iter().any(|w| w.contains("theme_transition")),
2554 "{:?}",
2555 bad.warnings
2556 );
2557 }
2558
2559 #[test]
2560 #[serial]
2561 fn test_max_fps_config_parse_and_validation() {
2562 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
2563 let dir = home.join(".synaps-cli");
2564 std::fs::create_dir_all(&dir).unwrap();
2565
2566 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
2568 with_home(&home, || {
2569 let config = load_config();
2570 assert_eq!(config.max_fps, 144);
2571 assert!(
2572 config.warnings.is_empty(),
2573 "warnings: {:?}",
2574 config.warnings
2575 );
2576 });
2577
2578 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
2580 with_home(&home, || {
2581 let config = load_config();
2582 assert_eq!(config.max_fps, 60);
2583 assert!(
2584 config.warnings.iter().any(|w| w.contains("max_fps")),
2585 "warnings: {:?}",
2586 config.warnings
2587 );
2588 });
2589
2590 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
2592 with_home(&home, || {
2593 let config = load_config();
2594 assert_eq!(config.max_fps, 60);
2595 assert!(
2596 config.warnings.iter().any(|w| w.contains("max_fps")),
2597 "warnings: {:?}",
2598 config.warnings
2599 );
2600 });
2601
2602 let _ = std::fs::remove_dir_all(&home);
2603 }
2604
2605 #[test]
2606 #[serial]
2607 fn test_cache_ttl_config_parse_and_garbage_warning() {
2608 let home =
2609 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
2610 let dir = home.join(".synaps-cli");
2611 std::fs::create_dir_all(&dir).unwrap();
2612
2613 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
2615 with_home(&home, || {
2616 let config = load_config();
2617 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
2618 assert!(
2619 config.warnings.is_empty(),
2620 "warnings: {:?}",
2621 config.warnings
2622 );
2623 });
2624
2625 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
2627 with_home(&home, || {
2628 let config = load_config();
2629 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
2630 assert!(
2631 config.warnings.iter().any(|w| w.contains("cache_ttl")),
2632 "warnings: {:?}",
2633 config.warnings
2634 );
2635 });
2636
2637 let _ = std::fs::remove_dir_all(&home);
2638 }
2639
2640 #[test]
2641 fn test_parse_thinking_budget() {
2642 use crate::core::reasoning::ThinkingSpec;
2643 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
2645 assert_eq!(
2646 ThinkingSpec::parse("medium").unwrap().to_budget(),
2647 Some(4096)
2648 );
2649 assert_eq!(
2650 ThinkingSpec::parse("high").unwrap().to_budget(),
2651 Some(16384)
2652 );
2653 assert_eq!(
2654 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
2655 Some(32768)
2656 );
2657 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
2658 let config = load_config_from_str("thinking = 8192\n");
2659 assert_eq!(config.thinking_level, None);
2660 assert_eq!(config.thinking_budget, Some(8192));
2661 assert_eq!(ThinkingSpec::parse("invalid"), None);
2662 }
2663
2664 #[test]
2665 fn test_base_dir() {
2666 let path = base_dir();
2667 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
2668 }
2669
2670 #[test]
2671 fn test_resolve_system_prompt_explicit() {
2672 let result = resolve_system_prompt(Some("test prompt"));
2673 assert_eq!(result, "test prompt");
2674 }
2675
2676 #[test]
2677 fn test_resolve_system_prompt_none() {
2678 let result = resolve_system_prompt(None);
2679 assert!(result.contains("helpful AI agent"));
2680 }
2681
2682 #[test]
2687 fn test_synaps_config_default() {
2688 let config = SynapsConfig::default();
2689 assert_eq!(config.model, None);
2690 assert_eq!(config.thinking_budget, None);
2691 assert_eq!(config.max_tool_output, 30000);
2692 assert_eq!(config.bash_timeout, 30);
2693 assert_eq!(config.bash_max_timeout, 300);
2694 assert_eq!(config.subagent_timeout, 300);
2695 assert_eq!(config.api_retries, 3);
2696 assert_eq!(config.theme, None);
2697 assert!(config.disabled_plugins.is_empty());
2698 assert!(config.favorite_models.is_empty());
2699 assert!(config.disabled_skills.is_empty());
2700 assert!(!config.progressive_tool_disclosure);
2701 assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
2702 assert_eq!(config.shell.max_sessions, 5);
2703 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
2704 assert!(config.server.allowed_origins.is_empty());
2706 assert_eq!(config.server.token, None);
2707 assert!(!config.server.auto_approve_confirms);
2708 assert_eq!(config.server.max_message_size, None);
2709 assert!(config.bridge.uds_path.is_none());
2711 assert!(!config.bridge.heartbeat_mirror);
2712 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2713 }
2714
2715 #[test]
2716 #[serial]
2717 fn test_load_config_tools_activation_confirm_modes() {
2718 for (raw, expected, warns) in [
2719 ("prompt", ActivationConfirm::Prompt, false),
2720 ("deny", ActivationConfirm::Deny, false),
2721 ("AUTO", ActivationConfirm::Auto, false),
2722 ("garbage", ActivationConfirm::Auto, true),
2723 ] {
2724 let home = make_test_home(&format!("activation-confirm-{raw}"));
2725 let cfg = home.join(".synaps-cli/config");
2726 std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
2727 with_home(&home, || {
2728 let config = load_config();
2729 assert_eq!(config.tools_activation_confirm, expected, "{raw}");
2730 assert_eq!(
2731 config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
2732 warns,
2733 "{raw}: {:?}",
2734 config.warnings
2735 );
2736 });
2737 let _ = std::fs::remove_dir_all(&home);
2738 }
2739 }
2740
2741 #[test]
2742 #[serial]
2743 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
2744 let home = make_test_home("progressive-tool-disclosure");
2745 let cfg = home.join(".synaps-cli/config");
2746 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
2747
2748 with_home(&home, || {
2749 let config = load_config();
2750 assert!(config.progressive_tool_disclosure);
2751 assert!(config.warnings.is_empty());
2752 });
2753
2754 let _ = std::fs::remove_dir_all(&home);
2755 }
2756
2757 #[test]
2758 #[serial]
2759 fn test_load_config_bridge_keys() {
2760 let home = make_test_home("bridge-keys");
2761 let cfg = home.join(".synaps-cli/config");
2762 std::fs::write(
2763 &cfg,
2764 "\
2765bridge.uds_path = /tmp/some/control.sock\n\
2766bridge.heartbeat_mirror = true\n\
2767bridge.heartbeat_timeout_ms = 750\n\
2768",
2769 )
2770 .unwrap();
2771
2772 with_home(&home, || {
2773 let config = load_config();
2774 assert_eq!(
2775 config.bridge.uds_path,
2776 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
2777 );
2778 assert!(config.bridge.heartbeat_mirror);
2779 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
2780 assert_eq!(
2781 config.bridge.resolved_uds_path(),
2782 std::path::PathBuf::from("/tmp/some/control.sock"),
2783 );
2784 });
2785
2786 let _ = std::fs::remove_dir_all(&home);
2787 }
2788
2789 #[test]
2790 fn test_bridge_config_defaults() {
2791 let cfg = BridgeConfig::default();
2792 assert!(cfg.uds_path.is_none());
2793 assert!(!cfg.heartbeat_mirror);
2794 assert_eq!(cfg.heartbeat_timeout_ms, 250);
2795 let resolved = cfg.resolved_uds_path();
2797 assert!(resolved.ends_with("bridge/control.sock"));
2798 }
2799
2800 #[test]
2801 #[serial]
2802 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
2803 let home = make_test_home("bridge-default-off");
2804 let cfg = home.join(".synaps-cli/config");
2805 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
2806
2807 with_home(&home, || {
2808 let config = load_config();
2809 assert!(!config.bridge.heartbeat_mirror);
2810 assert!(config.bridge.uds_path.is_none());
2811 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2812 });
2813
2814 let _ = std::fs::remove_dir_all(&home);
2815 }
2816
2817 #[test]
2818 fn compaction_disclosure_keys_parse_with_typed_warnings() {
2819 let config = load_config_from_str(
2820 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
2821 );
2822 assert_eq!(
2823 config.compaction_mode,
2824 crate::core::compaction::CompactionMode::LocalOnly
2825 );
2826 assert_eq!(
2827 config.compaction_exclude,
2828 vec![
2829 crate::core::compaction::ContentClass::Thinking,
2830 crate::core::compaction::ContentClass::ToolResults,
2831 ]
2832 );
2833 assert!(
2834 config.warnings.iter().any(|w| w.contains("bogus")),
2835 "unknown class must warn: {:?}",
2836 config.warnings
2837 );
2838
2839 let defaults = load_config_from_str("");
2840 assert_eq!(
2841 defaults.compaction_mode,
2842 crate::core::compaction::CompactionMode::Remote
2843 );
2844 assert!(defaults.compaction_exclude.is_empty());
2845
2846 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
2847 assert_eq!(
2848 bad_mode.compaction_mode,
2849 crate::core::compaction::CompactionMode::Remote
2850 );
2851 assert!(bad_mode
2852 .warnings
2853 .iter()
2854 .any(|w| w.contains("compaction_mode")));
2855 }
2856
2857 #[test]
2858 #[serial]
2859 fn test_load_config_server_keys() {
2860 let home = make_test_home("server-keys");
2861 let cfg = home.join(".synaps-cli/config");
2862 std::fs::write(
2863 &cfg,
2864 "\
2865server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
2866server.token = my-secret-token\n\
2867server.auto_approve_confirms = true\n\
2868server.max_message_size = 65536\n\
2869context_window = 200k\n\
2870",
2871 )
2872 .unwrap();
2873
2874 with_home(&home, || {
2875 let config = load_config();
2876 assert_eq!(
2877 config.server.allowed_origins,
2878 vec![
2879 "http://localhost:3000".to_string(),
2880 "http://localhost:5193".to_string(),
2881 ]
2882 );
2883 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
2884 assert!(config.server.auto_approve_confirms);
2885 assert_eq!(config.server.max_message_size, Some(65536));
2887 });
2888
2889 let _ = std::fs::remove_dir_all(&home);
2890 }
2891
2892 #[test]
2893 #[serial]
2894 fn test_server_max_message_size_derived_from_context_window() {
2895 let home = make_test_home("server-derive");
2896 let cfg = home.join(".synaps-cli/config");
2897 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
2898
2899 with_home(&home, || {
2900 let config = load_config();
2901 assert_eq!(config.server.max_message_size, Some(800_000));
2903 });
2904
2905 let _ = std::fs::remove_dir_all(&home);
2906 }
2907
2908 fn make_test_home(subdir: &str) -> std::path::PathBuf {
2909 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
2910 let _ = std::fs::remove_dir_all(&dir);
2911 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
2912 dir
2913 }
2914
2915 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
2916 let original = std::env::var("HOME").ok();
2917 std::env::set_var("HOME", home);
2918 f();
2919 if let Some(h) = original {
2920 std::env::set_var("HOME", h);
2921 } else {
2922 std::env::remove_var("HOME");
2923 }
2924 }
2925
2926 #[test]
2927 #[serial]
2928 fn write_config_value_replaces_existing_key() {
2929 let home = make_test_home("replace");
2930 let cfg = home.join(".synaps-cli/config");
2931 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
2932
2933 with_home(&home, || {
2934 write_config_value("model", "claude-sonnet-4-6").unwrap();
2935 });
2936
2937 let contents = std::fs::read_to_string(&cfg).unwrap();
2938 assert!(contents.contains("model = claude-sonnet-4-6"));
2939 assert!(contents.contains("thinking = low"));
2940 let _ = std::fs::remove_dir_all(&home);
2941 }
2942
2943 #[test]
2944 #[serial]
2945 fn write_config_value_appends_when_missing() {
2946 let home = make_test_home("append");
2947 let cfg = home.join(".synaps-cli/config");
2948 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
2949
2950 with_home(&home, || {
2951 write_config_value("theme", "dracula").unwrap();
2952 });
2953
2954 let contents = std::fs::read_to_string(&cfg).unwrap();
2955 assert!(contents.contains("model = claude-opus-4-6"));
2956 assert!(contents.contains("theme = dracula"));
2957 let _ = std::fs::remove_dir_all(&home);
2958 }
2959
2960 #[test]
2961 #[serial]
2962 fn write_config_value_concurrent_writers_lose_nothing() {
2963 let home = make_test_home("concurrent");
2964 let cfg = home.join(".synaps-cli/config");
2965 std::fs::write(&cfg, "# keep\n").unwrap();
2966
2967 with_home(&home, || {
2969 let a = std::thread::spawn(|| {
2970 for i in 0..200 {
2971 write_config_value("a_key", &i.to_string()).unwrap();
2972 }
2973 });
2974 let b = std::thread::spawn(|| {
2975 for i in 0..200 {
2976 write_config_value("b_key", &i.to_string()).unwrap();
2977 }
2978 });
2979 a.join().unwrap();
2980 b.join().unwrap();
2981 });
2982
2983 let contents = std::fs::read_to_string(&cfg).unwrap();
2984 assert!(contents.contains("# keep"), "{contents}");
2985 assert!(contents.contains("a_key = 199"), "{contents}");
2986 assert!(contents.contains("b_key = 199"), "{contents}");
2987 assert_eq!(contents.matches("a_key").count(), 1);
2988 assert_eq!(contents.matches("b_key").count(), 1);
2989
2990 #[cfg(unix)]
2991 {
2992 use std::os::unix::fs::PermissionsExt;
2993 let lock = home.join(".synaps-cli/config.lock");
2994 let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
2995 assert_eq!(mode, 0o600);
2996 }
2997 let _ = std::fs::remove_dir_all(&home);
2998 }
2999
3000 #[test]
3001 #[serial]
3002 fn config_lock_kill_switch_still_writes() {
3003 let home = make_test_home("lock-off");
3004 let cfg = home.join(".synaps-cli/config");
3005 std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
3006 with_home(&home, || {
3007 write_config_value("model", "claude-sonnet-4-6").unwrap();
3008 });
3009 std::env::remove_var("SYNAPS_CONFIG_LOCK");
3010 let contents = std::fs::read_to_string(&cfg).unwrap();
3011 assert!(contents.contains("model = claude-sonnet-4-6"));
3012 assert!(!home.join(".synaps-cli/config.lock").exists());
3013 let _ = std::fs::remove_dir_all(&home);
3014 }
3015
3016 #[test]
3017 #[serial]
3018 fn add_favorite_model_end_to_end_does_not_deadlock() {
3019 let home = make_test_home("favorite-lock");
3020 let cfg = home.join(".synaps-cli/config");
3021 with_home(&home, || {
3022 add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3023 add_favorite_model("openai/gpt-5").unwrap();
3024 remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3025 });
3026 let contents = std::fs::read_to_string(&cfg).unwrap();
3027 assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
3028 let _ = std::fs::remove_dir_all(&home);
3029 }
3030
3031 #[test]
3032 #[serial]
3033 fn write_config_value_preserves_comments() {
3034 let home = make_test_home("comments");
3035 let cfg = home.join(".synaps-cli/config");
3036 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
3037
3038 with_home(&home, || {
3039 write_config_value("model", "claude-sonnet-4-6").unwrap();
3040 });
3041
3042 let contents = std::fs::read_to_string(&cfg).unwrap();
3043 assert!(contents.contains("# user comment"));
3044 assert!(contents.contains("# another"));
3045 assert!(contents.contains("model = claude-sonnet-4-6"));
3046 let _ = std::fs::remove_dir_all(&home);
3047 }
3048
3049 #[test]
3050 #[serial]
3051 fn write_config_value_preserves_unknown_keys() {
3052 let home = make_test_home("unknown");
3053 let cfg = home.join(".synaps-cli/config");
3054 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
3055
3056 with_home(&home, || {
3057 write_config_value("model", "claude-sonnet-4-6").unwrap();
3058 });
3059
3060 let contents = std::fs::read_to_string(&cfg).unwrap();
3061 assert!(contents.contains("custom_thing = 42"));
3062 let _ = std::fs::remove_dir_all(&home);
3063 }
3064
3065 #[test]
3066 #[serial]
3067 fn write_config_value_creates_file_if_absent() {
3068 let home = make_test_home("create");
3069 let cfg = home.join(".synaps-cli/config");
3070 assert!(!cfg.exists());
3071
3072 with_home(&home, || {
3073 write_config_value("model", "claude-sonnet-4-6").unwrap();
3074 });
3075
3076 let contents = std::fs::read_to_string(&cfg).unwrap();
3077 assert!(contents.contains("model = claude-sonnet-4-6"));
3078 let _ = std::fs::remove_dir_all(&home);
3079 }
3080
3081 #[test]
3082 #[serial]
3083 fn load_config_parses_theme_key() {
3084 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
3085 let _ = std::fs::create_dir_all(&dir);
3086 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
3087
3088 let original_home = std::env::var("HOME").ok();
3089 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
3090
3091 let config = load_config();
3092
3093 if let Some(home) = original_home {
3094 std::env::set_var("HOME", home);
3095 } else {
3096 std::env::remove_var("HOME");
3097 }
3098 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
3099
3100 assert_eq!(config.theme.as_deref(), Some("dracula"));
3101 }
3102
3103 #[test]
3104 #[serial]
3105 fn test_load_config_disable_lists() {
3106 let test_dir =
3107 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
3108 let _ = std::fs::create_dir_all(&test_dir);
3109 let config_path = test_dir.join("config");
3110
3111 let config_content = r#"
3112# Test config with disable lists
3113favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
3114
3115disabled_plugins = foo, bar
3116disabled_skills = baz, plug:qual
3117"#;
3118 std::fs::write(&config_path, config_content).unwrap();
3119
3120 let original_home = std::env::var("HOME").ok();
3121 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
3122
3123 let config = load_config();
3124
3125 if let Some(home) = original_home {
3126 std::env::set_var("HOME", home);
3127 } else {
3128 std::env::remove_var("HOME");
3129 }
3130
3131 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
3132
3133 assert_eq!(
3134 config.disabled_plugins,
3135 vec!["foo".to_string(), "bar".to_string()]
3136 );
3137 assert_eq!(
3138 config.favorite_models,
3139 vec![
3140 "claude/claude-opus-4-7".to_string(),
3141 "groq/llama-3.3-70b-versatile".to_string(),
3142 ]
3143 );
3144 assert_eq!(
3145 config.disabled_skills,
3146 vec!["baz".to_string(), "plug:qual".to_string()]
3147 );
3148 }
3149
3150 #[test]
3151 #[serial]
3152 fn favorite_model_helpers_round_trip_through_config_file() {
3153 let home = make_test_home("favorite-models");
3154 let cfg = home.join(".synaps-cli/config");
3155 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
3156
3157 with_home(&home, || {
3158 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3159 add_favorite_model("claude/claude-opus-4-7").unwrap();
3160 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3161 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
3162 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3163 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
3164 assert!(is_favorite_model("claude/claude-opus-4-7"));
3165 });
3166
3167 let contents = std::fs::read_to_string(&cfg).unwrap();
3168 assert!(contents.contains("model = claude-opus-4-7"));
3169 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
3170 let _ = std::fs::remove_dir_all(&home);
3171 }
3172
3173 #[test]
3174 #[serial]
3175 fn test_load_config_new_keys() {
3176 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
3178 let _ = std::fs::create_dir_all(&test_dir);
3179 let config_path = test_dir.join("config");
3180
3181 let config_content = r#"
3182# Test config with new keys
3183model = claude-haiku
3184thinking = medium
3185max_tool_output = 50000
3186bash_timeout = 45
3187bash_max_timeout = 600
3188subagent_timeout = 120
3189api_retries = 5
3190"#;
3191 std::fs::write(&config_path, config_content).unwrap();
3192
3193 let original_home = std::env::var("HOME").ok();
3195 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
3196
3197 let config = load_config();
3198
3199 if let Some(home) = original_home {
3201 std::env::set_var("HOME", home);
3202 } else {
3203 std::env::remove_var("HOME");
3204 }
3205
3206 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
3208
3209 assert_eq!(config.model, Some("claude-haiku".to_string()));
3210 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
3212 assert_eq!(config.bash_timeout, 45);
3213 assert_eq!(config.bash_max_timeout, 600);
3214 assert_eq!(config.subagent_timeout, 120);
3215 assert_eq!(config.api_retries, 5);
3216 }
3217
3218 #[test]
3223 fn events_auto_turn_explicit_true_values() {
3224 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
3225 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3226 assert!(
3227 cfg.events.auto_turn,
3228 "expected true for events.auto_turn = {val}"
3229 );
3230 }
3231 }
3232
3233 #[test]
3234 fn events_auto_turn_explicit_false_values() {
3235 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
3236 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3237 assert!(
3238 !cfg.events.auto_turn,
3239 "expected false for events.auto_turn = {val}"
3240 );
3241 }
3242 }
3243
3244 #[test]
3245 fn events_auto_turn_typo_fails_safe_false() {
3246 let cfg = load_config_from_str("events.auto_turn = fales");
3248 assert!(
3249 !cfg.events.auto_turn,
3250 "typo 'fales' must fail safe to false"
3251 );
3252 }
3253
3254 #[test]
3257 fn events_auto_turn_cap_default_is_five() {
3258 let cfg = load_config_from_str("");
3259 assert_eq!(cfg.events.auto_turn_cap, 5);
3260 assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
3261 }
3262
3263 #[test]
3264 fn events_auto_turn_cap_zero_is_unlimited() {
3265 let cfg = load_config_from_str("events.auto_turn_cap = 0");
3266 assert_eq!(cfg.events.auto_turn_cap, 0);
3267 }
3268
3269 #[test]
3270 fn events_auto_turn_cap_unlimited_aliases() {
3271 for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
3272 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3273 assert_eq!(
3274 cfg.events.auto_turn_cap, 0,
3275 "expected 0 for events.auto_turn_cap = {val:?}"
3276 );
3277 }
3278 }
3279
3280 #[test]
3281 fn events_auto_turn_cap_explicit_number() {
3282 let cfg = load_config_from_str("events.auto_turn_cap = 12");
3283 assert_eq!(cfg.events.auto_turn_cap, 12);
3284 }
3285
3286 #[test]
3287 fn events_auto_turn_cap_garbage_keeps_default() {
3288 for val in ["banana", "-3", "5.5", "", "true"] {
3289 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3290 assert_eq!(
3291 cfg.events.auto_turn_cap, 5,
3292 "garbage {val:?} must keep default 5"
3293 );
3294 }
3295 }
3296
3297 #[test]
3298 fn events_auto_turn_cap_independent_of_auto_turn() {
3299 let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
3300 assert!(!cfg.events.auto_turn);
3301 assert_eq!(cfg.events.auto_turn_cap, 0);
3302 }
3303
3304 #[test]
3305 fn events_auto_turn_default_is_true() {
3306 let cfg = load_config_from_str("model = claude-haiku");
3307 assert!(cfg.events.auto_turn, "default must be true when key absent");
3308 }
3309
3310 #[test]
3311 #[serial]
3312 fn test_auth_config_parses_endpoint_and_token() {
3313 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
3314 let dir = home.join(".synaps-cli");
3315 std::fs::create_dir_all(&dir).unwrap();
3316 std::fs::write(
3317 dir.join("config"),
3318 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
3319 )
3320 .unwrap();
3321 with_home(&home, || {
3322 let config = load_config();
3323 assert_eq!(
3324 config.auth.remote_endpoint.as_deref(),
3325 Some("https://jade.jade:8181")
3326 );
3327 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
3328 assert!(
3330 !config.warnings.iter().any(|w| w.contains("auth.")),
3331 "{:?}",
3332 config.warnings
3333 );
3334 });
3335 let _ = std::fs::remove_dir_all(&home);
3336 }
3337
3338 #[test]
3339 #[serial]
3340 fn test_credential_source_local_by_default() {
3341 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3342 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3343 assert!(!AuthConfig::default().credential_source().is_remote());
3344 }
3345
3346 #[test]
3347 #[serial]
3348 fn test_credential_source_remote_from_config() {
3349 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3350 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3351 let auth = AuthConfig {
3352 remote_endpoint: Some("https://b".into()),
3353 machine_token: Some("m".into()),
3354 };
3355 assert_eq!(
3356 auth.credential_source(),
3357 crate::core::auth::CredentialSource::Remote {
3358 endpoint: "https://b".into(),
3359 machine_token: "m".into()
3360 }
3361 );
3362 }
3363
3364 #[test]
3365 #[serial]
3366 fn test_credential_source_env_overrides_config() {
3367 let auth = AuthConfig {
3368 remote_endpoint: Some("https://config-host".into()),
3369 machine_token: Some("config-tok".into()),
3370 };
3371 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
3372 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
3373 let src = auth.credential_source();
3374 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3375 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3376 assert_eq!(
3377 src,
3378 crate::core::auth::CredentialSource::Remote {
3379 endpoint: "https://env-host".into(),
3380 machine_token: "env-tok".into()
3381 }
3382 );
3383 }
3384 #[test]
3385 fn config_parses_ultracode_as_distinct_canonical_level() {
3386 let cfg = load_config_from_str("thinking = ultracode\n");
3387 assert_eq!(
3388 cfg.thinking_level,
3389 Some(crate::reasoning::ReasoningLevel::UltraCode)
3390 );
3391 assert_eq!(cfg.thinking_budget, None);
3392 for other in [
3393 crate::reasoning::ReasoningLevel::Ultra,
3394 crate::reasoning::ReasoningLevel::Max,
3395 crate::reasoning::ReasoningLevel::XHigh,
3396 ] {
3397 assert_ne!(cfg.thinking_level, Some(other));
3398 }
3399 }
3400}