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
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct SubagentLimits {
314 pub max_concurrent: usize,
316 pub max_total: usize,
319 pub max_depth: u16,
322}
323
324impl Default for SubagentLimits {
325 fn default() -> Self {
326 Self {
327 max_concurrent: 16,
328 max_total: 64,
329 max_depth: 4,
330 }
331 }
332}
333
334fn parse_subagent_config_key(
337 config: &mut SubagentLimits,
338 key: &str,
339 value: &str,
340) -> Result<(), &'static str> {
341 let positive = || match value.trim().parse::<usize>() {
342 Ok(n) if n > 0 => Ok(n),
343 _ => Err("expected a whole number of at least 1"),
344 };
345 match key {
346 "subagent.max_concurrent" => config.max_concurrent = positive()?,
347 "subagent.max_total" => config.max_total = positive()?,
348 "subagent.max_depth" => {
349 config.max_depth =
350 u16::try_from(positive()?).map_err(|_| "expected a whole number from 1 to 65535")?
351 }
352 _ => return Err("unknown subagent key"),
353 }
354 Ok(())
355}
356
357fn parse_startup_config_key(
359 config: &mut StartupConfig,
360 key: &str,
361 value: &str,
362) -> Result<(), &'static str> {
363 match key {
364 "startup.quick_start" => {
365 config.quick_start = match value.trim() {
366 "true" | "1" | "on" | "yes" => true,
367 "false" | "0" | "off" | "no" => false,
368 _ => return Err("expected on or off"),
369 };
370 }
371 "startup.extensions_ready_timeout_secs" => {
372 config.extensions_ready_timeout_secs =
373 value.trim().parse().map_err(|_| "expected an unsigned second count")?;
374 }
375 _ => return Err("unknown startup key"),
376 }
377 Ok(())
378}
379
380fn parse_daemon_config_key(
382 config: &mut DaemonConfig,
383 key: &str,
384 value: &str,
385) -> Result<(), &'static str> {
386 let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
387 match key {
388 "daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
389 "daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
390 "daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
391 _ => return Err("unknown daemon key"),
392 }
393 Ok(())
394}
395
396#[derive(Debug, Clone, Default)]
398pub struct ServerConfig {
399 pub allowed_origins: Vec<String>,
401 pub token: Option<String>,
404 pub auto_approve_confirms: bool,
406 pub max_message_size: Option<usize>,
409}
410
411#[derive(Debug, Clone)]
417pub struct BridgeConfig {
418 pub uds_path: Option<PathBuf>,
421 pub heartbeat_mirror: bool,
424 pub heartbeat_timeout_ms: u64,
426}
427
428impl Default for BridgeConfig {
429 fn default() -> Self {
430 Self {
431 uds_path: None,
432 heartbeat_mirror: false,
433 heartbeat_timeout_ms: 250,
434 }
435 }
436}
437
438impl BridgeConfig {
439 pub fn resolved_uds_path(&self) -> PathBuf {
441 self.uds_path
442 .clone()
443 .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
444 }
445}
446
447#[derive(Debug, Clone, Default)]
451pub struct AuthConfig {
452 pub remote_endpoint: Option<String>,
456 pub machine_token: Option<String>,
459}
460
461impl AuthConfig {
462 pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
466 let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
467 .ok()
468 .filter(|s| !s.trim().is_empty())
469 .or_else(|| self.remote_endpoint.clone());
470 let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
471 .ok()
472 .filter(|s| !s.trim().is_empty())
473 .or_else(|| self.machine_token.clone());
474 crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
475 }
476}
477
478pub const MAX_CACHE_KEEPWARM_HOURS: u32 = 48;
482
483pub const DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS: u32 = 24;
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
504pub enum CacheKeepWarm {
505 #[default]
506 Off,
507 Auto,
508 Hours(u32),
509}
510
511impl CacheKeepWarm {
512 pub fn parse(val: &str) -> Option<CacheKeepWarm> {
516 let v = val.trim().to_ascii_lowercase();
517 match v.as_str() {
518 "off" | "false" | "no" | "0" => return Some(CacheKeepWarm::Off),
519 "auto" | "on" => return Some(CacheKeepWarm::Auto),
520 _ => {}
521 }
522 let digits = v.strip_suffix('h')?;
523 match digits.parse::<u32>().ok()? {
524 0 => Some(CacheKeepWarm::Off),
525 h if h <= MAX_CACHE_KEEPWARM_HOURS => Some(CacheKeepWarm::Hours(h)),
526 _ => None,
527 }
528 }
529
530 pub fn is_off(self) -> bool {
531 self == CacheKeepWarm::Off
532 }
533}
534
535impl std::fmt::Display for CacheKeepWarm {
536 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537 match self {
538 CacheKeepWarm::Off => f.write_str("off"),
539 CacheKeepWarm::Auto => f.write_str("auto"),
540 CacheKeepWarm::Hours(h) => write!(f, "{h}h"),
541 }
542 }
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
555pub enum CacheTtl {
556 #[default]
557 FiveMinutes,
558 OneHour,
559 Hybrid,
560}
561
562impl CacheTtl {
563 pub fn parse(val: &str) -> Option<CacheTtl> {
566 match val.to_ascii_lowercase().as_str() {
567 "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
568 "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
569 "hybrid" => Some(CacheTtl::Hybrid),
570 _ => None,
571 }
572 }
573}
574
575#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
578pub enum ActivationConfirm {
579 #[default]
581 Auto,
582 Prompt,
585 Deny,
587}
588
589impl ActivationConfirm {
590 pub fn parse(val: &str) -> Option<Self> {
591 match val.trim().to_ascii_lowercase().as_str() {
592 "auto" => Some(Self::Auto),
593 "prompt" => Some(Self::Prompt),
594 "deny" => Some(Self::Deny),
595 _ => None,
596 }
597 }
598
599 pub fn as_str(self) -> &'static str {
600 match self {
601 Self::Auto => "auto",
602 Self::Prompt => "prompt",
603 Self::Deny => "deny",
604 }
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
617pub enum ThemeTransitionMode {
618 #[default]
619 On,
620 Off,
621 Ms(u64),
622}
623
624impl ThemeTransitionMode {
625 pub const DEFAULT_MS: u64 = 350;
628 pub const MAX_MS: u64 = 2000;
630
631 pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
634 match val.to_ascii_lowercase().as_str() {
635 "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
636 "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
637 other => other
638 .parse::<u64>()
639 .ok()
640 .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
641 }
642 }
643
644 pub fn duration_ms(self) -> u64 {
646 match self {
647 ThemeTransitionMode::On => Self::DEFAULT_MS,
648 ThemeTransitionMode::Off => 0,
649 ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
650 }
651 }
652}
653
654#[derive(Debug, Clone)]
659pub struct EventsConfig {
660 pub auto_turn: bool,
666 pub auto_turn_cap: u32,
673}
674
675pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
677
678impl Default for EventsConfig {
679 fn default() -> Self {
680 Self {
681 auto_turn: true,
682 auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
683 }
684 }
685}
686
687pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
692 let normalised = val.trim().to_lowercase();
693 match normalised.as_str() {
694 "unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
695 n => n.parse::<u32>().ok(),
696 }
697}
698
699fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) -> Result<(), String> {
703 if key == "events.auto_turn" {
704 let normalised = val.trim().to_lowercase();
705 match normalised.as_str() {
706 "true" | "1" | "yes" | "on" => cfg.auto_turn = true,
708 "false" | "0" | "no" | "off" => cfg.auto_turn = false,
710 other => {
712 cfg.auto_turn = false;
713 return Err(format!(
714 "warning: config: unrecognised value for events.auto_turn = {:?}; \
715 expected true/false/yes/no/on/off/1/0 — defaulting to false",
716 other
717 ));
718 }
719 }
720 } else if key == "events.auto_turn_cap" {
721 match parse_auto_turn_cap(val) {
722 Some(cap) => cfg.auto_turn_cap = cap,
723 None => {
724 return Err(format!(
725 "warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
726 expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
727 — keeping default {}",
728 val.trim(),
729 DEFAULT_AUTO_TURN_CAP
730 ));
731 }
732 }
733 } Ok(())
735}
736
737#[derive(Debug, Clone, Default, PartialEq)]
741pub struct TurnBudgetOverrides {
742 pub max_provider_rounds: Option<u32>,
743 pub max_tool_calls: Option<u32>,
744 pub max_elapsed_secs: Option<u64>,
747 pub max_accumulated_tool_result_bytes: Option<usize>,
748 pub max_context_tokens: Option<u64>,
749 pub max_cost_usd: Option<f64>,
750 pub max_round_renewals: Option<u32>,
755}
756
757#[derive(Debug, Clone, Default, PartialEq)]
759pub struct TurnBudgetsConfig {
760 pub foreground: TurnBudgetOverrides,
761 pub autonomous: TurnBudgetOverrides,
762 pub worker: TurnBudgetOverrides,
763}
764
765fn parse_turn_budget_config_key(
769 budgets: &mut TurnBudgetsConfig,
770 key: &str,
771 val: &str,
772) -> Result<(), String> {
773 let Some(rest) = key.strip_prefix("turn_budget.") else {
774 return Ok(());
775 };
776 let Some((role, field)) = rest.split_once('.') else {
777 return Err(format!(
778 "Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)"
779 ));
780 };
781 let overrides = match role {
782 "foreground" => &mut budgets.foreground,
783 "autonomous" => &mut budgets.autonomous,
784 "worker" => &mut budgets.worker,
785 other => {
786 return Err(format!(
787 "Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)"
788 ));
789 }
790 };
791 macro_rules! set {
792 ($slot:expr, $ty:ty) => {
793 match val.parse::<$ty>() {
794 Ok(parsed) => $slot = Some(parsed),
795 Err(_) => {
796 return Err(format!("Warning: invalid value for {key}: '{val}', using default"))
797 }
798 }
799 };
800 }
801 match field {
802 "max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
803 "max_tool_calls" => set!(overrides.max_tool_calls, u32),
804 "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
805 "max_accumulated_tool_result_bytes" => {
806 set!(overrides.max_accumulated_tool_result_bytes, usize)
807 }
808 "max_context_tokens" => set!(overrides.max_context_tokens, u64),
809 "max_cost_usd" => set!(overrides.max_cost_usd, f64),
810 "max_round_renewals" => set!(overrides.max_round_renewals, u32),
811 other => return Err(format!("Warning: unknown turn_budget field '{other}'")),
812 }
813 Ok(())
814}
815
816#[derive(Debug, Clone, PartialEq)]
825pub struct MemoryConfig {
826 pub default_mode: String,
832 pub default_mode_confirmed: bool,
836 pub recall_max_records: u32,
838 pub recall_max_tokens: u32,
840 pub recall_timeout_ms: u64,
842 pub capture_tools: String,
844 pub capture_assistant: bool,
846 pub capture_user: bool,
848 pub auto_consolidate: String,
850 pub local_embeddings: String,
852 pub gliner: String,
854}
855
856impl Default for MemoryConfig {
857 fn default() -> Self {
858 Self {
859 default_mode: "off".to_string(),
860 default_mode_confirmed: false,
861 recall_max_records: 8,
862 recall_max_tokens: 4096,
863 recall_timeout_ms: 150,
864 capture_tools: "summary_only".to_string(),
865 capture_assistant: true,
866 capture_user: true,
867 auto_consolidate: "off".to_string(),
868 local_embeddings: "off".to_string(),
869 gliner: "off".to_string(),
870 }
871 }
872}
873
874const KNOWN_MEMORY_MODES: &[&str] = &[
879 "off",
880 "recall_once",
881 "recall_each_prompt",
882 "capture_only",
883 "capture_and_recall",
884];
885
886fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) -> Result<(), String> {
890 let Some(field) = key.strip_prefix("memory.") else {
891 return Ok(());
892 };
893 fn parse_bool(slot: &mut bool, key: &str, val: &str) -> Result<(), String> {
896 match val {
897 "true" | "1" | "yes" | "on" => *slot = true,
898 "false" | "0" | "no" | "off" => *slot = false,
899 other => {
900 return Err(format!(
901 "Warning: invalid value for {key}: '{other}', expected true/false — using default"
902 ))
903 }
904 }
905 Ok(())
906 }
907 fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) -> Result<(), String> {
910 if allowed.contains(&val) {
911 *slot = val.to_string();
912 Ok(())
913 } else {
914 Err(format!(
915 "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
916 allowed.join("|")
917 ))
918 }
919 }
920 macro_rules! set_num {
921 ($slot:expr, $ty:ty) => {
922 match val.parse::<$ty>() {
923 Ok(parsed) => {
924 $slot = parsed;
925 Ok(())
926 }
927 Err(_) => Err(format!("Warning: invalid value for {key}: '{val}', using default")),
928 }
929 };
930 }
931 match field {
932 "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
933 "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
934 "recall_max_records" => set_num!(memory.recall_max_records, u32),
935 "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
936 "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
937 "capture_tools" => parse_enum(
938 &mut memory.capture_tools,
939 key,
940 val,
941 &["off", "summary_only"],
942 ),
943 "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
944 "capture_user" => parse_bool(&mut memory.capture_user, key, val),
945 "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
946 "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
947 "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
948 other => Err(format!("Warning: unknown memory field '{other}'")),
949 }
950}
951
952#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
954pub enum MemoryBackendKind {
955 #[default]
956 Legacy,
957 Axel,
958 Unavailable,
960}
961
962#[derive(Debug, Clone, PartialEq, Eq, Default)]
963pub struct MemoryBackendConfig {
964 pub kind: MemoryBackendKind,
965 pub executable: Option<PathBuf>,
967 pub brain: Option<PathBuf>,
969 pub user_scope: bool,
971}
972
973#[derive(Debug, Clone)]
975pub struct SynapsConfig {
976 pub model: Option<String>,
977 pub thinking_budget: Option<u32>,
978 pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
982 pub context_window: Option<u64>, pub context_management: ContextManagementConfig,
984 pub startup: StartupConfig,
986 pub daemon: DaemonConfig,
988 pub subagent: SubagentLimits,
990 pub compaction_model: Option<String>, pub compaction_mode: crate::core::compaction::CompactionMode,
994 pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
996 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,
1006 pub cache_keepwarm: CacheKeepWarm,
1010 pub cache_keepwarm_auto_max_hours: u32,
1015 pub max_fps: u32,
1019 pub scroll_lines: Option<u16>,
1023 pub theme: Option<String>,
1024 pub theme_transition: ThemeTransitionMode,
1027 pub tui_background_opaque: bool,
1029 pub tui_streaming_glow: bool,
1031 pub process_scopes: bool,
1037 pub agent_name: Option<String>,
1038 pub identity: Option<String>,
1039 pub disabled_plugins: Vec<String>,
1040 pub favorite_models: Vec<String>,
1041 pub disabled_skills: Vec<String>,
1042 pub progressive_tool_disclosure: bool,
1047 pub tools_activation_confirm: ActivationConfirm,
1053 pub tools_deferred_loading: bool,
1059 pub session_persistence: crate::core::session_journal::SessionPersistence,
1064 pub disabled_tools: Vec<String>,
1067 pub shell: ShellConfig,
1068 pub server: ServerConfig,
1069 pub bridge: BridgeConfig,
1070 pub auth: AuthConfig,
1071 pub events: EventsConfig,
1072 pub provider_keys: BTreeMap<String, String>,
1073 pub keybinds: std::collections::HashMap<String, String>,
1074 pub turn_budgets: TurnBudgetsConfig,
1076 pub memory: MemoryConfig,
1078 pub memory_backend: MemoryBackendConfig,
1080 pub warnings: Vec<String>,
1083}
1084
1085impl Default for SynapsConfig {
1086 fn default() -> Self {
1087 Self {
1088 model: None,
1089 thinking_budget: None,
1090 thinking_level: None,
1091 context_window: None,
1092 context_management: ContextManagementConfig::default(),
1093 startup: StartupConfig::default(),
1094 daemon: DaemonConfig::default(),
1095 subagent: SubagentLimits::default(),
1096 compaction_model: None,
1097 compaction_mode: crate::core::compaction::CompactionMode::default(),
1098 compaction_exclude: Vec::new(),
1099 max_tool_output: 30000,
1100 bash_timeout: 30,
1101 bash_max_timeout: 300,
1102 subagent_timeout: 300,
1103 api_retries: 3,
1104 refusal_retries: 2,
1105 telemetry: "off".to_string(),
1106 cache_diagnostics: false,
1107 cache_ttl: CacheTtl::default(),
1108 cache_keepwarm: CacheKeepWarm::Off,
1109 cache_keepwarm_auto_max_hours: DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS,
1110 max_fps: 60,
1111 scroll_lines: None,
1112 theme: None,
1113 theme_transition: ThemeTransitionMode::default(),
1114 tui_background_opaque: true,
1115 tui_streaming_glow: false,
1116 process_scopes: true,
1117 agent_name: None,
1118 identity: None,
1119 disabled_plugins: Vec::new(),
1120 favorite_models: Vec::new(),
1121 disabled_skills: Vec::new(),
1122 progressive_tool_disclosure: false,
1123 tools_activation_confirm: ActivationConfirm::default(),
1124 tools_deferred_loading: true,
1125 session_persistence: crate::core::session_journal::SessionPersistence::default(),
1126 disabled_tools: Vec::new(),
1127 shell: ShellConfig::default(),
1128 server: ServerConfig::default(),
1129 bridge: BridgeConfig::default(),
1130 auth: AuthConfig::default(),
1131 events: EventsConfig::default(),
1132 provider_keys: BTreeMap::new(),
1133 keybinds: std::collections::HashMap::new(),
1134 turn_budgets: TurnBudgetsConfig::default(),
1135 memory: MemoryConfig::default(),
1136 memory_backend: MemoryBackendConfig::default(),
1137 warnings: Vec::new(),
1138 }
1139 }
1140}
1141
1142pub const KNOWN_CONFIG_KEYS: &[&str] = &[
1146 "model",
1147 "thinking",
1148 "compaction_model",
1149 "compaction_mode",
1150 "compaction_exclude",
1151 "context_window",
1152 "context_management.mode",
1153 "context_management.pressure_tokens",
1154 "context_management.rollover_tokens",
1155 "context_management.reserve_tokens",
1156 "context_management.finish_rounds",
1157 "startup.quick_start",
1158 "startup.extensions_ready_timeout_secs",
1159 "daemon.idle_exit_secs",
1160 "daemon.prompt_abandon_secs",
1161 "daemon.parked_evict_secs",
1162 "max_tool_output",
1163 "bash_timeout",
1164 "bash_max_timeout",
1165 "subagent_timeout",
1166 "subagent.max_concurrent",
1167 "subagent.max_total",
1168 "subagent.max_depth",
1169 "api_retries",
1170 "refusal_retries",
1171 "telemetry",
1172 "cache_diagnostics",
1173 "cache_ttl",
1174 "cache_keepwarm",
1175 "cache_keepwarm_auto_max_hours",
1176 "max_fps",
1177 "scroll_lines",
1178 "theme",
1179 "theme_transition",
1180 "tui_background_opaque",
1181 "tui_streaming_glow",
1182 "process_scopes",
1183 "agent_name",
1184 "identity",
1185 "disabled_plugins",
1186 "favorite_models",
1187 "disabled_skills",
1188 "disabled_tools",
1189 "progressive_tool_disclosure",
1190 "tools.activation_confirm",
1191 "tools.deferred_loading",
1192 "session_persistence",
1193 "sidecar_toggle_key",
1194];
1195
1196pub const FAMILY_CONFIG_KEYS: &[&str] = &[
1203 "shell.max_sessions",
1205 "shell.idle_timeout",
1206 "shell.readiness_timeout_ms",
1207 "shell.max_readiness_timeout_ms",
1208 "shell.default_rows",
1209 "shell.default_cols",
1210 "shell.readiness_strategy",
1211 "shell.max_output",
1212 "events.auto_turn",
1214 "events.auto_turn_cap",
1215 "turn_budget.foreground.max_provider_rounds",
1217 "turn_budget.foreground.max_tool_calls",
1218 "turn_budget.foreground.max_elapsed_secs",
1219 "turn_budget.foreground.max_accumulated_tool_result_bytes",
1220 "turn_budget.foreground.max_context_tokens",
1221 "turn_budget.foreground.max_cost_usd",
1222 "turn_budget.foreground.max_round_renewals",
1223 "turn_budget.autonomous.max_provider_rounds",
1224 "turn_budget.autonomous.max_tool_calls",
1225 "turn_budget.autonomous.max_elapsed_secs",
1226 "turn_budget.autonomous.max_accumulated_tool_result_bytes",
1227 "turn_budget.autonomous.max_context_tokens",
1228 "turn_budget.autonomous.max_cost_usd",
1229 "turn_budget.autonomous.max_round_renewals",
1230 "turn_budget.worker.max_provider_rounds",
1231 "turn_budget.worker.max_tool_calls",
1232 "turn_budget.worker.max_elapsed_secs",
1233 "turn_budget.worker.max_accumulated_tool_result_bytes",
1234 "turn_budget.worker.max_context_tokens",
1235 "turn_budget.worker.max_cost_usd",
1236 "turn_budget.worker.max_round_renewals",
1237 "memory.backend",
1239 "memory.user_scope",
1240 "memory.axel.executable",
1241 "memory.axel.brain",
1242 "memory.default_mode",
1244 "memory.default_mode_confirmed",
1245 "memory.recall_max_records",
1246 "memory.recall_max_tokens",
1247 "memory.recall_timeout_ms",
1248 "memory.capture_tools",
1249 "memory.capture_assistant",
1250 "memory.capture_user",
1251 "memory.auto_consolidate",
1252 "memory.local_embeddings",
1253 "memory.gliner",
1254 "server.allowed_origins",
1256 "server.token",
1257 "server.auto_approve_confirms",
1258 "server.max_message_size",
1259 "bridge.uds_path",
1261 "bridge.heartbeat_mirror",
1262 "bridge.heartbeat_timeout_ms",
1263 "auth.remote_endpoint",
1265 "auth.machine_token",
1266 "provider.*",
1268 "keybind.*",
1269];
1270
1271fn levenshtein(a: &str, b: &str) -> usize {
1273 let a: Vec<char> = a.chars().collect();
1274 let b: Vec<char> = b.chars().collect();
1275 let mut prev: Vec<usize> = (0..=b.len()).collect();
1276 let mut cur = vec![0; b.len() + 1];
1277 for (i, ca) in a.iter().enumerate() {
1278 cur[0] = i + 1;
1279 for (j, cb) in b.iter().enumerate() {
1280 let cost = if ca == cb { 0 } else { 1 };
1281 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1282 }
1283 std::mem::swap(&mut prev, &mut cur);
1284 }
1285 prev[b.len()]
1286}
1287
1288fn did_you_mean(key: &str) -> Option<&'static str> {
1290 KNOWN_CONFIG_KEYS
1291 .iter()
1292 .map(|k| (*k, levenshtein(key, k)))
1293 .filter(|(_, d)| *d <= 2)
1294 .min_by_key(|(_, d)| *d)
1295 .map(|(k, _)| k)
1296}
1297
1298fn parse_comma_list(val: &str) -> Vec<String> {
1301 val.split(',')
1302 .map(|s| s.trim().to_string())
1303 .filter(|s| !s.is_empty())
1304 .collect()
1305}
1306
1307fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1308 write_config_value_locked(key, &values.join(", "))
1309}
1310
1311fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) -> Result<(), String> {
1314 let invalid = || format!("Warning: invalid value for {key}: '{val}', using default");
1315 match key {
1316 "shell.max_sessions" => {
1317 shell_config.max_sessions = val.parse::<usize>().map_err(|_| invalid())?;
1318 }
1319 "shell.idle_timeout" => {
1320 let timeout = val.parse::<u64>().map_err(|_| invalid())?;
1321 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1322 }
1323 "shell.readiness_timeout_ms" => {
1324 shell_config.readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1325 }
1326 "shell.max_readiness_timeout_ms" => {
1327 shell_config.max_readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1328 }
1329 "shell.default_rows" => {
1330 shell_config.default_rows = val.parse::<u16>().map_err(|_| invalid())?;
1331 }
1332 "shell.default_cols" => {
1333 shell_config.default_cols = val.parse::<u16>().map_err(|_| invalid())?;
1334 }
1335 "shell.readiness_strategy" => {
1336 let val_lower = val.to_lowercase();
1337 match val_lower.as_str() {
1338 "timeout" | "prompt" | "hybrid" => {
1339 shell_config.readiness_strategy = val.to_string();
1340 }
1341 _ => return Err(invalid()),
1342 }
1343 }
1344 "shell.max_output" => {
1345 shell_config.max_output = val.parse::<usize>().map_err(|_| invalid())?;
1346 }
1347 _ => {
1348 }
1350 }
1351 Ok(())
1352}
1353
1354#[allow(clippy::collapsible_match)]
1356fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1357 match key {
1358 "server.allowed_origins" => {
1359 server_config.allowed_origins = parse_comma_list(val);
1360 }
1361 "server.token" => {
1362 if !val.is_empty() {
1363 server_config.token = Some(val.to_string());
1364 }
1365 }
1366 "server.auto_approve_confirms" => {
1367 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1368 }
1369 "server.max_message_size" => {
1370 if let Ok(size) = val.parse::<usize>() {
1371 server_config.max_message_size = Some(size);
1372 } else {
1373 eprintln!(
1374 "Warning: invalid value for server.max_message_size: '{}', ignored",
1375 val
1376 );
1377 }
1378 }
1379 _ => {
1380 }
1382 }
1383}
1384
1385fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1387 match key {
1388 "bridge.uds_path" => {
1389 if val.is_empty() {
1390 bridge_config.uds_path = None;
1391 } else {
1392 bridge_config.uds_path = Some(PathBuf::from(val));
1393 }
1394 }
1395 "bridge.heartbeat_mirror" => {
1396 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
1397 }
1398 "bridge.heartbeat_timeout_ms" => {
1399 if let Ok(ms) = val.parse::<u64>() {
1400 bridge_config.heartbeat_timeout_ms = ms;
1401 } else {
1402 eprintln!(
1403 "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
1404 val
1405 );
1406 }
1407 }
1408 _ => {
1409 }
1411 }
1412}
1413
1414fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1416 let v = val.trim();
1417 match key {
1418 "auth.remote_endpoint" => {
1419 auth_config.remote_endpoint = if v.is_empty() {
1420 None
1421 } else {
1422 Some(v.to_string())
1423 };
1424 }
1425 "auth.machine_token" => {
1426 auth_config.machine_token = if v.is_empty() {
1427 None
1428 } else {
1429 Some(v.to_string())
1430 };
1431 }
1432 _ => {
1433 }
1435 }
1436}
1437
1438pub fn load_config_from_str(content: &str) -> SynapsConfig {
1441 let mut config = SynapsConfig::default();
1442 apply_config_content(&mut config, content);
1443 config
1444}
1445
1446pub fn config_content_problems(content: &str) -> Vec<String> {
1451 let mut config = SynapsConfig::default();
1452 let mut problems = Vec::new();
1453 apply_config_content_with(&mut config, content, &mut |diagnostic: String| problems.push(diagnostic));
1454 problems.extend(config.warnings);
1455 problems
1456}
1457
1458pub fn validate_config_edit(key: &str, value: &str) -> Result<(), String> {
1467 if key.contains(['=', '\n', '\r'])
1468 || value.contains(['\n', '\r'])
1469 {
1470 return Err("value must be a single line".to_string());
1471 }
1472 let target = get_active_config_dir().join("config");
1475 let existing = std::fs::read_to_string(&target).unwrap_or_default();
1476 let before = config_content_problems(&existing);
1477 let after = config_content_problems(&replace_config_line(&existing, key, value));
1478 let introduced: Vec<String> = after.into_iter().filter(|p| !before.contains(p)).collect();
1479 if introduced.is_empty() {
1480 Ok(())
1481 } else {
1482 Err(introduced.join("; "))
1483 }
1484}
1485
1486pub fn load_config() -> SynapsConfig {
1489 let path = resolve_read_path("config");
1490 let mut config = SynapsConfig::default();
1491
1492 let Ok(content) = std::fs::read_to_string(&path) else {
1493 return config;
1494 };
1495
1496 apply_config_content(&mut config, &content);
1497
1498 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1501
1502 let identity_val = config
1504 .identity
1505 .clone()
1506 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1507 let _ = IDENTITY.set(identity_val);
1508
1509 config
1510}
1511
1512fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1515 apply_config_content_with(config, content, &mut |diagnostic: String| eprintln!("{diagnostic}"));
1518}
1519
1520fn apply_config_content_with(
1524 config: &mut SynapsConfig,
1525 content: &str,
1526 family_diagnostic: &mut dyn FnMut(String),
1527) {
1528 let mut invalid_context_management = false;
1529 let mut invalid_memory_backend = false;
1530 for line in content.lines() {
1531 let line = line.trim();
1532 if line.is_empty() || line.starts_with('#') {
1533 continue;
1534 }
1535 let Some((key, val)) = line.split_once('=') else {
1536 continue;
1537 };
1538 let key = key.trim();
1539 let val = val.trim();
1540 match key {
1541 "model" => config.model = Some(val.to_string()),
1542 "thinking" => {
1543 use crate::core::reasoning::ThinkingSpec;
1544 match ThinkingSpec::parse(val) {
1545 Some(ThinkingSpec::Named(level)) => {
1546 config.thinking_level = Some(level);
1547 config.thinking_budget = level.to_legacy_budget();
1548 }
1549 Some(ThinkingSpec::Budget(budget)) => {
1550 config.thinking_level = None;
1553 config.thinking_budget = Some(budget);
1554 }
1555 None => {
1556 config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
1557 }
1558 }
1559 }
1560 "compaction_model" => config.compaction_model = Some(val.to_string()),
1561 "compaction_mode" => match val {
1562 "remote" => {
1563 config.compaction_mode = crate::core::compaction::CompactionMode::Remote
1564 }
1565 "local" | "local_only" | "local-only" => {
1566 config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
1567 }
1568 _ => {
1569 config.warnings.push(format!(
1570 "compaction_mode = {val} — expected remote or local; ignored"
1571 ));
1572 }
1573 },
1574 "compaction_exclude" => {
1575 let mut classes = Vec::new();
1576 for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1577 match crate::core::compaction::ContentClass::parse(part) {
1578 Some(class) => {
1579 if !classes.contains(&class) {
1580 classes.push(class);
1581 }
1582 }
1583 None => config.warnings.push(format!(
1584 "compaction_exclude — unknown content class '{part}'; \
1585 expected one of: user_text, assistant_text, thinking, \
1586 tool_calls, tool_results, file_paths, event_data"
1587 )),
1588 }
1589 }
1590 config.compaction_exclude = classes;
1591 }
1592 "context_window" if val.eq_ignore_ascii_case("auto") => config.context_window = None,
1595 "context_window" => {
1596 let parsed = match val {
1597 "200k" | "200K" => Some(200_000),
1598 "1m" | "1M" => Some(1_000_000),
1599 _ => val.parse::<u64>().ok(),
1600 };
1601 if parsed.is_none() {
1602 config.warnings.push(format!(
1603 "context_window = {val} — expected 200k, 1m, auto, or a token count; ignored"
1604 ));
1605 }
1606 config.context_window = parsed;
1607 }
1608 "max_tool_output" => match val.parse::<usize>() {
1609 Ok(size) => config.max_tool_output = size,
1610 Err(_) => config.warnings.push(format!(
1611 "max_tool_output = {val} — not a number; using {}",
1612 config.max_tool_output
1613 )),
1614 },
1615 "bash_timeout" => match val.parse::<u64>() {
1616 Ok(t) if t >= 1 => config.bash_timeout = t,
1617 Ok(_) => config.warnings.push(format!(
1618 "bash_timeout = {val} — below minimum (1s); using {}",
1619 config.bash_timeout
1620 )),
1621 Err(_) => config.warnings.push(format!(
1622 "bash_timeout = {val} — not a number; using {}",
1623 config.bash_timeout
1624 )),
1625 },
1626 "bash_max_timeout" => {
1627 if let Ok(timeout) = val.parse::<u64>() {
1628 config.bash_max_timeout = timeout;
1629 }
1630 }
1631 "subagent_timeout" => {
1632 if let Ok(timeout) = val.parse::<u64>() {
1633 config.subagent_timeout = timeout;
1634 }
1635 }
1636 "api_retries" => {
1637 if let Ok(retries) = val.parse::<u32>() {
1638 config.api_retries = retries;
1639 }
1640 }
1641 "refusal_retries" => {
1642 if let Ok(retries) = val.parse::<u32>() {
1643 config.refusal_retries = retries;
1644 }
1645 }
1646 "telemetry" => config.telemetry = val.to_string(),
1647 "cache_diagnostics" => {
1648 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1649 }
1650 "cache_ttl" => match CacheTtl::parse(val) {
1651 Some(ttl) => config.cache_ttl = ttl,
1652 None => config.warnings.push(format!(
1653 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1654 )),
1655 },
1656 "cache_keepwarm" => match CacheKeepWarm::parse(val) {
1657 Some(mode) => config.cache_keepwarm = mode,
1658 None => config.warnings.push(format!(
1659 "cache_keepwarm = {val} — expected off, auto, or 1h–{MAX_CACHE_KEEPWARM_HOURS}h; keep-warm stays off"
1660 )),
1661 },
1662 "cache_keepwarm_auto_max_hours" => {
1663 match val.trim().trim_end_matches(['h', 'H']).parse::<u32>() {
1664 Ok(h) if (1..=MAX_CACHE_KEEPWARM_HOURS).contains(&h) => {
1665 config.cache_keepwarm_auto_max_hours = h
1666 }
1667 _ => config.warnings.push(format!(
1668 "cache_keepwarm_auto_max_hours = {val} — expected 1–{MAX_CACHE_KEEPWARM_HOURS}; using {DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS}"
1669 )),
1670 }
1671 }
1672 "max_fps" => match val.parse::<u32>() {
1673 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1674 Ok(_) => config.warnings.push(format!(
1675 "max_fps = {val} — expected 1–1000; using {}",
1676 config.max_fps
1677 )),
1678 Err(_) => config.warnings.push(format!(
1679 "max_fps = {val} — not a number; using {}",
1680 config.max_fps
1681 )),
1682 },
1683 "scroll_lines" => match val.parse::<u16>() {
1684 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1685 Ok(_) => config
1686 .warnings
1687 .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1688 Err(_) => config
1689 .warnings
1690 .push(format!("scroll_lines = {val} — not a number; ignoring")),
1691 },
1692 "theme" => config.theme = Some(val.to_string()),
1693 "theme_transition" => match ThemeTransitionMode::parse(val) {
1694 Some(mode) => config.theme_transition = mode,
1695 None => config.warnings.push(format!(
1696 "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1697 )),
1698 },
1699 "tui_background_opaque" => match val {
1700 "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1701 "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1702 _ => config.warnings.push(format!(
1703 "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1704 if config.tui_background_opaque {
1705 "opaque"
1706 } else {
1707 "invisible"
1708 }
1709 )),
1710 },
1711 "tui_streaming_glow" => match val {
1712 "true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
1713 "false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
1714 _ => config.warnings.push(format!(
1715 "tui_streaming_glow = {val} — expected on or off; using off"
1716 )),
1717 },
1718 "process_scopes" => match val {
1719 "true" | "1" | "on" | "yes" | "auto" => config.process_scopes = true,
1720 "false" | "0" | "off" | "no" => config.process_scopes = false,
1721 _ => config.warnings.push(format!(
1722 "process_scopes = {val} — expected auto or off; using auto"
1723 )),
1724 },
1725 "sidecar_toggle_key" => {}
1729 "agent_name" => config.agent_name = Some(val.to_string()),
1730 "identity" => config.identity = Some(val.to_string()),
1731 "disabled_plugins" => {
1732 config.disabled_plugins = parse_comma_list(val);
1733 }
1734 "favorite_models" => {
1735 config.favorite_models = parse_comma_list(val);
1736 }
1737 "disabled_skills" => {
1738 config.disabled_skills = parse_comma_list(val);
1739 }
1740 "progressive_tool_disclosure" => {
1741 config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1742 }
1743 "tools.deferred_loading" => match val {
1744 "on" | "true" | "1" | "yes" => config.tools_deferred_loading = true,
1745 "off" | "false" | "0" | "no" => config.tools_deferred_loading = false,
1746 _ => config.warnings.push(format!(
1747 "tools.deferred_loading = {val} — expected on or off; keeping the default (on)"
1748 )),
1749 },
1750 "tools.activation_confirm" => match ActivationConfirm::parse(val) {
1751 Some(mode) => config.tools_activation_confirm = mode,
1752 None => config.warnings.push(format!(
1753 "tools.activation_confirm = {val} — expected auto, prompt or deny; \
1754 keeping the default (auto)"
1755 )),
1756 },
1757 "session_persistence" => {
1758 match crate::core::session_journal::SessionPersistence::parse(val) {
1759 Some(mode) => config.session_persistence = mode,
1760 None => config.warnings.push(format!(
1761 "session_persistence = {val} — expected json or journal; \
1762 keeping the default (json)"
1763 )),
1764 }
1765 }
1766 "disabled_tools" => {
1767 config.disabled_tools = parse_comma_list(val);
1768 }
1769 "memory.backend" => {
1770 config.memory_backend.kind = match val {
1771 "legacy" => MemoryBackendKind::Legacy,
1772 "axel" => MemoryBackendKind::Axel,
1773 _ => {
1774 invalid_memory_backend = true;
1775 config.warnings.push(format!(
1776 "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
1777 ));
1778 MemoryBackendKind::Unavailable
1779 }
1780 };
1781 }
1782 "memory.user_scope" => match val {
1783 "true" => config.memory_backend.user_scope = true,
1784 "false" => config.memory_backend.user_scope = false,
1785 _ => {
1786 invalid_memory_backend = true;
1787 config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
1788 }
1789 },
1790 "memory.axel.executable" | "memory.axel.brain" => {
1791 let path = PathBuf::from(val);
1792 if !path.is_absolute() {
1793 invalid_memory_backend = true;
1794 config.warnings.push(format!(
1795 "{key} — expected an absolute path; memory backend unavailable"
1796 ));
1797 }
1798 if key == "memory.axel.executable" {
1801 config.memory_backend.executable = Some(path);
1802 } else {
1803 config.memory_backend.brain = Some(path);
1804 }
1805 }
1806 _ => {
1807 if key.starts_with("shell.") {
1809 if let Err(diagnostic) = parse_shell_config_key(&mut config.shell, key, val) {
1810 family_diagnostic(diagnostic);
1811 }
1812 } else if key.starts_with("server.") {
1813 parse_server_config_key(&mut config.server, key, val);
1814 } else if key.starts_with("bridge.") {
1815 parse_bridge_config_key(&mut config.bridge, key, val);
1816 } else if key.starts_with("auth.") {
1817 parse_auth_config_key(&mut config.auth, key, val);
1818 } else if key.starts_with("events.") {
1819 if let Err(diagnostic) = parse_events_config_key(&mut config.events, key, val)
1820 {
1821 family_diagnostic(diagnostic);
1822 }
1823 } else if key.starts_with("turn_budget.") {
1824 if let Err(diagnostic) =
1825 parse_turn_budget_config_key(&mut config.turn_budgets, key, val)
1826 {
1827 family_diagnostic(diagnostic);
1828 }
1829 } else if key.starts_with("memory.") {
1830 if let Err(diagnostic) = parse_memory_config_key(&mut config.memory, key, val)
1831 {
1832 family_diagnostic(diagnostic);
1833 }
1834 } else if key.starts_with("startup.") {
1835 if let Err(reason) =
1836 parse_startup_config_key(&mut config.startup, key, val)
1837 {
1838 config.warnings.push(format!("{key} — {reason}"));
1839 }
1840 } else if key.starts_with("daemon.") {
1841 if let Err(reason) =
1842 parse_daemon_config_key(&mut config.daemon, key, val)
1843 {
1844 config.warnings.push(format!("{key} — {reason}"));
1845 }
1846 } else if key.starts_with("subagent.") {
1847 if let Err(reason) =
1848 parse_subagent_config_key(&mut config.subagent, key, val)
1849 {
1850 config.warnings.push(format!("{key} — {reason}"));
1851 }
1852 } else if key.starts_with("context_management.") {
1853 if let Err(reason) = parse_context_management_config_key(
1854 &mut config.context_management, key, val,
1855 ) {
1856 invalid_context_management = true;
1857 config.warnings.push(format!("{key} — {reason}"));
1858 }
1859 } else if let Some(provider_key) = key.strip_prefix("provider.") {
1860 config
1861 .provider_keys
1862 .insert(provider_key.to_string(), val.to_string());
1863 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1864 config
1865 .keybinds
1866 .insert(keybind_key.to_string(), val.to_string());
1867 } else if key.contains('.') {
1868 } else {
1872 match did_you_mean(key) {
1874 Some(suggestion) => config.warnings.push(format!(
1875 "unknown key '{key}' (did you mean '{suggestion}'?)"
1876 )),
1877 None => config
1878 .warnings
1879 .push(format!("unknown key '{key}' — ignored")),
1880 }
1881 }
1882 }
1883 }
1884 }
1885
1886 let context_validation = match config.context_window {
1889 Some(window) => config.context_management.validate_for_window(window),
1890 None => config.context_management.validate(),
1891 };
1892 if let Err(reason) = context_validation {
1893 invalid_context_management = true;
1894 config
1895 .warnings
1896 .push(format!("context_management — {reason}"));
1897 }
1898 if config.subagent.max_concurrent > config.subagent.max_total {
1902 config.warnings.push(format!(
1903 "subagent.max_concurrent ({}) is above subagent.max_total ({}); using {} for both",
1904 config.subagent.max_concurrent, config.subagent.max_total, config.subagent.max_total
1905 ));
1906 config.subagent.max_concurrent = config.subagent.max_total;
1907 }
1908 if invalid_context_management {
1909 config.context_management = ContextManagementConfig::default();
1910 config.warnings.push(
1911 "invalid context_management configuration — using defaults (mode off)".to_string(),
1912 );
1913 }
1914
1915 if invalid_memory_backend {
1918 config.memory_backend.kind = MemoryBackendKind::Unavailable;
1919 }
1920
1921 if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1926 config.warnings.push(format!(
1927 "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1928 in the same config; reverting to off",
1929 config.memory.default_mode
1930 ));
1931 config.memory.default_mode = "off".to_string();
1932 }
1933
1934 if config.server.max_message_size.is_none() {
1937 if let Some(ctx_tokens) = config.context_window {
1938 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1939 }
1940 }
1941}
1942
1943pub fn read_config_value(key: &str) -> Option<String> {
1945 let path = resolve_read_path("config");
1946 let content = std::fs::read_to_string(&path).ok()?;
1947 for line in content.lines() {
1948 let line = line.trim();
1949 if line.is_empty() || line.starts_with('#') {
1950 continue;
1951 }
1952 let Some((k, v)) = line.split_once('=') else {
1953 continue;
1954 };
1955 if k.trim() == key.trim() {
1956 return Some(v.trim().to_string());
1957 }
1958 }
1959 None
1960}
1961
1962struct ConfigLock {
1974 _file: Option<std::fs::File>,
1975}
1976
1977impl ConfigLock {
1978 fn acquire() -> std::io::Result<Self> {
1979 if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
1980 return Ok(Self { _file: None });
1981 }
1982 use fs4::fs_std::FileExt;
1983 let lock_path = resolve_write_path("config").with_extension("lock");
1984 if let Some(parent) = lock_path.parent() {
1985 std::fs::create_dir_all(parent)?;
1986 }
1987 let mut open = std::fs::OpenOptions::new();
1988 open.create(true).write(true).truncate(false);
1989 #[cfg(unix)]
1990 {
1991 use std::os::unix::fs::OpenOptionsExt;
1992 open.mode(0o600);
1993 }
1994 let file = open.open(&lock_path)?;
1995 FileExt::lock_exclusive(&file)?;
1996 Ok(Self { _file: Some(file) })
1997 }
1998}
1999
2000pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
2004 let _guard = ConfigLock::acquire()?;
2005 write_config_value_locked(key, value)
2006}
2007
2008fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
2010 let path = resolve_write_path("config");
2011 let existing = std::fs::read_to_string(&path).unwrap_or_default();
2012 let out = replace_config_line(&existing, key, value);
2013
2014 let tmp = path.with_extension("tmp");
2015 std::fs::write(&tmp, out)?;
2016 #[cfg(unix)]
2018 {
2019 use std::os::unix::fs::PermissionsExt;
2020 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
2021 }
2022 std::fs::rename(&tmp, &path)?;
2023 Ok(())
2024}
2025
2026fn replace_config_line(existing: &str, key: &str, value: &str) -> String {
2030 let key_trimmed = key.trim();
2031 let replacement = format!("{} = {}", key_trimmed, value);
2032
2033 let mut found = false;
2034 let mut new_lines: Vec<String> = existing
2035 .lines()
2036 .map(|line| {
2037 if found {
2038 return line.to_string();
2039 }
2040 let t = line.trim_start();
2041 if t.starts_with('#') || t.is_empty() {
2042 return line.to_string();
2043 }
2044 if let Some((k, _)) = t.split_once('=') {
2045 if k.trim() == key_trimmed {
2046 found = true;
2047 return replacement.clone();
2048 }
2049 }
2050 line.to_string()
2051 })
2052 .collect();
2053
2054 if !found {
2055 new_lines.push(replacement);
2056 }
2057
2058 let mut out = new_lines.join("\n");
2059 if !out.ends_with('\n') {
2060 out.push('\n');
2061 }
2062 out
2063}
2064
2065pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
2067 let trimmed = id.trim();
2068 if trimmed.is_empty() {
2069 return Ok(());
2070 }
2071 let _guard = ConfigLock::acquire()?;
2072 let mut values = load_config().favorite_models;
2073 if !values.iter().any(|v| v == trimmed) {
2074 values.push(trimmed.to_string());
2075 values.sort();
2076 }
2077 write_comma_list_locked("favorite_models", &values)
2078}
2079
2080pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
2082 let _guard = ConfigLock::acquire()?;
2083 let mut values = load_config().favorite_models;
2084 values.retain(|v| v != id.trim());
2085 write_comma_list_locked("favorite_models", &values)
2086}
2087
2088pub fn is_favorite_model(id: &str) -> bool {
2090 load_config().favorite_models.iter().any(|v| v == id.trim())
2091}
2092
2093pub fn is_secret_key(key: &str) -> bool {
2097 let upper = key.to_ascii_uppercase();
2098 upper.ends_with("_KEY")
2099 || upper.ends_with("_TOKEN")
2100 || upper.ends_with("_CREDENTIALS")
2101 || upper.ends_with("_API_KEY")
2102 || upper.contains("SECRET")
2103 || upper.contains("PASSWORD")
2104 || upper.contains("PASSWD")
2105 || upper.ends_with("_PAT")
2106 || upper.ends_with("_DSN")
2107}
2108
2109pub fn is_secret_env_value(value: &str) -> bool {
2117 let mut rest = value;
2118 while let Some(i) = rest.find("://") {
2119 let scheme_ok = rest[..i]
2120 .chars()
2121 .next_back()
2122 .is_some_and(|c| c.is_ascii_alphanumeric());
2123 let after = &rest[i + 3..];
2124 let end = after
2127 .find(|c: char| {
2128 matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
2129 })
2130 .unwrap_or(after.len());
2131 if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
2132 return true;
2133 }
2134 rest = after;
2135 }
2136 false
2137}
2138
2139pub fn is_secret_env(key: &str, value: &str) -> bool {
2142 is_secret_key(key) || is_secret_env_value(value)
2143}
2144
2145pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
2148 #[cfg(windows)]
2149 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
2150 You have access to powershell, bash, read, and write tools. \
2151 Prefer the powershell tool for system commands and filesystem operations — it is native \
2152 to Windows and avoids quoting/path translation issues. \
2153 Use bash only when the task specifically requires WSL or a Unix environment. \
2154 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
2155 #[cfg(not(windows))]
2156 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
2157 You have access to bash, read, and write tools. \
2158 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
2159
2160 if let Some(val) = explicit {
2161 let path = std::path::Path::new(val);
2162 if path.exists() && path.is_file() {
2163 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
2164 }
2165 return val.to_string();
2166 }
2167
2168 let system_path = resolve_read_path("system.md");
2169 if system_path.exists() {
2170 return std::fs::read_to_string(&system_path).unwrap_or_default();
2171 }
2172
2173 DEFAULT_PROMPT.to_string()
2174}
2175
2176#[cfg(test)]
2177mod tests {
2178 #[test]
2179 fn tools_deferred_loading_defaults_on_and_parses() {
2180 assert!(super::load_config_from_str("").tools_deferred_loading);
2181 assert!(
2182 !super::load_config_from_str("tools.deferred_loading = off\n").tools_deferred_loading
2183 );
2184 let bad = super::load_config_from_str("tools.deferred_loading = maybe\n");
2185 assert!(
2186 bad.tools_deferred_loading,
2187 "invalid value keeps the default"
2188 );
2189 assert!(bad
2190 .warnings
2191 .iter()
2192 .any(|w| w.contains("tools.deferred_loading")));
2193 }
2194
2195 #[test]
2196 fn secret_env_value_detects_credential_urls() {
2197 use super::is_secret_env_value as v;
2198 assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
2200 assert!(v("redis://:pw@cache:6379/0"));
2201 assert!(v("http://user:pass@proxy.corp:3128"));
2202 assert!(v(
2203 "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
2204 ));
2205 assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
2207 assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
2209 assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
2210 assert!(!v("https://api.example.com/v1/items?q=a@b"));
2212 assert!(!v("http://host/path/with@sign"));
2213 assert!(!v("https://@host/empty-userinfo"));
2214 assert!(!v("git@github.com:org/repo.git"));
2215 assert!(!v("mailto:someone@example.com"));
2216 assert!(!v("someone@example.com"));
2217 assert!(!v("/usr/local/bin:/usr/bin"));
2218 assert!(!v(""));
2219 }
2220
2221 #[test]
2222 fn secret_key_covers_pat_and_dsn() {
2223 assert!(super::is_secret_key("GITHUB_PAT"));
2224 assert!(super::is_secret_key("gh_pat"));
2225 assert!(super::is_secret_key("SENTRY_DSN"));
2226 assert!(!super::is_secret_key("PATH"));
2227 assert!(!super::is_secret_key("PATTERN_DIR"));
2228 }
2229
2230 #[test]
2231 fn secret_env_is_key_or_value() {
2232 assert!(super::is_secret_env(
2233 "DATABASE_URL",
2234 "postgres://app:hunter2@db/prod"
2235 ));
2236 assert!(super::is_secret_env("GH_TOKEN", "anything"));
2237 assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
2238 assert!(!super::is_secret_env("HOME", "/home/u"));
2239 }
2240 #[test]
2241 fn startup_and_daemon_defaults() {
2242 let c = super::load_config_from_str("");
2243 assert!(c.startup.quick_start, "quick_start defaults ON");
2244 assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
2245 assert_eq!(c.daemon.idle_exit_secs, 10);
2246 assert_eq!(c.daemon.prompt_abandon_secs, 3600);
2247 assert_eq!(c.daemon.parked_evict_secs, 3600);
2248 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2250 }
2251
2252 #[test]
2253 fn startup_and_daemon_parse_all_keys() {
2254 let c = super::load_config_from_str(concat!(
2255 "startup.quick_start = off\n",
2256 "startup.extensions_ready_timeout_secs = 45\n",
2257 "daemon.idle_exit_secs = 20\n",
2258 "daemon.prompt_abandon_secs = 0\n",
2259 "daemon.parked_evict_secs = 120\n",
2260 ));
2261 assert!(!c.startup.quick_start);
2262 assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
2263 assert_eq!(c.daemon.idle_exit_secs, 20);
2264 assert_eq!(c.daemon.prompt_abandon_secs, 0);
2265 assert_eq!(c.daemon.parked_evict_secs, 120);
2266 assert!(
2267 !c.warnings.iter().any(|w| w.contains("unknown")),
2268 "no unknown-key warning: {:?}",
2269 c.warnings
2270 );
2271 }
2272
2273 #[test]
2274 fn subagent_limits_default_to_16_64_4() {
2275 let c = super::load_config_from_str("");
2276 assert_eq!(c.subagent, super::SubagentLimits::default());
2277 assert_eq!(c.subagent.max_concurrent, 16);
2278 assert_eq!(c.subagent.max_total, 64);
2279 assert_eq!(c.subagent.max_depth, 4);
2280 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2281 }
2282
2283 #[test]
2284 fn subagent_limits_parse_and_are_known_keys() {
2285 let c = super::load_config_from_str(concat!(
2286 "subagent.max_concurrent = 24\n",
2287 "subagent.max_total = 100\n",
2288 "subagent.max_depth = 2\n",
2289 "subagent_timeout = 600\n",
2290 ));
2291 assert_eq!(c.subagent.max_concurrent, 24);
2292 assert_eq!(c.subagent.max_total, 100);
2293 assert_eq!(c.subagent.max_depth, 2);
2294 assert_eq!(c.subagent_timeout, 600, "the old flat key is unaffected");
2295 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2296 for k in [
2297 "subagent.max_concurrent",
2298 "subagent.max_total",
2299 "subagent.max_depth",
2300 ] {
2301 assert!(
2302 super::KNOWN_CONFIG_KEYS.contains(&k),
2303 "{k} not in KNOWN_CONFIG_KEYS"
2304 );
2305 }
2306 }
2307
2308 #[test]
2309 fn subagent_limits_reject_zero_and_junk_and_keep_defaults() {
2310 let c = super::load_config_from_str(concat!(
2311 "subagent.max_concurrent = 0\n",
2312 "subagent.max_total = lots\n",
2313 "subagent.max_depth = 70000\n",
2314 "subagent.max_children = 4\n",
2315 ));
2316 assert_eq!(c.subagent, super::SubagentLimits::default());
2317 for key in [
2318 "subagent.max_concurrent",
2319 "subagent.max_total",
2320 "subagent.max_depth",
2321 "subagent.max_children",
2322 ] {
2323 assert!(
2324 c.warnings.iter().any(|w| w.starts_with(key)),
2325 "expected a warning for {key}: {:?}",
2326 c.warnings
2327 );
2328 }
2329 }
2330
2331 #[test]
2332 fn subagent_concurrency_above_total_uses_the_stricter_number() {
2333 for text in [
2335 "subagent.max_total = 10\nsubagent.max_concurrent = 32\n",
2336 "subagent.max_concurrent = 32\nsubagent.max_total = 10\n",
2337 ] {
2338 let c = super::load_config_from_str(text);
2339 assert_eq!(c.subagent.max_concurrent, 10);
2340 assert_eq!(c.subagent.max_total, 10);
2341 assert!(
2342 c.warnings
2343 .iter()
2344 .any(|w| w.contains("subagent.max_concurrent (32)")),
2345 "{:?}",
2346 c.warnings
2347 );
2348 }
2349 let c = super::load_config_from_str("subagent.max_total = 5\n");
2351 assert_eq!(c.subagent.max_concurrent, 5);
2352 }
2353
2354 #[test]
2355 fn process_scopes_defaults_on_and_parses_auto_and_off() {
2356 assert!(super::load_config_from_str("").process_scopes);
2357 assert!(super::load_config_from_str("process_scopes = auto\n").process_scopes);
2358 let off = super::load_config_from_str("process_scopes = off\n");
2359 assert!(!off.process_scopes);
2360 assert!(off.warnings.is_empty(), "{:?}", off.warnings);
2361 let bad = super::load_config_from_str("process_scopes = sometimes\n");
2362 assert!(bad.process_scopes, "an invalid value keeps the default");
2363 assert!(bad.warnings.iter().any(|w| w.starts_with("process_scopes")));
2364 assert!(super::KNOWN_CONFIG_KEYS.contains(&"process_scopes"));
2365 }
2366
2367 #[test]
2368 fn startup_quick_start_accepts_on() {
2369 let c = super::load_config_from_str("startup.quick_start = on\n");
2370 assert!(c.startup.quick_start);
2371 }
2372
2373 #[test]
2374 fn startup_and_daemon_keys_are_known() {
2375 for k in [
2376 "startup.quick_start",
2377 "startup.extensions_ready_timeout_secs",
2378 "daemon.idle_exit_secs",
2379 "daemon.prompt_abandon_secs",
2380 "daemon.parked_evict_secs",
2381 ] {
2382 assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
2383 }
2384 }
2385
2386 #[test]
2387 fn startup_daemon_bad_values_warn_and_keep_defaults() {
2388 let c = super::load_config_from_str(concat!(
2389 "startup.quick_start = maybe\n",
2390 "daemon.idle_exit_secs = soon\n",
2391 ));
2392 assert!(c.startup.quick_start, "invalid bool keeps default");
2393 assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
2394 assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
2395 assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
2396 }
2397
2398 #[test]
2399 fn context_management_defaults_off_and_parses_all_keys() {
2400 use super::{ContextManagementConfig, ContextManagementMode};
2401 assert_eq!(
2402 super::load_config_from_str("").context_management,
2403 ContextManagementConfig::default()
2404 );
2405 let parsed = super::load_config_from_str(
2406 "context_management.mode = AUTO\n\
2407 context_management.pressure_tokens = 250000\n\
2408 context_management.rollover_tokens = 400000\n\
2409 context_management.reserve_tokens = 24000\n\
2410 context_management.finish_rounds = 3\n\
2411 context_window = 1m\n",
2412 );
2413 assert_eq!(
2414 parsed.context_management,
2415 ContextManagementConfig {
2416 mode: ContextManagementMode::Auto,
2417 pressure_tokens: Some(250_000),
2418 rollover_tokens: Some(400_000),
2419 reserve_tokens: 24_000,
2420 finish_rounds: 3,
2421 }
2422 );
2423 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
2424 assert!(parsed.provider_keys.is_empty());
2425 assert!(super::load_config_from_str("context_management.mode = off")
2426 .warnings
2427 .is_empty());
2428 }
2429
2430 #[test]
2431 fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
2432 for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
2433 let parsed = super::load_config_from_str(&format!(
2434 "context_management.mode = auto\n\
2435 context_management.pressure_tokens = 300000\n\
2436 context_management.rollover_tokens = 450000\n\
2437 context_management.pressure_tokens = auto\n\
2438 context_management.rollover_tokens = AUTO\n\
2439 context_management.finish_rounds = {rounds}\n"
2440 ));
2441 assert_eq!(parsed.context_management.pressure_tokens, None);
2442 assert_eq!(parsed.context_management.rollover_tokens, None);
2443 assert_eq!(parsed.context_management.finish_rounds, rounds);
2444 assert!(parsed.warnings.is_empty());
2445 }
2446 }
2447
2448 #[test]
2449 fn context_management_invalid_values_reset_entire_policy() {
2450 for (key, value) in [
2451 ("mode", "on"),
2452 ("pressure_tokens", "0"),
2453 ("pressure_tokens", "-1"),
2454 ("pressure_tokens", "250k"),
2455 ("pressure_tokens", "18446744073709551616"),
2456 ("rollover_tokens", "1"),
2457 ("rollover_tokens", "nope"),
2458 ("reserve_tokens", "0"),
2459 ("reserve_tokens", "1000001"),
2460 ("reserve_tokens", "auto"),
2461 ("finish_rounds", "9"),
2462 ("finish_rounds", "-1"),
2463 ("finish_rounds", "4294967296"),
2464 ("finish_rounds", ""),
2465 ("mod", "auto"),
2466 ] {
2467 let parsed = super::load_config_from_str(&format!(
2468 "context_management.mode = auto\ncontext_management.{key} = {value}\n"
2469 ));
2470 assert_eq!(
2471 parsed.context_management,
2472 super::ContextManagementConfig::default(),
2473 "{key}={value}"
2474 );
2475 assert!(!parsed.warnings.is_empty(), "{key}={value}");
2476 assert!(parsed.provider_keys.is_empty());
2477 }
2478 let duplicate = super::load_config_from_str(
2479 "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
2480 );
2481 assert_eq!(
2482 duplicate.context_management,
2483 super::ContextManagementConfig::default()
2484 );
2485 }
2486
2487 #[test]
2488 fn context_management_validates_final_threshold_order_and_window() {
2489 for fields in [
2490 "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
2491 "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
2492 "pressure_tokens = 1000000",
2493 "rollover_tokens = 1000001",
2494 ] {
2495 for window_first in [false, true] {
2496 let policy =
2497 format!("context_management.mode = auto\ncontext_management.{fields}\n");
2498 let content = if window_first {
2499 format!("context_window = 1m\n{policy}")
2500 } else {
2501 format!("{policy}context_window = 1m\n")
2502 };
2503 let parsed = super::load_config_from_str(&content);
2504 assert_eq!(
2505 parsed.context_management,
2506 super::ContextManagementConfig::default()
2507 );
2508 assert!(!parsed.warnings.is_empty());
2509 }
2510 }
2511 let parsed = super::load_config_from_str(
2513 "context_management.mode = auto\n\
2514 context_management.rollover_tokens = 100000\n\
2515 context_management.pressure_tokens = 300000\n\
2516 context_management.rollover_tokens = 450000\ncontext_window = 1m",
2517 );
2518 assert!(parsed.warnings.is_empty());
2519 assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
2520 }
2521
2522 #[test]
2523 fn context_management_parsed_finish_override_bounds_rollover_extension() {
2524 use crate::core::context_policy::{
2525 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2526 };
2527 let config = super::load_config_from_str(
2528 "context_management.mode = auto\n\
2529 context_management.finish_rounds = 1\ncontext_window = 1m",
2530 );
2531 let budget = ContextBudget {
2532 context_window_tokens: 1_000_000,
2533 used_tokens: 400_000,
2534 hard_remaining_tokens: 600_000,
2535 required_next_round_tokens: 8_000,
2536 };
2537 let mut state = ContextState::default();
2538 state.report_phase(WorkPhase::Plan);
2539 let first = assess_context(&config.context_management, &state, budget);
2540 assert_eq!(
2541 first.action,
2542 ContextAction::FinishBounded {
2543 rounds_remaining: 0
2544 }
2545 );
2546 state = first.next_state;
2547 state.report_phase(WorkPhase::WrapUp);
2548 assert_eq!(
2549 assess_context(&config.context_management, &state, budget).action,
2550 ContextAction::Rollover
2551 );
2552 }
2553
2554 #[test]
2555 fn context_management_keys_are_known_for_suggestions() {
2556 for key in [
2557 "mode",
2558 "pressure_tokens",
2559 "rollover_tokens",
2560 "reserve_tokens",
2561 "finish_rounds",
2562 ] {
2563 let full = format!("context_management.{key}");
2564 assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
2565 assert_eq!(
2566 super::did_you_mean(&format!("{full}x")),
2567 Some(full.as_str())
2568 );
2569 }
2570 }
2571
2572 #[test]
2573 fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
2574 use crate::core::context_policy::{
2575 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2576 };
2577 let config =
2578 super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
2579 let budget = ContextBudget {
2580 context_window_tokens: config.context_window.unwrap(),
2581 used_tokens: 350_000,
2582 hard_remaining_tokens: 650_000,
2583 required_next_round_tokens: 8_000,
2584 };
2585 let mut state = ContextState::default();
2586 state.report_phase(WorkPhase::Plan);
2587 let plan = assess_context(&config.context_management, &state, budget);
2588 assert_eq!(plan.action, ContextAction::Advisory);
2589 state = plan.next_state;
2590 state.report_phase(WorkPhase::Execute);
2591 assert_eq!(
2592 assess_context(&config.context_management, &state, budget).action,
2593 ContextAction::Rollover
2594 );
2595 }
2596
2597 #[test]
2598 fn tui_streaming_glow_defaults_off_and_parses() {
2599 assert!(!super::load_config_from_str("").tui_streaming_glow);
2600 assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
2601 assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
2602 let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
2603 assert!(
2604 !invalid.tui_streaming_glow,
2605 "invalid keeps the default (off)"
2606 );
2607 assert!(invalid
2608 .warnings
2609 .iter()
2610 .any(|w| w.contains("tui_streaming_glow")));
2611 }
2612
2613 #[test]
2614 fn tui_background_opaque_defaults_and_parses_supported_values() {
2615 assert!(super::load_config_from_str("").tui_background_opaque);
2616 assert!(
2617 super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
2618 );
2619 assert!(
2620 !super::load_config_from_str("tui_background_opaque = invisible\n")
2621 .tui_background_opaque
2622 );
2623 let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
2624 assert!(invalid.tui_background_opaque);
2625 assert!(invalid
2626 .warnings
2627 .iter()
2628 .any(|w| w.contains("tui_background_opaque")));
2629 }
2630
2631 #[test]
2632 fn turn_budget_keys_parse_typed_per_role() {
2633 let config = super::load_config_from_str(
2634 "turn_budget.worker.max_provider_rounds = 3\n\
2635 turn_budget.worker.max_cost_usd = 0.25\n\
2636 turn_budget.autonomous.max_elapsed_secs = 90\n\
2637 turn_budget.foreground.max_tool_calls = 7\n\
2638 turn_budget.foreground.max_round_renewals = 12\n\
2639 turn_budget.worker.max_provider_rounds_bogus = 1\n\
2640 turn_budget.nosuchrole.max_tool_calls = 1\n",
2641 );
2642 assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
2643 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
2644 assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
2645 assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
2646 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
2647 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
2649 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
2651 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
2652 }
2653
2654 #[test]
2655 fn memory_keys_all_parse_with_consent() {
2656 let config = super::load_config_from_str(
2657 "memory.default_mode = capture_and_recall\n\
2658 memory.default_mode_confirmed = true\n\
2659 memory.recall_max_records = 12\n\
2660 memory.recall_max_tokens = 2048\n\
2661 memory.recall_timeout_ms = 250\n\
2662 memory.capture_tools = off\n\
2663 memory.capture_assistant = false\n\
2664 memory.capture_user = false\n\
2665 memory.auto_consolidate = on\n\
2666 memory.local_embeddings = on\n\
2667 memory.gliner = on\n",
2668 );
2669 assert_eq!(config.memory.default_mode, "capture_and_recall");
2670 assert!(config.memory.default_mode_confirmed);
2671 assert_eq!(config.memory.recall_max_records, 12);
2672 assert_eq!(config.memory.recall_max_tokens, 2048);
2673 assert_eq!(config.memory.recall_timeout_ms, 250);
2674 assert_eq!(config.memory.capture_tools, "off");
2675 assert!(!config.memory.capture_assistant);
2676 assert!(!config.memory.capture_user);
2677 assert_eq!(config.memory.auto_consolidate, "on");
2678 assert_eq!(config.memory.local_embeddings, "on");
2679 assert_eq!(config.memory.gliner, "on");
2680 }
2681
2682 #[test]
2683 fn memory_defaults_match_spec_12() {
2684 let config = super::load_config_from_str("");
2685 assert_eq!(config.memory, super::MemoryConfig::default());
2686 assert_eq!(config.memory.default_mode, "off");
2687 assert!(!config.memory.default_mode_confirmed);
2688 assert_eq!(config.memory.recall_max_records, 8);
2689 assert_eq!(config.memory.recall_max_tokens, 4096);
2690 assert_eq!(config.memory.recall_timeout_ms, 150);
2691 assert_eq!(config.memory.capture_tools, "summary_only");
2692 assert!(config.memory.capture_assistant);
2693 assert!(config.memory.capture_user);
2694 assert_eq!(config.memory.auto_consolidate, "off");
2695 assert_eq!(config.memory.local_embeddings, "off");
2696 assert_eq!(config.memory.gliner, "off");
2697 }
2698
2699 #[test]
2700 fn memory_unknown_values_fail_closed_to_defaults() {
2701 let config = super::load_config_from_str(
2702 "memory.default_mode = recall_everything_forever\n\
2703 memory.default_mode_confirmed = true\n\
2704 memory.recall_max_records = lots\n\
2705 memory.recall_max_tokens = -5\n\
2706 memory.recall_timeout_ms = fast\n\
2707 memory.capture_tools = full\n\
2708 memory.capture_assistant = maybe\n\
2709 memory.capture_user = sometimes\n\
2710 memory.auto_consolidate = aggressive\n\
2711 memory.local_embeddings = auto\n\
2712 memory.gliner = download\n\
2713 memory.nosuchfield = 1\n",
2714 );
2715 assert_eq!(config.memory.default_mode, "off");
2717 assert_eq!(config.memory.recall_max_records, 8);
2718 assert_eq!(config.memory.recall_max_tokens, 4096);
2719 assert_eq!(config.memory.recall_timeout_ms, 150);
2720 assert_eq!(config.memory.capture_tools, "summary_only");
2721 assert!(config.memory.capture_assistant);
2722 assert!(config.memory.capture_user);
2723 assert_eq!(config.memory.auto_consolidate, "off");
2724 assert_eq!(config.memory.local_embeddings, "off");
2725 assert_eq!(config.memory.gliner, "off");
2726 }
2727
2728 #[test]
2729 fn memory_default_mode_without_consent_reverts_to_off() {
2730 let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
2731 assert_eq!(config.memory.default_mode, "off");
2732 assert!(
2733 config
2734 .warnings
2735 .iter()
2736 .any(|w| w.contains("memory.default_mode_confirmed")),
2737 "reversion must surface a warning; got: {:?}",
2738 config.warnings
2739 );
2740
2741 let denied = super::load_config_from_str(
2743 "memory.default_mode = capture_only\n\
2744 memory.default_mode_confirmed = false\n",
2745 );
2746 assert_eq!(denied.memory.default_mode, "off");
2747
2748 let garbage = super::load_config_from_str(
2750 "memory.default_mode = capture_only\n\
2751 memory.default_mode_confirmed = definitely\n",
2752 );
2753 assert_eq!(garbage.memory.default_mode, "off");
2754 }
2755
2756 #[test]
2757 fn memory_consent_is_order_independent() {
2758 let before = super::load_config_from_str(
2760 "memory.default_mode_confirmed = true\n\
2761 memory.default_mode = recall_once\n",
2762 );
2763 assert_eq!(before.memory.default_mode, "recall_once");
2764 let after = super::load_config_from_str(
2766 "memory.default_mode = recall_once\n\
2767 memory.default_mode_confirmed = true\n",
2768 );
2769 assert_eq!(after.memory.default_mode, "recall_once");
2770 }
2771
2772 #[test]
2773 fn memory_backend_defaults_are_independent_of_consent() {
2774 let config = super::load_config_from_str("");
2775 assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
2776 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
2777 let axel = super::load_config_from_str("memory.backend = axel\n");
2778 assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
2779 assert_eq!(axel.memory, config.memory);
2780 assert!(axel.warnings.is_empty());
2781 }
2782
2783 #[test]
2784 fn user_wide_notes_require_explicit_valid_opt_in() {
2785 let default = super::load_config_from_str("memory.backend = axel\n");
2786 assert!(!default.memory_backend.user_scope);
2787 let enabled =
2788 super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
2789 assert!(enabled.memory_backend.user_scope);
2790 assert_eq!(enabled.memory, default.memory);
2791 for bad in ["yes", "", "1", "TRUE"] {
2792 let config = super::load_config_from_str(&format!(
2793 "memory.user_scope = {bad}\nmemory.backend = axel\n"
2794 ));
2795 assert_eq!(
2796 config.memory_backend.kind,
2797 super::MemoryBackendKind::Unavailable
2798 );
2799 }
2800 }
2801
2802 #[test]
2803 fn memory_backend_invalid_selector_fails_closed() {
2804 for content in [
2805 "memory.backend = typo\n",
2806 "memory.backend = \n",
2807 "memory.backend = unavailable\n",
2808 "memory.backend = axel\nmemory.backend = typo\n",
2809 "memory.backend = typo\nmemory.backend = legacy\n",
2810 ] {
2811 let config = super::load_config_from_str(content);
2812 assert_eq!(
2813 config.memory_backend.kind,
2814 super::MemoryBackendKind::Unavailable
2815 );
2816 assert!(config
2817 .warnings
2818 .iter()
2819 .any(|w| w.contains("memory backend unavailable")));
2820 }
2821 }
2822
2823 #[test]
2824 fn memory_backend_absolute_paths_are_preserved() {
2825 let root = std::env::current_dir().unwrap();
2826 let executable = root.join("fixture bin/axel");
2827 let brain = root.join("fixture brain");
2828 let config = super::load_config_from_str(&format!(
2829 "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
2830 brain.display(),
2831 executable.display()
2832 ));
2833 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
2834 assert_eq!(config.memory_backend.executable, Some(executable));
2835 assert_eq!(config.memory_backend.brain, Some(brain));
2836 assert!(config.warnings.is_empty());
2837 }
2838
2839 #[test]
2840 fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
2841 for key in ["memory.axel.executable", "memory.axel.brain"] {
2842 for path in ["relative/path", "~/brain", ""] {
2843 for content in [
2844 format!("memory.backend = axel\n{key} = {path}\n"),
2845 format!("{key} = {path}\nmemory.backend = legacy\n"),
2846 ] {
2847 let config = super::load_config_from_str(&content);
2848 assert_eq!(
2849 config.memory_backend.kind,
2850 super::MemoryBackendKind::Unavailable
2851 );
2852 assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
2853 }
2854 }
2855 }
2856 }
2857
2858 #[test]
2859 fn memory_config_surface_is_closed_by_construction() {
2860 let _closed_surface = super::MemoryConfig {
2868 default_mode: "off".to_string(),
2869 default_mode_confirmed: false,
2870 recall_max_records: 8,
2871 recall_max_tokens: 4096,
2872 recall_timeout_ms: 150,
2873 capture_tools: "summary_only".to_string(),
2874 capture_assistant: true,
2875 capture_user: true,
2876 auto_consolidate: "off".to_string(),
2877 local_embeddings: "off".to_string(),
2878 gliner: "off".to_string(),
2879 };
2880 }
2881
2882 use super::*;
2883 use serial_test::serial;
2884
2885 #[test]
2886 fn test_levenshtein_basics() {
2887 assert_eq!(levenshtein("model", "model"), 0);
2888 assert_eq!(levenshtein("modle", "model"), 2);
2889 assert_eq!(levenshtein("them", "theme"), 1);
2890 }
2891
2892 #[test]
2893 fn test_did_you_mean_close_typos() {
2894 assert_eq!(did_you_mean("modle"), Some("model"));
2895 assert_eq!(did_you_mean("them"), Some("theme"));
2896 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
2897 assert_eq!(did_you_mean("completely_unrelated_key"), None);
2898 }
2899
2900 #[test]
2901 #[serial]
2902 fn test_config_warnings_unknown_key_and_bad_values() {
2903 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
2904 let dir = home.join(".synaps-cli");
2905 std::fs::create_dir_all(&dir).unwrap();
2906 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();
2907
2908 with_home(&home, || {
2909 let config = load_config();
2910 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
2912 assert!(
2913 !config.warnings.iter().any(|w| w.contains("knowledge")),
2914 "{:?}",
2915 config.warnings
2916 );
2917 assert!(
2918 config
2919 .warnings
2920 .iter()
2921 .any(|w| w.contains("did you mean 'model'")),
2922 "{:?}",
2923 config.warnings
2924 );
2925 assert!(
2926 config.warnings.iter().any(|w| w.contains("thinking")),
2927 "{:?}",
2928 config.warnings
2929 );
2930 assert!(
2931 config.warnings.iter().any(|w| w.contains("below minimum")),
2932 "{:?}",
2933 config.warnings
2934 );
2935 assert_eq!(config.bash_timeout, 30);
2937 assert_eq!(config.thinking_budget, None);
2938 });
2939 let _ = std::fs::remove_dir_all(&home);
2940 }
2941
2942 #[test]
2945 fn test_cache_keepwarm_is_off_by_default() {
2946 assert_eq!(SynapsConfig::default().cache_keepwarm, CacheKeepWarm::Off);
2947 assert_eq!(load_config_from_str("").cache_keepwarm, CacheKeepWarm::Off);
2948 }
2949
2950 #[test]
2951 fn test_cache_keepwarm_parse_table() {
2952 for (raw, want) in [
2953 ("off", Some(CacheKeepWarm::Off)),
2954 ("OFF", Some(CacheKeepWarm::Off)),
2955 ("0h", Some(CacheKeepWarm::Off)),
2956 ("auto", Some(CacheKeepWarm::Auto)),
2957 (" Auto ", Some(CacheKeepWarm::Auto)),
2958 ("1h", Some(CacheKeepWarm::Hours(1))),
2959 ("12H", Some(CacheKeepWarm::Hours(12))),
2960 ("48h", Some(CacheKeepWarm::Hours(MAX_CACHE_KEEPWARM_HOURS))),
2961 ("49h", None),
2962 ("12", None),
2963 ("h", None),
2964 ("-1h", None),
2965 ("lots", None),
2966 ] {
2967 assert_eq!(CacheKeepWarm::parse(raw), want, "{raw:?}");
2968 }
2969 for mode in [CacheKeepWarm::Off, CacheKeepWarm::Auto, CacheKeepWarm::Hours(6)] {
2970 assert_eq!(CacheKeepWarm::parse(&mode.to_string()), Some(mode));
2971 }
2972 }
2973
2974 #[test]
2975 fn test_cache_keepwarm_config_key() {
2976 let c = load_config_from_str("cache_keepwarm = auto\ncache_ttl = 5m\n");
2977 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Auto);
2978 assert_eq!(c.cache_ttl, CacheTtl::FiveMinutes);
2979 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2980
2981 let c = load_config_from_str("cache_keepwarm = 6h\n");
2982 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Hours(6));
2983
2984 assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "default auto cap");
2985
2986 let c = load_config_from_str("cache_keepwarm_auto_max_hours = 8\n");
2987 assert_eq!(c.cache_keepwarm_auto_max_hours, 8);
2988 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2989 let c = load_config_from_str("cache_keepwarm_auto_max_hours = 12h\n");
2990 assert_eq!(c.cache_keepwarm_auto_max_hours, 12);
2991 for bad in ["0", "49", "lots"] {
2992 let c = load_config_from_str(&format!("cache_keepwarm_auto_max_hours = {bad}\n"));
2993 assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "{bad}");
2994 assert!(
2995 c.warnings.iter().any(|w| w.contains("auto_max_hours")),
2996 "{bad}"
2997 );
2998 }
2999
3000 let c = load_config_from_str("cache_keepwarm = forever\n");
3001 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Off, "garbage keeps keep-warm off");
3002 assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm")));
3003 }
3004
3005 #[test]
3008 fn test_cache_ttl_parse_table() {
3009 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
3011 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
3012 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
3013 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
3015 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
3016 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
3017 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
3019 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
3021 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
3022 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
3023 assert_eq!(CacheTtl::parse("2h"), None);
3025 assert_eq!(CacheTtl::parse(""), None);
3026 assert_eq!(CacheTtl::parse("forever"), None);
3027 }
3028
3029 #[test]
3030 fn test_cache_ttl_default_is_five_minutes() {
3031 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
3032 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
3033 }
3034
3035 #[test]
3036 fn test_max_fps_default_is_60() {
3037 assert_eq!(SynapsConfig::default().max_fps, 60);
3038 }
3039
3040 #[test]
3043 fn test_theme_transition_parse_table() {
3044 use ThemeTransitionMode as M;
3045 assert_eq!(M::parse("on"), Some(M::On));
3046 assert_eq!(M::parse("ON"), Some(M::On));
3047 assert_eq!(M::parse("off"), Some(M::Off));
3048 assert_eq!(M::parse("Off"), Some(M::Off));
3049 assert_eq!(M::parse("500"), Some(M::Ms(500)));
3051 assert_eq!(M::parse("0"), Some(M::Ms(0)));
3052 assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
3053 assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
3054 assert_eq!(M::parse("fast"), None);
3056 assert_eq!(M::parse(""), None);
3057 assert_eq!(M::parse("-5"), None);
3058 assert_eq!(M::parse("1.5"), None);
3059 }
3060
3061 #[test]
3062 fn test_theme_transition_duration_ms() {
3063 use ThemeTransitionMode as M;
3064 assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
3065 assert_eq!(M::DEFAULT_MS, 350);
3066 assert_eq!(M::Off.duration_ms(), 0);
3067 assert_eq!(M::Ms(120).duration_ms(), 120);
3068 assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
3069 assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
3070 }
3071
3072 #[test]
3073 fn test_theme_transition_config_key_parses_and_defaults() {
3074 use ThemeTransitionMode as M;
3075 assert_eq!(SynapsConfig::default().theme_transition, M::On);
3076 assert_eq!(
3077 super::load_config_from_str("theme_transition = off\n").theme_transition,
3078 M::Off
3079 );
3080 assert_eq!(
3081 super::load_config_from_str("theme_transition = 750\n").theme_transition,
3082 M::Ms(750)
3083 );
3084 let bad = super::load_config_from_str("theme_transition = warp\n");
3085 assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
3086 assert!(
3087 bad.warnings.iter().any(|w| w.contains("theme_transition")),
3088 "{:?}",
3089 bad.warnings
3090 );
3091 }
3092
3093 #[test]
3094 #[serial]
3095 fn test_max_fps_config_parse_and_validation() {
3096 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
3097 let dir = home.join(".synaps-cli");
3098 std::fs::create_dir_all(&dir).unwrap();
3099
3100 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
3102 with_home(&home, || {
3103 let config = load_config();
3104 assert_eq!(config.max_fps, 144);
3105 assert!(
3106 config.warnings.is_empty(),
3107 "warnings: {:?}",
3108 config.warnings
3109 );
3110 });
3111
3112 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
3114 with_home(&home, || {
3115 let config = load_config();
3116 assert_eq!(config.max_fps, 60);
3117 assert!(
3118 config.warnings.iter().any(|w| w.contains("max_fps")),
3119 "warnings: {:?}",
3120 config.warnings
3121 );
3122 });
3123
3124 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
3126 with_home(&home, || {
3127 let config = load_config();
3128 assert_eq!(config.max_fps, 60);
3129 assert!(
3130 config.warnings.iter().any(|w| w.contains("max_fps")),
3131 "warnings: {:?}",
3132 config.warnings
3133 );
3134 });
3135
3136 let _ = std::fs::remove_dir_all(&home);
3137 }
3138
3139 #[test]
3140 #[serial]
3141 fn test_cache_ttl_config_parse_and_garbage_warning() {
3142 let home =
3143 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
3144 let dir = home.join(".synaps-cli");
3145 std::fs::create_dir_all(&dir).unwrap();
3146
3147 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
3149 with_home(&home, || {
3150 let config = load_config();
3151 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
3152 assert!(
3153 config.warnings.is_empty(),
3154 "warnings: {:?}",
3155 config.warnings
3156 );
3157 });
3158
3159 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
3161 with_home(&home, || {
3162 let config = load_config();
3163 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
3164 assert!(
3165 config.warnings.iter().any(|w| w.contains("cache_ttl")),
3166 "warnings: {:?}",
3167 config.warnings
3168 );
3169 });
3170
3171 let _ = std::fs::remove_dir_all(&home);
3172 }
3173
3174 #[test]
3175 fn test_parse_thinking_budget() {
3176 use crate::core::reasoning::ThinkingSpec;
3177 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
3179 assert_eq!(
3180 ThinkingSpec::parse("medium").unwrap().to_budget(),
3181 Some(4096)
3182 );
3183 assert_eq!(
3184 ThinkingSpec::parse("high").unwrap().to_budget(),
3185 Some(16384)
3186 );
3187 assert_eq!(
3188 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
3189 Some(32768)
3190 );
3191 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
3192 let config = load_config_from_str("thinking = 8192\n");
3193 assert_eq!(config.thinking_level, None);
3194 assert_eq!(config.thinking_budget, Some(8192));
3195 assert_eq!(ThinkingSpec::parse("invalid"), None);
3196 }
3197
3198 #[test]
3199 fn test_base_dir() {
3200 let path = base_dir();
3201 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
3202 }
3203
3204 #[test]
3205 fn test_resolve_system_prompt_explicit() {
3206 let result = resolve_system_prompt(Some("test prompt"));
3207 assert_eq!(result, "test prompt");
3208 }
3209
3210 #[test]
3211 fn test_resolve_system_prompt_none() {
3212 let result = resolve_system_prompt(None);
3213 assert!(result.contains("helpful AI agent"));
3214 }
3215
3216 #[test]
3221 fn test_synaps_config_default() {
3222 let config = SynapsConfig::default();
3223 assert_eq!(config.model, None);
3224 assert_eq!(config.thinking_budget, None);
3225 assert_eq!(config.max_tool_output, 30000);
3226 assert_eq!(config.bash_timeout, 30);
3227 assert_eq!(config.bash_max_timeout, 300);
3228 assert_eq!(config.subagent_timeout, 300);
3229 assert_eq!(config.api_retries, 3);
3230 assert_eq!(config.theme, None);
3231 assert!(config.disabled_plugins.is_empty());
3232 assert!(config.favorite_models.is_empty());
3233 assert!(config.disabled_skills.is_empty());
3234 assert!(!config.progressive_tool_disclosure);
3235 assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
3236 assert_eq!(config.shell.max_sessions, 5);
3237 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
3238 assert!(config.server.allowed_origins.is_empty());
3240 assert_eq!(config.server.token, None);
3241 assert!(!config.server.auto_approve_confirms);
3242 assert_eq!(config.server.max_message_size, None);
3243 assert!(config.bridge.uds_path.is_none());
3245 assert!(!config.bridge.heartbeat_mirror);
3246 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
3247 }
3248
3249 #[test]
3250 #[serial]
3251 fn test_load_config_tools_activation_confirm_modes() {
3252 for (raw, expected, warns) in [
3253 ("prompt", ActivationConfirm::Prompt, false),
3254 ("deny", ActivationConfirm::Deny, false),
3255 ("AUTO", ActivationConfirm::Auto, false),
3256 ("garbage", ActivationConfirm::Auto, true),
3257 ] {
3258 let home = make_test_home(&format!("activation-confirm-{raw}"));
3259 let cfg = home.join(".synaps-cli/config");
3260 std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
3261 with_home(&home, || {
3262 let config = load_config();
3263 assert_eq!(config.tools_activation_confirm, expected, "{raw}");
3264 assert_eq!(
3265 config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
3266 warns,
3267 "{raw}: {:?}",
3268 config.warnings
3269 );
3270 });
3271 let _ = std::fs::remove_dir_all(&home);
3272 }
3273 }
3274
3275 #[test]
3276 #[serial]
3277 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
3278 let home = make_test_home("progressive-tool-disclosure");
3279 let cfg = home.join(".synaps-cli/config");
3280 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
3281
3282 with_home(&home, || {
3283 let config = load_config();
3284 assert!(config.progressive_tool_disclosure);
3285 assert!(config.warnings.is_empty());
3286 });
3287
3288 let _ = std::fs::remove_dir_all(&home);
3289 }
3290
3291 #[test]
3292 #[serial]
3293 fn test_load_config_bridge_keys() {
3294 let home = make_test_home("bridge-keys");
3295 let cfg = home.join(".synaps-cli/config");
3296 std::fs::write(
3297 &cfg,
3298 "\
3299bridge.uds_path = /tmp/some/control.sock\n\
3300bridge.heartbeat_mirror = true\n\
3301bridge.heartbeat_timeout_ms = 750\n\
3302",
3303 )
3304 .unwrap();
3305
3306 with_home(&home, || {
3307 let config = load_config();
3308 assert_eq!(
3309 config.bridge.uds_path,
3310 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
3311 );
3312 assert!(config.bridge.heartbeat_mirror);
3313 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
3314 assert_eq!(
3315 config.bridge.resolved_uds_path(),
3316 std::path::PathBuf::from("/tmp/some/control.sock"),
3317 );
3318 });
3319
3320 let _ = std::fs::remove_dir_all(&home);
3321 }
3322
3323 #[test]
3324 fn test_bridge_config_defaults() {
3325 let cfg = BridgeConfig::default();
3326 assert!(cfg.uds_path.is_none());
3327 assert!(!cfg.heartbeat_mirror);
3328 assert_eq!(cfg.heartbeat_timeout_ms, 250);
3329 let resolved = cfg.resolved_uds_path();
3331 assert!(resolved.ends_with("bridge/control.sock"));
3332 }
3333
3334 #[test]
3335 #[serial]
3336 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
3337 let home = make_test_home("bridge-default-off");
3338 let cfg = home.join(".synaps-cli/config");
3339 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
3340
3341 with_home(&home, || {
3342 let config = load_config();
3343 assert!(!config.bridge.heartbeat_mirror);
3344 assert!(config.bridge.uds_path.is_none());
3345 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
3346 });
3347
3348 let _ = std::fs::remove_dir_all(&home);
3349 }
3350
3351 #[test]
3352 fn compaction_disclosure_keys_parse_with_typed_warnings() {
3353 let config = load_config_from_str(
3354 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
3355 );
3356 assert_eq!(
3357 config.compaction_mode,
3358 crate::core::compaction::CompactionMode::LocalOnly
3359 );
3360 assert_eq!(
3361 config.compaction_exclude,
3362 vec![
3363 crate::core::compaction::ContentClass::Thinking,
3364 crate::core::compaction::ContentClass::ToolResults,
3365 ]
3366 );
3367 assert!(
3368 config.warnings.iter().any(|w| w.contains("bogus")),
3369 "unknown class must warn: {:?}",
3370 config.warnings
3371 );
3372
3373 let defaults = load_config_from_str("");
3374 assert_eq!(
3375 defaults.compaction_mode,
3376 crate::core::compaction::CompactionMode::Remote
3377 );
3378 assert!(defaults.compaction_exclude.is_empty());
3379
3380 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
3381 assert_eq!(
3382 bad_mode.compaction_mode,
3383 crate::core::compaction::CompactionMode::Remote
3384 );
3385 assert!(bad_mode
3386 .warnings
3387 .iter()
3388 .any(|w| w.contains("compaction_mode")));
3389 }
3390
3391 #[test]
3392 #[serial]
3393 fn test_load_config_server_keys() {
3394 let home = make_test_home("server-keys");
3395 let cfg = home.join(".synaps-cli/config");
3396 std::fs::write(
3397 &cfg,
3398 "\
3399server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
3400server.token = my-secret-token\n\
3401server.auto_approve_confirms = true\n\
3402server.max_message_size = 65536\n\
3403context_window = 200k\n\
3404",
3405 )
3406 .unwrap();
3407
3408 with_home(&home, || {
3409 let config = load_config();
3410 assert_eq!(
3411 config.server.allowed_origins,
3412 vec![
3413 "http://localhost:3000".to_string(),
3414 "http://localhost:5193".to_string(),
3415 ]
3416 );
3417 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
3418 assert!(config.server.auto_approve_confirms);
3419 assert_eq!(config.server.max_message_size, Some(65536));
3421 });
3422
3423 let _ = std::fs::remove_dir_all(&home);
3424 }
3425
3426 #[test]
3427 #[serial]
3428 fn test_server_max_message_size_derived_from_context_window() {
3429 let home = make_test_home("server-derive");
3430 let cfg = home.join(".synaps-cli/config");
3431 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
3432
3433 with_home(&home, || {
3434 let config = load_config();
3435 assert_eq!(config.server.max_message_size, Some(800_000));
3437 });
3438
3439 let _ = std::fs::remove_dir_all(&home);
3440 }
3441
3442 fn make_test_home(subdir: &str) -> std::path::PathBuf {
3443 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
3444 let _ = std::fs::remove_dir_all(&dir);
3445 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
3446 dir
3447 }
3448
3449 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
3450 let original = std::env::var("HOME").ok();
3453 let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
3454 std::env::set_var("HOME", home);
3455 std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
3456 f();
3457 if let Some(h) = original {
3458 std::env::set_var("HOME", h);
3459 } else {
3460 std::env::remove_var("HOME");
3461 }
3462 if let Some(b) = original_base {
3463 std::env::set_var("SYNAPS_BASE_DIR", b);
3464 } else {
3465 std::env::remove_var("SYNAPS_BASE_DIR");
3466 }
3467 }
3468
3469 #[test]
3470 #[serial]
3471 fn write_config_value_replaces_existing_key() {
3472 let home = make_test_home("replace");
3473 let cfg = home.join(".synaps-cli/config");
3474 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
3475
3476 with_home(&home, || {
3477 write_config_value("model", "claude-sonnet-4-6").unwrap();
3478 });
3479
3480 let contents = std::fs::read_to_string(&cfg).unwrap();
3481 assert!(contents.contains("model = claude-sonnet-4-6"));
3482 assert!(contents.contains("thinking = low"));
3483 let _ = std::fs::remove_dir_all(&home);
3484 }
3485
3486 #[test]
3487 #[serial]
3488 fn write_config_value_appends_when_missing() {
3489 let home = make_test_home("append");
3490 let cfg = home.join(".synaps-cli/config");
3491 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
3492
3493 with_home(&home, || {
3494 write_config_value("theme", "dracula").unwrap();
3495 });
3496
3497 let contents = std::fs::read_to_string(&cfg).unwrap();
3498 assert!(contents.contains("model = claude-opus-4-6"));
3499 assert!(contents.contains("theme = dracula"));
3500 let _ = std::fs::remove_dir_all(&home);
3501 }
3502
3503 #[test]
3504 #[serial]
3505 fn write_config_value_concurrent_writers_lose_nothing() {
3506 let home = make_test_home("concurrent");
3507 let cfg = home.join(".synaps-cli/config");
3508 std::fs::write(&cfg, "# keep\n").unwrap();
3509
3510 with_home(&home, || {
3512 let a = std::thread::spawn(|| {
3513 for i in 0..200 {
3514 write_config_value("a_key", &i.to_string()).unwrap();
3515 }
3516 });
3517 let b = std::thread::spawn(|| {
3518 for i in 0..200 {
3519 write_config_value("b_key", &i.to_string()).unwrap();
3520 }
3521 });
3522 a.join().unwrap();
3523 b.join().unwrap();
3524 });
3525
3526 let contents = std::fs::read_to_string(&cfg).unwrap();
3527 assert!(contents.contains("# keep"), "{contents}");
3528 assert!(contents.contains("a_key = 199"), "{contents}");
3529 assert!(contents.contains("b_key = 199"), "{contents}");
3530 assert_eq!(contents.matches("a_key").count(), 1);
3531 assert_eq!(contents.matches("b_key").count(), 1);
3532
3533 #[cfg(unix)]
3534 {
3535 use std::os::unix::fs::PermissionsExt;
3536 let lock = home.join(".synaps-cli/config.lock");
3537 let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
3538 assert_eq!(mode, 0o600);
3539 }
3540 let _ = std::fs::remove_dir_all(&home);
3541 }
3542
3543 #[test]
3544 #[serial]
3545 fn config_lock_kill_switch_still_writes() {
3546 let home = make_test_home("lock-off");
3547 let cfg = home.join(".synaps-cli/config");
3548 std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
3549 with_home(&home, || {
3550 write_config_value("model", "claude-sonnet-4-6").unwrap();
3551 });
3552 std::env::remove_var("SYNAPS_CONFIG_LOCK");
3553 let contents = std::fs::read_to_string(&cfg).unwrap();
3554 assert!(contents.contains("model = claude-sonnet-4-6"));
3555 assert!(!home.join(".synaps-cli/config.lock").exists());
3556 let _ = std::fs::remove_dir_all(&home);
3557 }
3558
3559 #[test]
3560 #[serial]
3561 fn add_favorite_model_end_to_end_does_not_deadlock() {
3562 let home = make_test_home("favorite-lock");
3563 let cfg = home.join(".synaps-cli/config");
3564 with_home(&home, || {
3565 add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3566 add_favorite_model("openai/gpt-5").unwrap();
3567 remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3568 });
3569 let contents = std::fs::read_to_string(&cfg).unwrap();
3570 assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
3571 let _ = std::fs::remove_dir_all(&home);
3572 }
3573
3574 #[test]
3575 #[serial]
3576 fn write_config_value_preserves_comments() {
3577 let home = make_test_home("comments");
3578 let cfg = home.join(".synaps-cli/config");
3579 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
3580
3581 with_home(&home, || {
3582 write_config_value("model", "claude-sonnet-4-6").unwrap();
3583 });
3584
3585 let contents = std::fs::read_to_string(&cfg).unwrap();
3586 assert!(contents.contains("# user comment"));
3587 assert!(contents.contains("# another"));
3588 assert!(contents.contains("model = claude-sonnet-4-6"));
3589 let _ = std::fs::remove_dir_all(&home);
3590 }
3591
3592 #[test]
3593 #[serial]
3594 fn write_config_value_preserves_unknown_keys() {
3595 let home = make_test_home("unknown");
3596 let cfg = home.join(".synaps-cli/config");
3597 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
3598
3599 with_home(&home, || {
3600 write_config_value("model", "claude-sonnet-4-6").unwrap();
3601 });
3602
3603 let contents = std::fs::read_to_string(&cfg).unwrap();
3604 assert!(contents.contains("custom_thing = 42"));
3605 let _ = std::fs::remove_dir_all(&home);
3606 }
3607
3608 #[test]
3609 #[serial]
3610 fn write_config_value_creates_file_if_absent() {
3611 let home = make_test_home("create");
3612 let cfg = home.join(".synaps-cli/config");
3613 assert!(!cfg.exists());
3614
3615 with_home(&home, || {
3616 write_config_value("model", "claude-sonnet-4-6").unwrap();
3617 });
3618
3619 let contents = std::fs::read_to_string(&cfg).unwrap();
3620 assert!(contents.contains("model = claude-sonnet-4-6"));
3621 let _ = std::fs::remove_dir_all(&home);
3622 }
3623
3624 #[test]
3625 #[serial]
3626 fn load_config_parses_theme_key() {
3627 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
3628 let _ = std::fs::create_dir_all(&dir);
3629 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
3630
3631 let original_home = std::env::var("HOME").ok();
3632 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
3633
3634 let config = load_config();
3635
3636 if let Some(home) = original_home {
3637 std::env::set_var("HOME", home);
3638 } else {
3639 std::env::remove_var("HOME");
3640 }
3641 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
3642
3643 assert_eq!(config.theme.as_deref(), Some("dracula"));
3644 }
3645
3646 #[test]
3647 #[serial]
3648 fn test_load_config_disable_lists() {
3649 let test_dir =
3650 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
3651 let _ = std::fs::create_dir_all(&test_dir);
3652 let config_path = test_dir.join("config");
3653
3654 let config_content = r#"
3655# Test config with disable lists
3656favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
3657
3658disabled_plugins = foo, bar
3659disabled_skills = baz, plug:qual
3660"#;
3661 std::fs::write(&config_path, config_content).unwrap();
3662
3663 let original_home = std::env::var("HOME").ok();
3664 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
3665
3666 let config = load_config();
3667
3668 if let Some(home) = original_home {
3669 std::env::set_var("HOME", home);
3670 } else {
3671 std::env::remove_var("HOME");
3672 }
3673
3674 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
3675
3676 assert_eq!(
3677 config.disabled_plugins,
3678 vec!["foo".to_string(), "bar".to_string()]
3679 );
3680 assert_eq!(
3681 config.favorite_models,
3682 vec![
3683 "claude/claude-opus-4-7".to_string(),
3684 "groq/llama-3.3-70b-versatile".to_string(),
3685 ]
3686 );
3687 assert_eq!(
3688 config.disabled_skills,
3689 vec!["baz".to_string(), "plug:qual".to_string()]
3690 );
3691 }
3692
3693 #[test]
3694 #[serial]
3695 fn favorite_model_helpers_round_trip_through_config_file() {
3696 let home = make_test_home("favorite-models");
3697 let cfg = home.join(".synaps-cli/config");
3698 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
3699
3700 with_home(&home, || {
3701 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3702 add_favorite_model("claude/claude-opus-4-7").unwrap();
3703 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3704 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
3705 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3706 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
3707 assert!(is_favorite_model("claude/claude-opus-4-7"));
3708 });
3709
3710 let contents = std::fs::read_to_string(&cfg).unwrap();
3711 assert!(contents.contains("model = claude-opus-4-7"));
3712 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
3713 let _ = std::fs::remove_dir_all(&home);
3714 }
3715
3716 #[test]
3717 #[serial]
3718 fn test_load_config_new_keys() {
3719 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
3721 let _ = std::fs::create_dir_all(&test_dir);
3722 let config_path = test_dir.join("config");
3723
3724 let config_content = r#"
3725# Test config with new keys
3726model = claude-haiku
3727thinking = medium
3728max_tool_output = 50000
3729bash_timeout = 45
3730bash_max_timeout = 600
3731subagent_timeout = 120
3732api_retries = 5
3733"#;
3734 std::fs::write(&config_path, config_content).unwrap();
3735
3736 let original_home = std::env::var("HOME").ok();
3738 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
3739
3740 let config = load_config();
3741
3742 if let Some(home) = original_home {
3744 std::env::set_var("HOME", home);
3745 } else {
3746 std::env::remove_var("HOME");
3747 }
3748
3749 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
3751
3752 assert_eq!(config.model, Some("claude-haiku".to_string()));
3753 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
3755 assert_eq!(config.bash_timeout, 45);
3756 assert_eq!(config.bash_max_timeout, 600);
3757 assert_eq!(config.subagent_timeout, 120);
3758 assert_eq!(config.api_retries, 5);
3759 }
3760
3761 #[test]
3766 fn events_auto_turn_explicit_true_values() {
3767 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
3768 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3769 assert!(
3770 cfg.events.auto_turn,
3771 "expected true for events.auto_turn = {val}"
3772 );
3773 }
3774 }
3775
3776 #[test]
3777 fn events_auto_turn_explicit_false_values() {
3778 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
3779 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3780 assert!(
3781 !cfg.events.auto_turn,
3782 "expected false for events.auto_turn = {val}"
3783 );
3784 }
3785 }
3786
3787 #[test]
3788 fn events_auto_turn_typo_fails_safe_false() {
3789 let cfg = load_config_from_str("events.auto_turn = fales");
3791 assert!(
3792 !cfg.events.auto_turn,
3793 "typo 'fales' must fail safe to false"
3794 );
3795 }
3796
3797 #[test]
3800 fn events_auto_turn_cap_default_is_five() {
3801 let cfg = load_config_from_str("");
3802 assert_eq!(cfg.events.auto_turn_cap, 5);
3803 assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
3804 }
3805
3806 #[test]
3807 fn events_auto_turn_cap_zero_is_unlimited() {
3808 let cfg = load_config_from_str("events.auto_turn_cap = 0");
3809 assert_eq!(cfg.events.auto_turn_cap, 0);
3810 }
3811
3812 #[test]
3813 fn events_auto_turn_cap_unlimited_aliases() {
3814 for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
3815 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3816 assert_eq!(
3817 cfg.events.auto_turn_cap, 0,
3818 "expected 0 for events.auto_turn_cap = {val:?}"
3819 );
3820 }
3821 }
3822
3823 #[test]
3824 fn events_auto_turn_cap_explicit_number() {
3825 let cfg = load_config_from_str("events.auto_turn_cap = 12");
3826 assert_eq!(cfg.events.auto_turn_cap, 12);
3827 }
3828
3829 #[test]
3830 fn events_auto_turn_cap_garbage_keeps_default() {
3831 for val in ["banana", "-3", "5.5", "", "true"] {
3832 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3833 assert_eq!(
3834 cfg.events.auto_turn_cap, 5,
3835 "garbage {val:?} must keep default 5"
3836 );
3837 }
3838 }
3839
3840 #[test]
3841 fn events_auto_turn_cap_independent_of_auto_turn() {
3842 let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
3843 assert!(!cfg.events.auto_turn);
3844 assert_eq!(cfg.events.auto_turn_cap, 0);
3845 }
3846
3847 #[test]
3848 fn events_auto_turn_default_is_true() {
3849 let cfg = load_config_from_str("model = claude-haiku");
3850 assert!(cfg.events.auto_turn, "default must be true when key absent");
3851 }
3852
3853 #[test]
3854 #[serial]
3855 fn test_auth_config_parses_endpoint_and_token() {
3856 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
3857 let dir = home.join(".synaps-cli");
3858 std::fs::create_dir_all(&dir).unwrap();
3859 std::fs::write(
3860 dir.join("config"),
3861 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
3862 )
3863 .unwrap();
3864 with_home(&home, || {
3865 let config = load_config();
3866 assert_eq!(
3867 config.auth.remote_endpoint.as_deref(),
3868 Some("https://jade.jade:8181")
3869 );
3870 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
3871 assert!(
3873 !config.warnings.iter().any(|w| w.contains("auth.")),
3874 "{:?}",
3875 config.warnings
3876 );
3877 });
3878 let _ = std::fs::remove_dir_all(&home);
3879 }
3880
3881 #[test]
3882 #[serial]
3883 fn test_credential_source_local_by_default() {
3884 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3885 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3886 assert!(!AuthConfig::default().credential_source().is_remote());
3887 }
3888
3889 #[test]
3890 #[serial]
3891 fn test_credential_source_remote_from_config() {
3892 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3893 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3894 let auth = AuthConfig {
3895 remote_endpoint: Some("https://b".into()),
3896 machine_token: Some("m".into()),
3897 };
3898 assert_eq!(
3899 auth.credential_source(),
3900 crate::core::auth::CredentialSource::Remote {
3901 endpoint: "https://b".into(),
3902 machine_token: "m".into()
3903 }
3904 );
3905 }
3906
3907 #[test]
3908 #[serial]
3909 fn test_credential_source_env_overrides_config() {
3910 let auth = AuthConfig {
3911 remote_endpoint: Some("https://config-host".into()),
3912 machine_token: Some("config-tok".into()),
3913 };
3914 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
3915 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
3916 let src = auth.credential_source();
3917 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3918 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3919 assert_eq!(
3920 src,
3921 crate::core::auth::CredentialSource::Remote {
3922 endpoint: "https://env-host".into(),
3923 machine_token: "env-tok".into()
3924 }
3925 );
3926 }
3927 #[test]
3928 fn config_parses_ultracode_as_distinct_canonical_level() {
3929 let cfg = load_config_from_str("thinking = ultracode\n");
3930 assert_eq!(
3931 cfg.thinking_level,
3932 Some(crate::reasoning::ReasoningLevel::UltraCode)
3933 );
3934 assert_eq!(cfg.thinking_budget, None);
3935 for other in [
3936 crate::reasoning::ReasoningLevel::Ultra,
3937 crate::reasoning::ReasoningLevel::Max,
3938 crate::reasoning::ReasoningLevel::XHigh,
3939 ] {
3940 assert_ne!(cfg.thinking_level, Some(other));
3941 }
3942 }
3943
3944 #[test]
3949 fn context_window_auto_is_model_default_without_warning() {
3950 for spelling in ["auto", "AUTO", "Auto"] {
3951 let cfg = load_config_from_str(&format!("context_window = {spelling}\n"));
3952 assert_eq!(cfg.context_window, None, "{spelling}");
3953 assert!(cfg.warnings.is_empty(), "{spelling}: {:?}", cfg.warnings);
3954 }
3955 let cfg = load_config_from_str("context_window = 1m\ncontext_window = auto\n");
3957 assert_eq!(cfg.context_window, None);
3958 assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
3959 assert_eq!(
3961 load_config_from_str("context_window = 200k\n").context_window,
3962 Some(200_000)
3963 );
3964 let bad = load_config_from_str("context_window = huge\n");
3965 assert_eq!(bad.context_window, None);
3966 assert!(bad.warnings.iter().any(|w| w.contains("context_window")));
3967 }
3968
3969 #[test]
3972 fn sidecar_toggle_key_is_a_known_key() {
3973 let cfg = load_config_from_str("sidecar_toggle_key = F2\n");
3974 assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
3975 assert!(KNOWN_CONFIG_KEYS.contains(&"sidecar_toggle_key"));
3976 assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_mode"));
3977 assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_exclude"));
3978 }
3979
3980 #[test]
3984 fn config_content_problems_covers_family_diagnostics() {
3985 for bad in [
3986 "shell.max_sessions = lots",
3987 "shell.readiness_strategy = eventually",
3988 "events.auto_turn = maybe",
3989 "events.auto_turn_cap = many",
3990 "turn_budget.worker.max_cost_usd = cheap",
3991 "turn_budget.nobody.max_tool_calls = 3",
3992 "memory.capture_tools = everything",
3993 "memory.recall_max_records = -1",
3994 "memory.user_scope = yes",
3995 "cache_ttl = forever",
3996 ] {
3997 let problems = config_content_problems(bad);
3998 assert!(!problems.is_empty(), "{bad} should be reported");
3999 }
4000 for good in [
4001 "shell.max_sessions = 8",
4002 "shell.readiness_strategy = prompt",
4003 "events.auto_turn = off",
4004 "events.auto_turn_cap = unlimited",
4005 "turn_budget.worker.max_cost_usd = 2.5",
4006 "memory.capture_tools = off",
4007 "memory.recall_max_records = 4",
4008 "memory.user_scope = true",
4009 "cache_ttl = hybrid",
4010 ] {
4011 let problems = config_content_problems(good);
4012 assert!(problems.is_empty(), "{good}: {problems:?}");
4013 }
4014 let cfg = load_config_from_str("shell.max_sessions = 8\nevents.auto_turn = maybe\n");
4016 assert_eq!(cfg.shell.max_sessions, 8);
4017 assert!(!cfg.events.auto_turn, "unrecognised auto_turn still fails safe to false");
4018 }
4019
4020 #[test]
4023 fn family_config_keys_are_real_parser_keys() {
4024 let sample = |key: &str| -> &'static str {
4025 match key {
4026 "shell.readiness_strategy" => "hybrid",
4027 "memory.backend" => "legacy",
4028 "memory.user_scope" | "memory.default_mode_confirmed" => "true",
4029 "memory.default_mode" => "off",
4030 "memory.capture_tools" => "summary_only",
4031 "memory.capture_assistant" | "memory.capture_user" => "on",
4032 "memory.auto_consolidate" | "memory.local_embeddings" | "memory.gliner" => "off",
4033 "memory.axel.executable" | "memory.axel.brain" => "/opt/axel",
4034 "events.auto_turn" => "on",
4035 "server.allowed_origins" | "server.token" => "x",
4036 "server.auto_approve_confirms" | "bridge.heartbeat_mirror" => "false",
4037 "bridge.uds_path" => "/tmp/x.sock",
4038 "auth.remote_endpoint" | "auth.machine_token" => "x",
4039 _ => "7",
4040 }
4041 };
4042 for key in FAMILY_CONFIG_KEYS.iter().filter(|k| !k.ends_with(".*")) {
4043 assert!(!KNOWN_CONFIG_KEYS.contains(key), "{key} is listed twice");
4044 let content = format!("{key} = {}\n", sample(key));
4045 let problems = config_content_problems(&content);
4046 assert!(problems.is_empty(), "{key}: {problems:?}");
4047 }
4048 }
4049
4050 #[test]
4051 #[serial]
4052 fn validate_config_edit_uses_the_real_parser_and_whole_file() {
4053 let home = make_test_home("validate-edit");
4054 let cfg = home.join(".synaps-cli/config");
4055 with_home(&home, || {
4056 assert!(validate_config_edit("cache_ttl", "1h").is_ok());
4058 assert!(validate_config_edit("cache_ttl", "2h").is_err());
4059 assert!(validate_config_edit("shell.max_output", "big").is_err());
4060 assert!(validate_config_edit("memory.axel.brain", "relative/brain").is_err());
4061 assert!(validate_config_edit("memory.axel.brain", "/abs/brain.r8").is_ok());
4062 assert!(validate_config_edit("model", "a\nb").is_err());
4063
4064 std::fs::write(&cfg, "context_management.rollover_tokens = 100000\n").unwrap();
4066 assert!(validate_config_edit("context_management.pressure_tokens", "90000").is_ok());
4067 assert!(validate_config_edit("context_management.pressure_tokens", "150000").is_err());
4068 std::fs::write(&cfg, "subagent.max_total = 8\n").unwrap();
4069 assert!(validate_config_edit("subagent.max_concurrent", "8").is_ok());
4070 assert!(validate_config_edit("subagent.max_concurrent", "9").is_err());
4071 std::fs::write(&cfg, "").unwrap();
4072 assert!(validate_config_edit("memory.default_mode", "recall_once").is_err());
4073 assert!(validate_config_edit("memory.default_mode", "off").is_ok());
4074 std::fs::write(&cfg, "memory.default_mode_confirmed = true\n").unwrap();
4075 assert!(validate_config_edit("memory.default_mode", "recall_once").is_ok());
4076
4077 std::fs::write(&cfg, "cache_ttl = forever\n").unwrap();
4079 assert!(validate_config_edit("max_fps", "120").is_ok());
4080 assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "cache_ttl = forever\n");
4082 });
4083 let _ = std::fs::remove_dir_all(&home);
4084 }
4085}