1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4use crate::core::shell_config::ShellConfig;
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 = "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
11
12pub fn get_identity() -> String {
16 IDENTITY.get().cloned().unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
17}
18
19pub fn get_provider_keys() -> BTreeMap<String, String> {
23 PROVIDER_KEYS.get().cloned().unwrap_or_default()
24}
25
26pub fn get_profile() -> Option<String> {
29 PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
30}
31
32pub fn set_profile(name: Option<String>) {
36 let _ = PROFILE_NAME.set(name);
37}
38
39pub fn base_dir() -> PathBuf {
40 if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
41 return PathBuf::from(path);
42 }
43 let home = std::env::var("HOME")
44 .or_else(|_| std::env::var("USERPROFILE"))
45 .unwrap_or_else(|_| ".".to_string());
46 PathBuf::from(home).join(".synaps-cli")
47}
48
49#[doc(hidden)]
51pub fn set_base_dir_for_tests(path: PathBuf) {
52 std::env::set_var("SYNAPS_BASE_DIR", path);
53}
54
55pub fn resolve_read_path(filename: &str) -> PathBuf {
57 let base = base_dir();
58
59 if let Some(profile) = get_profile() {
60 let profile_path = base.join(&profile).join(filename);
61 if profile_path.exists() {
62 return profile_path;
63 }
64 }
65
66 base.join(filename)
67}
68
69pub fn resolve_read_path_extended(path: &str) -> PathBuf {
71 let base = base_dir();
72
73 if let Some(profile) = get_profile() {
74 let profile_path = base.join(&profile).join(path);
75 if profile_path.exists() {
76 return profile_path;
77 }
78 }
79
80 base.join(path)
81}
82
83pub fn resolve_write_path(filename: &str) -> PathBuf {
85 let mut base = base_dir();
86
87 if let Some(profile) = get_profile() {
88 base.push(profile);
89 }
90
91 let _ = std::fs::create_dir_all(&base);
92 base.join(filename)
93}
94
95pub fn get_active_config_dir() -> PathBuf {
97 let mut base = base_dir();
98 if let Some(profile) = get_profile() {
99 base.push(profile);
100 }
101 base
102}
103
104#[derive(Debug, Clone, Default)]
106pub struct ServerConfig {
107 pub allowed_origins: Vec<String>,
109 pub token: Option<String>,
112 pub auto_approve_confirms: bool,
114 pub max_message_size: Option<usize>,
117}
118
119#[derive(Debug, Clone)]
125pub struct BridgeConfig {
126 pub uds_path: Option<PathBuf>,
129 pub heartbeat_mirror: bool,
132 pub heartbeat_timeout_ms: u64,
134}
135
136impl Default for BridgeConfig {
137 fn default() -> Self {
138 Self {
139 uds_path: None,
140 heartbeat_mirror: false,
141 heartbeat_timeout_ms: 250,
142 }
143 }
144}
145
146impl BridgeConfig {
147 pub fn resolved_uds_path(&self) -> PathBuf {
149 self.uds_path
150 .clone()
151 .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
152 }
153}
154
155#[derive(Debug, Clone, Default)]
159pub struct AuthConfig {
160 pub remote_endpoint: Option<String>,
164 pub machine_token: Option<String>,
167}
168
169impl AuthConfig {
170 pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
174 let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
175 .ok()
176 .filter(|s| !s.trim().is_empty())
177 .or_else(|| self.remote_endpoint.clone());
178 let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
179 .ok()
180 .filter(|s| !s.trim().is_empty())
181 .or_else(|| self.machine_token.clone());
182 crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum CacheTtl {
197 #[default]
198 FiveMinutes,
199 OneHour,
200 Hybrid,
201}
202
203impl CacheTtl {
204 pub fn parse(val: &str) -> Option<CacheTtl> {
207 match val.to_ascii_lowercase().as_str() {
208 "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
209 "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
210 "hybrid" => Some(CacheTtl::Hybrid),
211 _ => None,
212 }
213 }
214}
215
216#[derive(Debug, Clone)]
221pub struct EventsConfig {
222 pub auto_turn: bool,
228}
229
230impl Default for EventsConfig {
231 fn default() -> Self {
232 Self { auto_turn: true }
233 }
234}
235
236fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
238 if key == "events.auto_turn" {
239 let normalised = val.trim().to_lowercase();
240 cfg.auto_turn = match normalised.as_str() {
241 "true" | "1" | "yes" | "on" => true,
243 "false" | "0" | "no" | "off" => false,
245 other => {
247 eprintln!(
248 "warning: config: unrecognised value for events.auto_turn = {:?}; \
249 expected true/false/yes/no/on/off/1/0 — defaulting to false",
250 other
251 );
252 false
253 }
254 };
255 } }
257
258#[derive(Debug, Clone)]
260pub struct SynapsConfig {
261 pub model: Option<String>,
262 pub thinking_budget: Option<u32>,
263 pub context_window: Option<u64>, pub compaction_model: Option<String>, 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,
275 pub max_fps: u32,
279 pub scroll_lines: Option<u16>,
283 pub theme: Option<String>,
284 pub agent_name: Option<String>,
285 pub identity: Option<String>,
286 pub disabled_plugins: Vec<String>,
287 pub favorite_models: Vec<String>,
288 pub disabled_skills: Vec<String>,
289 pub disabled_tools: Vec<String>,
292 pub shell: ShellConfig,
293 pub server: ServerConfig,
294 pub bridge: BridgeConfig,
295 pub auth: AuthConfig,
296 pub events: EventsConfig,
297 pub provider_keys: BTreeMap<String, String>,
298 pub keybinds: std::collections::HashMap<String, String>,
299 pub warnings: Vec<String>,
302}
303
304impl Default for SynapsConfig {
305 fn default() -> Self {
306 Self {
307 model: None,
308 thinking_budget: None,
309 context_window: None,
310 compaction_model: None,
311 max_tool_output: 30000,
312 bash_timeout: 30,
313 bash_max_timeout: 300,
314 subagent_timeout: 300,
315 api_retries: 3,
316 refusal_retries: 2,
317 telemetry: "off".to_string(),
318 cache_diagnostics: false,
319 cache_ttl: CacheTtl::default(),
320 max_fps: 60,
321 scroll_lines: None,
322 theme: None,
323 agent_name: None,
324 identity: None,
325 disabled_plugins: Vec::new(),
326 favorite_models: Vec::new(),
327 disabled_skills: Vec::new(),
328 disabled_tools: Vec::new(),
329 shell: ShellConfig::default(),
330 server: ServerConfig::default(),
331 bridge: BridgeConfig::default(),
332 auth: AuthConfig::default(),
333 events: EventsConfig::default(),
334 provider_keys: BTreeMap::new(),
335 keybinds: std::collections::HashMap::new(),
336 warnings: Vec::new(),
337 }
338 }
339}
340
341const KNOWN_CONFIG_KEYS: &[&str] = &[
343 "model", "thinking", "compaction_model", "context_window", "max_tool_output",
344 "bash_timeout", "bash_max_timeout", "subagent_timeout", "api_retries", "refusal_retries",
345 "telemetry", "cache_diagnostics", "cache_ttl", "max_fps", "scroll_lines", "theme", "agent_name", "identity",
346 "disabled_plugins", "favorite_models", "disabled_skills", "disabled_tools",
347];
348
349fn levenshtein(a: &str, b: &str) -> usize {
351 let a: Vec<char> = a.chars().collect();
352 let b: Vec<char> = b.chars().collect();
353 let mut prev: Vec<usize> = (0..=b.len()).collect();
354 let mut cur = vec![0; b.len() + 1];
355 for (i, ca) in a.iter().enumerate() {
356 cur[0] = i + 1;
357 for (j, cb) in b.iter().enumerate() {
358 let cost = if ca == cb { 0 } else { 1 };
359 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
360 }
361 std::mem::swap(&mut prev, &mut cur);
362 }
363 prev[b.len()]
364}
365
366fn did_you_mean(key: &str) -> Option<&'static str> {
368 KNOWN_CONFIG_KEYS
369 .iter()
370 .map(|k| (*k, levenshtein(key, k)))
371 .filter(|(_, d)| *d <= 2)
372 .min_by_key(|(_, d)| *d)
373 .map(|(k, _)| k)
374}
375
376
377fn parse_thinking_budget(val: &str) -> Option<u32> {
378 match val {
379 "low" => Some(2048),
380 "medium" => Some(4096),
381 "high" => Some(16384),
382 "xhigh" => Some(32768),
383 "adaptive" => Some(0), _ => val.parse::<u32>().ok(),
385 }
386}
387
388fn parse_comma_list(val: &str) -> Vec<String> {
389 val.split(',')
390 .map(|s| s.trim().to_string())
391 .filter(|s| !s.is_empty())
392 .collect()
393}
394
395fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
396 write_config_value(key, &values.join(", "))
397}
398
399fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
401 match key {
402 "shell.max_sessions" => {
403 if let Ok(sessions) = val.parse::<usize>() {
404 shell_config.max_sessions = sessions;
405 } else {
406 eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
407 }
408 }
409 "shell.idle_timeout" => {
410 if let Ok(timeout) = val.parse::<u64>() {
411 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
412 } else {
413 eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
414 }
415 }
416 "shell.readiness_timeout_ms" => {
417 if let Ok(timeout) = val.parse::<u64>() {
418 shell_config.readiness_timeout_ms = timeout;
419 } else {
420 eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
421 }
422 }
423 "shell.max_readiness_timeout_ms" => {
424 if let Ok(timeout) = val.parse::<u64>() {
425 shell_config.max_readiness_timeout_ms = timeout;
426 } else {
427 eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
428 }
429 }
430 "shell.default_rows" => {
431 if let Ok(rows) = val.parse::<u16>() {
432 shell_config.default_rows = rows;
433 } else {
434 eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
435 }
436 }
437 "shell.default_cols" => {
438 if let Ok(cols) = val.parse::<u16>() {
439 shell_config.default_cols = cols;
440 } else {
441 eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
442 }
443 }
444 "shell.readiness_strategy" => {
445 let val_lower = val.to_lowercase();
446 match val_lower.as_str() {
447 "timeout" | "prompt" | "hybrid" => {
448 shell_config.readiness_strategy = val.to_string();
449 }
450 _ => {
451 eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
452 }
453 }
454 }
455 "shell.max_output" => {
456 if let Ok(max_output) = val.parse::<usize>() {
457 shell_config.max_output = max_output;
458 } else {
459 eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
460 }
461 }
462 _ => {
463 }
465 }
466}
467
468#[allow(clippy::collapsible_match)]
470fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
471 match key {
472 "server.allowed_origins" => {
473 server_config.allowed_origins = parse_comma_list(val);
474 }
475 "server.token" => {
476 if !val.is_empty() {
477 server_config.token = Some(val.to_string());
478 }
479 }
480 "server.auto_approve_confirms" => {
481 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
482 }
483 "server.max_message_size" => {
484 if let Ok(size) = val.parse::<usize>() {
485 server_config.max_message_size = Some(size);
486 } else {
487 eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
488 }
489 }
490 _ => {
491 }
493 }
494}
495
496fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
498 match key {
499 "bridge.uds_path" => {
500 if val.is_empty() {
501 bridge_config.uds_path = None;
502 } else {
503 bridge_config.uds_path = Some(PathBuf::from(val));
504 }
505 }
506 "bridge.heartbeat_mirror" => {
507 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
508 }
509 "bridge.heartbeat_timeout_ms" => {
510 if let Ok(ms) = val.parse::<u64>() {
511 bridge_config.heartbeat_timeout_ms = ms;
512 } else {
513 eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
514 }
515 }
516 _ => {
517 }
519 }
520}
521
522fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
524 let v = val.trim();
525 match key {
526 "auth.remote_endpoint" => {
527 auth_config.remote_endpoint = if v.is_empty() { None } else { Some(v.to_string()) };
528 }
529 "auth.machine_token" => {
530 auth_config.machine_token = if v.is_empty() { None } else { Some(v.to_string()) };
531 }
532 _ => {
533 }
535 }
536}
537
538pub fn load_config_from_str(content: &str) -> SynapsConfig {
541 let mut config = SynapsConfig::default();
542 apply_config_content(&mut config, content);
543 config
544}
545
546pub fn load_config() -> SynapsConfig {
549 let path = resolve_read_path("config");
550 let mut config = SynapsConfig::default();
551
552 let Ok(content) = std::fs::read_to_string(&path) else {
553 return config;
554 };
555
556 apply_config_content(&mut config, &content);
557
558 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
561
562 let identity_val = config.identity.clone().unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
564 let _ = IDENTITY.set(identity_val);
565
566 config
567}
568
569fn apply_config_content(config: &mut SynapsConfig, content: &str) {
572 for line in content.lines() {
573 let line = line.trim();
574 if line.is_empty() || line.starts_with('#') { continue; }
575 let Some((key, val)) = line.split_once('=') else { continue };
576 let key = key.trim();
577 let val = val.trim();
578 match key {
579 "model" => config.model = Some(val.to_string()),
580 "thinking" => {
581 config.thinking_budget = parse_thinking_budget(val);
582 if config.thinking_budget.is_none() {
583 config.warnings.push(format!("thinking = {val} — expected low|medium|high|xhigh|adaptive or a token count; thinking disabled"));
584 }
585 }
586 "compaction_model" => config.compaction_model = Some(val.to_string()),
587 "context_window" => {
588 let parsed = match val {
589 "200k" | "200K" => Some(200_000),
590 "1m" | "1M" => Some(1_000_000),
591 _ => val.parse::<u64>().ok(),
592 };
593 if parsed.is_none() {
594 config.warnings.push(format!("context_window = {val} — expected 200k, 1m, or a token count; ignored"));
595 }
596 config.context_window = parsed;
597 }
598 "max_tool_output" => {
599 match val.parse::<usize>() {
600 Ok(size) => config.max_tool_output = size,
601 Err(_) => config.warnings.push(format!("max_tool_output = {val} — not a number; using {}", config.max_tool_output)),
602 }
603 }
604 "bash_timeout" => {
605 match val.parse::<u64>() {
606 Ok(t) if t >= 1 => config.bash_timeout = t,
607 Ok(_) => config.warnings.push(format!("bash_timeout = {val} — below minimum (1s); using {}", config.bash_timeout)),
608 Err(_) => config.warnings.push(format!("bash_timeout = {val} — not a number; using {}", config.bash_timeout)),
609 }
610 }
611 "bash_max_timeout" => {
612 if let Ok(timeout) = val.parse::<u64>() {
613 config.bash_max_timeout = timeout;
614 }
615 }
616 "subagent_timeout" => {
617 if let Ok(timeout) = val.parse::<u64>() {
618 config.subagent_timeout = timeout;
619 }
620 }
621 "api_retries" => {
622 if let Ok(retries) = val.parse::<u32>() {
623 config.api_retries = retries;
624 }
625 }
626 "refusal_retries" => {
627 if let Ok(retries) = val.parse::<u32>() {
628 config.refusal_retries = retries;
629 }
630 }
631 "telemetry" => config.telemetry = val.to_string(),
632 "cache_diagnostics" => {
633 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
634 }
635 "cache_ttl" => {
636 match CacheTtl::parse(val) {
637 Some(ttl) => config.cache_ttl = ttl,
638 None => config.warnings.push(format!(
639 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
640 )),
641 }
642 }
643 "max_fps" => {
644 match val.parse::<u32>() {
645 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
646 Ok(_) => config.warnings.push(format!(
647 "max_fps = {val} — expected 1–1000; using {}", config.max_fps
648 )),
649 Err(_) => config.warnings.push(format!(
650 "max_fps = {val} — not a number; using {}", config.max_fps
651 )),
652 }
653 }
654 "scroll_lines" => {
655 match val.parse::<u16>() {
656 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
657 Ok(_) => config.warnings.push(format!(
658 "scroll_lines = {val} — expected 1–20; ignoring"
659 )),
660 Err(_) => config.warnings.push(format!(
661 "scroll_lines = {val} — not a number; ignoring"
662 )),
663 }
664 }
665 "theme" => config.theme = Some(val.to_string()),
666 "agent_name" => config.agent_name = Some(val.to_string()),
667 "identity" => config.identity = Some(val.to_string()),
668 "disabled_plugins" => {
669 config.disabled_plugins = parse_comma_list(val);
670 }
671 "favorite_models" => {
672 config.favorite_models = parse_comma_list(val);
673 }
674 "disabled_skills" => {
675 config.disabled_skills = parse_comma_list(val);
676 }
677 "disabled_tools" => {
678 config.disabled_tools = parse_comma_list(val);
679 }
680 _ => {
681 if key.starts_with("shell.") {
683 parse_shell_config_key(&mut config.shell, key, val);
684 } else if key.starts_with("server.") {
685 parse_server_config_key(&mut config.server, key, val);
686 } else if key.starts_with("bridge.") {
687 parse_bridge_config_key(&mut config.bridge, key, val);
688 } else if key.starts_with("auth.") {
689 parse_auth_config_key(&mut config.auth, key, val);
690 } else if key.starts_with("events.") {
691 parse_events_config_key(&mut config.events, key, val);
692 } else if let Some(provider_key) = key.strip_prefix("provider.") {
693 config.provider_keys.insert(provider_key.to_string(), val.to_string());
694 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
695 config.keybinds.insert(keybind_key.to_string(), val.to_string());
696 } else if key.contains('.') {
697 } else {
701 match did_you_mean(key) {
703 Some(suggestion) => config.warnings.push(format!("unknown key '{key}' (did you mean '{suggestion}'?)")),
704 None => config.warnings.push(format!("unknown key '{key}' — ignored")),
705 }
706 }
707 }
708 }
709 }
710
711 if config.server.max_message_size.is_none() {
714 if let Some(ctx_tokens) = config.context_window {
715 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
716 }
717 }
718}
719
720pub fn read_config_value(key: &str) -> Option<String> {
722 let path = resolve_read_path("config");
723 let content = std::fs::read_to_string(&path).ok()?;
724 for line in content.lines() {
725 let line = line.trim();
726 if line.is_empty() || line.starts_with('#') { continue; }
727 let Some((k, v)) = line.split_once('=') else { continue };
728 if k.trim() == key.trim() {
729 return Some(v.trim().to_string());
730 }
731 }
732 None
733}
734
735pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
739 let path = resolve_write_path("config");
740 let existing = std::fs::read_to_string(&path).unwrap_or_default();
741
742 let key_trimmed = key.trim();
743 let replacement = format!("{} = {}", key_trimmed, value);
744
745 let mut found = false;
746 let mut new_lines: Vec<String> = existing.lines().map(|line| {
747 if found { return line.to_string(); }
748 let t = line.trim_start();
749 if t.starts_with('#') || t.is_empty() { return line.to_string(); }
750 if let Some((k, _)) = t.split_once('=') {
751 if k.trim() == key_trimmed {
752 found = true;
753 return replacement.clone();
754 }
755 }
756 line.to_string()
757 }).collect();
758
759 if !found {
760 new_lines.push(replacement);
761 }
762
763 let mut out = new_lines.join("\n");
764 if !out.ends_with('\n') { out.push('\n'); }
765
766 let tmp = path.with_extension("tmp");
767 std::fs::write(&tmp, out)?;
768 #[cfg(unix)]
770 {
771 use std::os::unix::fs::PermissionsExt;
772 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
773 }
774 std::fs::rename(&tmp, &path)?;
775 Ok(())
776}
777
778pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
780 let trimmed = id.trim();
781 if trimmed.is_empty() {
782 return Ok(());
783 }
784 let mut values = load_config().favorite_models;
785 if !values.iter().any(|v| v == trimmed) {
786 values.push(trimmed.to_string());
787 values.sort();
788 }
789 write_comma_list("favorite_models", &values)
790}
791
792pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
794 let mut values = load_config().favorite_models;
795 values.retain(|v| v != id.trim());
796 write_comma_list("favorite_models", &values)
797}
798
799pub fn is_favorite_model(id: &str) -> bool {
801 load_config().favorite_models.iter().any(|v| v == id.trim())
802}
803
804pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
807 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
808 You have access to bash, read, and write tools. \
809 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
810
811 if let Some(val) = explicit {
812 let path = std::path::Path::new(val);
813 if path.exists() && path.is_file() {
814 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
815 }
816 return val.to_string();
817 }
818
819 let system_path = resolve_read_path("system.md");
820 if system_path.exists() {
821 return std::fs::read_to_string(&system_path).unwrap_or_default();
822 }
823
824 DEFAULT_PROMPT.to_string()
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use serial_test::serial;
831
832 #[test]
833 fn test_levenshtein_basics() {
834 assert_eq!(levenshtein("model", "model"), 0);
835 assert_eq!(levenshtein("modle", "model"), 2);
836 assert_eq!(levenshtein("them", "theme"), 1);
837 }
838
839 #[test]
840 fn test_did_you_mean_close_typos() {
841 assert_eq!(did_you_mean("modle"), Some("model"));
842 assert_eq!(did_you_mean("them"), Some("theme"));
843 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
844 assert_eq!(did_you_mean("completely_unrelated_key"), None);
845 }
846
847 #[test]
848 #[serial]
849 fn test_config_warnings_unknown_key_and_bad_values() {
850 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
851 let dir = home.join(".synaps-cli");
852 std::fs::create_dir_all(&dir).unwrap();
853 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();
854
855 with_home(&home, || {
856 let config = load_config();
857 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
859 assert!(!config.warnings.iter().any(|w| w.contains("knowledge")), "{:?}", config.warnings);
860 assert!(config.warnings.iter().any(|w| w.contains("did you mean 'model'")), "{:?}", config.warnings);
861 assert!(config.warnings.iter().any(|w| w.contains("thinking")), "{:?}", config.warnings);
862 assert!(config.warnings.iter().any(|w| w.contains("below minimum")), "{:?}", config.warnings);
863 assert_eq!(config.bash_timeout, 30);
865 assert_eq!(config.thinking_budget, None);
866 });
867 let _ = std::fs::remove_dir_all(&home);
868 }
869
870 #[test]
873 fn test_cache_ttl_parse_table() {
874 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
876 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
877 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
878 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
880 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
881 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
882 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
884 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
886 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
887 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
888 assert_eq!(CacheTtl::parse("2h"), None);
890 assert_eq!(CacheTtl::parse(""), None);
891 assert_eq!(CacheTtl::parse("forever"), None);
892 }
893
894 #[test]
895 fn test_cache_ttl_default_is_five_minutes() {
896 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
897 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
898 }
899
900 #[test]
901 fn test_max_fps_default_is_60() {
902 assert_eq!(SynapsConfig::default().max_fps, 60);
903 }
904
905 #[test]
906 #[serial]
907 fn test_max_fps_config_parse_and_validation() {
908 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
909 let dir = home.join(".synaps-cli");
910 std::fs::create_dir_all(&dir).unwrap();
911
912 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
914 with_home(&home, || {
915 let config = load_config();
916 assert_eq!(config.max_fps, 144);
917 assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
918 });
919
920 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
922 with_home(&home, || {
923 let config = load_config();
924 assert_eq!(config.max_fps, 60);
925 assert!(
926 config.warnings.iter().any(|w| w.contains("max_fps")),
927 "warnings: {:?}", config.warnings
928 );
929 });
930
931 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
933 with_home(&home, || {
934 let config = load_config();
935 assert_eq!(config.max_fps, 60);
936 assert!(
937 config.warnings.iter().any(|w| w.contains("max_fps")),
938 "warnings: {:?}", config.warnings
939 );
940 });
941
942 let _ = std::fs::remove_dir_all(&home);
943 }
944
945 #[test]
946 #[serial]
947 fn test_cache_ttl_config_parse_and_garbage_warning() {
948 let home = std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
949 let dir = home.join(".synaps-cli");
950 std::fs::create_dir_all(&dir).unwrap();
951
952 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
954 with_home(&home, || {
955 let config = load_config();
956 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
957 assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
958 });
959
960 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
962 with_home(&home, || {
963 let config = load_config();
964 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
965 assert!(
966 config.warnings.iter().any(|w| w.contains("cache_ttl")),
967 "warnings: {:?}", config.warnings
968 );
969 });
970
971 let _ = std::fs::remove_dir_all(&home);
972 }
973
974 #[test]
975 fn test_parse_thinking_budget() {
976 assert_eq!(parse_thinking_budget("low"), Some(2048));
977 assert_eq!(parse_thinking_budget("medium"), Some(4096));
978 assert_eq!(parse_thinking_budget("high"), Some(16384));
979 assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
980 assert_eq!(parse_thinking_budget("8192"), Some(8192));
981 assert_eq!(parse_thinking_budget("invalid"), None);
982 }
983
984 #[test]
985 fn test_base_dir() {
986 let path = base_dir();
987 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
988 }
989
990 #[test]
991 fn test_resolve_system_prompt_explicit() {
992 let result = resolve_system_prompt(Some("test prompt"));
993 assert_eq!(result, "test prompt");
994 }
995
996 #[test]
997 fn test_resolve_system_prompt_none() {
998 let result = resolve_system_prompt(None);
999 assert!(result.contains("helpful AI agent"));
1000 }
1001
1002 #[test]
1007 fn test_synaps_config_default() {
1008 let config = SynapsConfig::default();
1009 assert_eq!(config.model, None);
1010 assert_eq!(config.thinking_budget, None);
1011 assert_eq!(config.max_tool_output, 30000);
1012 assert_eq!(config.bash_timeout, 30);
1013 assert_eq!(config.bash_max_timeout, 300);
1014 assert_eq!(config.subagent_timeout, 300);
1015 assert_eq!(config.api_retries, 3);
1016 assert_eq!(config.theme, None);
1017 assert!(config.disabled_plugins.is_empty());
1018 assert!(config.favorite_models.is_empty());
1019 assert!(config.disabled_skills.is_empty());
1020 assert_eq!(config.shell.max_sessions, 5);
1021 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
1022 assert!(config.server.allowed_origins.is_empty());
1024 assert_eq!(config.server.token, None);
1025 assert!(!config.server.auto_approve_confirms);
1026 assert_eq!(config.server.max_message_size, None);
1027 assert!(config.bridge.uds_path.is_none());
1029 assert!(!config.bridge.heartbeat_mirror);
1030 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1031 }
1032
1033 #[test]
1034 #[serial]
1035 fn test_load_config_bridge_keys() {
1036 let home = make_test_home("bridge-keys");
1037 let cfg = home.join(".synaps-cli/config");
1038 std::fs::write(&cfg, "\
1039bridge.uds_path = /tmp/some/control.sock\n\
1040bridge.heartbeat_mirror = true\n\
1041bridge.heartbeat_timeout_ms = 750\n\
1042").unwrap();
1043
1044 with_home(&home, || {
1045 let config = load_config();
1046 assert_eq!(
1047 config.bridge.uds_path,
1048 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
1049 );
1050 assert!(config.bridge.heartbeat_mirror);
1051 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
1052 assert_eq!(
1053 config.bridge.resolved_uds_path(),
1054 std::path::PathBuf::from("/tmp/some/control.sock"),
1055 );
1056 });
1057
1058 let _ = std::fs::remove_dir_all(&home);
1059 }
1060
1061 #[test]
1062 fn test_bridge_config_defaults() {
1063 let cfg = BridgeConfig::default();
1064 assert!(cfg.uds_path.is_none());
1065 assert!(!cfg.heartbeat_mirror);
1066 assert_eq!(cfg.heartbeat_timeout_ms, 250);
1067 let resolved = cfg.resolved_uds_path();
1069 assert!(resolved.ends_with("bridge/control.sock"));
1070 }
1071
1072 #[test]
1073 #[serial]
1074 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
1075 let home = make_test_home("bridge-default-off");
1076 let cfg = home.join(".synaps-cli/config");
1077 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1078
1079 with_home(&home, || {
1080 let config = load_config();
1081 assert!(!config.bridge.heartbeat_mirror);
1082 assert!(config.bridge.uds_path.is_none());
1083 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1084 });
1085
1086 let _ = std::fs::remove_dir_all(&home);
1087 }
1088
1089 #[test]
1090 #[serial]
1091 fn test_load_config_server_keys() {
1092 let home = make_test_home("server-keys");
1093 let cfg = home.join(".synaps-cli/config");
1094 std::fs::write(&cfg, "\
1095server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1096server.token = my-secret-token\n\
1097server.auto_approve_confirms = true\n\
1098server.max_message_size = 65536\n\
1099context_window = 200k\n\
1100").unwrap();
1101
1102 with_home(&home, || {
1103 let config = load_config();
1104 assert_eq!(config.server.allowed_origins, vec![
1105 "http://localhost:3000".to_string(),
1106 "http://localhost:5193".to_string(),
1107 ]);
1108 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1109 assert!(config.server.auto_approve_confirms);
1110 assert_eq!(config.server.max_message_size, Some(65536));
1112 });
1113
1114 let _ = std::fs::remove_dir_all(&home);
1115 }
1116
1117 #[test]
1118 #[serial]
1119 fn test_server_max_message_size_derived_from_context_window() {
1120 let home = make_test_home("server-derive");
1121 let cfg = home.join(".synaps-cli/config");
1122 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1123
1124 with_home(&home, || {
1125 let config = load_config();
1126 assert_eq!(config.server.max_message_size, Some(800_000));
1128 });
1129
1130 let _ = std::fs::remove_dir_all(&home);
1131 }
1132
1133 fn make_test_home(subdir: &str) -> std::path::PathBuf {
1134 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1135 let _ = std::fs::remove_dir_all(&dir);
1136 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1137 dir
1138 }
1139
1140 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1141 let original = std::env::var("HOME").ok();
1142 std::env::set_var("HOME", home);
1143 f();
1144 if let Some(h) = original {
1145 std::env::set_var("HOME", h);
1146 } else {
1147 std::env::remove_var("HOME");
1148 }
1149 }
1150
1151 #[test]
1152 #[serial]
1153 fn write_config_value_replaces_existing_key() {
1154 let home = make_test_home("replace");
1155 let cfg = home.join(".synaps-cli/config");
1156 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1157
1158 with_home(&home, || {
1159 write_config_value("model", "claude-sonnet-4-6").unwrap();
1160 });
1161
1162 let contents = std::fs::read_to_string(&cfg).unwrap();
1163 assert!(contents.contains("model = claude-sonnet-4-6"));
1164 assert!(contents.contains("thinking = low"));
1165 let _ = std::fs::remove_dir_all(&home);
1166 }
1167
1168 #[test]
1169 #[serial]
1170 fn write_config_value_appends_when_missing() {
1171 let home = make_test_home("append");
1172 let cfg = home.join(".synaps-cli/config");
1173 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1174
1175 with_home(&home, || {
1176 write_config_value("theme", "dracula").unwrap();
1177 });
1178
1179 let contents = std::fs::read_to_string(&cfg).unwrap();
1180 assert!(contents.contains("model = claude-opus-4-6"));
1181 assert!(contents.contains("theme = dracula"));
1182 let _ = std::fs::remove_dir_all(&home);
1183 }
1184
1185 #[test]
1186 #[serial]
1187 fn write_config_value_preserves_comments() {
1188 let home = make_test_home("comments");
1189 let cfg = home.join(".synaps-cli/config");
1190 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
1191
1192 with_home(&home, || {
1193 write_config_value("model", "claude-sonnet-4-6").unwrap();
1194 });
1195
1196 let contents = std::fs::read_to_string(&cfg).unwrap();
1197 assert!(contents.contains("# user comment"));
1198 assert!(contents.contains("# another"));
1199 assert!(contents.contains("model = claude-sonnet-4-6"));
1200 let _ = std::fs::remove_dir_all(&home);
1201 }
1202
1203 #[test]
1204 #[serial]
1205 fn write_config_value_preserves_unknown_keys() {
1206 let home = make_test_home("unknown");
1207 let cfg = home.join(".synaps-cli/config");
1208 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1209
1210 with_home(&home, || {
1211 write_config_value("model", "claude-sonnet-4-6").unwrap();
1212 });
1213
1214 let contents = std::fs::read_to_string(&cfg).unwrap();
1215 assert!(contents.contains("custom_thing = 42"));
1216 let _ = std::fs::remove_dir_all(&home);
1217 }
1218
1219 #[test]
1220 #[serial]
1221 fn write_config_value_creates_file_if_absent() {
1222 let home = make_test_home("create");
1223 let cfg = home.join(".synaps-cli/config");
1224 assert!(!cfg.exists());
1225
1226 with_home(&home, || {
1227 write_config_value("model", "claude-sonnet-4-6").unwrap();
1228 });
1229
1230 let contents = std::fs::read_to_string(&cfg).unwrap();
1231 assert!(contents.contains("model = claude-sonnet-4-6"));
1232 let _ = std::fs::remove_dir_all(&home);
1233 }
1234
1235 #[test]
1236 #[serial]
1237 fn load_config_parses_theme_key() {
1238 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1239 let _ = std::fs::create_dir_all(&dir);
1240 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1241
1242 let original_home = std::env::var("HOME").ok();
1243 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1244
1245 let config = load_config();
1246
1247 if let Some(home) = original_home {
1248 std::env::set_var("HOME", home);
1249 } else {
1250 std::env::remove_var("HOME");
1251 }
1252 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1253
1254 assert_eq!(config.theme.as_deref(), Some("dracula"));
1255 }
1256
1257 #[test]
1258 #[serial]
1259 fn test_load_config_disable_lists() {
1260 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1261 let _ = std::fs::create_dir_all(&test_dir);
1262 let config_path = test_dir.join("config");
1263
1264 let config_content = r#"
1265# Test config with disable lists
1266favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1267
1268disabled_plugins = foo, bar
1269disabled_skills = baz, plug:qual
1270"#;
1271 std::fs::write(&config_path, config_content).unwrap();
1272
1273 let original_home = std::env::var("HOME").ok();
1274 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1275
1276 let config = load_config();
1277
1278 if let Some(home) = original_home {
1279 std::env::set_var("HOME", home);
1280 } else {
1281 std::env::remove_var("HOME");
1282 }
1283
1284 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1285
1286 assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
1287 assert_eq!(config.favorite_models, vec![
1288 "claude/claude-opus-4-7".to_string(),
1289 "groq/llama-3.3-70b-versatile".to_string(),
1290 ]);
1291 assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
1292 }
1293
1294 #[test]
1295 #[serial]
1296 fn favorite_model_helpers_round_trip_through_config_file() {
1297 let home = make_test_home("favorite-models");
1298 let cfg = home.join(".synaps-cli/config");
1299 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
1300
1301 with_home(&home, || {
1302 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1303 add_favorite_model("claude/claude-opus-4-7").unwrap();
1304 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1305 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
1306 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1307 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
1308 assert!(is_favorite_model("claude/claude-opus-4-7"));
1309 });
1310
1311 let contents = std::fs::read_to_string(&cfg).unwrap();
1312 assert!(contents.contains("model = claude-opus-4-7"));
1313 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
1314 let _ = std::fs::remove_dir_all(&home);
1315 }
1316
1317 #[test]
1318 #[serial]
1319 fn test_load_config_new_keys() {
1320 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
1322 let _ = std::fs::create_dir_all(&test_dir);
1323 let config_path = test_dir.join("config");
1324
1325 let config_content = r#"
1326# Test config with new keys
1327model = claude-haiku
1328thinking = medium
1329max_tool_output = 50000
1330bash_timeout = 45
1331bash_max_timeout = 600
1332subagent_timeout = 120
1333api_retries = 5
1334"#;
1335 std::fs::write(&config_path, config_content).unwrap();
1336
1337 let original_home = std::env::var("HOME").ok();
1339 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
1340
1341 let config = load_config();
1342
1343 if let Some(home) = original_home {
1345 std::env::set_var("HOME", home);
1346 } else {
1347 std::env::remove_var("HOME");
1348 }
1349
1350 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
1352
1353 assert_eq!(config.model, Some("claude-haiku".to_string()));
1354 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
1356 assert_eq!(config.bash_timeout, 45);
1357 assert_eq!(config.bash_max_timeout, 600);
1358 assert_eq!(config.subagent_timeout, 120);
1359 assert_eq!(config.api_retries, 5);
1360 }
1361
1362 #[test]
1367 fn events_auto_turn_explicit_true_values() {
1368 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
1369 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
1370 assert!(cfg.events.auto_turn, "expected true for events.auto_turn = {val}");
1371 }
1372 }
1373
1374 #[test]
1375 fn events_auto_turn_explicit_false_values() {
1376 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
1377 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
1378 assert!(!cfg.events.auto_turn, "expected false for events.auto_turn = {val}");
1379 }
1380 }
1381
1382 #[test]
1383 fn events_auto_turn_typo_fails_safe_false() {
1384 let cfg = load_config_from_str("events.auto_turn = fales");
1386 assert!(!cfg.events.auto_turn, "typo 'fales' must fail safe to false");
1387 }
1388
1389 #[test]
1390 fn events_auto_turn_default_is_true() {
1391 let cfg = load_config_from_str("model = claude-haiku");
1392 assert!(cfg.events.auto_turn, "default must be true when key absent");
1393 }
1394
1395 #[test]
1396 #[serial]
1397 fn test_auth_config_parses_endpoint_and_token() {
1398 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
1399 let dir = home.join(".synaps-cli");
1400 std::fs::create_dir_all(&dir).unwrap();
1401 std::fs::write(
1402 dir.join("config"),
1403 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
1404 )
1405 .unwrap();
1406 with_home(&home, || {
1407 let config = load_config();
1408 assert_eq!(config.auth.remote_endpoint.as_deref(), Some("https://jade.jade:8181"));
1409 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
1410 assert!(!config.warnings.iter().any(|w| w.contains("auth.")), "{:?}", config.warnings);
1412 });
1413 let _ = std::fs::remove_dir_all(&home);
1414 }
1415
1416 #[test]
1417 #[serial]
1418 fn test_credential_source_local_by_default() {
1419 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1420 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1421 assert!(!AuthConfig::default().credential_source().is_remote());
1422 }
1423
1424 #[test]
1425 #[serial]
1426 fn test_credential_source_remote_from_config() {
1427 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1428 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1429 let auth = AuthConfig {
1430 remote_endpoint: Some("https://b".into()),
1431 machine_token: Some("m".into()),
1432 };
1433 assert_eq!(
1434 auth.credential_source(),
1435 crate::core::auth::CredentialSource::Remote { endpoint: "https://b".into(), machine_token: "m".into() }
1436 );
1437 }
1438
1439 #[test]
1440 #[serial]
1441 fn test_credential_source_env_overrides_config() {
1442 let auth = AuthConfig {
1443 remote_endpoint: Some("https://config-host".into()),
1444 machine_token: Some("config-tok".into()),
1445 };
1446 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
1447 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
1448 let src = auth.credential_source();
1449 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1450 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1451 assert_eq!(
1452 src,
1453 crate::core::auth::CredentialSource::Remote { endpoint: "https://env-host".into(), machine_token: "env-tok".into() }
1454 );
1455 }
1456}