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