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/// Parsed configuration from the config file.
110#[derive(Debug, Clone)]
111pub struct SynapsConfig {
112    pub model: Option<String>,
113    pub thinking_budget: Option<u32>,
114    pub context_window: Option<u64>,   // override auto-detected context window (tokens)
115    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
116    pub max_tool_output: usize,        // default 30000
117    pub bash_timeout: u64,             // default 30
118    pub bash_max_timeout: u64,         // default 300
119    pub subagent_timeout: u64,         // default 300
120    pub api_retries: u32,              // default 3
121    pub theme: Option<String>,
122    pub agent_name: Option<String>,
123    pub disabled_plugins: Vec<String>,
124    pub favorite_models: Vec<String>,
125    pub disabled_skills: Vec<String>,
126    pub shell: ShellConfig,
127    pub server: ServerConfig,
128    pub provider_keys: BTreeMap<String, String>,
129    pub keybinds: std::collections::HashMap<String, String>,
130}
131
132impl Default for SynapsConfig {
133    fn default() -> Self {
134        Self {
135            model: None,
136            thinking_budget: None,
137            context_window: None,
138            compaction_model: None,
139            max_tool_output: 30000,
140            bash_timeout: 30,
141            bash_max_timeout: 300,
142            subagent_timeout: 300,
143            api_retries: 3,
144            theme: None,
145            agent_name: None,
146            disabled_plugins: Vec::new(),
147            favorite_models: Vec::new(),
148            disabled_skills: Vec::new(),
149            shell: ShellConfig::default(),
150            server: ServerConfig::default(),
151            provider_keys: BTreeMap::new(),
152            keybinds: std::collections::HashMap::new(),
153        }
154    }
155}
156
157
158fn parse_thinking_budget(val: &str) -> Option<u32> {
159    match val {
160        "low" => Some(2048),
161        "medium" => Some(4096),
162        "high" => Some(16384),
163        "xhigh" => Some(32768),
164        "adaptive" => Some(0), // sentinel: model decides depth
165        _ => val.parse::<u32>().ok(),
166    }
167}
168
169fn parse_comma_list(val: &str) -> Vec<String> {
170    val.split(',')
171        .map(|s| s.trim().to_string())
172        .filter(|s| !s.is_empty())
173        .collect()
174}
175
176fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
177    write_config_value(key, &values.join(", "))
178}
179
180/// Parse shell.* configuration keys and update the ShellConfig.
181fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
182    match key {
183        "shell.max_sessions" => {
184            if let Ok(sessions) = val.parse::<usize>() {
185                shell_config.max_sessions = sessions;
186            } else {
187                eprintln!("Warning: invalid value for shell.max_sessions: '{}', using default", val);
188            }
189        }
190        "shell.idle_timeout" => {
191            if let Ok(timeout) = val.parse::<u64>() {
192                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
193            } else {
194                eprintln!("Warning: invalid value for shell.idle_timeout: '{}', using default", val);
195            }
196        }
197        "shell.readiness_timeout_ms" => {
198            if let Ok(timeout) = val.parse::<u64>() {
199                shell_config.readiness_timeout_ms = timeout;
200            } else {
201                eprintln!("Warning: invalid value for shell.readiness_timeout_ms: '{}', using default", val);
202            }
203        }
204        "shell.max_readiness_timeout_ms" => {
205            if let Ok(timeout) = val.parse::<u64>() {
206                shell_config.max_readiness_timeout_ms = timeout;
207            } else {
208                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
209            }
210        }
211        "shell.default_rows" => {
212            if let Ok(rows) = val.parse::<u16>() {
213                shell_config.default_rows = rows;
214            } else {
215                eprintln!("Warning: invalid value for shell.default_rows: '{}', using default", val);
216            }
217        }
218        "shell.default_cols" => {
219            if let Ok(cols) = val.parse::<u16>() {
220                shell_config.default_cols = cols;
221            } else {
222                eprintln!("Warning: invalid value for shell.default_cols: '{}', using default", val);
223            }
224        }
225        "shell.readiness_strategy" => {
226            let val_lower = val.to_lowercase();
227            match val_lower.as_str() {
228                "timeout" | "prompt" | "hybrid" => {
229                    shell_config.readiness_strategy = val.to_string();
230                }
231                _ => {
232                    eprintln!("Warning: invalid value for shell.readiness_strategy: '{}', using default", val);
233                }
234            }
235        }
236        "shell.max_output" => {
237            if let Ok(max_output) = val.parse::<usize>() {
238                shell_config.max_output = max_output;
239            } else {
240                eprintln!("Warning: invalid value for shell.max_output: '{}', using default", val);
241            }
242        }
243        _ => {
244            // Unknown shell.* keys are preserved (not rejected)
245        }
246    }
247}
248
249/// Parse server.* configuration keys and update the ServerConfig.
250#[allow(clippy::collapsible_match)]
251fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
252    match key {
253        "server.allowed_origins" => {
254            server_config.allowed_origins = parse_comma_list(val);
255        }
256        "server.token" => {
257            if !val.is_empty() {
258                server_config.token = Some(val.to_string());
259            }
260        }
261        "server.auto_approve_confirms" => {
262            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
263        }
264        "server.max_message_size" => {
265            if let Ok(size) = val.parse::<usize>() {
266                server_config.max_message_size = Some(size);
267            } else {
268                eprintln!("Warning: invalid value for server.max_message_size: '{}', ignored", val);
269            }
270        }
271        _ => {
272            // Unknown server.* keys preserved (not rejected)
273        }
274    }
275}
276
277/// Parse the config file at ~/.synaps-cli/config (or profile variant).
278/// Returns default config if file doesn't exist or can't be read.
279pub fn load_config() -> SynapsConfig {
280    let path = resolve_read_path("config");
281    let mut config = SynapsConfig::default();
282    
283    let Ok(content) = std::fs::read_to_string(&path) else {
284        return config;
285    };
286    
287    for line in content.lines() {
288        let line = line.trim();
289        if line.is_empty() || line.starts_with('#') { continue; }
290        let Some((key, val)) = line.split_once('=') else { continue };
291        let key = key.trim();
292        let val = val.trim();
293        match key {
294            "model" => config.model = Some(val.to_string()),
295            "thinking" => config.thinking_budget = parse_thinking_budget(val),
296            "compaction_model" => config.compaction_model = Some(val.to_string()),
297            "context_window" => {
298                let parsed = match val {
299                    "200k" | "200K" => Some(200_000),
300                    "1m" | "1M" => Some(1_000_000),
301                    _ => val.parse::<u64>().ok(),
302                };
303                config.context_window = parsed;
304            }
305            "max_tool_output" => {
306                if let Ok(size) = val.parse::<usize>() {
307                    config.max_tool_output = size;
308                }
309            }
310            "bash_timeout" => {
311                if let Ok(timeout) = val.parse::<u64>() {
312                    config.bash_timeout = timeout;
313                }
314            }
315            "bash_max_timeout" => {
316                if let Ok(timeout) = val.parse::<u64>() {
317                    config.bash_max_timeout = timeout;
318                }
319            }
320            "subagent_timeout" => {
321                if let Ok(timeout) = val.parse::<u64>() {
322                    config.subagent_timeout = timeout;
323                }
324            }
325            "api_retries" => {
326                if let Ok(retries) = val.parse::<u32>() {
327                    config.api_retries = retries;
328                }
329            }
330            "theme" => config.theme = Some(val.to_string()),
331            "agent_name" => config.agent_name = Some(val.to_string()),
332            "disabled_plugins" => {
333                config.disabled_plugins = parse_comma_list(val);
334            }
335            "favorite_models" => {
336                config.favorite_models = parse_comma_list(val);
337            }
338            "disabled_skills" => {
339                config.disabled_skills = parse_comma_list(val);
340            }
341            _ => {
342                // Handle namespaced keys
343                if key.starts_with("shell.") {
344                    parse_shell_config_key(&mut config.shell, key, val);
345                } else if key.starts_with("server.") {
346                    parse_server_config_key(&mut config.server, key, val);
347                } else if let Some(provider_key) = key.strip_prefix("provider.") {
348                    config.provider_keys.insert(provider_key.to_string(), val.to_string());
349                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
350                    config.keybinds.insert(keybind_key.to_string(), val.to_string());
351                }
352                // Other unknown keys silently ignored
353            }
354        }
355    }
356
357    // Derive max_message_size from context_window if not explicitly set.
358    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
359    if config.server.max_message_size.is_none() {
360        if let Some(ctx_tokens) = config.context_window {
361            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
362        }
363    }
364
365    // Publish provider keys to the process-wide cache for the API router.
366    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
367    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
368
369    config
370}
371
372/// Read a single config value by exact key from the active config file.
373pub fn read_config_value(key: &str) -> Option<String> {
374    let path = resolve_read_path("config");
375    let content = std::fs::read_to_string(&path).ok()?;
376    for line in content.lines() {
377        let line = line.trim();
378        if line.is_empty() || line.starts_with('#') { continue; }
379        let Some((k, v)) = line.split_once('=') else { continue };
380        if k.trim() == key.trim() {
381            return Some(v.trim().to_string());
382        }
383    }
384    None
385}
386
387/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
388/// Replaces the first existing line that matches the key, or appends if absent.
389/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
390pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
391    let path = resolve_write_path("config");
392    let existing = std::fs::read_to_string(&path).unwrap_or_default();
393
394    let key_trimmed = key.trim();
395    let replacement = format!("{} = {}", key_trimmed, value);
396
397    let mut found = false;
398    let mut new_lines: Vec<String> = existing.lines().map(|line| {
399        if found { return line.to_string(); }
400        let t = line.trim_start();
401        if t.starts_with('#') || t.is_empty() { return line.to_string(); }
402        if let Some((k, _)) = t.split_once('=') {
403            if k.trim() == key_trimmed {
404                found = true;
405                return replacement.clone();
406            }
407        }
408        line.to_string()
409    }).collect();
410
411    if !found {
412        new_lines.push(replacement);
413    }
414
415    let mut out = new_lines.join("\n");
416    if !out.ends_with('\n') { out.push('\n'); }
417
418    let tmp = path.with_extension("tmp");
419    std::fs::write(&tmp, out)?;
420    // Config may contain API keys — restrict to owner-only
421    #[cfg(unix)]
422    {
423        use std::os::unix::fs::PermissionsExt;
424        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
425    }
426    std::fs::rename(&tmp, &path)?;
427    Ok(())
428}
429
430/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
431pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
432    let trimmed = id.trim();
433    if trimmed.is_empty() {
434        return Ok(());
435    }
436    let mut values = load_config().favorite_models;
437    if !values.iter().any(|v| v == trimmed) {
438        values.push(trimmed.to_string());
439        values.sort();
440    }
441    write_comma_list("favorite_models", &values)
442}
443
444/// Remove a favorite model id (`provider/model`) from config.
445pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
446    let mut values = load_config().favorite_models;
447    values.retain(|v| v != id.trim());
448    write_comma_list("favorite_models", &values)
449}
450
451/// Return whether a model id is marked as favorite.
452pub fn is_favorite_model(id: &str) -> bool {
453    load_config().favorite_models.iter().any(|v| v == id.trim())
454}
455
456/// Resolve the system prompt from CLI flag, config file, or default.
457/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
458pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
459    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
460        You have access to bash, read, and write tools. \
461        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
462
463    if let Some(val) = explicit {
464        let path = std::path::Path::new(val);
465        if path.exists() && path.is_file() {
466            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
467        }
468        return val.to_string();
469    }
470
471    let system_path = resolve_read_path("system.md");
472    if system_path.exists() {
473        return std::fs::read_to_string(&system_path).unwrap_or_default();
474    }
475
476    DEFAULT_PROMPT.to_string()
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use serial_test::serial;
483
484    #[test]
485    fn test_parse_thinking_budget() {
486        assert_eq!(parse_thinking_budget("low"), Some(2048));
487        assert_eq!(parse_thinking_budget("medium"), Some(4096));
488        assert_eq!(parse_thinking_budget("high"), Some(16384));
489        assert_eq!(parse_thinking_budget("xhigh"), Some(32768));
490        assert_eq!(parse_thinking_budget("8192"), Some(8192));
491        assert_eq!(parse_thinking_budget("invalid"), None);
492    }
493
494    #[test]
495    fn test_base_dir() {
496        let path = base_dir();
497        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
498    }
499
500    #[test]
501    fn test_resolve_system_prompt_explicit() {
502        let result = resolve_system_prompt(Some("test prompt"));
503        assert_eq!(result, "test prompt");
504    }
505
506    #[test]
507    fn test_resolve_system_prompt_none() {
508        let result = resolve_system_prompt(None);
509        assert!(result.contains("helpful AI agent"));
510    }
511
512    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
513    // is not thread-safe and races with shell config tests. Coverage provided
514    // by shell::config::tests::test_shell_config_from_file.
515
516    #[test]
517    fn test_synaps_config_default() {
518        let config = SynapsConfig::default();
519        assert_eq!(config.model, None);
520        assert_eq!(config.thinking_budget, None);
521        assert_eq!(config.max_tool_output, 30000);
522        assert_eq!(config.bash_timeout, 30);
523        assert_eq!(config.bash_max_timeout, 300);
524        assert_eq!(config.subagent_timeout, 300);
525        assert_eq!(config.api_retries, 3);
526        assert_eq!(config.theme, None);
527        assert!(config.disabled_plugins.is_empty());
528        assert!(config.favorite_models.is_empty());
529        assert!(config.disabled_skills.is_empty());
530        assert_eq!(config.shell.max_sessions, 5);
531        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
532        // Server config defaults
533        assert!(config.server.allowed_origins.is_empty());
534        assert_eq!(config.server.token, None);
535        assert!(!config.server.auto_approve_confirms);
536        assert_eq!(config.server.max_message_size, None);
537    }
538
539    #[test]
540    #[serial]
541    fn test_load_config_server_keys() {
542        let home = make_test_home("server-keys");
543        let cfg = home.join(".synaps-cli/config");
544        std::fs::write(&cfg, "\
545server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
546server.token = my-secret-token\n\
547server.auto_approve_confirms = true\n\
548server.max_message_size = 65536\n\
549context_window = 200k\n\
550").unwrap();
551
552        with_home(&home, || {
553            let config = load_config();
554            assert_eq!(config.server.allowed_origins, vec![
555                "http://localhost:3000".to_string(),
556                "http://localhost:5193".to_string(),
557            ]);
558            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
559            assert!(config.server.auto_approve_confirms);
560            // Explicit max_message_size takes precedence over context_window derivation
561            assert_eq!(config.server.max_message_size, Some(65536));
562        });
563
564        let _ = std::fs::remove_dir_all(&home);
565    }
566
567    #[test]
568    #[serial]
569    fn test_server_max_message_size_derived_from_context_window() {
570        let home = make_test_home("server-derive");
571        let cfg = home.join(".synaps-cli/config");
572        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
573
574        with_home(&home, || {
575            let config = load_config();
576            // 200_000 tokens * 4 bytes/token = 800_000 bytes
577            assert_eq!(config.server.max_message_size, Some(800_000));
578        });
579
580        let _ = std::fs::remove_dir_all(&home);
581    }
582
583    fn make_test_home(subdir: &str) -> std::path::PathBuf {
584        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
585        let _ = std::fs::remove_dir_all(&dir);
586        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
587        dir
588    }
589
590    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
591        let original = std::env::var("HOME").ok();
592        std::env::set_var("HOME", home);
593        f();
594        if let Some(h) = original {
595            std::env::set_var("HOME", h);
596        } else {
597            std::env::remove_var("HOME");
598        }
599    }
600
601    #[test]
602    #[serial]
603    fn write_config_value_replaces_existing_key() {
604        let home = make_test_home("replace");
605        let cfg = home.join(".synaps-cli/config");
606        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
607
608        with_home(&home, || {
609            write_config_value("model", "claude-sonnet-4-6").unwrap();
610        });
611
612        let contents = std::fs::read_to_string(&cfg).unwrap();
613        assert!(contents.contains("model = claude-sonnet-4-6"));
614        assert!(contents.contains("thinking = low"));
615        let _ = std::fs::remove_dir_all(&home);
616    }
617
618    #[test]
619    #[serial]
620    fn write_config_value_appends_when_missing() {
621        let home = make_test_home("append");
622        let cfg = home.join(".synaps-cli/config");
623        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
624
625        with_home(&home, || {
626            write_config_value("theme", "dracula").unwrap();
627        });
628
629        let contents = std::fs::read_to_string(&cfg).unwrap();
630        assert!(contents.contains("model = claude-opus-4-6"));
631        assert!(contents.contains("theme = dracula"));
632        let _ = std::fs::remove_dir_all(&home);
633    }
634
635    #[test]
636    #[serial]
637    fn write_config_value_preserves_comments() {
638        let home = make_test_home("comments");
639        let cfg = home.join(".synaps-cli/config");
640        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
641
642        with_home(&home, || {
643            write_config_value("model", "claude-sonnet-4-6").unwrap();
644        });
645
646        let contents = std::fs::read_to_string(&cfg).unwrap();
647        assert!(contents.contains("# user comment"));
648        assert!(contents.contains("# another"));
649        assert!(contents.contains("model = claude-sonnet-4-6"));
650        let _ = std::fs::remove_dir_all(&home);
651    }
652
653    #[test]
654    #[serial]
655    fn write_config_value_preserves_unknown_keys() {
656        let home = make_test_home("unknown");
657        let cfg = home.join(".synaps-cli/config");
658        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
659
660        with_home(&home, || {
661            write_config_value("model", "claude-sonnet-4-6").unwrap();
662        });
663
664        let contents = std::fs::read_to_string(&cfg).unwrap();
665        assert!(contents.contains("custom_thing = 42"));
666        let _ = std::fs::remove_dir_all(&home);
667    }
668
669    #[test]
670    #[serial]
671    fn write_config_value_creates_file_if_absent() {
672        let home = make_test_home("create");
673        let cfg = home.join(".synaps-cli/config");
674        assert!(!cfg.exists());
675
676        with_home(&home, || {
677            write_config_value("model", "claude-sonnet-4-6").unwrap();
678        });
679
680        let contents = std::fs::read_to_string(&cfg).unwrap();
681        assert!(contents.contains("model = claude-sonnet-4-6"));
682        let _ = std::fs::remove_dir_all(&home);
683    }
684
685    #[test]
686    #[serial]
687    fn load_config_parses_theme_key() {
688        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
689        let _ = std::fs::create_dir_all(&dir);
690        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
691
692        let original_home = std::env::var("HOME").ok();
693        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
694
695        let config = load_config();
696
697        if let Some(home) = original_home {
698            std::env::set_var("HOME", home);
699        } else {
700            std::env::remove_var("HOME");
701        }
702        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
703
704        assert_eq!(config.theme.as_deref(), Some("dracula"));
705    }
706
707    #[test]
708    #[serial]
709    fn test_load_config_disable_lists() {
710        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
711        let _ = std::fs::create_dir_all(&test_dir);
712        let config_path = test_dir.join("config");
713
714        let config_content = r#"
715# Test config with disable lists
716favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
717
718disabled_plugins = foo, bar
719disabled_skills = baz, plug:qual
720"#;
721        std::fs::write(&config_path, config_content).unwrap();
722
723        let original_home = std::env::var("HOME").ok();
724        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
725
726        let config = load_config();
727
728        if let Some(home) = original_home {
729            std::env::set_var("HOME", home);
730        } else {
731            std::env::remove_var("HOME");
732        }
733
734        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
735
736        assert_eq!(config.disabled_plugins, vec!["foo".to_string(), "bar".to_string()]);
737        assert_eq!(config.favorite_models, vec![
738            "claude/claude-opus-4-7".to_string(),
739            "groq/llama-3.3-70b-versatile".to_string(),
740        ]);
741        assert_eq!(config.disabled_skills, vec!["baz".to_string(), "plug:qual".to_string()]);
742    }
743
744    #[test]
745    #[serial]
746    fn favorite_model_helpers_round_trip_through_config_file() {
747        let home = make_test_home("favorite-models");
748        let cfg = home.join(".synaps-cli/config");
749        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
750
751        with_home(&home, || {
752            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
753            add_favorite_model("claude/claude-opus-4-7").unwrap();
754            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
755            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
756            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
757            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
758            assert!(is_favorite_model("claude/claude-opus-4-7"));
759        });
760
761        let contents = std::fs::read_to_string(&cfg).unwrap();
762        assert!(contents.contains("model = claude-opus-4-7"));
763        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
764        let _ = std::fs::remove_dir_all(&home);
765    }
766
767    #[test]
768    #[serial]
769    fn test_load_config_new_keys() {
770        // Create a temporary config directory with the new keys
771        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
772        let _ = std::fs::create_dir_all(&test_dir);
773        let config_path = test_dir.join("config");
774        
775        let config_content = r#"
776# Test config with new keys
777model = claude-haiku
778thinking = medium
779max_tool_output = 50000
780bash_timeout = 45
781bash_max_timeout = 600
782subagent_timeout = 120
783api_retries = 5
784"#;
785        std::fs::write(&config_path, config_content).unwrap();
786        
787        // Temporarily override the config path for this test
788        let original_home = std::env::var("HOME").ok();
789        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
790        
791        let config = load_config();
792        
793        // Restore original HOME
794        if let Some(home) = original_home {
795            std::env::set_var("HOME", home);
796        } else {
797            std::env::remove_var("HOME");
798        }
799        
800        // Cleanup
801        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
802        
803        assert_eq!(config.model, Some("claude-haiku".to_string()));
804        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
805        assert_eq!(config.max_tool_output, 50000);
806        assert_eq!(config.bash_timeout, 45);
807        assert_eq!(config.bash_max_timeout, 600);
808        assert_eq!(config.subagent_timeout, 120);
809        assert_eq!(config.api_retries, 5);
810    }
811}