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