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