Skip to main content

agent_core/core/
config.rs

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