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 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1259 You have access to bash, read, and write tools. \
1260 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1261
1262 if let Some(val) = explicit {
1263 let path = std::path::Path::new(val);
1264 if path.exists() && path.is_file() {
1265 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1266 }
1267 return val.to_string();
1268 }
1269
1270 let system_path = resolve_read_path("system.md");
1271 if system_path.exists() {
1272 return std::fs::read_to_string(&system_path).unwrap_or_default();
1273 }
1274
1275 DEFAULT_PROMPT.to_string()
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280 #[test]
1281 fn tui_background_opaque_defaults_and_parses_supported_values() {
1282 assert!(super::load_config_from_str("").tui_background_opaque);
1283 assert!(
1284 super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
1285 );
1286 assert!(
1287 !super::load_config_from_str("tui_background_opaque = invisible\n")
1288 .tui_background_opaque
1289 );
1290 let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
1291 assert!(invalid.tui_background_opaque);
1292 assert!(invalid
1293 .warnings
1294 .iter()
1295 .any(|w| w.contains("tui_background_opaque")));
1296 }
1297
1298 #[test]
1299 fn turn_budget_keys_parse_typed_per_role() {
1300 let config = super::load_config_from_str(
1301 "turn_budget.worker.max_provider_rounds = 3\n\
1302 turn_budget.worker.max_cost_usd = 0.25\n\
1303 turn_budget.autonomous.max_elapsed_secs = 90\n\
1304 turn_budget.foreground.max_tool_calls = 7\n\
1305 turn_budget.foreground.max_round_renewals = 12\n\
1306 turn_budget.worker.max_provider_rounds_bogus = 1\n\
1307 turn_budget.nosuchrole.max_tool_calls = 1\n",
1308 );
1309 assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
1310 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
1311 assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
1312 assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
1313 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
1314 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
1316 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
1318 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
1319 }
1320
1321 #[test]
1322 fn memory_keys_all_parse_with_consent() {
1323 let config = super::load_config_from_str(
1324 "memory.default_mode = capture_and_recall\n\
1325 memory.default_mode_confirmed = true\n\
1326 memory.recall_max_records = 12\n\
1327 memory.recall_max_tokens = 2048\n\
1328 memory.recall_timeout_ms = 250\n\
1329 memory.capture_tools = off\n\
1330 memory.capture_assistant = false\n\
1331 memory.capture_user = false\n\
1332 memory.auto_consolidate = on\n\
1333 memory.local_embeddings = on\n\
1334 memory.gliner = on\n",
1335 );
1336 assert_eq!(config.memory.default_mode, "capture_and_recall");
1337 assert!(config.memory.default_mode_confirmed);
1338 assert_eq!(config.memory.recall_max_records, 12);
1339 assert_eq!(config.memory.recall_max_tokens, 2048);
1340 assert_eq!(config.memory.recall_timeout_ms, 250);
1341 assert_eq!(config.memory.capture_tools, "off");
1342 assert!(!config.memory.capture_assistant);
1343 assert!(!config.memory.capture_user);
1344 assert_eq!(config.memory.auto_consolidate, "on");
1345 assert_eq!(config.memory.local_embeddings, "on");
1346 assert_eq!(config.memory.gliner, "on");
1347 }
1348
1349 #[test]
1350 fn memory_defaults_match_spec_12() {
1351 let config = super::load_config_from_str("");
1352 assert_eq!(config.memory, super::MemoryConfig::default());
1353 assert_eq!(config.memory.default_mode, "off");
1354 assert!(!config.memory.default_mode_confirmed);
1355 assert_eq!(config.memory.recall_max_records, 8);
1356 assert_eq!(config.memory.recall_max_tokens, 4096);
1357 assert_eq!(config.memory.recall_timeout_ms, 150);
1358 assert_eq!(config.memory.capture_tools, "summary_only");
1359 assert!(config.memory.capture_assistant);
1360 assert!(config.memory.capture_user);
1361 assert_eq!(config.memory.auto_consolidate, "off");
1362 assert_eq!(config.memory.local_embeddings, "off");
1363 assert_eq!(config.memory.gliner, "off");
1364 }
1365
1366 #[test]
1367 fn memory_unknown_values_fail_closed_to_defaults() {
1368 let config = super::load_config_from_str(
1369 "memory.default_mode = recall_everything_forever\n\
1370 memory.default_mode_confirmed = true\n\
1371 memory.recall_max_records = lots\n\
1372 memory.recall_max_tokens = -5\n\
1373 memory.recall_timeout_ms = fast\n\
1374 memory.capture_tools = full\n\
1375 memory.capture_assistant = maybe\n\
1376 memory.capture_user = sometimes\n\
1377 memory.auto_consolidate = aggressive\n\
1378 memory.local_embeddings = auto\n\
1379 memory.gliner = download\n\
1380 memory.nosuchfield = 1\n",
1381 );
1382 assert_eq!(config.memory.default_mode, "off");
1384 assert_eq!(config.memory.recall_max_records, 8);
1385 assert_eq!(config.memory.recall_max_tokens, 4096);
1386 assert_eq!(config.memory.recall_timeout_ms, 150);
1387 assert_eq!(config.memory.capture_tools, "summary_only");
1388 assert!(config.memory.capture_assistant);
1389 assert!(config.memory.capture_user);
1390 assert_eq!(config.memory.auto_consolidate, "off");
1391 assert_eq!(config.memory.local_embeddings, "off");
1392 assert_eq!(config.memory.gliner, "off");
1393 }
1394
1395 #[test]
1396 fn memory_default_mode_without_consent_reverts_to_off() {
1397 let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
1398 assert_eq!(config.memory.default_mode, "off");
1399 assert!(
1400 config
1401 .warnings
1402 .iter()
1403 .any(|w| w.contains("memory.default_mode_confirmed")),
1404 "reversion must surface a warning; got: {:?}",
1405 config.warnings
1406 );
1407
1408 let denied = super::load_config_from_str(
1410 "memory.default_mode = capture_only\n\
1411 memory.default_mode_confirmed = false\n",
1412 );
1413 assert_eq!(denied.memory.default_mode, "off");
1414
1415 let garbage = super::load_config_from_str(
1417 "memory.default_mode = capture_only\n\
1418 memory.default_mode_confirmed = definitely\n",
1419 );
1420 assert_eq!(garbage.memory.default_mode, "off");
1421 }
1422
1423 #[test]
1424 fn memory_consent_is_order_independent() {
1425 let before = super::load_config_from_str(
1427 "memory.default_mode_confirmed = true\n\
1428 memory.default_mode = recall_once\n",
1429 );
1430 assert_eq!(before.memory.default_mode, "recall_once");
1431 let after = super::load_config_from_str(
1433 "memory.default_mode = recall_once\n\
1434 memory.default_mode_confirmed = true\n",
1435 );
1436 assert_eq!(after.memory.default_mode, "recall_once");
1437 }
1438
1439 #[test]
1440 fn memory_config_surface_is_closed_by_construction() {
1441 let _closed_surface = super::MemoryConfig {
1449 default_mode: "off".to_string(),
1450 default_mode_confirmed: false,
1451 recall_max_records: 8,
1452 recall_max_tokens: 4096,
1453 recall_timeout_ms: 150,
1454 capture_tools: "summary_only".to_string(),
1455 capture_assistant: true,
1456 capture_user: true,
1457 auto_consolidate: "off".to_string(),
1458 local_embeddings: "off".to_string(),
1459 gliner: "off".to_string(),
1460 };
1461 }
1462
1463 use super::*;
1464 use serial_test::serial;
1465
1466 #[test]
1467 fn test_levenshtein_basics() {
1468 assert_eq!(levenshtein("model", "model"), 0);
1469 assert_eq!(levenshtein("modle", "model"), 2);
1470 assert_eq!(levenshtein("them", "theme"), 1);
1471 }
1472
1473 #[test]
1474 fn test_did_you_mean_close_typos() {
1475 assert_eq!(did_you_mean("modle"), Some("model"));
1476 assert_eq!(did_you_mean("them"), Some("theme"));
1477 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
1478 assert_eq!(did_you_mean("completely_unrelated_key"), None);
1479 }
1480
1481 #[test]
1482 #[serial]
1483 fn test_config_warnings_unknown_key_and_bad_values() {
1484 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
1485 let dir = home.join(".synaps-cli");
1486 std::fs::create_dir_all(&dir).unwrap();
1487 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();
1488
1489 with_home(&home, || {
1490 let config = load_config();
1491 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
1493 assert!(
1494 !config.warnings.iter().any(|w| w.contains("knowledge")),
1495 "{:?}",
1496 config.warnings
1497 );
1498 assert!(
1499 config
1500 .warnings
1501 .iter()
1502 .any(|w| w.contains("did you mean 'model'")),
1503 "{:?}",
1504 config.warnings
1505 );
1506 assert!(
1507 config.warnings.iter().any(|w| w.contains("thinking")),
1508 "{:?}",
1509 config.warnings
1510 );
1511 assert!(
1512 config.warnings.iter().any(|w| w.contains("below minimum")),
1513 "{:?}",
1514 config.warnings
1515 );
1516 assert_eq!(config.bash_timeout, 30);
1518 assert_eq!(config.thinking_budget, None);
1519 });
1520 let _ = std::fs::remove_dir_all(&home);
1521 }
1522
1523 #[test]
1526 fn test_cache_ttl_parse_table() {
1527 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
1529 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
1530 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
1531 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
1533 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
1534 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
1535 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
1537 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
1539 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
1540 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
1541 assert_eq!(CacheTtl::parse("2h"), None);
1543 assert_eq!(CacheTtl::parse(""), None);
1544 assert_eq!(CacheTtl::parse("forever"), None);
1545 }
1546
1547 #[test]
1548 fn test_cache_ttl_default_is_five_minutes() {
1549 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
1550 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
1551 }
1552
1553 #[test]
1554 fn test_max_fps_default_is_60() {
1555 assert_eq!(SynapsConfig::default().max_fps, 60);
1556 }
1557
1558 #[test]
1561 fn test_theme_transition_parse_table() {
1562 use ThemeTransitionMode as M;
1563 assert_eq!(M::parse("on"), Some(M::On));
1564 assert_eq!(M::parse("ON"), Some(M::On));
1565 assert_eq!(M::parse("off"), Some(M::Off));
1566 assert_eq!(M::parse("Off"), Some(M::Off));
1567 assert_eq!(M::parse("500"), Some(M::Ms(500)));
1569 assert_eq!(M::parse("0"), Some(M::Ms(0)));
1570 assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
1571 assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
1572 assert_eq!(M::parse("fast"), None);
1574 assert_eq!(M::parse(""), None);
1575 assert_eq!(M::parse("-5"), None);
1576 assert_eq!(M::parse("1.5"), None);
1577 }
1578
1579 #[test]
1580 fn test_theme_transition_duration_ms() {
1581 use ThemeTransitionMode as M;
1582 assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
1583 assert_eq!(M::DEFAULT_MS, 350);
1584 assert_eq!(M::Off.duration_ms(), 0);
1585 assert_eq!(M::Ms(120).duration_ms(), 120);
1586 assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
1587 assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
1588 }
1589
1590 #[test]
1591 fn test_theme_transition_config_key_parses_and_defaults() {
1592 use ThemeTransitionMode as M;
1593 assert_eq!(SynapsConfig::default().theme_transition, M::On);
1594 assert_eq!(
1595 super::load_config_from_str("theme_transition = off\n").theme_transition,
1596 M::Off
1597 );
1598 assert_eq!(
1599 super::load_config_from_str("theme_transition = 750\n").theme_transition,
1600 M::Ms(750)
1601 );
1602 let bad = super::load_config_from_str("theme_transition = warp\n");
1603 assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
1604 assert!(
1605 bad.warnings.iter().any(|w| w.contains("theme_transition")),
1606 "{:?}",
1607 bad.warnings
1608 );
1609 }
1610
1611 #[test]
1612 #[serial]
1613 fn test_max_fps_config_parse_and_validation() {
1614 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
1615 let dir = home.join(".synaps-cli");
1616 std::fs::create_dir_all(&dir).unwrap();
1617
1618 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
1620 with_home(&home, || {
1621 let config = load_config();
1622 assert_eq!(config.max_fps, 144);
1623 assert!(
1624 config.warnings.is_empty(),
1625 "warnings: {:?}",
1626 config.warnings
1627 );
1628 });
1629
1630 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
1632 with_home(&home, || {
1633 let config = load_config();
1634 assert_eq!(config.max_fps, 60);
1635 assert!(
1636 config.warnings.iter().any(|w| w.contains("max_fps")),
1637 "warnings: {:?}",
1638 config.warnings
1639 );
1640 });
1641
1642 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
1644 with_home(&home, || {
1645 let config = load_config();
1646 assert_eq!(config.max_fps, 60);
1647 assert!(
1648 config.warnings.iter().any(|w| w.contains("max_fps")),
1649 "warnings: {:?}",
1650 config.warnings
1651 );
1652 });
1653
1654 let _ = std::fs::remove_dir_all(&home);
1655 }
1656
1657 #[test]
1658 #[serial]
1659 fn test_cache_ttl_config_parse_and_garbage_warning() {
1660 let home =
1661 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
1662 let dir = home.join(".synaps-cli");
1663 std::fs::create_dir_all(&dir).unwrap();
1664
1665 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
1667 with_home(&home, || {
1668 let config = load_config();
1669 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
1670 assert!(
1671 config.warnings.is_empty(),
1672 "warnings: {:?}",
1673 config.warnings
1674 );
1675 });
1676
1677 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
1679 with_home(&home, || {
1680 let config = load_config();
1681 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
1682 assert!(
1683 config.warnings.iter().any(|w| w.contains("cache_ttl")),
1684 "warnings: {:?}",
1685 config.warnings
1686 );
1687 });
1688
1689 let _ = std::fs::remove_dir_all(&home);
1690 }
1691
1692 #[test]
1693 fn test_parse_thinking_budget() {
1694 use crate::core::reasoning::ThinkingSpec;
1695 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
1697 assert_eq!(
1698 ThinkingSpec::parse("medium").unwrap().to_budget(),
1699 Some(4096)
1700 );
1701 assert_eq!(
1702 ThinkingSpec::parse("high").unwrap().to_budget(),
1703 Some(16384)
1704 );
1705 assert_eq!(
1706 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
1707 Some(32768)
1708 );
1709 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
1710 let config = load_config_from_str("thinking = 8192\n");
1711 assert_eq!(config.thinking_level, None);
1712 assert_eq!(config.thinking_budget, Some(8192));
1713 assert_eq!(ThinkingSpec::parse("invalid"), None);
1714 }
1715
1716 #[test]
1717 fn test_base_dir() {
1718 let path = base_dir();
1719 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
1720 }
1721
1722 #[test]
1723 fn test_resolve_system_prompt_explicit() {
1724 let result = resolve_system_prompt(Some("test prompt"));
1725 assert_eq!(result, "test prompt");
1726 }
1727
1728 #[test]
1729 fn test_resolve_system_prompt_none() {
1730 let result = resolve_system_prompt(None);
1731 assert!(result.contains("helpful AI agent"));
1732 }
1733
1734 #[test]
1739 fn test_synaps_config_default() {
1740 let config = SynapsConfig::default();
1741 assert_eq!(config.model, None);
1742 assert_eq!(config.thinking_budget, None);
1743 assert_eq!(config.max_tool_output, 30000);
1744 assert_eq!(config.bash_timeout, 30);
1745 assert_eq!(config.bash_max_timeout, 300);
1746 assert_eq!(config.subagent_timeout, 300);
1747 assert_eq!(config.api_retries, 3);
1748 assert_eq!(config.theme, None);
1749 assert!(config.disabled_plugins.is_empty());
1750 assert!(config.favorite_models.is_empty());
1751 assert!(config.disabled_skills.is_empty());
1752 assert!(!config.progressive_tool_disclosure);
1753 assert_eq!(config.shell.max_sessions, 5);
1754 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
1755 assert!(config.server.allowed_origins.is_empty());
1757 assert_eq!(config.server.token, None);
1758 assert!(!config.server.auto_approve_confirms);
1759 assert_eq!(config.server.max_message_size, None);
1760 assert!(config.bridge.uds_path.is_none());
1762 assert!(!config.bridge.heartbeat_mirror);
1763 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1764 }
1765
1766 #[test]
1767 #[serial]
1768 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
1769 let home = make_test_home("progressive-tool-disclosure");
1770 let cfg = home.join(".synaps-cli/config");
1771 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
1772
1773 with_home(&home, || {
1774 let config = load_config();
1775 assert!(config.progressive_tool_disclosure);
1776 assert!(config.warnings.is_empty());
1777 });
1778
1779 let _ = std::fs::remove_dir_all(&home);
1780 }
1781
1782 #[test]
1783 #[serial]
1784 fn test_load_config_bridge_keys() {
1785 let home = make_test_home("bridge-keys");
1786 let cfg = home.join(".synaps-cli/config");
1787 std::fs::write(
1788 &cfg,
1789 "\
1790bridge.uds_path = /tmp/some/control.sock\n\
1791bridge.heartbeat_mirror = true\n\
1792bridge.heartbeat_timeout_ms = 750\n\
1793",
1794 )
1795 .unwrap();
1796
1797 with_home(&home, || {
1798 let config = load_config();
1799 assert_eq!(
1800 config.bridge.uds_path,
1801 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
1802 );
1803 assert!(config.bridge.heartbeat_mirror);
1804 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
1805 assert_eq!(
1806 config.bridge.resolved_uds_path(),
1807 std::path::PathBuf::from("/tmp/some/control.sock"),
1808 );
1809 });
1810
1811 let _ = std::fs::remove_dir_all(&home);
1812 }
1813
1814 #[test]
1815 fn test_bridge_config_defaults() {
1816 let cfg = BridgeConfig::default();
1817 assert!(cfg.uds_path.is_none());
1818 assert!(!cfg.heartbeat_mirror);
1819 assert_eq!(cfg.heartbeat_timeout_ms, 250);
1820 let resolved = cfg.resolved_uds_path();
1822 assert!(resolved.ends_with("bridge/control.sock"));
1823 }
1824
1825 #[test]
1826 #[serial]
1827 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
1828 let home = make_test_home("bridge-default-off");
1829 let cfg = home.join(".synaps-cli/config");
1830 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1831
1832 with_home(&home, || {
1833 let config = load_config();
1834 assert!(!config.bridge.heartbeat_mirror);
1835 assert!(config.bridge.uds_path.is_none());
1836 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1837 });
1838
1839 let _ = std::fs::remove_dir_all(&home);
1840 }
1841
1842 #[test]
1843 fn compaction_disclosure_keys_parse_with_typed_warnings() {
1844 let config = load_config_from_str(
1845 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
1846 );
1847 assert_eq!(
1848 config.compaction_mode,
1849 crate::core::compaction::CompactionMode::LocalOnly
1850 );
1851 assert_eq!(
1852 config.compaction_exclude,
1853 vec![
1854 crate::core::compaction::ContentClass::Thinking,
1855 crate::core::compaction::ContentClass::ToolResults,
1856 ]
1857 );
1858 assert!(
1859 config.warnings.iter().any(|w| w.contains("bogus")),
1860 "unknown class must warn: {:?}",
1861 config.warnings
1862 );
1863
1864 let defaults = load_config_from_str("");
1865 assert_eq!(
1866 defaults.compaction_mode,
1867 crate::core::compaction::CompactionMode::Remote
1868 );
1869 assert!(defaults.compaction_exclude.is_empty());
1870
1871 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
1872 assert_eq!(
1873 bad_mode.compaction_mode,
1874 crate::core::compaction::CompactionMode::Remote
1875 );
1876 assert!(bad_mode
1877 .warnings
1878 .iter()
1879 .any(|w| w.contains("compaction_mode")));
1880 }
1881
1882 #[test]
1883 #[serial]
1884 fn test_load_config_server_keys() {
1885 let home = make_test_home("server-keys");
1886 let cfg = home.join(".synaps-cli/config");
1887 std::fs::write(
1888 &cfg,
1889 "\
1890server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1891server.token = my-secret-token\n\
1892server.auto_approve_confirms = true\n\
1893server.max_message_size = 65536\n\
1894context_window = 200k\n\
1895",
1896 )
1897 .unwrap();
1898
1899 with_home(&home, || {
1900 let config = load_config();
1901 assert_eq!(
1902 config.server.allowed_origins,
1903 vec![
1904 "http://localhost:3000".to_string(),
1905 "http://localhost:5193".to_string(),
1906 ]
1907 );
1908 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1909 assert!(config.server.auto_approve_confirms);
1910 assert_eq!(config.server.max_message_size, Some(65536));
1912 });
1913
1914 let _ = std::fs::remove_dir_all(&home);
1915 }
1916
1917 #[test]
1918 #[serial]
1919 fn test_server_max_message_size_derived_from_context_window() {
1920 let home = make_test_home("server-derive");
1921 let cfg = home.join(".synaps-cli/config");
1922 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1923
1924 with_home(&home, || {
1925 let config = load_config();
1926 assert_eq!(config.server.max_message_size, Some(800_000));
1928 });
1929
1930 let _ = std::fs::remove_dir_all(&home);
1931 }
1932
1933 fn make_test_home(subdir: &str) -> std::path::PathBuf {
1934 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1935 let _ = std::fs::remove_dir_all(&dir);
1936 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1937 dir
1938 }
1939
1940 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1941 let original = std::env::var("HOME").ok();
1942 std::env::set_var("HOME", home);
1943 f();
1944 if let Some(h) = original {
1945 std::env::set_var("HOME", h);
1946 } else {
1947 std::env::remove_var("HOME");
1948 }
1949 }
1950
1951 #[test]
1952 #[serial]
1953 fn write_config_value_replaces_existing_key() {
1954 let home = make_test_home("replace");
1955 let cfg = home.join(".synaps-cli/config");
1956 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1957
1958 with_home(&home, || {
1959 write_config_value("model", "claude-sonnet-4-6").unwrap();
1960 });
1961
1962 let contents = std::fs::read_to_string(&cfg).unwrap();
1963 assert!(contents.contains("model = claude-sonnet-4-6"));
1964 assert!(contents.contains("thinking = low"));
1965 let _ = std::fs::remove_dir_all(&home);
1966 }
1967
1968 #[test]
1969 #[serial]
1970 fn write_config_value_appends_when_missing() {
1971 let home = make_test_home("append");
1972 let cfg = home.join(".synaps-cli/config");
1973 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1974
1975 with_home(&home, || {
1976 write_config_value("theme", "dracula").unwrap();
1977 });
1978
1979 let contents = std::fs::read_to_string(&cfg).unwrap();
1980 assert!(contents.contains("model = claude-opus-4-6"));
1981 assert!(contents.contains("theme = dracula"));
1982 let _ = std::fs::remove_dir_all(&home);
1983 }
1984
1985 #[test]
1986 #[serial]
1987 fn write_config_value_preserves_comments() {
1988 let home = make_test_home("comments");
1989 let cfg = home.join(".synaps-cli/config");
1990 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
1991
1992 with_home(&home, || {
1993 write_config_value("model", "claude-sonnet-4-6").unwrap();
1994 });
1995
1996 let contents = std::fs::read_to_string(&cfg).unwrap();
1997 assert!(contents.contains("# user comment"));
1998 assert!(contents.contains("# another"));
1999 assert!(contents.contains("model = claude-sonnet-4-6"));
2000 let _ = std::fs::remove_dir_all(&home);
2001 }
2002
2003 #[test]
2004 #[serial]
2005 fn write_config_value_preserves_unknown_keys() {
2006 let home = make_test_home("unknown");
2007 let cfg = home.join(".synaps-cli/config");
2008 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
2009
2010 with_home(&home, || {
2011 write_config_value("model", "claude-sonnet-4-6").unwrap();
2012 });
2013
2014 let contents = std::fs::read_to_string(&cfg).unwrap();
2015 assert!(contents.contains("custom_thing = 42"));
2016 let _ = std::fs::remove_dir_all(&home);
2017 }
2018
2019 #[test]
2020 #[serial]
2021 fn write_config_value_creates_file_if_absent() {
2022 let home = make_test_home("create");
2023 let cfg = home.join(".synaps-cli/config");
2024 assert!(!cfg.exists());
2025
2026 with_home(&home, || {
2027 write_config_value("model", "claude-sonnet-4-6").unwrap();
2028 });
2029
2030 let contents = std::fs::read_to_string(&cfg).unwrap();
2031 assert!(contents.contains("model = claude-sonnet-4-6"));
2032 let _ = std::fs::remove_dir_all(&home);
2033 }
2034
2035 #[test]
2036 #[serial]
2037 fn load_config_parses_theme_key() {
2038 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
2039 let _ = std::fs::create_dir_all(&dir);
2040 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
2041
2042 let original_home = std::env::var("HOME").ok();
2043 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
2044
2045 let config = load_config();
2046
2047 if let Some(home) = original_home {
2048 std::env::set_var("HOME", home);
2049 } else {
2050 std::env::remove_var("HOME");
2051 }
2052 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
2053
2054 assert_eq!(config.theme.as_deref(), Some("dracula"));
2055 }
2056
2057 #[test]
2058 #[serial]
2059 fn test_load_config_disable_lists() {
2060 let test_dir =
2061 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
2062 let _ = std::fs::create_dir_all(&test_dir);
2063 let config_path = test_dir.join("config");
2064
2065 let config_content = r#"
2066# Test config with disable lists
2067favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
2068
2069disabled_plugins = foo, bar
2070disabled_skills = baz, plug:qual
2071"#;
2072 std::fs::write(&config_path, config_content).unwrap();
2073
2074 let original_home = std::env::var("HOME").ok();
2075 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
2076
2077 let config = load_config();
2078
2079 if let Some(home) = original_home {
2080 std::env::set_var("HOME", home);
2081 } else {
2082 std::env::remove_var("HOME");
2083 }
2084
2085 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
2086
2087 assert_eq!(
2088 config.disabled_plugins,
2089 vec!["foo".to_string(), "bar".to_string()]
2090 );
2091 assert_eq!(
2092 config.favorite_models,
2093 vec![
2094 "claude/claude-opus-4-7".to_string(),
2095 "groq/llama-3.3-70b-versatile".to_string(),
2096 ]
2097 );
2098 assert_eq!(
2099 config.disabled_skills,
2100 vec!["baz".to_string(), "plug:qual".to_string()]
2101 );
2102 }
2103
2104 #[test]
2105 #[serial]
2106 fn favorite_model_helpers_round_trip_through_config_file() {
2107 let home = make_test_home("favorite-models");
2108 let cfg = home.join(".synaps-cli/config");
2109 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
2110
2111 with_home(&home, || {
2112 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2113 add_favorite_model("claude/claude-opus-4-7").unwrap();
2114 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2115 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
2116 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2117 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
2118 assert!(is_favorite_model("claude/claude-opus-4-7"));
2119 });
2120
2121 let contents = std::fs::read_to_string(&cfg).unwrap();
2122 assert!(contents.contains("model = claude-opus-4-7"));
2123 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
2124 let _ = std::fs::remove_dir_all(&home);
2125 }
2126
2127 #[test]
2128 #[serial]
2129 fn test_load_config_new_keys() {
2130 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
2132 let _ = std::fs::create_dir_all(&test_dir);
2133 let config_path = test_dir.join("config");
2134
2135 let config_content = r#"
2136# Test config with new keys
2137model = claude-haiku
2138thinking = medium
2139max_tool_output = 50000
2140bash_timeout = 45
2141bash_max_timeout = 600
2142subagent_timeout = 120
2143api_retries = 5
2144"#;
2145 std::fs::write(&config_path, config_content).unwrap();
2146
2147 let original_home = std::env::var("HOME").ok();
2149 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
2150
2151 let config = load_config();
2152
2153 if let Some(home) = original_home {
2155 std::env::set_var("HOME", home);
2156 } else {
2157 std::env::remove_var("HOME");
2158 }
2159
2160 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
2162
2163 assert_eq!(config.model, Some("claude-haiku".to_string()));
2164 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
2166 assert_eq!(config.bash_timeout, 45);
2167 assert_eq!(config.bash_max_timeout, 600);
2168 assert_eq!(config.subagent_timeout, 120);
2169 assert_eq!(config.api_retries, 5);
2170 }
2171
2172 #[test]
2177 fn events_auto_turn_explicit_true_values() {
2178 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
2179 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2180 assert!(
2181 cfg.events.auto_turn,
2182 "expected true for events.auto_turn = {val}"
2183 );
2184 }
2185 }
2186
2187 #[test]
2188 fn events_auto_turn_explicit_false_values() {
2189 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
2190 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2191 assert!(
2192 !cfg.events.auto_turn,
2193 "expected false for events.auto_turn = {val}"
2194 );
2195 }
2196 }
2197
2198 #[test]
2199 fn events_auto_turn_typo_fails_safe_false() {
2200 let cfg = load_config_from_str("events.auto_turn = fales");
2202 assert!(
2203 !cfg.events.auto_turn,
2204 "typo 'fales' must fail safe to false"
2205 );
2206 }
2207
2208 #[test]
2209 fn events_auto_turn_default_is_true() {
2210 let cfg = load_config_from_str("model = claude-haiku");
2211 assert!(cfg.events.auto_turn, "default must be true when key absent");
2212 }
2213
2214 #[test]
2215 #[serial]
2216 fn test_auth_config_parses_endpoint_and_token() {
2217 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
2218 let dir = home.join(".synaps-cli");
2219 std::fs::create_dir_all(&dir).unwrap();
2220 std::fs::write(
2221 dir.join("config"),
2222 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
2223 )
2224 .unwrap();
2225 with_home(&home, || {
2226 let config = load_config();
2227 assert_eq!(
2228 config.auth.remote_endpoint.as_deref(),
2229 Some("https://jade.jade:8181")
2230 );
2231 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
2232 assert!(
2234 !config.warnings.iter().any(|w| w.contains("auth.")),
2235 "{:?}",
2236 config.warnings
2237 );
2238 });
2239 let _ = std::fs::remove_dir_all(&home);
2240 }
2241
2242 #[test]
2243 #[serial]
2244 fn test_credential_source_local_by_default() {
2245 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2246 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2247 assert!(!AuthConfig::default().credential_source().is_remote());
2248 }
2249
2250 #[test]
2251 #[serial]
2252 fn test_credential_source_remote_from_config() {
2253 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2254 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2255 let auth = AuthConfig {
2256 remote_endpoint: Some("https://b".into()),
2257 machine_token: Some("m".into()),
2258 };
2259 assert_eq!(
2260 auth.credential_source(),
2261 crate::core::auth::CredentialSource::Remote {
2262 endpoint: "https://b".into(),
2263 machine_token: "m".into()
2264 }
2265 );
2266 }
2267
2268 #[test]
2269 #[serial]
2270 fn test_credential_source_env_overrides_config() {
2271 let auth = AuthConfig {
2272 remote_endpoint: Some("https://config-host".into()),
2273 machine_token: Some("config-tok".into()),
2274 };
2275 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
2276 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
2277 let src = auth.credential_source();
2278 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2279 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2280 assert_eq!(
2281 src,
2282 crate::core::auth::CredentialSource::Remote {
2283 endpoint: "https://env-host".into(),
2284 machine_token: "env-tok".into()
2285 }
2286 );
2287 }
2288 #[test]
2289 fn config_parses_ultracode_as_distinct_canonical_level() {
2290 let cfg = load_config_from_str("thinking = ultracode\n");
2291 assert_eq!(
2292 cfg.thinking_level,
2293 Some(crate::reasoning::ReasoningLevel::UltraCode)
2294 );
2295 assert_eq!(cfg.thinking_budget, None);
2296 for other in [
2297 crate::reasoning::ReasoningLevel::Ultra,
2298 crate::reasoning::ReasoningLevel::Max,
2299 crate::reasoning::ReasoningLevel::XHigh,
2300 ] {
2301 assert_ne!(cfg.thinking_level, Some(other));
2302 }
2303 }
2304}