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