Skip to main content

synaps_cli/core/
config.rs

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