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
10pub const 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/// Auth configuration parsed from `auth.*` keys. Controls whether this client
156/// resolves provider tokens from the local `auth.json` (default) or from a
157/// remote credential broker. See task #157 / `auth::credential_source`.
158#[derive(Debug, Clone, Default)]
159pub struct AuthConfig {
160    /// Broker base URL. When set (here, or via `SYNAPS_AUTH_ENDPOINT`), the
161    /// client fetches short-lived access tokens from the broker instead of
162    /// reading/refreshing the local `auth.json`.
163    pub remote_endpoint: Option<String>,
164    /// Per-machine bearer presented to the broker (or `SYNAPS_MACHINE_TOKEN`).
165    /// This is the machine's own identity, never the provider credential.
166    pub machine_token: Option<String>,
167}
168
169impl AuthConfig {
170    /// Resolve the credential source. Environment variables take precedence over
171    /// config-file values: `SYNAPS_AUTH_ENDPOINT` / `SYNAPS_MACHINE_TOKEN`.
172    /// Returns `Remote` iff an endpoint is set (env or config), else `Local`.
173    pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
174        let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
175            .ok()
176            .filter(|s| !s.trim().is_empty())
177            .or_else(|| self.remote_endpoint.clone());
178        let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
179            .ok()
180            .filter(|s| !s.trim().is_empty())
181            .or_else(|| self.machine_token.clone());
182        crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
183    }
184}
185
186/// Prompt-cache TTL strategy for Anthropic requests.
187///
188/// Controls the `cache_control` value emitted at every cache marker site:
189/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
190///   to historical payloads; the default path can never invalidate existing
191///   cached prefixes.
192/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
193/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
194///   bare 5m on the message-tail marker (written every turn).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum CacheTtl {
197    #[default]
198    FiveMinutes,
199    OneHour,
200    Hybrid,
201}
202
203impl CacheTtl {
204    /// Parse a config value (case-insensitive). Returns `None` for unknown
205    /// values so the caller can warn and fall back to the default.
206    pub fn parse(val: &str) -> Option<CacheTtl> {
207        match val.to_ascii_lowercase().as_str() {
208            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
209            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
210            "hybrid" => Some(CacheTtl::Hybrid),
211            _ => None,
212        }
213    }
214}
215
216/// Runtime event routing configuration parsed from `events.*` keys.
217///
218/// Controls how runtime events (from the `EventQueue`) are delivered in
219/// server and RPC modes.
220#[derive(Debug, Clone)]
221pub struct EventsConfig {
222    /// When `true` (default), the server/RPC session automatically triggers a
223    /// model turn when runtime events arrive while idle.  Set
224    /// `events.auto_turn = false` (or `0` / `no` / `off`) to opt out.
225    /// Unrecognised values fail safe to `false` with a warning.
226    /// The built-in cap (`AUTO_TURN_CAP = 5`) still applies regardless.
227    pub auto_turn: bool,
228}
229
230impl Default for EventsConfig {
231    fn default() -> Self {
232        Self { auto_turn: true }
233    }
234}
235
236/// Parse `events.*` configuration keys.
237fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
238    if key == "events.auto_turn" {
239        let normalised = val.trim().to_lowercase();
240        cfg.auto_turn = match normalised.as_str() {
241            // Explicit true values.
242            "true" | "1" | "yes" | "on" => true,
243            // Explicit false values.
244            "false" | "0" | "no" | "off" => false,
245            // Unrecognised: fail safe to false and warn so the user knows.
246            other => {
247                eprintln!(
248                    "warning: config: unrecognised value for events.auto_turn = {:?}; \
249                     expected true/false/yes/no/on/off/1/0 — defaulting to false",
250                    other
251                );
252                false
253            }
254        };
255    } // unknown events.* keys ignored
256}
257
258/// Parsed configuration from the config file.
259#[derive(Debug, Clone)]
260pub struct SynapsConfig {
261    pub model: Option<String>,
262    pub thinking_budget: Option<u32>,
263    pub context_window: Option<u64>,   // override auto-detected context window (tokens)
264    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
265    pub max_tool_output: usize,        // default 30000
266    pub bash_timeout: u64,             // default 30
267    pub bash_max_timeout: u64,         // default 300
268    pub subagent_timeout: u64,         // default 300
269    pub api_retries: u32,              // default 3
270    pub refusal_retries: u32,          // default 2 — retries on stop_reason=refusal
271    pub telemetry: String,             // off | basic | full (default off)
272    pub cache_diagnostics: bool,       // opt into cache-diagnosis beta (default false)
273    /// Prompt-cache TTL strategy: "5m" (default) | "1h" | "hybrid".
274    pub cache_ttl: CacheTtl,
275    /// Max TUI redraw rate in frames/sec — caps streaming redraws (e.g. 60,
276    /// 144, 240). User input always redraws immediately regardless. Default
277    /// 60. Range 1–1000. The frame budget is `1000 / max_fps` ms.
278    pub max_fps: u32,
279    /// Lines to scroll per mouse-wheel event. Different terminal emulators
280    /// emit 1–9+ scroll events per physical notch; set this to compensate.
281    /// Default: 3. Range 1–20.
282    pub scroll_lines: Option<u16>,
283    pub theme: Option<String>,
284    pub agent_name: Option<String>,
285    pub identity: Option<String>,
286    pub disabled_plugins: Vec<String>,
287    pub favorite_models: Vec<String>,
288    pub disabled_skills: Vec<String>,
289    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
290    /// from the registry at boot so they're never offered to the model.
291    pub disabled_tools: Vec<String>,
292    pub shell: ShellConfig,
293    pub server: ServerConfig,
294    pub bridge: BridgeConfig,
295    pub auth: AuthConfig,
296    pub events: EventsConfig,
297    pub provider_keys: BTreeMap<String, String>,
298    pub keybinds: std::collections::HashMap<String, String>,
299    /// Non-fatal problems found while parsing the config file (unknown keys,
300    /// unparseable values). Surfaced once at startup — never block boot.
301    pub warnings: Vec<String>,
302}
303
304impl Default for SynapsConfig {
305    fn default() -> Self {
306        Self {
307            model: None,
308            thinking_budget: None,
309            context_window: None,
310            compaction_model: None,
311            max_tool_output: 30000,
312            bash_timeout: 30,
313            bash_max_timeout: 300,
314            subagent_timeout: 300,
315            api_retries: 3,
316            refusal_retries: 2,
317            telemetry: "off".to_string(),
318            cache_diagnostics: false,
319            cache_ttl: CacheTtl::default(),
320            max_fps: 60,
321            scroll_lines: None,
322            theme: None,
323            agent_name: None,
324            identity: None,
325            disabled_plugins: Vec::new(),
326            favorite_models: Vec::new(),
327            disabled_skills: Vec::new(),
328            disabled_tools: Vec::new(),
329            shell: ShellConfig::default(),
330            server: ServerConfig::default(),
331            bridge: BridgeConfig::default(),
332            auth: AuthConfig::default(),
333            events: EventsConfig::default(),
334            provider_keys: BTreeMap::new(),
335            keybinds: std::collections::HashMap::new(),
336            warnings: Vec::new(),
337        }
338    }
339}
340
341/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
342const KNOWN_CONFIG_KEYS: &[&str] = &[
343    "model", "thinking", "compaction_model", "context_window", "max_tool_output",
344    "bash_timeout", "bash_max_timeout", "subagent_timeout", "api_retries", "refusal_retries",
345    "telemetry", "cache_diagnostics", "cache_ttl", "max_fps", "scroll_lines", "theme", "agent_name", "identity",
346    "disabled_plugins", "favorite_models", "disabled_skills", "disabled_tools",
347];
348
349/// Simple Levenshtein distance for did-you-mean suggestions.
350fn levenshtein(a: &str, b: &str) -> usize {
351    let a: Vec<char> = a.chars().collect();
352    let b: Vec<char> = b.chars().collect();
353    let mut prev: Vec<usize> = (0..=b.len()).collect();
354    let mut cur = vec![0; b.len() + 1];
355    for (i, ca) in a.iter().enumerate() {
356        cur[0] = i + 1;
357        for (j, cb) in b.iter().enumerate() {
358            let cost = if ca == cb { 0 } else { 1 };
359            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
360        }
361        std::mem::swap(&mut prev, &mut cur);
362    }
363    prev[b.len()]
364}
365
366/// Closest known key within edit distance 2, for typo suggestions.
367fn did_you_mean(key: &str) -> Option<&'static str> {
368    KNOWN_CONFIG_KEYS
369        .iter()
370        .map(|k| (*k, levenshtein(key, k)))
371        .filter(|(_, d)| *d <= 2)
372        .min_by_key(|(_, d)| *d)
373        .map(|(k, _)| k)
374}
375
376
377fn parse_thinking_budget(val: &str) -> Option<u32> {
378    match val {
379        "low" => Some(2048),
380        "medium" => Some(4096),
381        "high" => Some(16384),
382        "xhigh" => Some(32768),
383        "adaptive" => Some(0), // sentinel: model decides depth
384        _ => val.parse::<u32>().ok(),
385    }
386}
387
388fn parse_comma_list(val: &str) -> Vec<String> {
389    val.split(',')
390        .map(|s| s.trim().to_string())
391        .filter(|s| !s.is_empty())
392        .collect()
393}
394
395fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
396    write_config_value(key, &values.join(", "))
397}
398
399/// Parse shell.* configuration keys and update the ShellConfig.
400fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
401    match key {
402        "shell.max_sessions" => {
403            if let Ok(sessions) = val.parse::<usize>() {
404                shell_config.max_sessions = sessions;
405            } else {
406                eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
407            }
408        }
409        "shell.idle_timeout" => {
410            if let Ok(timeout) = val.parse::<u64>() {
411                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
412            } else {
413                eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
414            }
415        }
416        "shell.readiness_timeout_ms" => {
417            if let Ok(timeout) = val.parse::<u64>() {
418                shell_config.readiness_timeout_ms = timeout;
419            } else {
420                eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
421            }
422        }
423        "shell.max_readiness_timeout_ms" => {
424            if let Ok(timeout) = val.parse::<u64>() {
425                shell_config.max_readiness_timeout_ms = timeout;
426            } else {
427                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
428            }
429        }
430        "shell.default_rows" => {
431            if let Ok(rows) = val.parse::<u16>() {
432                shell_config.default_rows = rows;
433            } else {
434                eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
435            }
436        }
437        "shell.default_cols" => {
438            if let Ok(cols) = val.parse::<u16>() {
439                shell_config.default_cols = cols;
440            } else {
441                eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
442            }
443        }
444        "shell.readiness_strategy" => {
445            let val_lower = val.to_lowercase();
446            match val_lower.as_str() {
447                "timeout" | "prompt" | "hybrid" => {
448                    shell_config.readiness_strategy = val.to_string();
449                }
450                _ => {
451                    eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
452                }
453            }
454        }
455        "shell.max_output" => {
456            if let Ok(max_output) = val.parse::<usize>() {
457                shell_config.max_output = max_output;
458            } else {
459                eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
460            }
461        }
462        _ => {
463            // Unknown shell.* keys are preserved (not rejected)
464        }
465    }
466}
467
468/// Parse server.* configuration keys and update the ServerConfig.
469#[allow(clippy::collapsible_match)]
470fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
471    match key {
472        "server.allowed_origins" => {
473            server_config.allowed_origins = parse_comma_list(val);
474        }
475        "server.token" => {
476            if !val.is_empty() {
477                server_config.token = Some(val.to_string());
478            }
479        }
480        "server.auto_approve_confirms" => {
481            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
482        }
483        "server.max_message_size" => {
484            if let Ok(size) = val.parse::<usize>() {
485                server_config.max_message_size = Some(size);
486            } else {
487                eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
488            }
489        }
490        _ => {
491            // Unknown server.* keys preserved (not rejected)
492        }
493    }
494}
495
496/// Parse bridge.* configuration keys and update the BridgeConfig.
497fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
498    match key {
499        "bridge.uds_path" => {
500            if val.is_empty() {
501                bridge_config.uds_path = None;
502            } else {
503                bridge_config.uds_path = Some(PathBuf::from(val));
504            }
505        }
506        "bridge.heartbeat_mirror" => {
507            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
508        }
509        "bridge.heartbeat_timeout_ms" => {
510            if let Ok(ms) = val.parse::<u64>() {
511                bridge_config.heartbeat_timeout_ms = ms;
512            } else {
513                eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
514            }
515        }
516        _ => {
517            // Unknown bridge.* keys preserved (not rejected)
518        }
519    }
520}
521
522/// Parse auth.* configuration keys and update the AuthConfig.
523fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
524    let v = val.trim();
525    match key {
526        "auth.remote_endpoint" => {
527            auth_config.remote_endpoint = if v.is_empty() { None } else { Some(v.to_string()) };
528        }
529        "auth.machine_token" => {
530            auth_config.machine_token = if v.is_empty() { None } else { Some(v.to_string()) };
531        }
532        _ => {
533            // Unknown auth.* keys preserved (not rejected)
534        }
535    }
536}
537
538/// Parse config from a raw string — useful for tests and embedded harnesses.
539/// Does NOT write to `PROVIDER_KEYS` or `IDENTITY` OnceLocks.
540pub fn load_config_from_str(content: &str) -> SynapsConfig {
541    let mut config = SynapsConfig::default();
542    apply_config_content(&mut config, content);
543    config
544}
545
546/// Parse the config file at ~/.synaps-cli/config (or profile variant).
547/// Returns default config if file doesn't exist or can't be read.
548pub fn load_config() -> SynapsConfig {
549    let path = resolve_read_path("config");
550    let mut config = SynapsConfig::default();
551    
552    let Ok(content) = std::fs::read_to_string(&path) else {
553        return config;
554    };
555    
556    apply_config_content(&mut config, &content);
557
558    // Publish provider keys to the process-wide cache for the API router.
559    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
560    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
561
562    // Publish identity to the process-wide cache for API system prompt preamble.
563    let identity_val = config.identity.clone().unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
564    let _ = IDENTITY.set(identity_val);
565
566    config
567}
568
569/// Apply key=value config lines from `content` into `config`.
570/// Shared by `load_config` (file path) and `load_config_from_str` (test helper).
571fn apply_config_content(config: &mut SynapsConfig, content: &str) {
572    for line in content.lines() {
573        let line = line.trim();
574        if line.is_empty() || line.starts_with('#') { continue; }
575        let Some((key, val)) = line.split_once('=') else { continue };
576        let key = key.trim();
577        let val = val.trim();
578        match key {
579            "model" => config.model = Some(val.to_string()),
580            "thinking" => {
581                config.thinking_budget = parse_thinking_budget(val);
582                if config.thinking_budget.is_none() {
583                    config.warnings.push(format!("thinking = {val} — expected low|medium|high|xhigh|adaptive or a token count; thinking disabled"));
584                }
585            }
586            "compaction_model" => config.compaction_model = Some(val.to_string()),
587            "context_window" => {
588                let parsed = match val {
589                    "200k" | "200K" => Some(200_000),
590                    "1m" | "1M" => Some(1_000_000),
591                    _ => val.parse::<u64>().ok(),
592                };
593                if parsed.is_none() {
594                    config.warnings.push(format!("context_window = {val} — expected 200k, 1m, or a token count; ignored"));
595                }
596                config.context_window = parsed;
597            }
598            "max_tool_output" => {
599                match val.parse::<usize>() {
600                    Ok(size) => config.max_tool_output = size,
601                    Err(_) => config.warnings.push(format!("max_tool_output = {val} — not a number; using {}", config.max_tool_output)),
602                }
603            }
604            "bash_timeout" => {
605                match val.parse::<u64>() {
606                    Ok(t) if t >= 1 => config.bash_timeout = t,
607                    Ok(_) => config.warnings.push(format!("bash_timeout = {val} — below minimum (1s); using {}", config.bash_timeout)),
608                    Err(_) => config.warnings.push(format!("bash_timeout = {val} — not a number; using {}", config.bash_timeout)),
609                }
610            }
611            "bash_max_timeout" => {
612                if let Ok(timeout) = val.parse::<u64>() {
613                    config.bash_max_timeout = timeout;
614                }
615            }
616            "subagent_timeout" => {
617                if let Ok(timeout) = val.parse::<u64>() {
618                    config.subagent_timeout = timeout;
619                }
620            }
621            "api_retries" => {
622                if let Ok(retries) = val.parse::<u32>() {
623                    config.api_retries = retries;
624                }
625            }
626            "refusal_retries" => {
627                if let Ok(retries) = val.parse::<u32>() {
628                    config.refusal_retries = retries;
629                }
630            }
631            "telemetry" => config.telemetry = val.to_string(),
632            "cache_diagnostics" => {
633                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
634            }
635            "cache_ttl" => {
636                match CacheTtl::parse(val) {
637                    Some(ttl) => config.cache_ttl = ttl,
638                    None => config.warnings.push(format!(
639                        "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
640                    )),
641                }
642            }
643            "max_fps" => {
644                match val.parse::<u32>() {
645                    Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
646                    Ok(_) => config.warnings.push(format!(
647                        "max_fps = {val} — expected 1–1000; using {}", config.max_fps
648                    )),
649                    Err(_) => config.warnings.push(format!(
650                        "max_fps = {val} — not a number; using {}", config.max_fps
651                    )),
652                }
653            }
654            "scroll_lines" => {
655                match val.parse::<u16>() {
656                    Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
657                    Ok(_) => config.warnings.push(format!(
658                        "scroll_lines = {val} — expected 1–20; ignoring"
659                    )),
660                    Err(_) => config.warnings.push(format!(
661                        "scroll_lines = {val} — not a number; ignoring"
662                    )),
663                }
664            }
665            "theme" => config.theme = Some(val.to_string()),
666            "agent_name" => config.agent_name = Some(val.to_string()),
667            "identity" => config.identity = Some(val.to_string()),
668            "disabled_plugins" => {
669                config.disabled_plugins = parse_comma_list(val);
670            }
671            "favorite_models" => {
672                config.favorite_models = parse_comma_list(val);
673            }
674            "disabled_skills" => {
675                config.disabled_skills = parse_comma_list(val);
676            }
677            "disabled_tools" => {
678                config.disabled_tools = parse_comma_list(val);
679            }
680            _ => {
681                // Handle namespaced keys
682                if key.starts_with("shell.") {
683                    parse_shell_config_key(&mut config.shell, key, val);
684                } else if key.starts_with("server.") {
685                    parse_server_config_key(&mut config.server, key, val);
686                } else if key.starts_with("bridge.") {
687                    parse_bridge_config_key(&mut config.bridge, key, val);
688                } else if key.starts_with("auth.") {
689                    parse_auth_config_key(&mut config.auth, key, val);
690                } else if key.starts_with("events.") {
691                    parse_events_config_key(&mut config.events, key, val);
692                } else if let Some(provider_key) = key.strip_prefix("provider.") {
693                    config.provider_keys.insert(provider_key.to_string(), val.to_string());
694                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
695                    config.keybinds.insert(keybind_key.to_string(), val.to_string());
696                } else if key.contains('.') {
697                    // Dotted keys are namespaced (plugin/extension config, e.g.
698                    // `knowledge.jawz_notes`). Plugins define their own keys —
699                    // not ours to police. Silently preserved.
700                } else {
701                    // Unknown top-level key — warn with a did-you-mean if close.
702                    match did_you_mean(key) {
703                        Some(suggestion) => config.warnings.push(format!("unknown key '{key}' (did you mean '{suggestion}'?)")),
704                        None => config.warnings.push(format!("unknown key '{key}' — ignored")),
705                    }
706                }
707            }
708        }
709    }
710
711    // Derive max_message_size from context_window if not explicitly set.
712    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
713    if config.server.max_message_size.is_none() {
714        if let Some(ctx_tokens) = config.context_window {
715            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
716        }
717    }
718}
719
720/// Read a single config value by exact key from the active config file.
721pub fn read_config_value(key: &str) -> Option<String> {
722    let path = resolve_read_path("config");
723    let content = std::fs::read_to_string(&path).ok()?;
724    for line in content.lines() {
725        let line = line.trim();
726        if line.is_empty() || line.starts_with('#') { continue; }
727        let Some((k, v)) = line.split_once('=') else { continue };
728        if k.trim() == key.trim() {
729            return Some(v.trim().to_string());
730        }
731    }
732    None
733}
734
735/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
736/// Replaces the first existing line that matches the key, or appends if absent.
737/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
738pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
739    let path = resolve_write_path("config");
740    let existing = std::fs::read_to_string(&path).unwrap_or_default();
741
742    let key_trimmed = key.trim();
743    let replacement = format!("{} = {}", key_trimmed, value);
744
745    let mut found = false;
746    let mut new_lines: Vec<String> = existing.lines().map(|line| {
747        if found { return line.to_string(); }
748        let t = line.trim_start();
749        if t.starts_with('#') || t.is_empty() { return line.to_string(); }
750        if let Some((k, _)) = t.split_once('=') {
751            if k.trim() == key_trimmed {
752                found = true;
753                return replacement.clone();
754            }
755        }
756        line.to_string()
757    }).collect();
758
759    if !found {
760        new_lines.push(replacement);
761    }
762
763    let mut out = new_lines.join("\n");
764    if !out.ends_with('\n') { out.push('\n'); }
765
766    let tmp = path.with_extension("tmp");
767    std::fs::write(&tmp, out)?;
768    // Config may contain API keys — restrict to owner-only
769    #[cfg(unix)]
770    {
771        use std::os::unix::fs::PermissionsExt;
772        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
773    }
774    std::fs::rename(&tmp, &path)?;
775    Ok(())
776}
777
778/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
779pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
780    let trimmed = id.trim();
781    if trimmed.is_empty() {
782        return Ok(());
783    }
784    let mut values = load_config().favorite_models;
785    if !values.iter().any(|v| v == trimmed) {
786        values.push(trimmed.to_string());
787        values.sort();
788    }
789    write_comma_list("favorite_models", &values)
790}
791
792/// Remove a favorite model id (`provider/model`) from config.
793pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
794    let mut values = load_config().favorite_models;
795    values.retain(|v| v != id.trim());
796    write_comma_list("favorite_models", &values)
797}
798
799/// Return whether a model id is marked as favorite.
800pub fn is_favorite_model(id: &str) -> bool {
801    load_config().favorite_models.iter().any(|v| v == id.trim())
802}
803
804/// Resolve the system prompt from CLI flag, config file, or default.
805/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
806pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
807    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
808        You have access to bash, read, and write tools. \
809        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
810
811    if let Some(val) = explicit {
812        let path = std::path::Path::new(val);
813        if path.exists() && path.is_file() {
814            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
815        }
816        return val.to_string();
817    }
818
819    let system_path = resolve_read_path("system.md");
820    if system_path.exists() {
821        return std::fs::read_to_string(&system_path).unwrap_or_default();
822    }
823
824    DEFAULT_PROMPT.to_string()
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use serial_test::serial;
831
832    #[test]
833    fn test_levenshtein_basics() {
834        assert_eq!(levenshtein("model", "model"), 0);
835        assert_eq!(levenshtein("modle", "model"), 2);
836        assert_eq!(levenshtein("them", "theme"), 1);
837    }
838
839    #[test]
840    fn test_did_you_mean_close_typos() {
841        assert_eq!(did_you_mean("modle"), Some("model"));
842        assert_eq!(did_you_mean("them"), Some("theme"));
843        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
844        assert_eq!(did_you_mean("completely_unrelated_key"), None);
845    }
846
847    #[test]
848    #[serial]
849    fn test_config_warnings_unknown_key_and_bad_values() {
850        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
851        let dir = home.join(".synaps-cli");
852        std::fs::create_dir_all(&dir).unwrap();
853        std::fs::write(dir.join("config"), "modle = claude-opus-4-6\nthinking = hgih\nbash_timeout = 0\nknowledge.jawz_notes = ~/Jawz/notes\ncustom.plugin.key = 42\n").unwrap();
854
855        with_home(&home, || {
856            let config = load_config();
857            // Dotted (namespaced) keys must NOT warn — plugins own those.
858            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
859            assert!(!config.warnings.iter().any(|w| w.contains("knowledge")), "{:?}", config.warnings);
860            assert!(config.warnings.iter().any(|w| w.contains("did you mean 'model'")), "{:?}", config.warnings);
861            assert!(config.warnings.iter().any(|w| w.contains("thinking")), "{:?}", config.warnings);
862            assert!(config.warnings.iter().any(|w| w.contains("below minimum")), "{:?}", config.warnings);
863            // Bad values fall back to defaults
864            assert_eq!(config.bash_timeout, 30);
865            assert_eq!(config.thinking_budget, None);
866        });
867        let _ = std::fs::remove_dir_all(&home);
868    }
869
870    // ── cache_ttl parse table (spec §3.1) ──
871
872    #[test]
873    fn test_cache_ttl_parse_table() {
874        // 5m aliases
875        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
876        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
877        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
878        // 1h aliases
879        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
880        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
881        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
882        // hybrid
883        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
884        // case-insensitive
885        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
886        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
887        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
888        // garbage → None (caller warns + defaults)
889        assert_eq!(CacheTtl::parse("2h"), None);
890        assert_eq!(CacheTtl::parse(""), None);
891        assert_eq!(CacheTtl::parse("forever"), None);
892    }
893
894    #[test]
895    fn test_cache_ttl_default_is_five_minutes() {
896        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
897        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
898    }
899
900    #[test]
901    fn test_max_fps_default_is_60() {
902        assert_eq!(SynapsConfig::default().max_fps, 60);
903    }
904
905    #[test]
906    #[serial]
907    fn test_max_fps_config_parse_and_validation() {
908        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
909        let dir = home.join(".synaps-cli");
910        std::fs::create_dir_all(&dir).unwrap();
911
912        // Valid high-refresh value parses, no warning.
913        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
914        with_home(&home, || {
915            let config = load_config();
916            assert_eq!(config.max_fps, 144);
917            assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
918        });
919
920        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
921        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
922        with_home(&home, || {
923            let config = load_config();
924            assert_eq!(config.max_fps, 60);
925            assert!(
926                config.warnings.iter().any(|w| w.contains("max_fps")),
927                "warnings: {:?}", config.warnings
928            );
929        });
930
931        // Non-numeric → default 60 + warning.
932        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
933        with_home(&home, || {
934            let config = load_config();
935            assert_eq!(config.max_fps, 60);
936            assert!(
937                config.warnings.iter().any(|w| w.contains("max_fps")),
938                "warnings: {:?}", config.warnings
939            );
940        });
941
942        let _ = std::fs::remove_dir_all(&home);
943    }
944
945    #[test]
946    #[serial]
947    fn test_cache_ttl_config_parse_and_garbage_warning() {
948        let home = std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
949        let dir = home.join(".synaps-cli");
950        std::fs::create_dir_all(&dir).unwrap();
951
952        // Valid value parses, no warning.
953        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
954        with_home(&home, || {
955            let config = load_config();
956            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
957            assert!(config.warnings.is_empty(), "warnings: {:?}", config.warnings);
958        });
959
960        // Garbage value → 5m default + boot warning (never blocks boot).
961        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
962        with_home(&home, || {
963            let config = load_config();
964            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
965            assert!(
966                config.warnings.iter().any(|w| w.contains("cache_ttl")),
967                "warnings: {:?}", config.warnings
968            );
969        });
970
971        let _ = std::fs::remove_dir_all(&home);
972    }
973
974    #[test]
975    fn test_parse_thinking_budget() {
976        assert_eq!(parse_thinking_budget("low"), Some(2048));
977        assert_eq!(parse_thinking_budget("medium"), Some(4096));
978        assert_eq!(parse_thinking_budget("high"), Some(16384));
979        assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
980        assert_eq!(parse_thinking_budget("8192"), Some(8192));
981        assert_eq!(parse_thinking_budget("invalid"), None);
982    }
983
984    #[test]
985    fn test_base_dir() {
986        let path = base_dir();
987        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
988    }
989
990    #[test]
991    fn test_resolve_system_prompt_explicit() {
992        let result = resolve_system_prompt(Some("test prompt"));
993        assert_eq!(result, "test prompt");
994    }
995
996    #[test]
997    fn test_resolve_system_prompt_none() {
998        let result = resolve_system_prompt(None);
999        assert!(result.contains("helpful AI agent"));
1000    }
1001
1002    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
1003    // is not thread-safe and races with shell config tests. Coverage provided
1004    // by shell::config::tests::test_shell_config_from_file.
1005
1006    #[test]
1007    fn test_synaps_config_default() {
1008        let config = SynapsConfig::default();
1009        assert_eq!(config.model, None);
1010        assert_eq!(config.thinking_budget, None);
1011        assert_eq!(config.max_tool_output, 30000);
1012        assert_eq!(config.bash_timeout, 30);
1013        assert_eq!(config.bash_max_timeout, 300);
1014        assert_eq!(config.subagent_timeout, 300);
1015        assert_eq!(config.api_retries, 3);
1016        assert_eq!(config.theme, None);
1017        assert!(config.disabled_plugins.is_empty());
1018        assert!(config.favorite_models.is_empty());
1019        assert!(config.disabled_skills.is_empty());
1020        assert_eq!(config.shell.max_sessions, 5);
1021        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
1022        // Server config defaults
1023        assert!(config.server.allowed_origins.is_empty());
1024        assert_eq!(config.server.token, None);
1025        assert!(!config.server.auto_approve_confirms);
1026        assert_eq!(config.server.max_message_size, None);
1027        // Bridge config defaults
1028        assert!(config.bridge.uds_path.is_none());
1029        assert!(!config.bridge.heartbeat_mirror);
1030        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1031    }
1032
1033    #[test]
1034    #[serial]
1035    fn test_load_config_bridge_keys() {
1036        let home = make_test_home("bridge-keys");
1037        let cfg = home.join(".synaps-cli/config");
1038        std::fs::write(&cfg, "\
1039bridge.uds_path = /tmp/some/control.sock\n\
1040bridge.heartbeat_mirror = true\n\
1041bridge.heartbeat_timeout_ms = 750\n\
1042").unwrap();
1043
1044        with_home(&home, || {
1045            let config = load_config();
1046            assert_eq!(
1047                config.bridge.uds_path,
1048                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
1049            );
1050            assert!(config.bridge.heartbeat_mirror);
1051            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
1052            assert_eq!(
1053                config.bridge.resolved_uds_path(),
1054                std::path::PathBuf::from("/tmp/some/control.sock"),
1055            );
1056        });
1057
1058        let _ = std::fs::remove_dir_all(&home);
1059    }
1060
1061    #[test]
1062    fn test_bridge_config_defaults() {
1063        let cfg = BridgeConfig::default();
1064        assert!(cfg.uds_path.is_none());
1065        assert!(!cfg.heartbeat_mirror);
1066        assert_eq!(cfg.heartbeat_timeout_ms, 250);
1067        // resolved path falls under base_dir()/bridge/control.sock
1068        let resolved = cfg.resolved_uds_path();
1069        assert!(resolved.ends_with("bridge/control.sock"));
1070    }
1071
1072    #[test]
1073    #[serial]
1074    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
1075        let home = make_test_home("bridge-default-off");
1076        let cfg = home.join(".synaps-cli/config");
1077        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1078
1079        with_home(&home, || {
1080            let config = load_config();
1081            assert!(!config.bridge.heartbeat_mirror);
1082            assert!(config.bridge.uds_path.is_none());
1083            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1084        });
1085
1086        let _ = std::fs::remove_dir_all(&home);
1087    }
1088
1089    #[test]
1090    #[serial]
1091    fn test_load_config_server_keys() {
1092        let home = make_test_home("server-keys");
1093        let cfg = home.join(".synaps-cli/config");
1094        std::fs::write(&cfg, "\
1095server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1096server.token = my-secret-token\n\
1097server.auto_approve_confirms = true\n\
1098server.max_message_size = 65536\n\
1099context_window = 200k\n\
1100").unwrap();
1101
1102        with_home(&home, || {
1103            let config = load_config();
1104            assert_eq!(config.server.allowed_origins, vec![
1105                "http://localhost:3000".to_string(),
1106                "http://localhost:5193".to_string(),
1107            ]);
1108            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1109            assert!(config.server.auto_approve_confirms);
1110            // Explicit max_message_size takes precedence over context_window derivation
1111            assert_eq!(config.server.max_message_size, Some(65536));
1112        });
1113
1114        let _ = std::fs::remove_dir_all(&home);
1115    }
1116
1117    #[test]
1118    #[serial]
1119    fn test_server_max_message_size_derived_from_context_window() {
1120        let home = make_test_home("server-derive");
1121        let cfg = home.join(".synaps-cli/config");
1122        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1123
1124        with_home(&home, || {
1125            let config = load_config();
1126            // 200_000 tokens * 4 bytes/token = 800_000 bytes
1127            assert_eq!(config.server.max_message_size, Some(800_000));
1128        });
1129
1130        let _ = std::fs::remove_dir_all(&home);
1131    }
1132
1133    fn make_test_home(subdir: &str) -> std::path::PathBuf {
1134        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1135        let _ = std::fs::remove_dir_all(&dir);
1136        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1137        dir
1138    }
1139
1140    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1141        let original = std::env::var("HOME").ok();
1142        std::env::set_var("HOME", home);
1143        f();
1144        if let Some(h) = original {
1145            std::env::set_var("HOME", h);
1146        } else {
1147            std::env::remove_var("HOME");
1148        }
1149    }
1150
1151    #[test]
1152    #[serial]
1153    fn write_config_value_replaces_existing_key() {
1154        let home = make_test_home("replace");
1155        let cfg = home.join(".synaps-cli/config");
1156        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1157
1158        with_home(&home, || {
1159            write_config_value("model", "claude-sonnet-4-6").unwrap();
1160        });
1161
1162        let contents = std::fs::read_to_string(&cfg).unwrap();
1163        assert!(contents.contains("model = claude-sonnet-4-6"));
1164        assert!(contents.contains("thinking = low"));
1165        let _ = std::fs::remove_dir_all(&home);
1166    }
1167
1168    #[test]
1169    #[serial]
1170    fn write_config_value_appends_when_missing() {
1171        let home = make_test_home("append");
1172        let cfg = home.join(".synaps-cli/config");
1173        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1174
1175        with_home(&home, || {
1176            write_config_value("theme", "dracula").unwrap();
1177        });
1178
1179        let contents = std::fs::read_to_string(&cfg).unwrap();
1180        assert!(contents.contains("model = claude-opus-4-6"));
1181        assert!(contents.contains("theme = dracula"));
1182        let _ = std::fs::remove_dir_all(&home);
1183    }
1184
1185    #[test]
1186    #[serial]
1187    fn write_config_value_preserves_comments() {
1188        let home = make_test_home("comments");
1189        let cfg = home.join(".synaps-cli/config");
1190        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
1191
1192        with_home(&home, || {
1193            write_config_value("model", "claude-sonnet-4-6").unwrap();
1194        });
1195
1196        let contents = std::fs::read_to_string(&cfg).unwrap();
1197        assert!(contents.contains("# user comment"));
1198        assert!(contents.contains("# another"));
1199        assert!(contents.contains("model = claude-sonnet-4-6"));
1200        let _ = std::fs::remove_dir_all(&home);
1201    }
1202
1203    #[test]
1204    #[serial]
1205    fn write_config_value_preserves_unknown_keys() {
1206        let home = make_test_home("unknown");
1207        let cfg = home.join(".synaps-cli/config");
1208        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1209
1210        with_home(&home, || {
1211            write_config_value("model", "claude-sonnet-4-6").unwrap();
1212        });
1213
1214        let contents = std::fs::read_to_string(&cfg).unwrap();
1215        assert!(contents.contains("custom_thing = 42"));
1216        let _ = std::fs::remove_dir_all(&home);
1217    }
1218
1219    #[test]
1220    #[serial]
1221    fn write_config_value_creates_file_if_absent() {
1222        let home = make_test_home("create");
1223        let cfg = home.join(".synaps-cli/config");
1224        assert!(!cfg.exists());
1225
1226        with_home(&home, || {
1227            write_config_value("model", "claude-sonnet-4-6").unwrap();
1228        });
1229
1230        let contents = std::fs::read_to_string(&cfg).unwrap();
1231        assert!(contents.contains("model = claude-sonnet-4-6"));
1232        let _ = std::fs::remove_dir_all(&home);
1233    }
1234
1235    #[test]
1236    #[serial]
1237    fn load_config_parses_theme_key() {
1238        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1239        let _ = std::fs::create_dir_all(&dir);
1240        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1241
1242        let original_home = std::env::var("HOME").ok();
1243        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1244
1245        let config = load_config();
1246
1247        if let Some(home) = original_home {
1248            std::env::set_var("HOME", home);
1249        } else {
1250            std::env::remove_var("HOME");
1251        }
1252        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1253
1254        assert_eq!(config.theme.as_deref(), Some("dracula"));
1255    }
1256
1257    #[test]
1258    #[serial]
1259    fn test_load_config_disable_lists() {
1260        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1261        let _ = std::fs::create_dir_all(&test_dir);
1262        let config_path = test_dir.join("config");
1263
1264        let config_content = r#"
1265# Test config with disable lists
1266favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1267
1268disabled_plugins = foo, bar
1269disabled_skills = baz, plug:qual
1270"#;
1271        std::fs::write(&config_path, config_content).unwrap();
1272
1273        let original_home = std::env::var("HOME").ok();
1274        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1275
1276        let config = load_config();
1277
1278        if let Some(home) = original_home {
1279            std::env::set_var("HOME", home);
1280        } else {
1281            std::env::remove_var("HOME");
1282        }
1283
1284        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1285
1286        assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
1287        assert_eq!(config.favorite_models, vec![
1288            "claude/claude-opus-4-7".to_string(),
1289            "groq/llama-3.3-70b-versatile".to_string(),
1290        ]);
1291        assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
1292    }
1293
1294    #[test]
1295    #[serial]
1296    fn favorite_model_helpers_round_trip_through_config_file() {
1297        let home = make_test_home("favorite-models");
1298        let cfg = home.join(".synaps-cli/config");
1299        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
1300
1301        with_home(&home, || {
1302            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1303            add_favorite_model("claude/claude-opus-4-7").unwrap();
1304            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1305            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
1306            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
1307            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
1308            assert!(is_favorite_model("claude/claude-opus-4-7"));
1309        });
1310
1311        let contents = std::fs::read_to_string(&cfg).unwrap();
1312        assert!(contents.contains("model = claude-opus-4-7"));
1313        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
1314        let _ = std::fs::remove_dir_all(&home);
1315    }
1316
1317    #[test]
1318    #[serial]
1319    fn test_load_config_new_keys() {
1320        // Create a temporary config directory with the new keys
1321        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
1322        let _ = std::fs::create_dir_all(&test_dir);
1323        let config_path = test_dir.join("config");
1324        
1325        let config_content = r#"
1326# Test config with new keys
1327model = claude-haiku
1328thinking = medium
1329max_tool_output = 50000
1330bash_timeout = 45
1331bash_max_timeout = 600
1332subagent_timeout = 120
1333api_retries = 5
1334"#;
1335        std::fs::write(&config_path, config_content).unwrap();
1336        
1337        // Temporarily override the config path for this test
1338        let original_home = std::env::var("HOME").ok();
1339        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
1340        
1341        let config = load_config();
1342        
1343        // Restore original HOME
1344        if let Some(home) = original_home {
1345            std::env::set_var("HOME", home);
1346        } else {
1347            std::env::remove_var("HOME");
1348        }
1349        
1350        // Cleanup
1351        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
1352        
1353        assert_eq!(config.model, Some("claude-haiku".to_string()));
1354        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
1355        assert_eq!(config.max_tool_output, 50000);
1356        assert_eq!(config.bash_timeout, 45);
1357        assert_eq!(config.bash_max_timeout, 600);
1358        assert_eq!(config.subagent_timeout, 120);
1359        assert_eq!(config.api_retries, 5);
1360    }
1361
1362    // ── auth.* config + credential source (#157) ──
1363
1364    // ── events.auto_turn parser ──────────────────────────────────────────────
1365
1366    #[test]
1367    fn events_auto_turn_explicit_true_values() {
1368        for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
1369            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
1370            assert!(cfg.events.auto_turn, "expected true for events.auto_turn = {val}");
1371        }
1372    }
1373
1374    #[test]
1375    fn events_auto_turn_explicit_false_values() {
1376        for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
1377            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
1378            assert!(!cfg.events.auto_turn, "expected false for events.auto_turn = {val}");
1379        }
1380    }
1381
1382    #[test]
1383    fn events_auto_turn_typo_fails_safe_false() {
1384        // Unrecognised value should fail safe to false (with a warning on stderr).
1385        let cfg = load_config_from_str("events.auto_turn = fales");
1386        assert!(!cfg.events.auto_turn, "typo 'fales' must fail safe to false");
1387    }
1388
1389    #[test]
1390    fn events_auto_turn_default_is_true() {
1391        let cfg = load_config_from_str("model = claude-haiku");
1392        assert!(cfg.events.auto_turn, "default must be true when key absent");
1393    }
1394
1395    #[test]
1396    #[serial]
1397    fn test_auth_config_parses_endpoint_and_token() {
1398        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
1399        let dir = home.join(".synaps-cli");
1400        std::fs::create_dir_all(&dir).unwrap();
1401        std::fs::write(
1402            dir.join("config"),
1403            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
1404        )
1405        .unwrap();
1406        with_home(&home, || {
1407            let config = load_config();
1408            assert_eq!(config.auth.remote_endpoint.as_deref(), Some("https://jade.jade:8181"));
1409            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
1410            // auth.* are namespaced -> must NOT warn as unknown keys
1411            assert!(!config.warnings.iter().any(|w| w.contains("auth.")), "{:?}", config.warnings);
1412        });
1413        let _ = std::fs::remove_dir_all(&home);
1414    }
1415
1416    #[test]
1417    #[serial]
1418    fn test_credential_source_local_by_default() {
1419        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1420        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1421        assert!(!AuthConfig::default().credential_source().is_remote());
1422    }
1423
1424    #[test]
1425    #[serial]
1426    fn test_credential_source_remote_from_config() {
1427        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1428        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1429        let auth = AuthConfig {
1430            remote_endpoint: Some("https://b".into()),
1431            machine_token: Some("m".into()),
1432        };
1433        assert_eq!(
1434            auth.credential_source(),
1435            crate::core::auth::CredentialSource::Remote { endpoint: "https://b".into(), machine_token: "m".into() }
1436        );
1437    }
1438
1439    #[test]
1440    #[serial]
1441    fn test_credential_source_env_overrides_config() {
1442        let auth = AuthConfig {
1443            remote_endpoint: Some("https://config-host".into()),
1444            machine_token: Some("config-tok".into()),
1445        };
1446        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
1447        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
1448        let src = auth.credential_source();
1449        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
1450        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
1451        assert_eq!(
1452            src,
1453            crate::core::auth::CredentialSource::Remote { endpoint: "https://env-host".into(), machine_token: "env-tok".into() }
1454        );
1455    }
1456}