Skip to main content

agent_core/core/
config.rs

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
10const DEFAULT_IDENTITY: &str = "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
11
12/// Returns the configured identity string for the system prompt preamble.
13/// Falls back to `DEFAULT_IDENTITY` (the SynapsCLI identity above) if not set
14/// in config. Initialized by `load_config()` — safe to call anytime after boot.
15pub fn get_identity() -> String {
16    IDENTITY.get().cloned().unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
17}
18
19/// Provider API keys parsed from `provider.<name> = ...` lines in config.
20/// Empty if `load_config()` hasn't been called. The registry falls back to
21/// env vars, so e.g. `GROQ_API_KEY` works even with an empty map.
22pub fn get_provider_keys() -> BTreeMap<String, String> {
23    PROVIDER_KEYS.get().cloned().unwrap_or_default()
24}
25
26/// Returns the active profile name, if any.
27/// Reads from `SYNAPS_PROFILE` environment variable if not already set programmatically.
28pub fn get_profile() -> Option<String> {
29    PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
30}
31
32/// Sets the active profile name. Must be called before any `get_profile()` call
33/// (i.e., before config resolution begins). Uses OnceLock — first write wins,
34/// subsequent calls are no-ops. No env var mutation (unsafe under tokio).
35pub 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/// Overrides the Synaps base directory. Intended for tests and embedded harnesses.
50#[doc(hidden)]
51pub fn set_base_dir_for_tests(path: PathBuf) {
52    std::env::set_var("SYNAPS_BASE_DIR", path);
53}
54
55/// Resolves a path for reading. Checks the profile folder first, then falls back to the default folder.
56pub 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
69/// Resolves a path for reading with an extended arbitrary path tree.
70pub 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
83/// Resolves a path for writing. Unconditionally writes to the profile folder if a profile is active.
84pub 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
95/// Gets the absolute directory for the current profile (or root if default).
96pub 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/// Server security configuration parsed from `server.*` keys.
105#[derive(Debug, Clone, Default)]
106pub struct ServerConfig {
107    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
108    pub allowed_origins: Vec<String>,
109    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
110    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
111    pub token: Option<String>,
112    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
113    pub auto_approve_confirms: bool,
114    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
115    /// None means no artificial cap.
116    pub max_message_size: Option<usize>,
117}
118
119/// Bridge daemon configuration parsed from `bridge.*` keys.
120///
121/// Controls best-effort mirroring of watcher heartbeats over the bridge
122/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
123/// and the feature is OFF by default.
124#[derive(Debug, Clone)]
125pub struct BridgeConfig {
126    /// Path to the bridge daemon's UDS control socket.
127    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
128    pub uds_path: Option<PathBuf>,
129    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
130    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
131    pub heartbeat_mirror: bool,
132    /// Per-call timeout in milliseconds (covers connect + write + read).
133    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    /// Resolve the UDS path, falling back to the default under `base_dir()`.
148    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/// Prompt-cache TTL strategy for Anthropic requests.
156///
157/// Controls the `cache_control` value emitted at every cache marker site:
158/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
159///   to historical payloads; the default path can never invalidate existing
160///   cached prefixes.
161/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
162/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
163///   bare 5m on the message-tail marker (written every turn).
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
165pub enum CacheTtl {
166    #[default]
167    FiveMinutes,
168    OneHour,
169    Hybrid,
170}
171
172impl CacheTtl {
173    /// Parse a config value (case-insensitive). Returns `None` for unknown
174    /// values so the caller can warn and fall back to the default.
175    pub fn parse(val: &str) -> Option<CacheTtl> {
176        match val.to_ascii_lowercase().as_str() {
177            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
178            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
179            "hybrid" => Some(CacheTtl::Hybrid),
180            _ => None,
181        }
182    }
183}
184
185/// Parsed configuration from the config file.
186#[derive(Debug, Clone)]
187pub struct SynapsConfig {
188    pub model: Option<String>,
189    pub thinking_budget: Option<u32>,
190    pub context_window: Option<u64>,   // override auto-detected context window (tokens)
191    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
192    pub max_tool_output: usize,        // default 30000
193    pub bash_timeout: u64,             // default 30
194    pub bash_max_timeout: u64,         // default 300
195    pub subagent_timeout: u64,         // default 300
196    pub api_retries: u32,              // default 3
197    pub telemetry: String,             // off | basic | full (default off)
198    pub cache_diagnostics: bool,       // opt into cache-diagnosis beta (default false)
199    /// Prompt-cache TTL strategy: "5m" (default) | "1h" | "hybrid".
200    pub cache_ttl: CacheTtl,
201    pub theme: Option<String>,
202    pub agent_name: Option<String>,
203    pub identity: Option<String>,
204    pub disabled_plugins: Vec<String>,
205    pub favorite_models: Vec<String>,
206    pub disabled_skills: Vec<String>,
207    pub shell: ShellConfig,
208    pub server: ServerConfig,
209    pub bridge: BridgeConfig,
210    pub provider_keys: BTreeMap<String, String>,
211    pub keybinds: std::collections::HashMap<String, String>,
212    /// Non-fatal problems found while parsing the config file (unknown keys,
213    /// unparseable values). Surfaced once at startup — never block boot.
214    pub warnings: Vec<String>,
215}
216
217impl Default for SynapsConfig {
218    fn default() -> Self {
219        Self {
220            model: None,
221            thinking_budget: None,
222            context_window: None,
223            compaction_model: None,
224            max_tool_output: 30000,
225            bash_timeout: 30,
226            bash_max_timeout: 300,
227            subagent_timeout: 300,
228            api_retries: 3,
229            telemetry: "off".to_string(),
230            cache_diagnostics: false,
231            cache_ttl: CacheTtl::default(),
232            theme: None,
233            agent_name: None,
234            identity: None,
235            disabled_plugins: Vec::new(),
236            favorite_models: Vec::new(),
237            disabled_skills: Vec::new(),
238            shell: ShellConfig::default(),
239            server: ServerConfig::default(),
240            bridge: BridgeConfig::default(),
241            provider_keys: BTreeMap::new(),
242            keybinds: std::collections::HashMap::new(),
243            warnings: Vec::new(),
244        }
245    }
246}
247
248/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
249const KNOWN_CONFIG_KEYS: &[&str] = &[
250    "model", "thinking", "compaction_model", "context_window", "max_tool_output",
251    "bash_timeout", "bash_max_timeout", "subagent_timeout", "api_retries",
252    "telemetry", "cache_diagnostics", "cache_ttl", "theme", "agent_name", "identity",
253    "disabled_plugins", "favorite_models", "disabled_skills",
254];
255
256/// Simple Levenshtein distance for did-you-mean suggestions.
257fn levenshtein(a: &str, b: &str) -> usize {
258    let a: Vec<char> = a.chars().collect();
259    let b: Vec<char> = b.chars().collect();
260    let mut prev: Vec<usize> = (0..=b.len()).collect();
261    let mut cur = vec![0; b.len() + 1];
262    for (i, ca) in a.iter().enumerate() {
263        cur[0] = i + 1;
264        for (j, cb) in b.iter().enumerate() {
265            let cost = if ca == cb { 0 } else { 1 };
266            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
267        }
268        std::mem::swap(&mut prev, &mut cur);
269    }
270    prev[b.len()]
271}
272
273/// Closest known key within edit distance 2, for typo suggestions.
274fn did_you_mean(key: &str) -> Option<&'static str> {
275    KNOWN_CONFIG_KEYS
276        .iter()
277        .map(|k| (*k, levenshtein(key, k)))
278        .filter(|(_, d)| *d <= 2)
279        .min_by_key(|(_, d)| *d)
280        .map(|(k, _)| k)
281}
282
283
284fn parse_thinking_budget(val: &str) -> Option<u32> {
285    match val {
286        "low" => Some(2048),
287        "medium" => Some(4096),
288        "high" => Some(16384),
289        "xhigh" => Some(32768),
290        "adaptive" => Some(0), // sentinel: model decides depth
291        _ => val.parse::<u32>().ok(),
292    }
293}
294
295fn parse_comma_list(val: &str) -> Vec<String> {
296    val.split(',')
297        .map(|s| s.trim().to_string())
298        .filter(|s| !s.is_empty())
299        .collect()
300}
301
302fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
303    write_config_value(key, &values.join(", "))
304}
305
306/// Parse shell.* configuration keys and update the ShellConfig.
307fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
308    match key {
309        "shell.max_sessions" => {
310            if let Ok(sessions) = val.parse::<usize>() {
311                shell_config.max_sessions = sessions;
312            } else {
313                eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
314            }
315        }
316        "shell.idle_timeout" => {
317            if let Ok(timeout) = val.parse::<u64>() {
318                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
319            } else {
320                eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
321            }
322        }
323        "shell.readiness_timeout_ms" => {
324            if let Ok(timeout) = val.parse::<u64>() {
325                shell_config.readiness_timeout_ms = timeout;
326            } else {
327                eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
328            }
329        }
330        "shell.max_readiness_timeout_ms" => {
331            if let Ok(timeout) = val.parse::<u64>() {
332                shell_config.max_readiness_timeout_ms = timeout;
333            } else {
334                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
335            }
336        }
337        "shell.default_rows" => {
338            if let Ok(rows) = val.parse::<u16>() {
339                shell_config.default_rows = rows;
340            } else {
341                eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
342            }
343        }
344        "shell.default_cols" => {
345            if let Ok(cols) = val.parse::<u16>() {
346                shell_config.default_cols = cols;
347            } else {
348                eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
349            }
350        }
351        "shell.readiness_strategy" => {
352            let val_lower = val.to_lowercase();
353            match val_lower.as_str() {
354                "timeout" | "prompt" | "hybrid" => {
355                    shell_config.readiness_strategy = val.to_string();
356                }
357                _ => {
358                    eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
359                }
360            }
361        }
362        "shell.max_output" => {
363            if let Ok(max_output) = val.parse::<usize>() {
364                shell_config.max_output = max_output;
365            } else {
366                eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
367            }
368        }
369        _ => {
370            // Unknown shell.* keys are preserved (not rejected)
371        }
372    }
373}
374
375/// Parse server.* configuration keys and update the ServerConfig.
376#[allow(clippy::collapsible_match)]
377fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
378    match key {
379        "server.allowed_origins" => {
380            server_config.allowed_origins = parse_comma_list(val);
381        }
382        "server.token" => {
383            if !val.is_empty() {
384                server_config.token = Some(val.to_string());
385            }
386        }
387        "server.auto_approve_confirms" => {
388            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
389        }
390        "server.max_message_size" => {
391            if let Ok(size) = val.parse::<usize>() {
392                server_config.max_message_size = Some(size);
393            } else {
394                eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
395            }
396        }
397        _ => {
398            // Unknown server.* keys preserved (not rejected)
399        }
400    }
401}
402
403/// Parse bridge.* configuration keys and update the BridgeConfig.
404fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
405    match key {
406        "bridge.uds_path" => {
407            if val.is_empty() {
408                bridge_config.uds_path = None;
409            } else {
410                bridge_config.uds_path = Some(PathBuf::from(val));
411            }
412        }
413        "bridge.heartbeat_mirror" => {
414            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
415        }
416        "bridge.heartbeat_timeout_ms" => {
417            if let Ok(ms) = val.parse::<u64>() {
418                bridge_config.heartbeat_timeout_ms = ms;
419            } else {
420                eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
421            }
422        }
423        _ => {
424            // Unknown bridge.* keys preserved (not rejected)
425        }
426    }
427}
428
429/// Parse the config file at ~/.synaps-cli/config (or profile variant).
430/// Returns default config if file doesn't exist or can't be read.
431pub fn load_config() -> SynapsConfig {
432    let path = resolve_read_path("config");
433    let mut config = SynapsConfig::default();
434    
435    let Ok(content) = std::fs::read_to_string(&path) else {
436        return config;
437    };
438    
439    for line in content.lines() {
440        let line = line.trim();
441        if line.is_empty() || line.starts_with('#') { continue; }
442        let Some((key, val)) = line.split_once('=') else { continue };
443        let key = key.trim();
444        let val = val.trim();
445        match key {
446            "model" => config.model = Some(val.to_string()),
447            "thinking" => {
448                config.thinking_budget = parse_thinking_budget(val);
449                if config.thinking_budget.is_none() {
450                    config.warnings.push(format!("thinking = {val} — expected low|medium|high|xhigh|adaptive or a token count; thinking disabled"));
451                }
452            }
453            "compaction_model" => config.compaction_model = Some(val.to_string()),
454            "context_window" => {
455                let parsed = match val {
456                    "200k" | "200K" => Some(200_000),
457                    "1m" | "1M" => Some(1_000_000),
458                    _ => val.parse::<u64>().ok(),
459                };
460                if parsed.is_none() {
461                    config.warnings.push(format!("context_window = {val} — expected 200k, 1m, or a token count; ignored"));
462                }
463                config.context_window = parsed;
464            }
465            "max_tool_output" => {
466                match val.parse::<usize>() {
467                    Ok(size) => config.max_tool_output = size,
468                    Err(_) => config.warnings.push(format!("max_tool_output = {val} — not a number; using {}", config.max_tool_output)),
469                }
470            }
471            "bash_timeout" => {
472                match val.parse::<u64>() {
473                    Ok(t) if t >= 1 => config.bash_timeout = t,
474                    Ok(_) => config.warnings.push(format!("bash_timeout = {val} — below minimum (1s); using {}", config.bash_timeout)),
475                    Err(_) => config.warnings.push(format!("bash_timeout = {val} — not a number; using {}", config.bash_timeout)),
476                }
477            }
478            "bash_max_timeout" => {
479                if let Ok(timeout) = val.parse::<u64>() {
480                    config.bash_max_timeout = timeout;
481                }
482            }
483            "subagent_timeout" => {
484                if let Ok(timeout) = val.parse::<u64>() {
485                    config.subagent_timeout = timeout;
486                }
487            }
488            "api_retries" => {
489                if let Ok(retries) = val.parse::<u32>() {
490                    config.api_retries = retries;
491                }
492            }
493            "telemetry" => config.telemetry = val.to_string(),
494            "cache_diagnostics" => {
495                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
496            }
497            "cache_ttl" => {
498                match CacheTtl::parse(val) {
499                    Some(ttl) => config.cache_ttl = ttl,
500                    None => config.warnings.push(format!(
501                        "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
502                    )),
503                }
504            }
505            "theme" => config.theme = Some(val.to_string()),
506            "agent_name" => config.agent_name = Some(val.to_string()),
507            "identity" => config.identity = Some(val.to_string()),
508            "disabled_plugins" => {
509                config.disabled_plugins = parse_comma_list(val);
510            }
511            "favorite_models" => {
512                config.favorite_models = parse_comma_list(val);
513            }
514            "disabled_skills" => {
515                config.disabled_skills = parse_comma_list(val);
516            }
517            _ => {
518                // Handle namespaced keys
519                if key.starts_with("shell.") {
520                    parse_shell_config_key(&mut config.shell, key, val);
521                } else if key.starts_with("server.") {
522                    parse_server_config_key(&mut config.server, key, val);
523                } else if key.starts_with("bridge.") {
524                    parse_bridge_config_key(&mut config.bridge, key, val);
525                } else if let Some(provider_key) = key.strip_prefix("provider.") {
526                    config.provider_keys.insert(provider_key.to_string(), val.to_string());
527                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
528                    config.keybinds.insert(keybind_key.to_string(), val.to_string());
529                } else if key.contains('.') {
530                    // Dotted keys are namespaced (plugin/extension config, e.g.
531                    // `knowledge.jawz_notes`). Plugins define their own keys —
532                    // not ours to police. Silently preserved.
533                } else {
534                    // Unknown top-level key — warn with a did-you-mean if close.
535                    match did_you_mean(key) {
536                        Some(suggestion) => config.warnings.push(format!("unknown key '{key}' (did you mean '{suggestion}'?)")),
537                        None => config.warnings.push(format!("unknown key '{key}' — ignored")),
538                    }
539                }
540            }
541        }
542    }
543
544    // Derive max_message_size from context_window if not explicitly set.
545    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
546    if config.server.max_message_size.is_none() {
547        if let Some(ctx_tokens) = config.context_window {
548            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
549        }
550    }
551
552    // Publish provider keys to the process-wide cache for the API router.
553    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
554    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
555
556    // Publish identity to the process-wide cache for API system prompt preamble.
557    let identity_val = config.identity.clone().unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
558    let _ = IDENTITY.set(identity_val);
559
560    config
561}
562
563/// Read a single config value by exact key from the active config file.
564pub fn read_config_value(key: &str) -> Option<String> {
565    let path = resolve_read_path("config");
566    let content = std::fs::read_to_string(&path).ok()?;
567    for line in content.lines() {
568        let line = line.trim();
569        if line.is_empty() || line.starts_with('#') { continue; }
570        let Some((k, v)) = line.split_once('=') else { continue };
571        if k.trim() == key.trim() {
572            return Some(v.trim().to_string());
573        }
574    }
575    None
576}
577
578/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
579/// Replaces the first existing line that matches the key, or appends if absent.
580/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
581pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
582    let path = resolve_write_path("config");
583    let existing = std::fs::read_to_string(&path).unwrap_or_default();
584
585    let key_trimmed = key.trim();
586    let replacement = format!("{} = {}", key_trimmed, value);
587
588    let mut found = false;
589    let mut new_lines: Vec<String> = existing.lines().map(|line| {
590        if found { return line.to_string(); }
591        let t = line.trim_start();
592        if t.starts_with('#') || t.is_empty() { return line.to_string(); }
593        if let Some((k, _)) = t.split_once('=') {
594            if k.trim() == key_trimmed {
595                found = true;
596                return replacement.clone();
597            }
598        }
599        line.to_string()
600    }).collect();
601
602    if !found {
603        new_lines.push(replacement);
604    }
605
606    let mut out = new_lines.join("\n");
607    if !out.ends_with('\n') { out.push('\n'); }
608
609    let tmp = path.with_extension("tmp");
610    std::fs::write(&tmp, out)?;
611    // Config may contain API keys — restrict to owner-only
612    #[cfg(unix)]
613    {
614        use std::os::unix::fs::PermissionsExt;
615        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
616    }
617    std::fs::rename(&tmp, &path)?;
618    Ok(())
619}
620
621/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
622pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
623    let trimmed = id.trim();
624    if trimmed.is_empty() {
625        return Ok(());
626    }
627    let mut values = load_config().favorite_models;
628    if !values.iter().any(|v| v == trimmed) {
629        values.push(trimmed.to_string());
630        values.sort();
631    }
632    write_comma_list("favorite_models", &values)
633}
634
635/// Remove a favorite model id (`provider/model`) from config.
636pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
637    let mut values = load_config().favorite_models;
638    values.retain(|v| v != id.trim());
639    write_comma_list("favorite_models", &values)
640}
641
642/// Return whether a model id is marked as favorite.
643pub fn is_favorite_model(id: &str) -> bool {
644    load_config().favorite_models.iter().any(|v| v == id.trim())
645}
646
647/// Resolve the system prompt from CLI flag, config file, or default.
648/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
649pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
650    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
651        You have access to bash, read, and write tools. \
652        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
653
654    if let Some(val) = explicit {
655        let path = std::path::Path::new(val);
656        if path.exists() && path.is_file() {
657            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
658        }
659        return val.to_string();
660    }
661
662    let system_path = resolve_read_path("system.md");
663    if system_path.exists() {
664        return std::fs::read_to_string(&system_path).unwrap_or_default();
665    }
666
667    DEFAULT_PROMPT.to_string()
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use serial_test::serial;
674
675    #[test]
676    fn test_levenshtein_basics() {
677        assert_eq!(levenshtein("model", "model"), 0);
678        assert_eq!(levenshtein("modle", "model"), 2);
679        assert_eq!(levenshtein("them", "theme"), 1);
680    }
681
682    #[test]
683    fn test_did_you_mean_close_typos() {
684        assert_eq!(did_you_mean("modle"), Some("model"));
685        assert_eq!(did_you_mean("them"), Some("theme"));
686        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
687        assert_eq!(did_you_mean("completely_unrelated_key"), None);
688    }
689
690    #[test]
691    #[serial]
692    fn test_config_warnings_unknown_key_and_bad_values() {
693        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
694        let dir = home.join(".synaps-cli");
695        std::fs::create_dir_all(&dir).unwrap();
696        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();
697
698        with_home(&home, || {
699            let config = load_config();
700            // Dotted (namespaced) keys must NOT warn — plugins own those.
701            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
702            assert!(!config.warnings.iter().any(|w| w.contains("knowledge")), "{:?}", config.warnings);
703            assert!(config.warnings.iter().any(|w| w.contains("did you mean 'model'")), "{:?}", config.warnings);
704            assert!(config.warnings.iter().any(|w| w.contains("thinking")), "{:?}", config.warnings);
705            assert!(config.warnings.iter().any(|w| w.contains("below minimum")), "{:?}", config.warnings);
706            // Bad values fall back to defaults
707            assert_eq!(config.bash_timeout, 30);
708            assert_eq!(config.thinking_budget, None);
709        });
710        let _ = std::fs::remove_dir_all(&home);
711    }
712
713    // ── cache_ttl parse table (spec §3.1) ──
714
715    #[test]
716    fn test_cache_ttl_parse_table() {
717        // 5m aliases
718        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
719        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
720        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
721        // 1h aliases
722        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
723        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
724        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
725        // hybrid
726        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
727        // case-insensitive
728        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
729        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
730        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
731        // garbage → None (caller warns + defaults)
732        assert_eq!(CacheTtl::parse("2h"), None);
733        assert_eq!(CacheTtl::parse(""), None);
734        assert_eq!(CacheTtl::parse("forever"), None);
735    }
736
737    #[test]
738    fn test_cache_ttl_default_is_five_minutes() {
739        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
740        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
741    }
742
743    #[test]
744    #[serial]
745    fn test_cache_ttl_config_parse_and_garbage_warning() {
746        let home = std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
747        let dir = home.join(".synaps-cli");
748        std::fs::create_dir_all(&dir).unwrap();
749
750        // Valid value parses, no warning.
751        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
752        with_home(&home, || {
753            let config = load_config();
754            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
755            assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
756        });
757
758        // Garbage value → 5m default + boot warning (never blocks boot).
759        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
760        with_home(&home, || {
761            let config = load_config();
762            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
763            assert!(
764                config.warnings.iter().any(|w| w.contains("cache_ttl")),
765                "warnings: {:?}", config.warnings
766            );
767        });
768
769        let _ = std::fs::remove_dir_all(&home);
770    }
771
772    #[test]
773    fn test_parse_thinking_budget() {
774        assert_eq!(parse_thinking_budget("low"), Some(2048));
775        assert_eq!(parse_thinking_budget("medium"), Some(4096));
776        assert_eq!(parse_thinking_budget("high"), Some(16384));
777        assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
778        assert_eq!(parse_thinking_budget("8192"), Some(8192));
779        assert_eq!(parse_thinking_budget("invalid"), None);
780    }
781
782    #[test]
783    fn test_base_dir() {
784        let path = base_dir();
785        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
786    }
787
788    #[test]
789    fn test_resolve_system_prompt_explicit() {
790        let result = resolve_system_prompt(Some("test prompt"));
791        assert_eq!(result, "test prompt");
792    }
793
794    #[test]
795    fn test_resolve_system_prompt_none() {
796        let result = resolve_system_prompt(None);
797        assert!(result.contains("helpful AI agent"));
798    }
799
800    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
801    // is not thread-safe and races with shell config tests. Coverage provided
802    // by shell::config::tests::test_shell_config_from_file.
803
804    #[test]
805    fn test_synaps_config_default() {
806        let config = SynapsConfig::default();
807        assert_eq!(config.model, None);
808        assert_eq!(config.thinking_budget, None);
809        assert_eq!(config.max_tool_output, 30000);
810        assert_eq!(config.bash_timeout, 30);
811        assert_eq!(config.bash_max_timeout, 300);
812        assert_eq!(config.subagent_timeout, 300);
813        assert_eq!(config.api_retries, 3);
814        assert_eq!(config.theme, None);
815        assert!(config.disabled_plugins.is_empty());
816        assert!(config.favorite_models.is_empty());
817        assert!(config.disabled_skills.is_empty());
818        assert_eq!(config.shell.max_sessions, 5);
819        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
820        // Server config defaults
821        assert!(config.server.allowed_origins.is_empty());
822        assert_eq!(config.server.token, None);
823        assert!(!config.server.auto_approve_confirms);
824        assert_eq!(config.server.max_message_size, None);
825        // Bridge config defaults
826        assert!(config.bridge.uds_path.is_none());
827        assert!(!config.bridge.heartbeat_mirror);
828        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
829    }
830
831    #[test]
832    #[serial]
833    fn test_load_config_bridge_keys() {
834        let home = make_test_home("bridge-keys");
835        let cfg = home.join(".synaps-cli/config");
836        std::fs::write(&cfg, "\
837bridge.uds_path = /tmp/some/control.sock\n\
838bridge.heartbeat_mirror = true\n\
839bridge.heartbeat_timeout_ms = 750\n\
840").unwrap();
841
842        with_home(&home, || {
843            let config = load_config();
844            assert_eq!(
845                config.bridge.uds_path,
846                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
847            );
848            assert!(config.bridge.heartbeat_mirror);
849            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
850            assert_eq!(
851                config.bridge.resolved_uds_path(),
852                std::path::PathBuf::from("/tmp/some/control.sock"),
853            );
854        });
855
856        let _ = std::fs::remove_dir_all(&home);
857    }
858
859    #[test]
860    fn test_bridge_config_defaults() {
861        let cfg = BridgeConfig::default();
862        assert!(cfg.uds_path.is_none());
863        assert!(!cfg.heartbeat_mirror);
864        assert_eq!(cfg.heartbeat_timeout_ms, 250);
865        // resolved path falls under base_dir()/bridge/control.sock
866        let resolved = cfg.resolved_uds_path();
867        assert!(resolved.ends_with("bridge/control.sock"));
868    }
869
870    #[test]
871    #[serial]
872    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
873        let home = make_test_home("bridge-default-off");
874        let cfg = home.join(".synaps-cli/config");
875        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
876
877        with_home(&home, || {
878            let config = load_config();
879            assert!(!config.bridge.heartbeat_mirror);
880            assert!(config.bridge.uds_path.is_none());
881            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
882        });
883
884        let _ = std::fs::remove_dir_all(&home);
885    }
886
887    #[test]
888    #[serial]
889    fn test_load_config_server_keys() {
890        let home = make_test_home("server-keys");
891        let cfg = home.join(".synaps-cli/config");
892        std::fs::write(&cfg, "\
893server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
894server.token = my-secret-token\n\
895server.auto_approve_confirms = true\n\
896server.max_message_size = 65536\n\
897context_window = 200k\n\
898").unwrap();
899
900        with_home(&home, || {
901            let config = load_config();
902            assert_eq!(config.server.allowed_origins, vec![
903                "http://localhost:3000".to_string(),
904                "http://localhost:5193".to_string(),
905            ]);
906            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
907            assert!(config.server.auto_approve_confirms);
908            // Explicit max_message_size takes precedence over context_window derivation
909            assert_eq!(config.server.max_message_size, Some(65536));
910        });
911
912        let _ = std::fs::remove_dir_all(&home);
913    }
914
915    #[test]
916    #[serial]
917    fn test_server_max_message_size_derived_from_context_window() {
918        let home = make_test_home("server-derive");
919        let cfg = home.join(".synaps-cli/config");
920        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
921
922        with_home(&home, || {
923            let config = load_config();
924            // 200_000 tokens * 4 bytes/token = 800_000 bytes
925            assert_eq!(config.server.max_message_size, Some(800_000));
926        });
927
928        let _ = std::fs::remove_dir_all(&home);
929    }
930
931    fn make_test_home(subdir: &str) -> std::path::PathBuf {
932        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
933        let _ = std::fs::remove_dir_all(&dir);
934        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
935        dir
936    }
937
938    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
939        let original = std::env::var("HOME").ok();
940        std::env::set_var("HOME", home);
941        f();
942        if let Some(h) = original {
943            std::env::set_var("HOME", h);
944        } else {
945            std::env::remove_var("HOME");
946        }
947    }
948
949    #[test]
950    #[serial]
951    fn write_config_value_replaces_existing_key() {
952        let home = make_test_home("replace");
953        let cfg = home.join(".synaps-cli/config");
954        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
955
956        with_home(&home, || {
957            write_config_value("model", "claude-sonnet-4-6").unwrap();
958        });
959
960        let contents = std::fs::read_to_string(&cfg).unwrap();
961        assert!(contents.contains("model = claude-sonnet-4-6"));
962        assert!(contents.contains("thinking = low"));
963        let _ = std::fs::remove_dir_all(&home);
964    }
965
966    #[test]
967    #[serial]
968    fn write_config_value_appends_when_missing() {
969        let home = make_test_home("append");
970        let cfg = home.join(".synaps-cli/config");
971        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
972
973        with_home(&home, || {
974            write_config_value("theme", "dracula").unwrap();
975        });
976
977        let contents = std::fs::read_to_string(&cfg).unwrap();
978        assert!(contents.contains("model = claude-opus-4-6"));
979        assert!(contents.contains("theme = dracula"));
980        let _ = std::fs::remove_dir_all(&home);
981    }
982
983    #[test]
984    #[serial]
985    fn write_config_value_preserves_comments() {
986        let home = make_test_home("comments");
987        let cfg = home.join(".synaps-cli/config");
988        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
989
990        with_home(&home, || {
991            write_config_value("model", "claude-sonnet-4-6").unwrap();
992        });
993
994        let contents = std::fs::read_to_string(&cfg).unwrap();
995        assert!(contents.contains("# user comment"));
996        assert!(contents.contains("# another"));
997        assert!(contents.contains("model = claude-sonnet-4-6"));
998        let _ = std::fs::remove_dir_all(&home);
999    }
1000
1001    #[test]
1002    #[serial]
1003    fn write_config_value_preserves_unknown_keys() {
1004        let home = make_test_home("unknown");
1005        let cfg = home.join(".synaps-cli/config");
1006        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1007
1008        with_home(&home, || {
1009            write_config_value("model", "claude-sonnet-4-6").unwrap();
1010        });
1011
1012        let contents = std::fs::read_to_string(&cfg).unwrap();
1013        assert!(contents.contains("custom_thing = 42"));
1014        let _ = std::fs::remove_dir_all(&home);
1015    }
1016
1017    #[test]
1018    #[serial]
1019    fn write_config_value_creates_file_if_absent() {
1020        let home = make_test_home("create");
1021        let cfg = home.join(".synaps-cli/config");
1022        assert!(!cfg.exists());
1023
1024        with_home(&home, || {
1025            write_config_value("model", "claude-sonnet-4-6").unwrap();
1026        });
1027
1028        let contents = std::fs::read_to_string(&cfg).unwrap();
1029        assert!(contents.contains("model = claude-sonnet-4-6"));
1030        let _ = std::fs::remove_dir_all(&home);
1031    }
1032
1033    #[test]
1034    #[serial]
1035    fn load_config_parses_theme_key() {
1036        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1037        let _ = std::fs::create_dir_all(&dir);
1038        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1039
1040        let original_home = std::env::var("HOME").ok();
1041        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1042
1043        let config = load_config();
1044
1045        if let Some(home) = original_home {
1046            std::env::set_var("HOME", home);
1047        } else {
1048            std::env::remove_var("HOME");
1049        }
1050        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1051
1052        assert_eq!(config.theme.as_deref(), Some("dracula"));
1053    }
1054
1055    #[test]
1056    #[serial]
1057    fn test_load_config_disable_lists() {
1058        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1059        let _ = std::fs::create_dir_all(&test_dir);
1060        let config_path = test_dir.join("config");
1061
1062        let config_content = r#"
1063# Test config with disable lists
1064favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1065
1066disabled_plugins = foo, bar
1067disabled_skills = baz, plug:qual
1068"#;
1069        std::fs::write(&config_path, config_content).unwrap();
1070
1071        let original_home = std::env::var("HOME").ok();
1072        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1073
1074        let config = load_config();
1075
1076        if let Some(home) = original_home {
1077            std::env::set_var("HOME", home);
1078        } else {
1079            std::env::remove_var("HOME");
1080        }
1081
1082        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1083
1084        assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
1085        assert_eq!(config.favorite_models, vec![
1086            "claude/claude-opus-4-7".to_string(),
1087            "groq/llama-3.3-70b-versatile".to_string(),
1088        ]);
1089        assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
1090    }
1091
1092    #[test]
1093    #[serial]
1094    fn favorite_model_helpers_round_trip_through_config_file() {
1095        let home = make_test_home("favorite-models");
1096        let cfg = home.join(".synaps-cli/config");
1097        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
1098
1099        with_home(&home, || {
1100            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1101            add_favorite_model("claude/claude-opus-4-7").unwrap();
1102            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1103            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
1104            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1105            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
1106            assert!(is_favorite_model("claude/claude-opus-4-7"));
1107        });
1108
1109        let contents = std::fs::read_to_string(&cfg).unwrap();
1110        assert!(contents.contains("model = claude-opus-4-7"));
1111        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
1112        let _ = std::fs::remove_dir_all(&home);
1113    }
1114
1115    #[test]
1116    #[serial]
1117    fn test_load_config_new_keys() {
1118        // Create a temporary config directory with the new keys
1119        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
1120        let _ = std::fs::create_dir_all(&test_dir);
1121        let config_path = test_dir.join("config");
1122        
1123        let config_content = r#"
1124# Test config with new keys
1125model = claude-haiku
1126thinking = medium
1127max_tool_output = 50000
1128bash_timeout = 45
1129bash_max_timeout = 600
1130subagent_timeout = 120
1131api_retries = 5
1132"#;
1133        std::fs::write(&config_path, config_content).unwrap();
1134        
1135        // Temporarily override the config path for this test
1136        let original_home = std::env::var("HOME").ok();
1137        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
1138        
1139        let config = load_config();
1140        
1141        // Restore original HOME
1142        if let Some(home) = original_home {
1143            std::env::set_var("HOME", home);
1144        } else {
1145            std::env::remove_var("HOME");
1146        }
1147        
1148        // Cleanup
1149        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
1150        
1151        assert_eq!(config.model, Some("claude-haiku".to_string()));
1152        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
1153        assert_eq!(config.max_tool_output, 50000);
1154        assert_eq!(config.bash_timeout, 45);
1155        assert_eq!(config.bash_max_timeout, 600);
1156        assert_eq!(config.subagent_timeout, 120);
1157        assert_eq!(config.api_retries, 5);
1158    }
1159}