Skip to main content

synaps_cli/core/
config.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4use crate::tools::shell::config::ShellConfig;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8
9/// Provider API keys parsed from `provider.<name> = ...` lines in config.
10/// Empty if `load_config()` hasn't been called. The registry falls back to
11/// env vars, so e.g. `GROQ_API_KEY` works even with an empty map.
12pub fn get_provider_keys() -> BTreeMap<String, String> {
13    PROVIDER_KEYS.get().cloned().unwrap_or_default()
14}
15
16/// Returns the active profile name, if any.
17/// Reads from `SYNAPS_PROFILE` environment variable if not already set programmatically.
18pub fn get_profile() -> Option<String> {
19    PROFILE_NAME.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok()).clone()
20}
21
22/// Sets the active profile name. Must be called before any `get_profile()` call
23/// (i.e., before config resolution begins). Uses OnceLock — first write wins,
24/// subsequent calls are no-ops. No env var mutation (unsafe under tokio).
25pub fn set_profile(name: Option<String>) {
26    let _ = PROFILE_NAME.set(name);
27}
28
29pub fn base_dir() -> PathBuf {
30    if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
31        return PathBuf::from(path);
32    }
33    let home = std::env::var("HOME")
34        .or_else(|_| std::env::var("USERPROFILE"))
35        .unwrap_or_else(|_| ".".to_string());
36    PathBuf::from(home).join(".synaps-cli")
37}
38
39/// Overrides the Synaps base directory. Intended for tests and embedded harnesses.
40#[doc(hidden)]
41pub fn set_base_dir_for_tests(path: PathBuf) {
42    std::env::set_var("SYNAPS_BASE_DIR", path);
43}
44
45/// Resolves a path for reading. Checks the profile folder first, then falls back to the default folder.
46pub fn resolve_read_path(filename: &str) -> PathBuf {
47    let base = base_dir();
48    
49    if let Some(profile) = get_profile() {
50        let profile_path = base.join(&profile).join(filename);
51        if profile_path.exists() {
52            return profile_path;
53        }
54    }
55    
56    base.join(filename)
57}
58
59/// Resolves a path for reading with an extended arbitrary path tree.
60pub fn resolve_read_path_extended(path: &str) -> PathBuf {
61    let base = base_dir();
62    
63    if let Some(profile) = get_profile() {
64        let profile_path = base.join(&profile).join(path);
65        if profile_path.exists() {
66            return profile_path;
67        }
68    }
69    
70    base.join(path)
71}
72
73/// Resolves a path for writing. Unconditionally writes to the profile folder if a profile is active.
74pub fn resolve_write_path(filename: &str) -> PathBuf {
75    let mut base = base_dir();
76    
77    if let Some(profile) = get_profile() {
78        base.push(profile);
79    }
80    
81    let _ = std::fs::create_dir_all(&base);
82    base.join(filename)
83}
84
85/// Gets the absolute directory for the current profile (or root if default).
86pub fn get_active_config_dir() -> PathBuf {
87    let mut base = base_dir();
88    if let Some(profile) = get_profile() {
89        base.push(profile);
90    }
91    base
92}
93
94/// Server security configuration parsed from `server.*` keys.
95#[derive(Debug, Clone, Default)]
96pub struct ServerConfig {
97    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
98    pub allowed_origins: Vec<String>,
99    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
100    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
101    pub token: Option<String>,
102    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
103    pub auto_approve_confirms: bool,
104    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
105    /// None means no artificial cap.
106    pub max_message_size: Option<usize>,
107}
108
109/// Bridge daemon configuration parsed from `bridge.*` keys.
110///
111/// Controls best-effort mirroring of watcher heartbeats over the bridge
112/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
113/// and the feature is OFF by default.
114#[derive(Debug, Clone)]
115pub struct BridgeConfig {
116    /// Path to the bridge daemon's UDS control socket.
117    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
118    pub uds_path: Option<PathBuf>,
119    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
120    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
121    pub heartbeat_mirror: bool,
122    /// Per-call timeout in milliseconds (covers connect + write + read).
123    pub heartbeat_timeout_ms: u64,
124}
125
126impl Default for BridgeConfig {
127    fn default() -> Self {
128        Self {
129            uds_path: None,
130            heartbeat_mirror: false,
131            heartbeat_timeout_ms: 250,
132        }
133    }
134}
135
136impl BridgeConfig {
137    /// Resolve the UDS path, falling back to the default under `base_dir()`.
138    pub fn resolved_uds_path(&self) -> PathBuf {
139        self.uds_path
140            .clone()
141            .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
142    }
143}
144
145/// Parsed configuration from the config file.
146#[derive(Debug, Clone)]
147pub struct SynapsConfig {
148    pub model: Option<String>,
149    pub thinking_budget: Option<u32>,
150    pub context_window: Option<u64>,   // override auto-detected context window (tokens)
151    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
152    pub max_tool_output: usize,        // default 30000
153    pub bash_timeout: u64,             // default 30
154    pub bash_max_timeout: u64,         // default 300
155    pub subagent_timeout: u64,         // default 300
156    pub api_retries: u32,              // default 3
157    pub theme: Option<String>,
158    pub agent_name: Option<String>,
159    pub disabled_plugins: Vec<String>,
160    pub favorite_models: Vec<String>,
161    pub disabled_skills: Vec<String>,
162    pub shell: ShellConfig,
163    pub server: ServerConfig,
164    pub bridge: BridgeConfig,
165    pub provider_keys: BTreeMap<String, String>,
166    pub keybinds: std::collections::HashMap<String, String>,
167}
168
169impl Default for SynapsConfig {
170    fn default() -> Self {
171        Self {
172            model: None,
173            thinking_budget: None,
174            context_window: None,
175            compaction_model: None,
176            max_tool_output: 30000,
177            bash_timeout: 30,
178            bash_max_timeout: 300,
179            subagent_timeout: 300,
180            api_retries: 3,
181            theme: None,
182            agent_name: None,
183            disabled_plugins: Vec::new(),
184            favorite_models: Vec::new(),
185            disabled_skills: Vec::new(),
186            shell: ShellConfig::default(),
187            server: ServerConfig::default(),
188            bridge: BridgeConfig::default(),
189            provider_keys: BTreeMap::new(),
190            keybinds: std::collections::HashMap::new(),
191        }
192    }
193}
194
195
196fn parse_thinking_budget(val: &str) -> Option<u32> {
197    match val {
198        "low" => Some(2048),
199        "medium" => Some(4096),
200        "high" => Some(16384),
201        "xhigh" => Some(32768),
202        "adaptive" => Some(0), // sentinel: model decides depth
203        _ => val.parse::<u32>().ok(),
204    }
205}
206
207fn parse_comma_list(val: &str) -> Vec<String> {
208    val.split(',')
209        .map(|s| s.trim().to_string())
210        .filter(|s| !s.is_empty())
211        .collect()
212}
213
214fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
215    write_config_value(key, &values.join(", "))
216}
217
218/// Parse shell.* configuration keys and update the ShellConfig.
219fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
220    match key {
221        "shell.max_sessions" => {
222            if let Ok(sessions) = val.parse::<usize>() {
223                shell_config.max_sessions = sessions;
224            } else {
225                eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
226            }
227        }
228        "shell.idle_timeout" => {
229            if let Ok(timeout) = val.parse::<u64>() {
230                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
231            } else {
232                eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
233            }
234        }
235        "shell.readiness_timeout_ms" => {
236            if let Ok(timeout) = val.parse::<u64>() {
237                shell_config.readiness_timeout_ms = timeout;
238            } else {
239                eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
240            }
241        }
242        "shell.max_readiness_timeout_ms" => {
243            if let Ok(timeout) = val.parse::<u64>() {
244                shell_config.max_readiness_timeout_ms = timeout;
245            } else {
246                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
247            }
248        }
249        "shell.default_rows" => {
250            if let Ok(rows) = val.parse::<u16>() {
251                shell_config.default_rows = rows;
252            } else {
253                eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
254            }
255        }
256        "shell.default_cols" => {
257            if let Ok(cols) = val.parse::<u16>() {
258                shell_config.default_cols = cols;
259            } else {
260                eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
261            }
262        }
263        "shell.readiness_strategy" => {
264            let val_lower = val.to_lowercase();
265            match val_lower.as_str() {
266                "timeout" | "prompt" | "hybrid" => {
267                    shell_config.readiness_strategy = val.to_string();
268                }
269                _ => {
270                    eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
271                }
272            }
273        }
274        "shell.max_output" => {
275            if let Ok(max_output) = val.parse::<usize>() {
276                shell_config.max_output = max_output;
277            } else {
278                eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
279            }
280        }
281        _ => {
282            // Unknown shell.* keys are preserved (not rejected)
283        }
284    }
285}
286
287/// Parse server.* configuration keys and update the ServerConfig.
288#[allow(clippy::collapsible_match)]
289fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
290    match key {
291        "server.allowed_origins" => {
292            server_config.allowed_origins = parse_comma_list(val);
293        }
294        "server.token" => {
295            if !val.is_empty() {
296                server_config.token = Some(val.to_string());
297            }
298        }
299        "server.auto_approve_confirms" => {
300            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
301        }
302        "server.max_message_size" => {
303            if let Ok(size) = val.parse::<usize>() {
304                server_config.max_message_size = Some(size);
305            } else {
306                eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
307            }
308        }
309        _ => {
310            // Unknown server.* keys preserved (not rejected)
311        }
312    }
313}
314
315/// Parse bridge.* configuration keys and update the BridgeConfig.
316fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
317    match key {
318        "bridge.uds_path" => {
319            if val.is_empty() {
320                bridge_config.uds_path = None;
321            } else {
322                bridge_config.uds_path = Some(PathBuf::from(val));
323            }
324        }
325        "bridge.heartbeat_mirror" => {
326            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
327        }
328        "bridge.heartbeat_timeout_ms" => {
329            if let Ok(ms) = val.parse::<u64>() {
330                bridge_config.heartbeat_timeout_ms = ms;
331            } else {
332                eprintln!("Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default", val);
333            }
334        }
335        _ => {
336            // Unknown bridge.* keys preserved (not rejected)
337        }
338    }
339}
340
341/// Parse the config file at ~/.synaps-cli/config (or profile variant).
342/// Returns default config if file doesn't exist or can't be read.
343pub fn load_config() -> SynapsConfig {
344    let path = resolve_read_path("config");
345    let mut config = SynapsConfig::default();
346    
347    let Ok(content) = std::fs::read_to_string(&path) else {
348        return config;
349    };
350    
351    for line in content.lines() {
352        let line = line.trim();
353        if line.is_empty() || line.starts_with('#') { continue; }
354        let Some((key, val)) = line.split_once('=') else { continue };
355        let key = key.trim();
356        let val = val.trim();
357        match key {
358            "model" => config.model = Some(val.to_string()),
359            "thinking" => config.thinking_budget = parse_thinking_budget(val),
360            "compaction_model" => config.compaction_model = Some(val.to_string()),
361            "context_window" => {
362                let parsed = match val {
363                    "200k" | "200K" => Some(200_000),
364                    "1m" | "1M" => Some(1_000_000),
365                    _ => val.parse::<u64>().ok(),
366                };
367                config.context_window = parsed;
368            }
369            "max_tool_output" => {
370                if let Ok(size) = val.parse::<usize>() {
371                    config.max_tool_output = size;
372                }
373            }
374            "bash_timeout" => {
375                if let Ok(timeout) = val.parse::<u64>() {
376                    config.bash_timeout = timeout;
377                }
378            }
379            "bash_max_timeout" => {
380                if let Ok(timeout) = val.parse::<u64>() {
381                    config.bash_max_timeout = timeout;
382                }
383            }
384            "subagent_timeout" => {
385                if let Ok(timeout) = val.parse::<u64>() {
386                    config.subagent_timeout = timeout;
387                }
388            }
389            "api_retries" => {
390                if let Ok(retries) = val.parse::<u32>() {
391                    config.api_retries = retries;
392                }
393            }
394            "theme" => config.theme = Some(val.to_string()),
395            "agent_name" => config.agent_name = Some(val.to_string()),
396            "disabled_plugins" => {
397                config.disabled_plugins = parse_comma_list(val);
398            }
399            "favorite_models" => {
400                config.favorite_models = parse_comma_list(val);
401            }
402            "disabled_skills" => {
403                config.disabled_skills = parse_comma_list(val);
404            }
405            _ => {
406                // Handle namespaced keys
407                if key.starts_with("shell.") {
408                    parse_shell_config_key(&mut config.shell, key, val);
409                } else if key.starts_with("server.") {
410                    parse_server_config_key(&mut config.server, key, val);
411                } else if key.starts_with("bridge.") {
412                    parse_bridge_config_key(&mut config.bridge, key, val);
413                } else if let Some(provider_key) = key.strip_prefix("provider.") {
414                    config.provider_keys.insert(provider_key.to_string(), val.to_string());
415                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
416                    config.keybinds.insert(keybind_key.to_string(), val.to_string());
417                }
418                // Other unknown keys silently ignored
419            }
420        }
421    }
422
423    // Derive max_message_size from context_window if not explicitly set.
424    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
425    if config.server.max_message_size.is_none() {
426        if let Some(ctx_tokens) = config.context_window {
427            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
428        }
429    }
430
431    // Publish provider keys to the process-wide cache for the API router.
432    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
433    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
434
435    config
436}
437
438/// Read a single config value by exact key from the active config file.
439pub fn read_config_value(key: &str) -> Option<String> {
440    let path = resolve_read_path("config");
441    let content = std::fs::read_to_string(&path).ok()?;
442    for line in content.lines() {
443        let line = line.trim();
444        if line.is_empty() || line.starts_with('#') { continue; }
445        let Some((k, v)) = line.split_once('=') else { continue };
446        if k.trim() == key.trim() {
447            return Some(v.trim().to_string());
448        }
449    }
450    None
451}
452
453/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
454/// Replaces the first existing line that matches the key, or appends if absent.
455/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
456pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
457    let path = resolve_write_path("config");
458    let existing = std::fs::read_to_string(&path).unwrap_or_default();
459
460    let key_trimmed = key.trim();
461    let replacement = format!("{} = {}", key_trimmed, value);
462
463    let mut found = false;
464    let mut new_lines: Vec<String> = existing.lines().map(|line| {
465        if found { return line.to_string(); }
466        let t = line.trim_start();
467        if t.starts_with('#') || t.is_empty() { return line.to_string(); }
468        if let Some((k, _)) = t.split_once('=') {
469            if k.trim() == key_trimmed {
470                found = true;
471                return replacement.clone();
472            }
473        }
474        line.to_string()
475    }).collect();
476
477    if !found {
478        new_lines.push(replacement);
479    }
480
481    let mut out = new_lines.join("\n");
482    if !out.ends_with('\n') { out.push('\n'); }
483
484    let tmp = path.with_extension("tmp");
485    std::fs::write(&tmp, out)?;
486    // Config may contain API keys — restrict to owner-only
487    #[cfg(unix)]
488    {
489        use std::os::unix::fs::PermissionsExt;
490        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
491    }
492    std::fs::rename(&tmp, &path)?;
493    Ok(())
494}
495
496/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
497pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
498    let trimmed = id.trim();
499    if trimmed.is_empty() {
500        return Ok(());
501    }
502    let mut values = load_config().favorite_models;
503    if !values.iter().any(|v| v == trimmed) {
504        values.push(trimmed.to_string());
505        values.sort();
506    }
507    write_comma_list("favorite_models", &values)
508}
509
510/// Remove a favorite model id (`provider/model`) from config.
511pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
512    let mut values = load_config().favorite_models;
513    values.retain(|v| v != id.trim());
514    write_comma_list("favorite_models", &values)
515}
516
517/// Return whether a model id is marked as favorite.
518pub fn is_favorite_model(id: &str) -> bool {
519    load_config().favorite_models.iter().any(|v| v == id.trim())
520}
521
522/// Resolve the system prompt from CLI flag, config file, or default.
523/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
524pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
525    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
526        You have access to bash, read, and write tools. \
527        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
528
529    if let Some(val) = explicit {
530        let path = std::path::Path::new(val);
531        if path.exists() && path.is_file() {
532            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
533        }
534        return val.to_string();
535    }
536
537    let system_path = resolve_read_path("system.md");
538    if system_path.exists() {
539        return std::fs::read_to_string(&system_path).unwrap_or_default();
540    }
541
542    DEFAULT_PROMPT.to_string()
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use serial_test::serial;
549
550    #[test]
551    fn test_parse_thinking_budget() {
552        assert_eq!(parse_thinking_budget("low"), Some(2048));
553        assert_eq!(parse_thinking_budget("medium"), Some(4096));
554        assert_eq!(parse_thinking_budget("high"), Some(16384));
555        assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
556        assert_eq!(parse_thinking_budget("8192"), Some(8192));
557        assert_eq!(parse_thinking_budget("invalid"), None);
558    }
559
560    #[test]
561    fn test_base_dir() {
562        let path = base_dir();
563        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
564    }
565
566    #[test]
567    fn test_resolve_system_prompt_explicit() {
568        let result = resolve_system_prompt(Some("test prompt"));
569        assert_eq!(result, "test prompt");
570    }
571
572    #[test]
573    fn test_resolve_system_prompt_none() {
574        let result = resolve_system_prompt(None);
575        assert!(result.contains("helpful AI agent"));
576    }
577
578    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
579    // is not thread-safe and races with shell config tests. Coverage provided
580    // by shell::config::tests::test_shell_config_from_file.
581
582    #[test]
583    fn test_synaps_config_default() {
584        let config = SynapsConfig::default();
585        assert_eq!(config.model, None);
586        assert_eq!(config.thinking_budget, None);
587        assert_eq!(config.max_tool_output, 30000);
588        assert_eq!(config.bash_timeout, 30);
589        assert_eq!(config.bash_max_timeout, 300);
590        assert_eq!(config.subagent_timeout, 300);
591        assert_eq!(config.api_retries, 3);
592        assert_eq!(config.theme, None);
593        assert!(config.disabled_plugins.is_empty());
594        assert!(config.favorite_models.is_empty());
595        assert!(config.disabled_skills.is_empty());
596        assert_eq!(config.shell.max_sessions, 5);
597        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
598        // Server config defaults
599        assert!(config.server.allowed_origins.is_empty());
600        assert_eq!(config.server.token, None);
601        assert!(!config.server.auto_approve_confirms);
602        assert_eq!(config.server.max_message_size, None);
603        // Bridge config defaults
604        assert!(config.bridge.uds_path.is_none());
605        assert!(!config.bridge.heartbeat_mirror);
606        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
607    }
608
609    #[test]
610    #[serial]
611    fn test_load_config_bridge_keys() {
612        let home = make_test_home("bridge-keys");
613        let cfg = home.join(".synaps-cli/config");
614        std::fs::write(&cfg, "\
615bridge.uds_path = /tmp/some/control.sock\n\
616bridge.heartbeat_mirror = true\n\
617bridge.heartbeat_timeout_ms = 750\n\
618").unwrap();
619
620        with_home(&home, || {
621            let config = load_config();
622            assert_eq!(
623                config.bridge.uds_path,
624                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
625            );
626            assert!(config.bridge.heartbeat_mirror);
627            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
628            assert_eq!(
629                config.bridge.resolved_uds_path(),
630                std::path::PathBuf::from("/tmp/some/control.sock"),
631            );
632        });
633
634        let _ = std::fs::remove_dir_all(&home);
635    }
636
637    #[test]
638    fn test_bridge_config_defaults() {
639        let cfg = BridgeConfig::default();
640        assert!(cfg.uds_path.is_none());
641        assert!(!cfg.heartbeat_mirror);
642        assert_eq!(cfg.heartbeat_timeout_ms, 250);
643        // resolved path falls under base_dir()/bridge/control.sock
644        let resolved = cfg.resolved_uds_path();
645        assert!(resolved.ends_with("bridge/control.sock"));
646    }
647
648    #[test]
649    #[serial]
650    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
651        let home = make_test_home("bridge-default-off");
652        let cfg = home.join(".synaps-cli/config");
653        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
654
655        with_home(&home, || {
656            let config = load_config();
657            assert!(!config.bridge.heartbeat_mirror);
658            assert!(config.bridge.uds_path.is_none());
659            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
660        });
661
662        let _ = std::fs::remove_dir_all(&home);
663    }
664
665    #[test]
666    #[serial]
667    fn test_load_config_server_keys() {
668        let home = make_test_home("server-keys");
669        let cfg = home.join(".synaps-cli/config");
670        std::fs::write(&cfg, "\
671server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
672server.token = my-secret-token\n\
673server.auto_approve_confirms = true\n\
674server.max_message_size = 65536\n\
675context_window = 200k\n\
676").unwrap();
677
678        with_home(&home, || {
679            let config = load_config();
680            assert_eq!(config.server.allowed_origins, vec![
681                "http://localhost:3000".to_string(),
682                "http://localhost:5193".to_string(),
683            ]);
684            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
685            assert!(config.server.auto_approve_confirms);
686            // Explicit max_message_size takes precedence over context_window derivation
687            assert_eq!(config.server.max_message_size, Some(65536));
688        });
689
690        let _ = std::fs::remove_dir_all(&home);
691    }
692
693    #[test]
694    #[serial]
695    fn test_server_max_message_size_derived_from_context_window() {
696        let home = make_test_home("server-derive");
697        let cfg = home.join(".synaps-cli/config");
698        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
699
700        with_home(&home, || {
701            let config = load_config();
702            // 200_000 tokens * 4 bytes/token = 800_000 bytes
703            assert_eq!(config.server.max_message_size, Some(800_000));
704        });
705
706        let _ = std::fs::remove_dir_all(&home);
707    }
708
709    fn make_test_home(subdir: &str) -> std::path::PathBuf {
710        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
711        let _ = std::fs::remove_dir_all(&dir);
712        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
713        dir
714    }
715
716    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
717        let original = std::env::var("HOME").ok();
718        std::env::set_var("HOME", home);
719        f();
720        if let Some(h) = original {
721            std::env::set_var("HOME", h);
722        } else {
723            std::env::remove_var("HOME");
724        }
725    }
726
727    #[test]
728    #[serial]
729    fn write_config_value_replaces_existing_key() {
730        let home = make_test_home("replace");
731        let cfg = home.join(".synaps-cli/config");
732        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
733
734        with_home(&home, || {
735            write_config_value("model", "claude-sonnet-4-6").unwrap();
736        });
737
738        let contents = std::fs::read_to_string(&cfg).unwrap();
739        assert!(contents.contains("model = claude-sonnet-4-6"));
740        assert!(contents.contains("thinking = low"));
741        let _ = std::fs::remove_dir_all(&home);
742    }
743
744    #[test]
745    #[serial]
746    fn write_config_value_appends_when_missing() {
747        let home = make_test_home("append");
748        let cfg = home.join(".synaps-cli/config");
749        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
750
751        with_home(&home, || {
752            write_config_value("theme", "dracula").unwrap();
753        });
754
755        let contents = std::fs::read_to_string(&cfg).unwrap();
756        assert!(contents.contains("model = claude-opus-4-6"));
757        assert!(contents.contains("theme = dracula"));
758        let _ = std::fs::remove_dir_all(&home);
759    }
760
761    #[test]
762    #[serial]
763    fn write_config_value_preserves_comments() {
764        let home = make_test_home("comments");
765        let cfg = home.join(".synaps-cli/config");
766        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
767
768        with_home(&home, || {
769            write_config_value("model", "claude-sonnet-4-6").unwrap();
770        });
771
772        let contents = std::fs::read_to_string(&cfg).unwrap();
773        assert!(contents.contains("# user comment"));
774        assert!(contents.contains("# another"));
775        assert!(contents.contains("model = claude-sonnet-4-6"));
776        let _ = std::fs::remove_dir_all(&home);
777    }
778
779    #[test]
780    #[serial]
781    fn write_config_value_preserves_unknown_keys() {
782        let home = make_test_home("unknown");
783        let cfg = home.join(".synaps-cli/config");
784        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
785
786        with_home(&home, || {
787            write_config_value("model", "claude-sonnet-4-6").unwrap();
788        });
789
790        let contents = std::fs::read_to_string(&cfg).unwrap();
791        assert!(contents.contains("custom_thing = 42"));
792        let _ = std::fs::remove_dir_all(&home);
793    }
794
795    #[test]
796    #[serial]
797    fn write_config_value_creates_file_if_absent() {
798        let home = make_test_home("create");
799        let cfg = home.join(".synaps-cli/config");
800        assert!(!cfg.exists());
801
802        with_home(&home, || {
803            write_config_value("model", "claude-sonnet-4-6").unwrap();
804        });
805
806        let contents = std::fs::read_to_string(&cfg).unwrap();
807        assert!(contents.contains("model = claude-sonnet-4-6"));
808        let _ = std::fs::remove_dir_all(&home);
809    }
810
811    #[test]
812    #[serial]
813    fn load_config_parses_theme_key() {
814        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
815        let _ = std::fs::create_dir_all(&dir);
816        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
817
818        let original_home = std::env::var("HOME").ok();
819        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
820
821        let config = load_config();
822
823        if let Some(home) = original_home {
824            std::env::set_var("HOME", home);
825        } else {
826            std::env::remove_var("HOME");
827        }
828        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
829
830        assert_eq!(config.theme.as_deref(), Some("dracula"));
831    }
832
833    #[test]
834    #[serial]
835    fn test_load_config_disable_lists() {
836        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
837        let _ = std::fs::create_dir_all(&test_dir);
838        let config_path = test_dir.join("config");
839
840        let config_content = r#"
841# Test config with disable lists
842favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
843
844disabled_plugins = foo, bar
845disabled_skills = baz, plug:qual
846"#;
847        std::fs::write(&config_path, config_content).unwrap();
848
849        let original_home = std::env::var("HOME").ok();
850        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
851
852        let config = load_config();
853
854        if let Some(home) = original_home {
855            std::env::set_var("HOME", home);
856        } else {
857            std::env::remove_var("HOME");
858        }
859
860        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
861
862        assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
863        assert_eq!(config.favorite_models, vec![
864            "claude/claude-opus-4-7".to_string(),
865            "groq/llama-3.3-70b-versatile".to_string(),
866        ]);
867        assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
868    }
869
870    #[test]
871    #[serial]
872    fn favorite_model_helpers_round_trip_through_config_file() {
873        let home = make_test_home("favorite-models");
874        let cfg = home.join(".synaps-cli/config");
875        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
876
877        with_home(&home, || {
878            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
879            add_favorite_model("claude/claude-opus-4-7").unwrap();
880            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
881            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
882            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
883            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
884            assert!(is_favorite_model("claude/claude-opus-4-7"));
885        });
886
887        let contents = std::fs::read_to_string(&cfg).unwrap();
888        assert!(contents.contains("model = claude-opus-4-7"));
889        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
890        let _ = std::fs::remove_dir_all(&home);
891    }
892
893    #[test]
894    #[serial]
895    fn test_load_config_new_keys() {
896        // Create a temporary config directory with the new keys
897        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
898        let _ = std::fs::create_dir_all(&test_dir);
899        let config_path = test_dir.join("config");
900        
901        let config_content = r#"
902# Test config with new keys
903model = claude-haiku
904thinking = medium
905max_tool_output = 50000
906bash_timeout = 45
907bash_max_timeout = 600
908subagent_timeout = 120
909api_retries = 5
910"#;
911        std::fs::write(&config_path, config_content).unwrap();
912        
913        // Temporarily override the config path for this test
914        let original_home = std::env::var("HOME").ok();
915        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
916        
917        let config = load_config();
918        
919        // Restore original HOME
920        if let Some(home) = original_home {
921            std::env::set_var("HOME", home);
922        } else {
923            std::env::remove_var("HOME");
924        }
925        
926        // Cleanup
927        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
928        
929        assert_eq!(config.model, Some("claude-haiku".to_string()));
930        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
931        assert_eq!(config.max_tool_output, 50000);
932        assert_eq!(config.bash_timeout, 45);
933        assert_eq!(config.bash_max_timeout, 600);
934        assert_eq!(config.subagent_timeout, 120);
935        assert_eq!(config.api_retries, 5);
936    }
937}