Skip to main content

agent_core/core/
config.rs

1use crate::core::shell_config::ShellConfig;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::sync::OnceLock;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8static IDENTITY: OnceLock<String> = OnceLock::new();
9
10pub const DEFAULT_IDENTITY: &str =
11    "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
12
13/// Returns the configured identity string for the system prompt preamble.
14/// Falls back to `DEFAULT_IDENTITY` (the SynapsCLI identity above) if not set
15/// in config. Initialized by `load_config()` — safe to call anytime after boot.
16pub fn get_identity() -> String {
17    IDENTITY
18        .get()
19        .cloned()
20        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
21}
22
23/// Provider API keys parsed from `provider.<name> = ...` lines in config.
24/// Empty if `load_config()` hasn't been called. The registry falls back to
25/// env vars, so e.g. `GROQ_API_KEY` works even with an empty map.
26pub fn get_provider_keys() -> BTreeMap<String, String> {
27    PROVIDER_KEYS.get().cloned().unwrap_or_default()
28}
29
30/// Returns the active profile name, if any.
31/// Reads from `SYNAPS_PROFILE` environment variable if not already set programmatically.
32pub fn get_profile() -> Option<String> {
33    PROFILE_NAME
34        .get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
35        .clone()
36}
37
38/// Sets the active profile name. Must be called before any `get_profile()` call
39/// (i.e., before config resolution begins). Uses OnceLock — first write wins,
40/// subsequent calls are no-ops. No env var mutation (unsafe under tokio).
41pub fn set_profile(name: Option<String>) {
42    let _ = PROFILE_NAME.set(name);
43}
44
45pub fn base_dir() -> PathBuf {
46    if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
47        return PathBuf::from(path);
48    }
49    let home = std::env::var("HOME")
50        .or_else(|_| std::env::var("USERPROFILE"))
51        .unwrap_or_else(|_| ".".to_string());
52    PathBuf::from(home).join(".synaps-cli")
53}
54
55/// Overrides the Synaps base directory. Intended for tests and embedded harnesses.
56#[doc(hidden)]
57pub fn set_base_dir_for_tests(path: PathBuf) {
58    std::env::set_var("SYNAPS_BASE_DIR", path);
59}
60
61/// Resolves a path for reading. Checks the profile folder first, then falls back to the default folder.
62pub fn resolve_read_path(filename: &str) -> PathBuf {
63    let base = base_dir();
64
65    if let Some(profile) = get_profile() {
66        let profile_path = base.join(&profile).join(filename);
67        if profile_path.exists() {
68            return profile_path;
69        }
70    }
71
72    base.join(filename)
73}
74
75/// Resolves a path for reading with an extended arbitrary path tree.
76pub fn resolve_read_path_extended(path: &str) -> PathBuf {
77    let base = base_dir();
78
79    if let Some(profile) = get_profile() {
80        let profile_path = base.join(&profile).join(path);
81        if profile_path.exists() {
82            return profile_path;
83        }
84    }
85
86    base.join(path)
87}
88
89/// Resolves a path for writing. Unconditionally writes to the profile folder if a profile is active.
90pub fn resolve_write_path(filename: &str) -> PathBuf {
91    let mut base = base_dir();
92
93    if let Some(profile) = get_profile() {
94        base.push(profile);
95    }
96
97    let _ = std::fs::create_dir_all(&base);
98    base.join(filename)
99}
100
101/// Gets the absolute directory for the current profile (or root if default).
102pub fn get_active_config_dir() -> PathBuf {
103    let mut base = base_dir();
104    if let Some(profile) = get_profile() {
105        base.push(profile);
106    }
107    base
108}
109
110/// Server security configuration parsed from `server.*` keys.
111#[derive(Debug, Clone, Default)]
112pub struct ServerConfig {
113    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
114    pub allowed_origins: Vec<String>,
115    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
116    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
117    pub token: Option<String>,
118    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
119    pub auto_approve_confirms: bool,
120    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
121    /// None means no artificial cap.
122    pub max_message_size: Option<usize>,
123}
124
125/// Bridge daemon configuration parsed from `bridge.*` keys.
126///
127/// Controls best-effort mirroring of watcher heartbeats over the bridge
128/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
129/// and the feature is OFF by default.
130#[derive(Debug, Clone)]
131pub struct BridgeConfig {
132    /// Path to the bridge daemon's UDS control socket.
133    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
134    pub uds_path: Option<PathBuf>,
135    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
136    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
137    pub heartbeat_mirror: bool,
138    /// Per-call timeout in milliseconds (covers connect + write + read).
139    pub heartbeat_timeout_ms: u64,
140}
141
142impl Default for BridgeConfig {
143    fn default() -> Self {
144        Self {
145            uds_path: None,
146            heartbeat_mirror: false,
147            heartbeat_timeout_ms: 250,
148        }
149    }
150}
151
152impl BridgeConfig {
153    /// Resolve the UDS path, falling back to the default under `base_dir()`.
154    pub fn resolved_uds_path(&self) -> PathBuf {
155        self.uds_path
156            .clone()
157            .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
158    }
159}
160
161/// Auth configuration parsed from `auth.*` keys. Controls whether this client
162/// resolves provider tokens from the local `auth.json` (default) or from a
163/// remote credential broker. See task #157 / `auth::credential_source`.
164#[derive(Debug, Clone, Default)]
165pub struct AuthConfig {
166    /// Broker base URL. When set (here, or via `SYNAPS_AUTH_ENDPOINT`), the
167    /// client fetches short-lived access tokens from the broker instead of
168    /// reading/refreshing the local `auth.json`.
169    pub remote_endpoint: Option<String>,
170    /// Per-machine bearer presented to the broker (or `SYNAPS_MACHINE_TOKEN`).
171    /// This is the machine's own identity, never the provider credential.
172    pub machine_token: Option<String>,
173}
174
175impl AuthConfig {
176    /// Resolve the credential source. Environment variables take precedence over
177    /// config-file values: `SYNAPS_AUTH_ENDPOINT` / `SYNAPS_MACHINE_TOKEN`.
178    /// Returns `Remote` iff an endpoint is set (env or config), else `Local`.
179    pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
180        let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
181            .ok()
182            .filter(|s| !s.trim().is_empty())
183            .or_else(|| self.remote_endpoint.clone());
184        let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
185            .ok()
186            .filter(|s| !s.trim().is_empty())
187            .or_else(|| self.machine_token.clone());
188        crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
189    }
190}
191
192/// Prompt-cache TTL strategy for Anthropic requests.
193///
194/// Controls the `cache_control` value emitted at every cache marker site:
195/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
196///   to historical payloads; the default path can never invalidate existing
197///   cached prefixes.
198/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
199/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
200///   bare 5m on the message-tail marker (written every turn).
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum CacheTtl {
203    #[default]
204    FiveMinutes,
205    OneHour,
206    Hybrid,
207}
208
209impl CacheTtl {
210    /// Parse a config value (case-insensitive). Returns `None` for unknown
211    /// values so the caller can warn and fall back to the default.
212    pub fn parse(val: &str) -> Option<CacheTtl> {
213        match val.to_ascii_lowercase().as_str() {
214            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
215            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
216            "hybrid" => Some(CacheTtl::Hybrid),
217            _ => None,
218        }
219    }
220}
221
222/// Runtime event routing configuration parsed from `events.*` keys.
223///
224/// Controls how runtime events (from the `EventQueue`) are delivered in
225/// server and RPC modes.
226#[derive(Debug, Clone)]
227pub struct EventsConfig {
228    /// When `true` (default), the server/RPC session automatically triggers a
229    /// model turn when runtime events arrive while idle.  Set
230    /// `events.auto_turn = false` (or `0` / `no` / `off`) to opt out.
231    /// Unrecognised values fail safe to `false` with a warning.
232    /// The built-in cap (`AUTO_TURN_CAP = 5`) still applies regardless.
233    pub auto_turn: bool,
234}
235
236impl Default for EventsConfig {
237    fn default() -> Self {
238        Self { auto_turn: true }
239    }
240}
241
242/// Parse `events.*` configuration keys.
243fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
244    if key == "events.auto_turn" {
245        let normalised = val.trim().to_lowercase();
246        cfg.auto_turn = match normalised.as_str() {
247            // Explicit true values.
248            "true" | "1" | "yes" | "on" => true,
249            // Explicit false values.
250            "false" | "0" | "no" | "off" => false,
251            // Unrecognised: fail safe to false and warn so the user knows.
252            other => {
253                eprintln!(
254                    "warning: config: unrecognised value for events.auto_turn = {:?}; \
255                     expected true/false/yes/no/on/off/1/0 — defaulting to false",
256                    other
257                );
258                false
259            }
260        };
261    } // unknown events.* keys ignored
262}
263
264/// Typed per-role turn-budget overrides (Task 23, spec §8.1). Every field
265/// is optional: unset fields keep the role's compiled default. Values are
266/// parsed from `turn_budget.<role>.<field>` config keys.
267#[derive(Debug, Clone, Default, PartialEq)]
268pub struct TurnBudgetOverrides {
269    pub max_provider_rounds: Option<u32>,
270    pub max_tool_calls: Option<u32>,
271    pub max_elapsed_secs: Option<u64>,
272    pub max_accumulated_tool_result_bytes: Option<usize>,
273    pub max_context_tokens: Option<u64>,
274    pub max_cost_usd: Option<f64>,
275    /// Bounded auto-renewals of the provider-round allowance after it is
276    /// exhausted mid-turn (spec §8.1). Each renewal resets the round counter;
277    /// wall-clock is never renewed, so total turn time stays bounded. `0`
278    /// disables graceful continuation (hard stop at the first exhaustion).
279    pub max_round_renewals: Option<u32>,
280}
281
282/// Per-role turn budgets (foreground, autonomous/watcher, worker).
283#[derive(Debug, Clone, Default, PartialEq)]
284pub struct TurnBudgetsConfig {
285    pub foreground: TurnBudgetOverrides,
286    pub autonomous: TurnBudgetOverrides,
287    pub worker: TurnBudgetOverrides,
288}
289
290/// Parse `turn_budget.<role>.<field>` keys. Invalid values warn and keep
291/// the default (parity with shell.* parsing).
292fn parse_turn_budget_config_key(budgets: &mut TurnBudgetsConfig, key: &str, val: &str) {
293    let Some(rest) = key.strip_prefix("turn_budget.") else {
294        return;
295    };
296    let Some((role, field)) = rest.split_once('.') else {
297        eprintln!("Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)");
298        return;
299    };
300    let overrides = match role {
301        "foreground" => &mut budgets.foreground,
302        "autonomous" => &mut budgets.autonomous,
303        "worker" => &mut budgets.worker,
304        other => {
305            eprintln!("Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)");
306            return;
307        }
308    };
309    macro_rules! set {
310        ($slot:expr, $ty:ty) => {
311            match val.parse::<$ty>() {
312                Ok(parsed) => $slot = Some(parsed),
313                Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
314            }
315        };
316    }
317    match field {
318        "max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
319        "max_tool_calls" => set!(overrides.max_tool_calls, u32),
320        "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
321        "max_accumulated_tool_result_bytes" => {
322            set!(overrides.max_accumulated_tool_result_bytes, usize)
323        }
324        "max_context_tokens" => set!(overrides.max_context_tokens, u64),
325        "max_cost_usd" => set!(overrides.max_cost_usd, f64),
326        "max_round_renewals" => set!(overrides.max_round_renewals, u32),
327        other => eprintln!("Warning: unknown turn_budget field '{other}'"),
328    }
329}
330
331/// Continuous-memory settings surface (Task A2, spec §12). Every field is
332/// fail-closed: unknown or invalid values warn and keep the compiled default.
333///
334/// By construction this struct is the complete `memory.*` config surface.
335/// Secret handling, retention, and project-scope rules are deliberately NOT
336/// fields here — they are not configurable at all (spec §12: "Secret,
337/// retention, and project rules are not configurable to fail open").
338#[derive(Debug, Clone, PartialEq)]
339pub struct MemoryConfig {
340    /// Durable default memory mode. "off" unless the operator explicitly
341    /// consents via `memory.default_mode_confirmed = true` in the same
342    /// config parse pass (spec §12 operator-consent rule). Known values:
343    /// off | recall_once | recall_each_prompt | capture_only |
344    /// capture_and_recall (spec §6.1).
345    pub default_mode: String,
346    /// Operator-consent latch for `default_mode` (spec §12). Must be set to
347    /// `true` in the same parse pass for a non-"off" `default_mode` to take
348    /// effect; otherwise `default_mode` reverts to "off" with a warning.
349    pub default_mode_confirmed: bool,
350    /// Max records surfaced per recall (spec §10.3 budget).
351    pub recall_max_records: u32,
352    /// Max tokens surfaced per recall (spec §10.3 budget).
353    pub recall_max_tokens: u32,
354    /// Recall time budget in milliseconds (spec §16).
355    pub recall_timeout_ms: u64,
356    /// Tool-result capture policy: off | summary_only (spec §11).
357    pub capture_tools: String,
358    /// Capture assistant turns (spec §8.2).
359    pub capture_assistant: bool,
360    /// Capture user turns (spec §8.2).
361    pub capture_user: bool,
362    /// Background consolidation: off | on (spec §11.3, explicit opt-in).
363    pub auto_consolidate: String,
364    /// Local embedding models: off | on (spec §11.2, never implicit).
365    pub local_embeddings: String,
366    /// GLiNER entity extraction: off | on (spec §11.2, never implicit).
367    pub gliner: String,
368}
369
370impl Default for MemoryConfig {
371    fn default() -> Self {
372        Self {
373            default_mode: "off".to_string(),
374            default_mode_confirmed: false,
375            recall_max_records: 8,
376            recall_max_tokens: 4096,
377            recall_timeout_ms: 150,
378            capture_tools: "summary_only".to_string(),
379            capture_assistant: true,
380            capture_user: true,
381            auto_consolidate: "off".to_string(),
382            local_embeddings: "off".to_string(),
383            gliner: "off".to_string(),
384        }
385    }
386}
387
388/// Memory mode strings accepted by `memory.default_mode` (spec §6.1,
389/// snake_case of `MemoryContextMode`). Anything else fails closed to the
390/// current value — `default_mode` can never silently become a non-off mode
391/// through an unrecognised string.
392const KNOWN_MEMORY_MODES: &[&str] = &[
393    "off",
394    "recall_once",
395    "recall_each_prompt",
396    "capture_only",
397    "capture_and_recall",
398];
399
400/// Parse `memory.<field>` keys (Task A2, spec §12). Invalid values warn and
401/// keep the default (parity with turn_budget.* parsing) — never fail open.
402fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) {
403    let Some(field) = key.strip_prefix("memory.") else {
404        return;
405    };
406    /// Strict boolean: recognised forms parse; anything else warns and
407    /// keeps the current (default) value.
408    fn parse_bool(slot: &mut bool, key: &str, val: &str) {
409        match val {
410            "true" | "1" | "yes" | "on" => *slot = true,
411            "false" | "0" | "no" | "off" => *slot = false,
412            other => eprintln!(
413                "Warning: invalid value for {key}: '{other}', expected true/false — using default"
414            ),
415        }
416    }
417    /// Closed string-enum: value must be in `allowed`, else warn and keep
418    /// the current (default) value.
419    fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) {
420        if allowed.contains(&val) {
421            *slot = val.to_string();
422        } else {
423            eprintln!(
424                "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
425                allowed.join("|")
426            );
427        }
428    }
429    macro_rules! set_num {
430        ($slot:expr, $ty:ty) => {
431            match val.parse::<$ty>() {
432                Ok(parsed) => $slot = parsed,
433                Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
434            }
435        };
436    }
437    match field {
438        "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
439        "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
440        "recall_max_records" => set_num!(memory.recall_max_records, u32),
441        "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
442        "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
443        "capture_tools" => parse_enum(
444            &mut memory.capture_tools,
445            key,
446            val,
447            &["off", "summary_only"],
448        ),
449        "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
450        "capture_user" => parse_bool(&mut memory.capture_user, key, val),
451        "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
452        "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
453        "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
454        other => eprintln!("Warning: unknown memory field '{other}'"),
455    }
456}
457
458/// Parsed configuration from the config file.
459#[derive(Debug, Clone)]
460pub struct SynapsConfig {
461    pub model: Option<String>,
462    pub thinking_budget: Option<u32>,
463    /// Named reasoning level, parsed from the same `thinking = …` key.
464    /// When `Some`, this is the authoritative level. When `None`, fall back
465    /// to `thinking_budget` for legacy numeric-only values.
466    pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
467    pub context_window: Option<u64>, // override auto-detected context window (tokens)
468    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
469    /// Where compaction summarization runs (spec §9.4): remote provider or
470    /// local-only (zero network construction).
471    pub compaction_mode: crate::core::compaction::CompactionMode,
472    /// Content classes excluded from remote compaction disclosure.
473    pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
474    pub max_tool_output: usize,  // default 30000
475    pub bash_timeout: u64,       // default 30
476    pub bash_max_timeout: u64,   // default 300
477    pub subagent_timeout: u64,   // default 300
478    pub api_retries: u32,        // default 3
479    pub refusal_retries: u32,    // default 2 — retries on stop_reason=refusal
480    pub telemetry: String,       // off | basic | full (default off)
481    pub cache_diagnostics: bool, // opt into cache-diagnosis beta (default false)
482    /// Prompt-cache TTL strategy: "5m" (default) | "1h" | "hybrid".
483    pub cache_ttl: CacheTtl,
484    /// Max TUI redraw rate in frames/sec — caps streaming redraws (e.g. 60,
485    /// 144, 240). User input always redraws immediately regardless. Default
486    /// 60. Range 1–1000. The frame budget is `1000 / max_fps` ms.
487    pub max_fps: u32,
488    /// Lines to scroll per mouse-wheel event. Different terminal emulators
489    /// emit 1–9+ scroll events per physical notch; set this to compensate.
490    /// Default: 3. Range 1–20.
491    pub scroll_lines: Option<u16>,
492    pub theme: Option<String>,
493    /// Whether the TUI paints its own opaque background. `false` preserves the terminal background.
494    pub tui_background_opaque: bool,
495    pub agent_name: Option<String>,
496    pub identity: Option<String>,
497    pub disabled_plugins: Vec<String>,
498    pub favorite_models: Vec<String>,
499    pub disabled_skills: Vec<String>,
500    /// Opt-in progressive tool disclosure (Task 18). When false, providers
501    /// receive the existing full tool schema byte-for-byte. When true, each
502    /// stream starts with the small essential local core plus discovery and
503    /// authorization gateways; exact activations are added per session.
504    pub progressive_tool_disclosure: bool,
505    /// Opt-in session persistence strategy (Task 35, spec §9.8). `Json`
506    /// (default) is the unchanged legacy full-rewrite path; `Journal` adds
507    /// an append-only delta journal with periodic atomic snapshots. See
508    /// docs/decisions/T35-session-journal-opt-in.md.
509    pub session_persistence: crate::core::session_journal::SessionPersistence,
510    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
511    /// from the registry at boot so they're never offered to the model.
512    pub disabled_tools: Vec<String>,
513    pub shell: ShellConfig,
514    pub server: ServerConfig,
515    pub bridge: BridgeConfig,
516    pub auth: AuthConfig,
517    pub events: EventsConfig,
518    pub provider_keys: BTreeMap<String, String>,
519    pub keybinds: std::collections::HashMap<String, String>,
520    /// Typed per-role turn budgets (Task 23, spec §8.1).
521    pub turn_budgets: TurnBudgetsConfig,
522    /// Continuous-memory settings surface (Task A2, spec §12).
523    pub memory: MemoryConfig,
524    /// Non-fatal problems found while parsing the config file (unknown keys,
525    /// unparseable values). Surfaced once at startup — never block boot.
526    pub warnings: Vec<String>,
527}
528
529impl Default for SynapsConfig {
530    fn default() -> Self {
531        Self {
532            model: None,
533            thinking_budget: None,
534            thinking_level: None,
535            context_window: None,
536            compaction_model: None,
537            compaction_mode: crate::core::compaction::CompactionMode::default(),
538            compaction_exclude: Vec::new(),
539            max_tool_output: 30000,
540            bash_timeout: 30,
541            bash_max_timeout: 300,
542            subagent_timeout: 300,
543            api_retries: 3,
544            refusal_retries: 2,
545            telemetry: "off".to_string(),
546            cache_diagnostics: false,
547            cache_ttl: CacheTtl::default(),
548            max_fps: 60,
549            scroll_lines: None,
550            theme: None,
551            tui_background_opaque: true,
552            agent_name: None,
553            identity: None,
554            disabled_plugins: Vec::new(),
555            favorite_models: Vec::new(),
556            disabled_skills: Vec::new(),
557            progressive_tool_disclosure: false,
558            session_persistence: crate::core::session_journal::SessionPersistence::default(),
559            disabled_tools: Vec::new(),
560            shell: ShellConfig::default(),
561            server: ServerConfig::default(),
562            bridge: BridgeConfig::default(),
563            auth: AuthConfig::default(),
564            events: EventsConfig::default(),
565            provider_keys: BTreeMap::new(),
566            keybinds: std::collections::HashMap::new(),
567            turn_budgets: TurnBudgetsConfig::default(),
568            memory: MemoryConfig::default(),
569            warnings: Vec::new(),
570        }
571    }
572}
573
574/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
575const KNOWN_CONFIG_KEYS: &[&str] = &[
576    "model",
577    "thinking",
578    "compaction_model",
579    "context_window",
580    "max_tool_output",
581    "bash_timeout",
582    "bash_max_timeout",
583    "subagent_timeout",
584    "api_retries",
585    "refusal_retries",
586    "telemetry",
587    "cache_diagnostics",
588    "cache_ttl",
589    "max_fps",
590    "scroll_lines",
591    "theme",
592    "tui_background_opaque",
593    "agent_name",
594    "identity",
595    "disabled_plugins",
596    "favorite_models",
597    "disabled_skills",
598    "disabled_tools",
599    "progressive_tool_disclosure",
600    "session_persistence",
601];
602
603/// Simple Levenshtein distance for did-you-mean suggestions.
604fn levenshtein(a: &str, b: &str) -> usize {
605    let a: Vec<char> = a.chars().collect();
606    let b: Vec<char> = b.chars().collect();
607    let mut prev: Vec<usize> = (0..=b.len()).collect();
608    let mut cur = vec![0; b.len() + 1];
609    for (i, ca) in a.iter().enumerate() {
610        cur[0] = i + 1;
611        for (j, cb) in b.iter().enumerate() {
612            let cost = if ca == cb { 0 } else { 1 };
613            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
614        }
615        std::mem::swap(&mut prev, &mut cur);
616    }
617    prev[b.len()]
618}
619
620/// Closest known key within edit distance 2, for typo suggestions.
621fn did_you_mean(key: &str) -> Option<&'static str> {
622    KNOWN_CONFIG_KEYS
623        .iter()
624        .map(|k| (*k, levenshtein(key, k)))
625        .filter(|(_, d)| *d <= 2)
626        .min_by_key(|(_, d)| *d)
627        .map(|(k, _)| k)
628}
629
630// parse_thinking_budget replaced by ThinkingSpec::parse in apply_config_content.
631
632fn parse_comma_list(val: &str) -> Vec<String> {
633    val.split(',')
634        .map(|s| s.trim().to_string())
635        .filter(|s| !s.is_empty())
636        .collect()
637}
638
639fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
640    write_config_value(key, &values.join(", "))
641}
642
643/// Parse shell.* configuration keys and update the ShellConfig.
644fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
645    match key {
646        "shell.max_sessions" => {
647            if let Ok(sessions) = val.parse::<usize>() {
648                shell_config.max_sessions = sessions;
649            } else {
650                eprintln!(
651                    "Warning: invalid value for shell.max_sessions: '{}', using default",
652                    val
653                );
654            }
655        }
656        "shell.idle_timeout" => {
657            if let Ok(timeout) = val.parse::<u64>() {
658                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
659            } else {
660                eprintln!(
661                    "Warning: invalid value for shell.idle_timeout: '{}', using default",
662                    val
663                );
664            }
665        }
666        "shell.readiness_timeout_ms" => {
667            if let Ok(timeout) = val.parse::<u64>() {
668                shell_config.readiness_timeout_ms = timeout;
669            } else {
670                eprintln!(
671                    "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
672                    val
673                );
674            }
675        }
676        "shell.max_readiness_timeout_ms" => {
677            if let Ok(timeout) = val.parse::<u64>() {
678                shell_config.max_readiness_timeout_ms = timeout;
679            } else {
680                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
681            }
682        }
683        "shell.default_rows" => {
684            if let Ok(rows) = val.parse::<u16>() {
685                shell_config.default_rows = rows;
686            } else {
687                eprintln!(
688                    "Warning: invalid value for shell.default_rows: '{}', using default",
689                    val
690                );
691            }
692        }
693        "shell.default_cols" => {
694            if let Ok(cols) = val.parse::<u16>() {
695                shell_config.default_cols = cols;
696            } else {
697                eprintln!(
698                    "Warning: invalid value for shell.default_cols: '{}', using default",
699                    val
700                );
701            }
702        }
703        "shell.readiness_strategy" => {
704            let val_lower = val.to_lowercase();
705            match val_lower.as_str() {
706                "timeout" | "prompt" | "hybrid" => {
707                    shell_config.readiness_strategy = val.to_string();
708                }
709                _ => {
710                    eprintln!(
711                        "Warning: invalid value for shell.readiness_strategy: '{}', using default",
712                        val
713                    );
714                }
715            }
716        }
717        "shell.max_output" => {
718            if let Ok(max_output) = val.parse::<usize>() {
719                shell_config.max_output = max_output;
720            } else {
721                eprintln!(
722                    "Warning: invalid value for shell.max_output: '{}', using default",
723                    val
724                );
725            }
726        }
727        _ => {
728            // Unknown shell.* keys are preserved (not rejected)
729        }
730    }
731}
732
733/// Parse server.* configuration keys and update the ServerConfig.
734#[allow(clippy::collapsible_match)]
735fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
736    match key {
737        "server.allowed_origins" => {
738            server_config.allowed_origins = parse_comma_list(val);
739        }
740        "server.token" => {
741            if !val.is_empty() {
742                server_config.token = Some(val.to_string());
743            }
744        }
745        "server.auto_approve_confirms" => {
746            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
747        }
748        "server.max_message_size" => {
749            if let Ok(size) = val.parse::<usize>() {
750                server_config.max_message_size = Some(size);
751            } else {
752                eprintln!(
753                    "Warning: invalid value for server.max_message_size: '{}', ignored",
754                    val
755                );
756            }
757        }
758        _ => {
759            // Unknown server.* keys preserved (not rejected)
760        }
761    }
762}
763
764/// Parse bridge.* configuration keys and update the BridgeConfig.
765fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
766    match key {
767        "bridge.uds_path" => {
768            if val.is_empty() {
769                bridge_config.uds_path = None;
770            } else {
771                bridge_config.uds_path = Some(PathBuf::from(val));
772            }
773        }
774        "bridge.heartbeat_mirror" => {
775            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
776        }
777        "bridge.heartbeat_timeout_ms" => {
778            if let Ok(ms) = val.parse::<u64>() {
779                bridge_config.heartbeat_timeout_ms = ms;
780            } else {
781                eprintln!(
782                    "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
783                    val
784                );
785            }
786        }
787        _ => {
788            // Unknown bridge.* keys preserved (not rejected)
789        }
790    }
791}
792
793/// Parse auth.* configuration keys and update the AuthConfig.
794fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
795    let v = val.trim();
796    match key {
797        "auth.remote_endpoint" => {
798            auth_config.remote_endpoint = if v.is_empty() {
799                None
800            } else {
801                Some(v.to_string())
802            };
803        }
804        "auth.machine_token" => {
805            auth_config.machine_token = if v.is_empty() {
806                None
807            } else {
808                Some(v.to_string())
809            };
810        }
811        _ => {
812            // Unknown auth.* keys preserved (not rejected)
813        }
814    }
815}
816
817/// Parse config from a raw string — useful for tests and embedded harnesses.
818/// Does NOT write to `PROVIDER_KEYS` or `IDENTITY` OnceLocks.
819pub fn load_config_from_str(content: &str) -> SynapsConfig {
820    let mut config = SynapsConfig::default();
821    apply_config_content(&mut config, content);
822    config
823}
824
825/// Parse the config file at ~/.synaps-cli/config (or profile variant).
826/// Returns default config if file doesn't exist or can't be read.
827pub fn load_config() -> SynapsConfig {
828    let path = resolve_read_path("config");
829    let mut config = SynapsConfig::default();
830
831    let Ok(content) = std::fs::read_to_string(&path) else {
832        return config;
833    };
834
835    apply_config_content(&mut config, &content);
836
837    // Publish provider keys to the process-wide cache for the API router.
838    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
839    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
840
841    // Publish identity to the process-wide cache for API system prompt preamble.
842    let identity_val = config
843        .identity
844        .clone()
845        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
846    let _ = IDENTITY.set(identity_val);
847
848    config
849}
850
851/// Apply key=value config lines from `content` into `config`.
852/// Shared by `load_config` (file path) and `load_config_from_str` (test helper).
853fn apply_config_content(config: &mut SynapsConfig, content: &str) {
854    for line in content.lines() {
855        let line = line.trim();
856        if line.is_empty() || line.starts_with('#') {
857            continue;
858        }
859        let Some((key, val)) = line.split_once('=') else {
860            continue;
861        };
862        let key = key.trim();
863        let val = val.trim();
864        match key {
865            "model" => config.model = Some(val.to_string()),
866            "thinking" => {
867                use crate::core::reasoning::ThinkingSpec;
868                match ThinkingSpec::parse(val) {
869                    Some(ThinkingSpec::Named(level)) => {
870                        config.thinking_level = Some(level);
871                        config.thinking_budget = level.to_legacy_budget();
872                    }
873                    Some(ThinkingSpec::Budget(budget)) => {
874                        // Preserve exact legacy budgets; do not make the derived
875                        // bucket authoritative over the user's token count.
876                        config.thinking_level = None;
877                        config.thinking_budget = Some(budget);
878                    }
879                    None => {
880                        config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
881                    }
882                }
883            }
884            "compaction_model" => config.compaction_model = Some(val.to_string()),
885            "compaction_mode" => match val {
886                "remote" => {
887                    config.compaction_mode = crate::core::compaction::CompactionMode::Remote
888                }
889                "local" | "local_only" | "local-only" => {
890                    config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
891                }
892                _ => {
893                    config.warnings.push(format!(
894                        "compaction_mode = {val} — expected remote or local; ignored"
895                    ));
896                }
897            },
898            "compaction_exclude" => {
899                let mut classes = Vec::new();
900                for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
901                    match crate::core::compaction::ContentClass::parse(part) {
902                        Some(class) => {
903                            if !classes.contains(&class) {
904                                classes.push(class);
905                            }
906                        }
907                        None => config.warnings.push(format!(
908                            "compaction_exclude — unknown content class '{part}'; \
909                             expected one of: user_text, assistant_text, thinking, \
910                             tool_calls, tool_results, file_paths, event_data"
911                        )),
912                    }
913                }
914                config.compaction_exclude = classes;
915            }
916            "context_window" => {
917                let parsed = match val {
918                    "200k" | "200K" => Some(200_000),
919                    "1m" | "1M" => Some(1_000_000),
920                    _ => val.parse::<u64>().ok(),
921                };
922                if parsed.is_none() {
923                    config.warnings.push(format!(
924                        "context_window = {val} — expected 200k, 1m, or a token count; ignored"
925                    ));
926                }
927                config.context_window = parsed;
928            }
929            "max_tool_output" => match val.parse::<usize>() {
930                Ok(size) => config.max_tool_output = size,
931                Err(_) => config.warnings.push(format!(
932                    "max_tool_output = {val} — not a number; using {}",
933                    config.max_tool_output
934                )),
935            },
936            "bash_timeout" => match val.parse::<u64>() {
937                Ok(t) if t >= 1 => config.bash_timeout = t,
938                Ok(_) => config.warnings.push(format!(
939                    "bash_timeout = {val} — below minimum (1s); using {}",
940                    config.bash_timeout
941                )),
942                Err(_) => config.warnings.push(format!(
943                    "bash_timeout = {val} — not a number; using {}",
944                    config.bash_timeout
945                )),
946            },
947            "bash_max_timeout" => {
948                if let Ok(timeout) = val.parse::<u64>() {
949                    config.bash_max_timeout = timeout;
950                }
951            }
952            "subagent_timeout" => {
953                if let Ok(timeout) = val.parse::<u64>() {
954                    config.subagent_timeout = timeout;
955                }
956            }
957            "api_retries" => {
958                if let Ok(retries) = val.parse::<u32>() {
959                    config.api_retries = retries;
960                }
961            }
962            "refusal_retries" => {
963                if let Ok(retries) = val.parse::<u32>() {
964                    config.refusal_retries = retries;
965                }
966            }
967            "telemetry" => config.telemetry = val.to_string(),
968            "cache_diagnostics" => {
969                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
970            }
971            "cache_ttl" => match CacheTtl::parse(val) {
972                Some(ttl) => config.cache_ttl = ttl,
973                None => config.warnings.push(format!(
974                    "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
975                )),
976            },
977            "max_fps" => match val.parse::<u32>() {
978                Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
979                Ok(_) => config.warnings.push(format!(
980                    "max_fps = {val} — expected 1–1000; using {}",
981                    config.max_fps
982                )),
983                Err(_) => config.warnings.push(format!(
984                    "max_fps = {val} — not a number; using {}",
985                    config.max_fps
986                )),
987            },
988            "scroll_lines" => match val.parse::<u16>() {
989                Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
990                Ok(_) => config
991                    .warnings
992                    .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
993                Err(_) => config
994                    .warnings
995                    .push(format!("scroll_lines = {val} — not a number; ignoring")),
996            },
997            "theme" => config.theme = Some(val.to_string()),
998            "tui_background_opaque" => match val {
999                "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1000                "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1001                _ => config.warnings.push(format!(
1002                    "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1003                    if config.tui_background_opaque {
1004                        "opaque"
1005                    } else {
1006                        "invisible"
1007                    }
1008                )),
1009            },
1010            "agent_name" => config.agent_name = Some(val.to_string()),
1011            "identity" => config.identity = Some(val.to_string()),
1012            "disabled_plugins" => {
1013                config.disabled_plugins = parse_comma_list(val);
1014            }
1015            "favorite_models" => {
1016                config.favorite_models = parse_comma_list(val);
1017            }
1018            "disabled_skills" => {
1019                config.disabled_skills = parse_comma_list(val);
1020            }
1021            "progressive_tool_disclosure" => {
1022                config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1023            }
1024            "session_persistence" => {
1025                match crate::core::session_journal::SessionPersistence::parse(val) {
1026                    Some(mode) => config.session_persistence = mode,
1027                    None => config.warnings.push(format!(
1028                        "session_persistence = {val} — expected json or journal; \
1029                         keeping the default (json)"
1030                    )),
1031                }
1032            }
1033            "disabled_tools" => {
1034                config.disabled_tools = parse_comma_list(val);
1035            }
1036            _ => {
1037                // Handle namespaced keys
1038                if key.starts_with("shell.") {
1039                    parse_shell_config_key(&mut config.shell, key, val);
1040                } else if key.starts_with("server.") {
1041                    parse_server_config_key(&mut config.server, key, val);
1042                } else if key.starts_with("bridge.") {
1043                    parse_bridge_config_key(&mut config.bridge, key, val);
1044                } else if key.starts_with("auth.") {
1045                    parse_auth_config_key(&mut config.auth, key, val);
1046                } else if key.starts_with("events.") {
1047                    parse_events_config_key(&mut config.events, key, val);
1048                } else if key.starts_with("turn_budget.") {
1049                    parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1050                } else if key.starts_with("memory.") {
1051                    parse_memory_config_key(&mut config.memory, key, val);
1052                } else if let Some(provider_key) = key.strip_prefix("provider.") {
1053                    config
1054                        .provider_keys
1055                        .insert(provider_key.to_string(), val.to_string());
1056                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1057                    config
1058                        .keybinds
1059                        .insert(keybind_key.to_string(), val.to_string());
1060                } else if key.contains('.') {
1061                    // Dotted keys are namespaced (plugin/extension config, e.g.
1062                    // `knowledge.jawz_notes`). Plugins define their own keys —
1063                    // not ours to police. Silently preserved.
1064                } else {
1065                    // Unknown top-level key — warn with a did-you-mean if close.
1066                    match did_you_mean(key) {
1067                        Some(suggestion) => config.warnings.push(format!(
1068                            "unknown key '{key}' (did you mean '{suggestion}'?)"
1069                        )),
1070                        None => config
1071                            .warnings
1072                            .push(format!("unknown key '{key}' — ignored")),
1073                    }
1074                }
1075            }
1076        }
1077    }
1078
1079    // Fail-closed operator-consent rule (Task A2, spec §12): a non-"off"
1080    // `memory.default_mode` takes effect only when the operator also set
1081    // `memory.default_mode_confirmed = true` in this same parse pass.
1082    // Checked after the loop so key order never matters.
1083    if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1084        config.warnings.push(format!(
1085            "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1086             in the same config; reverting to off",
1087            config.memory.default_mode
1088        ));
1089        config.memory.default_mode = "off".to_string();
1090    }
1091
1092    // Derive max_message_size from context_window if not explicitly set.
1093    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
1094    if config.server.max_message_size.is_none() {
1095        if let Some(ctx_tokens) = config.context_window {
1096            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1097        }
1098    }
1099}
1100
1101/// Read a single config value by exact key from the active config file.
1102pub fn read_config_value(key: &str) -> Option<String> {
1103    let path = resolve_read_path("config");
1104    let content = std::fs::read_to_string(&path).ok()?;
1105    for line in content.lines() {
1106        let line = line.trim();
1107        if line.is_empty() || line.starts_with('#') {
1108            continue;
1109        }
1110        let Some((k, v)) = line.split_once('=') else {
1111            continue;
1112        };
1113        if k.trim() == key.trim() {
1114            return Some(v.trim().to_string());
1115        }
1116    }
1117    None
1118}
1119
1120/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
1121/// Replaces the first existing line that matches the key, or appends if absent.
1122/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
1123pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1124    let path = resolve_write_path("config");
1125    let existing = std::fs::read_to_string(&path).unwrap_or_default();
1126
1127    let key_trimmed = key.trim();
1128    let replacement = format!("{} = {}", key_trimmed, value);
1129
1130    let mut found = false;
1131    let mut new_lines: Vec<String> = existing
1132        .lines()
1133        .map(|line| {
1134            if found {
1135                return line.to_string();
1136            }
1137            let t = line.trim_start();
1138            if t.starts_with('#') || t.is_empty() {
1139                return line.to_string();
1140            }
1141            if let Some((k, _)) = t.split_once('=') {
1142                if k.trim() == key_trimmed {
1143                    found = true;
1144                    return replacement.clone();
1145                }
1146            }
1147            line.to_string()
1148        })
1149        .collect();
1150
1151    if !found {
1152        new_lines.push(replacement);
1153    }
1154
1155    let mut out = new_lines.join("\n");
1156    if !out.ends_with('\n') {
1157        out.push('\n');
1158    }
1159
1160    let tmp = path.with_extension("tmp");
1161    std::fs::write(&tmp, out)?;
1162    // Config may contain API keys — restrict to owner-only
1163    #[cfg(unix)]
1164    {
1165        use std::os::unix::fs::PermissionsExt;
1166        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1167    }
1168    std::fs::rename(&tmp, &path)?;
1169    Ok(())
1170}
1171
1172/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
1173pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1174    let trimmed = id.trim();
1175    if trimmed.is_empty() {
1176        return Ok(());
1177    }
1178    let mut values = load_config().favorite_models;
1179    if !values.iter().any(|v| v == trimmed) {
1180        values.push(trimmed.to_string());
1181        values.sort();
1182    }
1183    write_comma_list("favorite_models", &values)
1184}
1185
1186/// Remove a favorite model id (`provider/model`) from config.
1187pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1188    let mut values = load_config().favorite_models;
1189    values.retain(|v| v != id.trim());
1190    write_comma_list("favorite_models", &values)
1191}
1192
1193/// Return whether a model id is marked as favorite.
1194pub fn is_favorite_model(id: &str) -> bool {
1195    load_config().favorite_models.iter().any(|v| v == id.trim())
1196}
1197
1198/// Resolve the system prompt from CLI flag, config file, or default.
1199/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
1200pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1201    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1202        You have access to bash, read, and write tools. \
1203        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1204
1205    if let Some(val) = explicit {
1206        let path = std::path::Path::new(val);
1207        if path.exists() && path.is_file() {
1208            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1209        }
1210        return val.to_string();
1211    }
1212
1213    let system_path = resolve_read_path("system.md");
1214    if system_path.exists() {
1215        return std::fs::read_to_string(&system_path).unwrap_or_default();
1216    }
1217
1218    DEFAULT_PROMPT.to_string()
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    #[test]
1224    fn tui_background_opaque_defaults_and_parses_supported_values() {
1225        assert!(super::load_config_from_str("").tui_background_opaque);
1226        assert!(
1227            super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
1228        );
1229        assert!(
1230            !super::load_config_from_str("tui_background_opaque = invisible\n")
1231                .tui_background_opaque
1232        );
1233        let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
1234        assert!(invalid.tui_background_opaque);
1235        assert!(invalid
1236            .warnings
1237            .iter()
1238            .any(|w| w.contains("tui_background_opaque")));
1239    }
1240
1241    #[test]
1242    fn turn_budget_keys_parse_typed_per_role() {
1243        let config = super::load_config_from_str(
1244            "turn_budget.worker.max_provider_rounds = 3\n\
1245             turn_budget.worker.max_cost_usd = 0.25\n\
1246             turn_budget.autonomous.max_elapsed_secs = 90\n\
1247             turn_budget.foreground.max_tool_calls = 7\n\
1248             turn_budget.foreground.max_round_renewals = 12\n\
1249             turn_budget.worker.max_provider_rounds_bogus = 1\n\
1250             turn_budget.nosuchrole.max_tool_calls = 1\n",
1251        );
1252        assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
1253        assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
1254        assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
1255        assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
1256        assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
1257        // Unknown fields/roles warn and change nothing.
1258        assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
1259        // Invalid values keep the default (None).
1260        let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
1261        assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
1262    }
1263
1264    #[test]
1265    fn memory_keys_all_parse_with_consent() {
1266        let config = super::load_config_from_str(
1267            "memory.default_mode = capture_and_recall\n\
1268             memory.default_mode_confirmed = true\n\
1269             memory.recall_max_records = 12\n\
1270             memory.recall_max_tokens = 2048\n\
1271             memory.recall_timeout_ms = 250\n\
1272             memory.capture_tools = off\n\
1273             memory.capture_assistant = false\n\
1274             memory.capture_user = false\n\
1275             memory.auto_consolidate = on\n\
1276             memory.local_embeddings = on\n\
1277             memory.gliner = on\n",
1278        );
1279        assert_eq!(config.memory.default_mode, "capture_and_recall");
1280        assert!(config.memory.default_mode_confirmed);
1281        assert_eq!(config.memory.recall_max_records, 12);
1282        assert_eq!(config.memory.recall_max_tokens, 2048);
1283        assert_eq!(config.memory.recall_timeout_ms, 250);
1284        assert_eq!(config.memory.capture_tools, "off");
1285        assert!(!config.memory.capture_assistant);
1286        assert!(!config.memory.capture_user);
1287        assert_eq!(config.memory.auto_consolidate, "on");
1288        assert_eq!(config.memory.local_embeddings, "on");
1289        assert_eq!(config.memory.gliner, "on");
1290    }
1291
1292    #[test]
1293    fn memory_defaults_match_spec_12() {
1294        let config = super::load_config_from_str("");
1295        assert_eq!(config.memory, super::MemoryConfig::default());
1296        assert_eq!(config.memory.default_mode, "off");
1297        assert!(!config.memory.default_mode_confirmed);
1298        assert_eq!(config.memory.recall_max_records, 8);
1299        assert_eq!(config.memory.recall_max_tokens, 4096);
1300        assert_eq!(config.memory.recall_timeout_ms, 150);
1301        assert_eq!(config.memory.capture_tools, "summary_only");
1302        assert!(config.memory.capture_assistant);
1303        assert!(config.memory.capture_user);
1304        assert_eq!(config.memory.auto_consolidate, "off");
1305        assert_eq!(config.memory.local_embeddings, "off");
1306        assert_eq!(config.memory.gliner, "off");
1307    }
1308
1309    #[test]
1310    fn memory_unknown_values_fail_closed_to_defaults() {
1311        let config = super::load_config_from_str(
1312            "memory.default_mode = recall_everything_forever\n\
1313             memory.default_mode_confirmed = true\n\
1314             memory.recall_max_records = lots\n\
1315             memory.recall_max_tokens = -5\n\
1316             memory.recall_timeout_ms = fast\n\
1317             memory.capture_tools = full\n\
1318             memory.capture_assistant = maybe\n\
1319             memory.capture_user = sometimes\n\
1320             memory.auto_consolidate = aggressive\n\
1321             memory.local_embeddings = auto\n\
1322             memory.gliner = download\n\
1323             memory.nosuchfield = 1\n",
1324        );
1325        // Unrecognised mode string must stay "off" — never a non-off mode.
1326        assert_eq!(config.memory.default_mode, "off");
1327        assert_eq!(config.memory.recall_max_records, 8);
1328        assert_eq!(config.memory.recall_max_tokens, 4096);
1329        assert_eq!(config.memory.recall_timeout_ms, 150);
1330        assert_eq!(config.memory.capture_tools, "summary_only");
1331        assert!(config.memory.capture_assistant);
1332        assert!(config.memory.capture_user);
1333        assert_eq!(config.memory.auto_consolidate, "off");
1334        assert_eq!(config.memory.local_embeddings, "off");
1335        assert_eq!(config.memory.gliner, "off");
1336    }
1337
1338    #[test]
1339    fn memory_default_mode_without_consent_reverts_to_off() {
1340        let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
1341        assert_eq!(config.memory.default_mode, "off");
1342        assert!(
1343            config
1344                .warnings
1345                .iter()
1346                .any(|w| w.contains("memory.default_mode_confirmed")),
1347            "reversion must surface a warning; got: {:?}",
1348            config.warnings
1349        );
1350
1351        // Explicit false consent is not consent.
1352        let denied = super::load_config_from_str(
1353            "memory.default_mode = capture_only\n\
1354             memory.default_mode_confirmed = false\n",
1355        );
1356        assert_eq!(denied.memory.default_mode, "off");
1357
1358        // Garbage consent value fails closed — not consent.
1359        let garbage = super::load_config_from_str(
1360            "memory.default_mode = capture_only\n\
1361             memory.default_mode_confirmed = definitely\n",
1362        );
1363        assert_eq!(garbage.memory.default_mode, "off");
1364    }
1365
1366    #[test]
1367    fn memory_consent_is_order_independent() {
1368        // Consent key before the mode key.
1369        let before = super::load_config_from_str(
1370            "memory.default_mode_confirmed = true\n\
1371             memory.default_mode = recall_once\n",
1372        );
1373        assert_eq!(before.memory.default_mode, "recall_once");
1374        // Consent key after the mode key.
1375        let after = super::load_config_from_str(
1376            "memory.default_mode = recall_once\n\
1377             memory.default_mode_confirmed = true\n",
1378        );
1379        assert_eq!(after.memory.default_mode, "recall_once");
1380    }
1381
1382    #[test]
1383    fn memory_config_surface_is_closed_by_construction() {
1384        // Exhaustive struct literal — no `..Default::default()` spread. This
1385        // fails to COMPILE if any field is added to or removed from
1386        // MemoryConfig, pinning the exact memory.* config surface. Secret,
1387        // retention, and project-scope semantics have no field here, so no
1388        // `memory.secret*` / `memory.retention*` / `memory.project*` config
1389        // key can exist (spec §12: those rules are not configurable to fail
1390        // open). Asserted by construction, not by test disproof.
1391        let _closed_surface = super::MemoryConfig {
1392            default_mode: "off".to_string(),
1393            default_mode_confirmed: false,
1394            recall_max_records: 8,
1395            recall_max_tokens: 4096,
1396            recall_timeout_ms: 150,
1397            capture_tools: "summary_only".to_string(),
1398            capture_assistant: true,
1399            capture_user: true,
1400            auto_consolidate: "off".to_string(),
1401            local_embeddings: "off".to_string(),
1402            gliner: "off".to_string(),
1403        };
1404    }
1405
1406    use super::*;
1407    use serial_test::serial;
1408
1409    #[test]
1410    fn test_levenshtein_basics() {
1411        assert_eq!(levenshtein("model", "model"), 0);
1412        assert_eq!(levenshtein("modle", "model"), 2);
1413        assert_eq!(levenshtein("them", "theme"), 1);
1414    }
1415
1416    #[test]
1417    fn test_did_you_mean_close_typos() {
1418        assert_eq!(did_you_mean("modle"), Some("model"));
1419        assert_eq!(did_you_mean("them"), Some("theme"));
1420        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
1421        assert_eq!(did_you_mean("completely_unrelated_key"), None);
1422    }
1423
1424    #[test]
1425    #[serial]
1426    fn test_config_warnings_unknown_key_and_bad_values() {
1427        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
1428        let dir = home.join(".synaps-cli");
1429        std::fs::create_dir_all(&dir).unwrap();
1430        std::fs::write(dir.join("config"), "modle = claude-opus-4-6\nthinking = hgih\nbash_timeout = 0\nknowledge.jawz_notes = ~/Jawz/notes\ncustom.plugin.key = 42\n").unwrap();
1431
1432        with_home(&home, || {
1433            let config = load_config();
1434            // Dotted (namespaced) keys must NOT warn — plugins own those.
1435            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
1436            assert!(
1437                !config.warnings.iter().any(|w| w.contains("knowledge")),
1438                "{:?}",
1439                config.warnings
1440            );
1441            assert!(
1442                config
1443                    .warnings
1444                    .iter()
1445                    .any(|w| w.contains("did you mean 'model'")),
1446                "{:?}",
1447                config.warnings
1448            );
1449            assert!(
1450                config.warnings.iter().any(|w| w.contains("thinking")),
1451                "{:?}",
1452                config.warnings
1453            );
1454            assert!(
1455                config.warnings.iter().any(|w| w.contains("below minimum")),
1456                "{:?}",
1457                config.warnings
1458            );
1459            // Bad values fall back to defaults
1460            assert_eq!(config.bash_timeout, 30);
1461            assert_eq!(config.thinking_budget, None);
1462        });
1463        let _ = std::fs::remove_dir_all(&home);
1464    }
1465
1466    // ── cache_ttl parse table (spec §3.1) ──
1467
1468    #[test]
1469    fn test_cache_ttl_parse_table() {
1470        // 5m aliases
1471        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
1472        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
1473        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
1474        // 1h aliases
1475        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
1476        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
1477        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
1478        // hybrid
1479        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
1480        // case-insensitive
1481        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
1482        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
1483        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
1484        // garbage → None (caller warns + defaults)
1485        assert_eq!(CacheTtl::parse("2h"), None);
1486        assert_eq!(CacheTtl::parse(""), None);
1487        assert_eq!(CacheTtl::parse("forever"), None);
1488    }
1489
1490    #[test]
1491    fn test_cache_ttl_default_is_five_minutes() {
1492        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
1493        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
1494    }
1495
1496    #[test]
1497    fn test_max_fps_default_is_60() {
1498        assert_eq!(SynapsConfig::default().max_fps, 60);
1499    }
1500
1501    #[test]
1502    #[serial]
1503    fn test_max_fps_config_parse_and_validation() {
1504        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
1505        let dir = home.join(".synaps-cli");
1506        std::fs::create_dir_all(&dir).unwrap();
1507
1508        // Valid high-refresh value parses, no warning.
1509        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
1510        with_home(&home, || {
1511            let config = load_config();
1512            assert_eq!(config.max_fps, 144);
1513            assert!(
1514                config.warnings.is_empty(),
1515                "warnings: {:?}",
1516                config.warnings
1517            );
1518        });
1519
1520        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
1521        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
1522        with_home(&home, || {
1523            let config = load_config();
1524            assert_eq!(config.max_fps, 60);
1525            assert!(
1526                config.warnings.iter().any(|w| w.contains("max_fps")),
1527                "warnings: {:?}",
1528                config.warnings
1529            );
1530        });
1531
1532        // Non-numeric → default 60 + warning.
1533        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
1534        with_home(&home, || {
1535            let config = load_config();
1536            assert_eq!(config.max_fps, 60);
1537            assert!(
1538                config.warnings.iter().any(|w| w.contains("max_fps")),
1539                "warnings: {:?}",
1540                config.warnings
1541            );
1542        });
1543
1544        let _ = std::fs::remove_dir_all(&home);
1545    }
1546
1547    #[test]
1548    #[serial]
1549    fn test_cache_ttl_config_parse_and_garbage_warning() {
1550        let home =
1551            std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
1552        let dir = home.join(".synaps-cli");
1553        std::fs::create_dir_all(&dir).unwrap();
1554
1555        // Valid value parses, no warning.
1556        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
1557        with_home(&home, || {
1558            let config = load_config();
1559            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
1560            assert!(
1561                config.warnings.is_empty(),
1562                "warnings: {:?}",
1563                config.warnings
1564            );
1565        });
1566
1567        // Garbage value → 5m default + boot warning (never blocks boot).
1568        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
1569        with_home(&home, || {
1570            let config = load_config();
1571            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
1572            assert!(
1573                config.warnings.iter().any(|w| w.contains("cache_ttl")),
1574                "warnings: {:?}",
1575                config.warnings
1576            );
1577        });
1578
1579        let _ = std::fs::remove_dir_all(&home);
1580    }
1581
1582    #[test]
1583    fn test_parse_thinking_budget() {
1584        use crate::core::reasoning::ThinkingSpec;
1585        // ThinkingSpec::parse replaced parse_thinking_budget; verify budget values.
1586        assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
1587        assert_eq!(
1588            ThinkingSpec::parse("medium").unwrap().to_budget(),
1589            Some(4096)
1590        );
1591        assert_eq!(
1592            ThinkingSpec::parse("high").unwrap().to_budget(),
1593            Some(16384)
1594        );
1595        assert_eq!(
1596            ThinkingSpec::parse("xhigh").unwrap().to_budget(),
1597            Some(32768)
1598        );
1599        assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
1600        let config = load_config_from_str("thinking = 8192\n");
1601        assert_eq!(config.thinking_level, None);
1602        assert_eq!(config.thinking_budget, Some(8192));
1603        assert_eq!(ThinkingSpec::parse("invalid"), None);
1604    }
1605
1606    #[test]
1607    fn test_base_dir() {
1608        let path = base_dir();
1609        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
1610    }
1611
1612    #[test]
1613    fn test_resolve_system_prompt_explicit() {
1614        let result = resolve_system_prompt(Some("test prompt"));
1615        assert_eq!(result, "test prompt");
1616    }
1617
1618    #[test]
1619    fn test_resolve_system_prompt_none() {
1620        let result = resolve_system_prompt(None);
1621        assert!(result.contains("helpful AI agent"));
1622    }
1623
1624    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
1625    // is not thread-safe and races with shell config tests. Coverage provided
1626    // by shell::config::tests::test_shell_config_from_file.
1627
1628    #[test]
1629    fn test_synaps_config_default() {
1630        let config = SynapsConfig::default();
1631        assert_eq!(config.model, None);
1632        assert_eq!(config.thinking_budget, None);
1633        assert_eq!(config.max_tool_output, 30000);
1634        assert_eq!(config.bash_timeout, 30);
1635        assert_eq!(config.bash_max_timeout, 300);
1636        assert_eq!(config.subagent_timeout, 300);
1637        assert_eq!(config.api_retries, 3);
1638        assert_eq!(config.theme, None);
1639        assert!(config.disabled_plugins.is_empty());
1640        assert!(config.favorite_models.is_empty());
1641        assert!(config.disabled_skills.is_empty());
1642        assert!(!config.progressive_tool_disclosure);
1643        assert_eq!(config.shell.max_sessions, 5);
1644        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
1645        // Server config defaults
1646        assert!(config.server.allowed_origins.is_empty());
1647        assert_eq!(config.server.token, None);
1648        assert!(!config.server.auto_approve_confirms);
1649        assert_eq!(config.server.max_message_size, None);
1650        // Bridge config defaults
1651        assert!(config.bridge.uds_path.is_none());
1652        assert!(!config.bridge.heartbeat_mirror);
1653        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1654    }
1655
1656    #[test]
1657    #[serial]
1658    fn test_load_config_progressive_tool_disclosure_is_opt_in() {
1659        let home = make_test_home("progressive-tool-disclosure");
1660        let cfg = home.join(".synaps-cli/config");
1661        std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
1662
1663        with_home(&home, || {
1664            let config = load_config();
1665            assert!(config.progressive_tool_disclosure);
1666            assert!(config.warnings.is_empty());
1667        });
1668
1669        let _ = std::fs::remove_dir_all(&home);
1670    }
1671
1672    #[test]
1673    #[serial]
1674    fn test_load_config_bridge_keys() {
1675        let home = make_test_home("bridge-keys");
1676        let cfg = home.join(".synaps-cli/config");
1677        std::fs::write(
1678            &cfg,
1679            "\
1680bridge.uds_path = /tmp/some/control.sock\n\
1681bridge.heartbeat_mirror = true\n\
1682bridge.heartbeat_timeout_ms = 750\n\
1683",
1684        )
1685        .unwrap();
1686
1687        with_home(&home, || {
1688            let config = load_config();
1689            assert_eq!(
1690                config.bridge.uds_path,
1691                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
1692            );
1693            assert!(config.bridge.heartbeat_mirror);
1694            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
1695            assert_eq!(
1696                config.bridge.resolved_uds_path(),
1697                std::path::PathBuf::from("/tmp/some/control.sock"),
1698            );
1699        });
1700
1701        let _ = std::fs::remove_dir_all(&home);
1702    }
1703
1704    #[test]
1705    fn test_bridge_config_defaults() {
1706        let cfg = BridgeConfig::default();
1707        assert!(cfg.uds_path.is_none());
1708        assert!(!cfg.heartbeat_mirror);
1709        assert_eq!(cfg.heartbeat_timeout_ms, 250);
1710        // resolved path falls under base_dir()/bridge/control.sock
1711        let resolved = cfg.resolved_uds_path();
1712        assert!(resolved.ends_with("bridge/control.sock"));
1713    }
1714
1715    #[test]
1716    #[serial]
1717    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
1718        let home = make_test_home("bridge-default-off");
1719        let cfg = home.join(".synaps-cli/config");
1720        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1721
1722        with_home(&home, || {
1723            let config = load_config();
1724            assert!(!config.bridge.heartbeat_mirror);
1725            assert!(config.bridge.uds_path.is_none());
1726            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1727        });
1728
1729        let _ = std::fs::remove_dir_all(&home);
1730    }
1731
1732    #[test]
1733    fn compaction_disclosure_keys_parse_with_typed_warnings() {
1734        let config = load_config_from_str(
1735            "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
1736        );
1737        assert_eq!(
1738            config.compaction_mode,
1739            crate::core::compaction::CompactionMode::LocalOnly
1740        );
1741        assert_eq!(
1742            config.compaction_exclude,
1743            vec![
1744                crate::core::compaction::ContentClass::Thinking,
1745                crate::core::compaction::ContentClass::ToolResults,
1746            ]
1747        );
1748        assert!(
1749            config.warnings.iter().any(|w| w.contains("bogus")),
1750            "unknown class must warn: {:?}",
1751            config.warnings
1752        );
1753
1754        let defaults = load_config_from_str("");
1755        assert_eq!(
1756            defaults.compaction_mode,
1757            crate::core::compaction::CompactionMode::Remote
1758        );
1759        assert!(defaults.compaction_exclude.is_empty());
1760
1761        let bad_mode = load_config_from_str("compaction_mode = cloud\n");
1762        assert_eq!(
1763            bad_mode.compaction_mode,
1764            crate::core::compaction::CompactionMode::Remote
1765        );
1766        assert!(bad_mode
1767            .warnings
1768            .iter()
1769            .any(|w| w.contains("compaction_mode")));
1770    }
1771
1772    #[test]
1773    #[serial]
1774    fn test_load_config_server_keys() {
1775        let home = make_test_home("server-keys");
1776        let cfg = home.join(".synaps-cli/config");
1777        std::fs::write(
1778            &cfg,
1779            "\
1780server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1781server.token = my-secret-token\n\
1782server.auto_approve_confirms = true\n\
1783server.max_message_size = 65536\n\
1784context_window = 200k\n\
1785",
1786        )
1787        .unwrap();
1788
1789        with_home(&home, || {
1790            let config = load_config();
1791            assert_eq!(
1792                config.server.allowed_origins,
1793                vec![
1794                    "http://localhost:3000".to_string(),
1795                    "http://localhost:5193".to_string(),
1796                ]
1797            );
1798            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1799            assert!(config.server.auto_approve_confirms);
1800            // Explicit max_message_size takes precedence over context_window derivation
1801            assert_eq!(config.server.max_message_size, Some(65536));
1802        });
1803
1804        let _ = std::fs::remove_dir_all(&home);
1805    }
1806
1807    #[test]
1808    #[serial]
1809    fn test_server_max_message_size_derived_from_context_window() {
1810        let home = make_test_home("server-derive");
1811        let cfg = home.join(".synaps-cli/config");
1812        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1813
1814        with_home(&home, || {
1815            let config = load_config();
1816            // 200_000 tokens * 4 bytes/token = 800_000 bytes
1817            assert_eq!(config.server.max_message_size, Some(800_000));
1818        });
1819
1820        let _ = std::fs::remove_dir_all(&home);
1821    }
1822
1823    fn make_test_home(subdir: &str) -> std::path::PathBuf {
1824        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1825        let _ = std::fs::remove_dir_all(&dir);
1826        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1827        dir
1828    }
1829
1830    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1831        let original = std::env::var("HOME").ok();
1832        std::env::set_var("HOME", home);
1833        f();
1834        if let Some(h) = original {
1835            std::env::set_var("HOME", h);
1836        } else {
1837            std::env::remove_var("HOME");
1838        }
1839    }
1840
1841    #[test]
1842    #[serial]
1843    fn write_config_value_replaces_existing_key() {
1844        let home = make_test_home("replace");
1845        let cfg = home.join(".synaps-cli/config");
1846        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1847
1848        with_home(&home, || {
1849            write_config_value("model", "claude-sonnet-4-6").unwrap();
1850        });
1851
1852        let contents = std::fs::read_to_string(&cfg).unwrap();
1853        assert!(contents.contains("model = claude-sonnet-4-6"));
1854        assert!(contents.contains("thinking = low"));
1855        let _ = std::fs::remove_dir_all(&home);
1856    }
1857
1858    #[test]
1859    #[serial]
1860    fn write_config_value_appends_when_missing() {
1861        let home = make_test_home("append");
1862        let cfg = home.join(".synaps-cli/config");
1863        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1864
1865        with_home(&home, || {
1866            write_config_value("theme", "dracula").unwrap();
1867        });
1868
1869        let contents = std::fs::read_to_string(&cfg).unwrap();
1870        assert!(contents.contains("model = claude-opus-4-6"));
1871        assert!(contents.contains("theme = dracula"));
1872        let _ = std::fs::remove_dir_all(&home);
1873    }
1874
1875    #[test]
1876    #[serial]
1877    fn write_config_value_preserves_comments() {
1878        let home = make_test_home("comments");
1879        let cfg = home.join(".synaps-cli/config");
1880        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
1881
1882        with_home(&home, || {
1883            write_config_value("model", "claude-sonnet-4-6").unwrap();
1884        });
1885
1886        let contents = std::fs::read_to_string(&cfg).unwrap();
1887        assert!(contents.contains("# user comment"));
1888        assert!(contents.contains("# another"));
1889        assert!(contents.contains("model = claude-sonnet-4-6"));
1890        let _ = std::fs::remove_dir_all(&home);
1891    }
1892
1893    #[test]
1894    #[serial]
1895    fn write_config_value_preserves_unknown_keys() {
1896        let home = make_test_home("unknown");
1897        let cfg = home.join(".synaps-cli/config");
1898        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
1899
1900        with_home(&home, || {
1901            write_config_value("model", "claude-sonnet-4-6").unwrap();
1902        });
1903
1904        let contents = std::fs::read_to_string(&cfg).unwrap();
1905        assert!(contents.contains("custom_thing = 42"));
1906        let _ = std::fs::remove_dir_all(&home);
1907    }
1908
1909    #[test]
1910    #[serial]
1911    fn write_config_value_creates_file_if_absent() {
1912        let home = make_test_home("create");
1913        let cfg = home.join(".synaps-cli/config");
1914        assert!(!cfg.exists());
1915
1916        with_home(&home, || {
1917            write_config_value("model", "claude-sonnet-4-6").unwrap();
1918        });
1919
1920        let contents = std::fs::read_to_string(&cfg).unwrap();
1921        assert!(contents.contains("model = claude-sonnet-4-6"));
1922        let _ = std::fs::remove_dir_all(&home);
1923    }
1924
1925    #[test]
1926    #[serial]
1927    fn load_config_parses_theme_key() {
1928        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
1929        let _ = std::fs::create_dir_all(&dir);
1930        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
1931
1932        let original_home = std::env::var("HOME").ok();
1933        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
1934
1935        let config = load_config();
1936
1937        if let Some(home) = original_home {
1938            std::env::set_var("HOME", home);
1939        } else {
1940            std::env::remove_var("HOME");
1941        }
1942        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
1943
1944        assert_eq!(config.theme.as_deref(), Some("dracula"));
1945    }
1946
1947    #[test]
1948    #[serial]
1949    fn test_load_config_disable_lists() {
1950        let test_dir =
1951            std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
1952        let _ = std::fs::create_dir_all(&test_dir);
1953        let config_path = test_dir.join("config");
1954
1955        let config_content = r#"
1956# Test config with disable lists
1957favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
1958
1959disabled_plugins = foo, bar
1960disabled_skills = baz, plug:qual
1961"#;
1962        std::fs::write(&config_path, config_content).unwrap();
1963
1964        let original_home = std::env::var("HOME").ok();
1965        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
1966
1967        let config = load_config();
1968
1969        if let Some(home) = original_home {
1970            std::env::set_var("HOME", home);
1971        } else {
1972            std::env::remove_var("HOME");
1973        }
1974
1975        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
1976
1977        assert_eq!(
1978            config.disabled_plugins,
1979            vec!["foo".to_string(), "bar".to_string()]
1980        );
1981        assert_eq!(
1982            config.favorite_models,
1983            vec![
1984                "claude/claude-opus-4-7".to_string(),
1985                "groq/llama-3.3-70b-versatile".to_string(),
1986            ]
1987        );
1988        assert_eq!(
1989            config.disabled_skills,
1990            vec!["baz".to_string(), "plug:qual".to_string()]
1991        );
1992    }
1993
1994    #[test]
1995    #[serial]
1996    fn favorite_model_helpers_round_trip_through_config_file() {
1997        let home = make_test_home("favorite-models");
1998        let cfg = home.join(".synaps-cli/config");
1999        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
2000
2001        with_home(&home, || {
2002            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2003            add_favorite_model("claude/claude-opus-4-7").unwrap();
2004            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2005            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
2006            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2007            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
2008            assert!(is_favorite_model("claude/claude-opus-4-7"));
2009        });
2010
2011        let contents = std::fs::read_to_string(&cfg).unwrap();
2012        assert!(contents.contains("model = claude-opus-4-7"));
2013        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
2014        let _ = std::fs::remove_dir_all(&home);
2015    }
2016
2017    #[test]
2018    #[serial]
2019    fn test_load_config_new_keys() {
2020        // Create a temporary config directory with the new keys
2021        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
2022        let _ = std::fs::create_dir_all(&test_dir);
2023        let config_path = test_dir.join("config");
2024
2025        let config_content = r#"
2026# Test config with new keys
2027model = claude-haiku
2028thinking = medium
2029max_tool_output = 50000
2030bash_timeout = 45
2031bash_max_timeout = 600
2032subagent_timeout = 120
2033api_retries = 5
2034"#;
2035        std::fs::write(&config_path, config_content).unwrap();
2036
2037        // Temporarily override the config path for this test
2038        let original_home = std::env::var("HOME").ok();
2039        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
2040
2041        let config = load_config();
2042
2043        // Restore original HOME
2044        if let Some(home) = original_home {
2045            std::env::set_var("HOME", home);
2046        } else {
2047            std::env::remove_var("HOME");
2048        }
2049
2050        // Cleanup
2051        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
2052
2053        assert_eq!(config.model, Some("claude-haiku".to_string()));
2054        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
2055        assert_eq!(config.max_tool_output, 50000);
2056        assert_eq!(config.bash_timeout, 45);
2057        assert_eq!(config.bash_max_timeout, 600);
2058        assert_eq!(config.subagent_timeout, 120);
2059        assert_eq!(config.api_retries, 5);
2060    }
2061
2062    // ── auth.* config + credential source (#157) ──
2063
2064    // ── events.auto_turn parser ──────────────────────────────────────────────
2065
2066    #[test]
2067    fn events_auto_turn_explicit_true_values() {
2068        for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
2069            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2070            assert!(
2071                cfg.events.auto_turn,
2072                "expected true for events.auto_turn = {val}"
2073            );
2074        }
2075    }
2076
2077    #[test]
2078    fn events_auto_turn_explicit_false_values() {
2079        for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
2080            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2081            assert!(
2082                !cfg.events.auto_turn,
2083                "expected false for events.auto_turn = {val}"
2084            );
2085        }
2086    }
2087
2088    #[test]
2089    fn events_auto_turn_typo_fails_safe_false() {
2090        // Unrecognised value should fail safe to false (with a warning on stderr).
2091        let cfg = load_config_from_str("events.auto_turn = fales");
2092        assert!(
2093            !cfg.events.auto_turn,
2094            "typo 'fales' must fail safe to false"
2095        );
2096    }
2097
2098    #[test]
2099    fn events_auto_turn_default_is_true() {
2100        let cfg = load_config_from_str("model = claude-haiku");
2101        assert!(cfg.events.auto_turn, "default must be true when key absent");
2102    }
2103
2104    #[test]
2105    #[serial]
2106    fn test_auth_config_parses_endpoint_and_token() {
2107        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
2108        let dir = home.join(".synaps-cli");
2109        std::fs::create_dir_all(&dir).unwrap();
2110        std::fs::write(
2111            dir.join("config"),
2112            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
2113        )
2114        .unwrap();
2115        with_home(&home, || {
2116            let config = load_config();
2117            assert_eq!(
2118                config.auth.remote_endpoint.as_deref(),
2119                Some("https://jade.jade:8181")
2120            );
2121            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
2122            // auth.* are namespaced -> must NOT warn as unknown keys
2123            assert!(
2124                !config.warnings.iter().any(|w| w.contains("auth.")),
2125                "{:?}",
2126                config.warnings
2127            );
2128        });
2129        let _ = std::fs::remove_dir_all(&home);
2130    }
2131
2132    #[test]
2133    #[serial]
2134    fn test_credential_source_local_by_default() {
2135        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2136        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2137        assert!(!AuthConfig::default().credential_source().is_remote());
2138    }
2139
2140    #[test]
2141    #[serial]
2142    fn test_credential_source_remote_from_config() {
2143        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2144        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2145        let auth = AuthConfig {
2146            remote_endpoint: Some("https://b".into()),
2147            machine_token: Some("m".into()),
2148        };
2149        assert_eq!(
2150            auth.credential_source(),
2151            crate::core::auth::CredentialSource::Remote {
2152                endpoint: "https://b".into(),
2153                machine_token: "m".into()
2154            }
2155        );
2156    }
2157
2158    #[test]
2159    #[serial]
2160    fn test_credential_source_env_overrides_config() {
2161        let auth = AuthConfig {
2162            remote_endpoint: Some("https://config-host".into()),
2163            machine_token: Some("config-tok".into()),
2164        };
2165        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
2166        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
2167        let src = auth.credential_source();
2168        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2169        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2170        assert_eq!(
2171            src,
2172            crate::core::auth::CredentialSource::Remote {
2173                endpoint: "https://env-host".into(),
2174                machine_token: "env-tok".into()
2175            }
2176        );
2177    }
2178    #[test]
2179    fn config_parses_ultracode_as_distinct_canonical_level() {
2180        let cfg = load_config_from_str("thinking = ultracode\n");
2181        assert_eq!(
2182            cfg.thinking_level,
2183            Some(crate::reasoning::ReasoningLevel::UltraCode)
2184        );
2185        assert_eq!(cfg.thinking_budget, None);
2186        for other in [
2187            crate::reasoning::ReasoningLevel::Ultra,
2188            crate::reasoning::ReasoningLevel::Max,
2189            crate::reasoning::ReasoningLevel::XHigh,
2190        ] {
2191            assert_ne!(cfg.thinking_level, Some(other));
2192        }
2193    }
2194}