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