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/// Automatic context management is opt-in; independent of compaction disclosure.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub enum ContextManagementMode {
113    #[default]
114    Off,
115    Auto,
116}
117
118impl ContextManagementMode {
119    pub fn parse(value: &str) -> Option<Self> {
120        match value.trim().to_ascii_lowercase().as_str() {
121            "off" => Some(Self::Off),
122            "auto" => Some(Self::Auto),
123            _ => None,
124        }
125    }
126
127    pub fn as_str(self) -> &'static str {
128        match self {
129            Self::Off => "off",
130            Self::Auto => "auto",
131        }
132    }
133}
134
135/// Task-aware soft context bands, parsed from `context_management.*` keys.
136///
137/// Missing thresholds use window-aware defaults (200k: 140k/180k;
138/// 1m: 250k/400k). A single override adjusts the other default as necessary
139/// to keep pressure below rollover. These settings never override the host's
140/// hard capacity or authorize tools, disclosure, or a session transition.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct ContextManagementConfig {
143    pub mode: ContextManagementMode,
144    /// Optional absolute token threshold; `auto` in config clears the override.
145    pub pressure_tokens: Option<u64>,
146    /// Optional absolute token threshold; `auto` in config clears the override.
147    pub rollover_tokens: Option<u64>,
148    /// Minimum remaining headroom before any model round. Default: 16,000.
149    pub reserve_tokens: u64,
150    /// Maximum extra Plan/WrapUp rounds after rollover becomes due. Default: 2.
151    /// Zero disables extensions; phase reports cannot renew this allowance.
152    pub finish_rounds: u32,
153}
154
155impl Default for ContextManagementConfig {
156    fn default() -> Self {
157        Self {
158            mode: ContextManagementMode::Off,
159            pressure_tokens: None,
160            rollover_tokens: None,
161            reserve_tokens: 16_000,
162            finish_rounds: 2,
163        }
164    }
165}
166
167impl ContextManagementConfig {
168    pub const MAX_FINISH_ROUNDS: u32 = 8;
169    pub const MAX_RESERVE_TOKENS: u64 = 1_000_000;
170
171    /// Validate independently of a model selection. Also call
172    /// `validate_for_window` when the effective context window is known.
173    pub fn validate(&self) -> Result<(), &'static str> {
174        if self.pressure_tokens == Some(0) {
175            return Err("pressure_tokens must be positive");
176        }
177        if self.rollover_tokens.is_some_and(|v| v < 2) {
178            return Err("rollover_tokens must be at least 2");
179        }
180        if let (Some(pressure), Some(rollover)) = (self.pressure_tokens, self.rollover_tokens) {
181            if pressure >= rollover {
182                return Err("pressure_tokens must be below rollover_tokens");
183            }
184        }
185        if !(1..=Self::MAX_RESERVE_TOKENS).contains(&self.reserve_tokens) {
186            return Err("reserve_tokens must be between 1 and 1000000");
187        }
188        if self.finish_rounds > Self::MAX_FINISH_ROUNDS {
189            return Err("finish_rounds must be between 0 and 8");
190        }
191        Ok(())
192    }
193
194    /// Threshold overrides must fit the effective window. The hard budget
195    /// remains authoritative even when an override fits this validation.
196    pub fn validate_for_window(&self, context_window_tokens: u64) -> Result<(), &'static str> {
197        self.validate()?;
198        if context_window_tokens < 2 {
199            return Err("context window must contain at least two tokens");
200        }
201        if self
202            .pressure_tokens
203            .is_some_and(|v| v >= context_window_tokens)
204        {
205            return Err("pressure_tokens must be below the context window");
206        }
207        if self
208            .rollover_tokens
209            .is_some_and(|v| v > context_window_tokens)
210        {
211            return Err("rollover_tokens must not exceed the context window");
212        }
213        Ok(())
214    }
215}
216
217/// Parse fields first; validate the complete policy after all lines so threshold
218/// ordering and an explicit context window do not depend on config key order.
219fn parse_context_management_config_key(
220    config: &mut ContextManagementConfig,
221    key: &str,
222    value: &str,
223) -> Result<(), &'static str> {
224    match key {
225        "context_management.mode" => {
226            config.mode = ContextManagementMode::parse(value).ok_or("expected off or auto")?;
227        }
228        "context_management.pressure_tokens" | "context_management.rollover_tokens" => {
229            let tokens = if value.eq_ignore_ascii_case("auto") {
230                None
231            } else {
232                Some(
233                    value
234                        .parse::<u64>()
235                        .map_err(|_| "expected auto or an unsigned token count")?,
236                )
237            };
238            if key == "context_management.pressure_tokens" {
239                config.pressure_tokens = tokens;
240            } else {
241                config.rollover_tokens = tokens;
242            }
243        }
244        "context_management.reserve_tokens" => {
245            config.reserve_tokens = value
246                .parse()
247                .map_err(|_| "expected an unsigned token count")?;
248        }
249        "context_management.finish_rounds" => {
250            config.finish_rounds = value
251                .parse()
252                .map_err(|_| "expected an unsigned round count")?;
253        }
254        _ => return Err("unknown context management key"),
255    }
256    Ok(())
257}
258
259/// Startup / first-turn behaviour (`startup.*` keys). Boot-time only — these
260/// take effect at the next launch, never mid-session.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub struct StartupConfig {
263    /// Skip blocking on extension discovery before the first turn. When true
264    /// the attach path sets `await_extensions=false` (fast cold start; a tool
265    /// from a slow extension may be unavailable on turn 1). Default: true.
266    pub quick_start: bool,
267    /// Max seconds to wait for extensions when Quick Start is off. Default: 30.
268    pub extensions_ready_timeout_secs: u64,
269}
270
271impl Default for StartupConfig {
272    fn default() -> Self {
273        Self {
274            quick_start: true,
275            extensions_ready_timeout_secs: 30,
276        }
277    }
278}
279
280/// Daemon lifetime knobs (`daemon.*` keys). Config-file fallbacks; the matching
281/// `SYNAPS_DAEMON_*` env vars still WIN over these when set.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct DaemonConfig {
284    /// Seconds an auto-spawned daemon stays alive after the last client
285    /// disconnects. `0` = never idle-exit. Env `SYNAPS_DAEMON_IDLE_EXIT_SECS`
286    /// overrides. Default: 10.
287    pub idle_exit_secs: u64,
288    /// Seconds a pending host confirmation on a detached session survives
289    /// before it is fail-closed answered `None`. `0` = disabled (survives
290    /// forever). Env `SYNAPS_DAEMON_PROMPT_ABANDON_SECS` overrides. Default: 3600.
291    pub prompt_abandon_secs: u64,
292    /// Seconds a PARKED session row lingers before eviction. `0` = keep
293    /// forever. Env `SYNAPS_DAEMON_PARKED_EVICT_SECS` overrides. Default: 3600.
294    pub parked_evict_secs: u64,
295}
296
297impl Default for DaemonConfig {
298    fn default() -> Self {
299        Self {
300            idle_exit_secs: 10,
301            prompt_abandon_secs: 3600,
302            parked_evict_secs: 3600,
303        }
304    }
305}
306
307/// Parse `startup.*` keys. Unknown keys return an error (caller warns).
308fn parse_startup_config_key(
309    config: &mut StartupConfig,
310    key: &str,
311    value: &str,
312) -> Result<(), &'static str> {
313    match key {
314        "startup.quick_start" => {
315            config.quick_start = match value.trim() {
316                "true" | "1" | "on" | "yes" => true,
317                "false" | "0" | "off" | "no" => false,
318                _ => return Err("expected on or off"),
319            };
320        }
321        "startup.extensions_ready_timeout_secs" => {
322            config.extensions_ready_timeout_secs =
323                value.trim().parse().map_err(|_| "expected an unsigned second count")?;
324        }
325        _ => return Err("unknown startup key"),
326    }
327    Ok(())
328}
329
330/// Parse `daemon.*` keys. Unknown keys return an error (caller warns).
331fn parse_daemon_config_key(
332    config: &mut DaemonConfig,
333    key: &str,
334    value: &str,
335) -> Result<(), &'static str> {
336    let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
337    match key {
338        "daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
339        "daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
340        "daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
341        _ => return Err("unknown daemon key"),
342    }
343    Ok(())
344}
345
346/// Server security configuration parsed from `server.*` keys.
347#[derive(Debug, Clone, Default)]
348pub struct ServerConfig {
349    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
350    pub allowed_origins: Vec<String>,
351    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
352    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
353    pub token: Option<String>,
354    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
355    pub auto_approve_confirms: bool,
356    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
357    /// None means no artificial cap.
358    pub max_message_size: Option<usize>,
359}
360
361/// Bridge daemon configuration parsed from `bridge.*` keys.
362///
363/// Controls best-effort mirroring of watcher heartbeats over the bridge
364/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
365/// and the feature is OFF by default.
366#[derive(Debug, Clone)]
367pub struct BridgeConfig {
368    /// Path to the bridge daemon's UDS control socket.
369    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
370    pub uds_path: Option<PathBuf>,
371    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
372    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
373    pub heartbeat_mirror: bool,
374    /// Per-call timeout in milliseconds (covers connect + write + read).
375    pub heartbeat_timeout_ms: u64,
376}
377
378impl Default for BridgeConfig {
379    fn default() -> Self {
380        Self {
381            uds_path: None,
382            heartbeat_mirror: false,
383            heartbeat_timeout_ms: 250,
384        }
385    }
386}
387
388impl BridgeConfig {
389    /// Resolve the UDS path, falling back to the default under `base_dir()`.
390    pub fn resolved_uds_path(&self) -> PathBuf {
391        self.uds_path
392            .clone()
393            .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
394    }
395}
396
397/// Auth configuration parsed from `auth.*` keys. Controls whether this client
398/// resolves provider tokens from the local `auth.json` (default) or from a
399/// remote credential broker. See task #157 / `auth::credential_source`.
400#[derive(Debug, Clone, Default)]
401pub struct AuthConfig {
402    /// Broker base URL. When set (here, or via `SYNAPS_AUTH_ENDPOINT`), the
403    /// client fetches short-lived access tokens from the broker instead of
404    /// reading/refreshing the local `auth.json`.
405    pub remote_endpoint: Option<String>,
406    /// Per-machine bearer presented to the broker (or `SYNAPS_MACHINE_TOKEN`).
407    /// This is the machine's own identity, never the provider credential.
408    pub machine_token: Option<String>,
409}
410
411impl AuthConfig {
412    /// Resolve the credential source. Environment variables take precedence over
413    /// config-file values: `SYNAPS_AUTH_ENDPOINT` / `SYNAPS_MACHINE_TOKEN`.
414    /// Returns `Remote` iff an endpoint is set (env or config), else `Local`.
415    pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
416        let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
417            .ok()
418            .filter(|s| !s.trim().is_empty())
419            .or_else(|| self.remote_endpoint.clone());
420        let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
421            .ok()
422            .filter(|s| !s.trim().is_empty())
423            .or_else(|| self.machine_token.clone());
424        crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
425    }
426}
427
428/// Prompt-cache TTL strategy for Anthropic requests.
429///
430/// Controls the `cache_control` value emitted at every cache marker site:
431/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
432///   to historical payloads; the default path can never invalidate existing
433///   cached prefixes.
434/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
435/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
436///   bare 5m on the message-tail marker (written every turn).
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
438pub enum CacheTtl {
439    #[default]
440    FiveMinutes,
441    OneHour,
442    Hybrid,
443}
444
445impl CacheTtl {
446    /// Parse a config value (case-insensitive). Returns `None` for unknown
447    /// values so the caller can warn and fall back to the default.
448    pub fn parse(val: &str) -> Option<CacheTtl> {
449        match val.to_ascii_lowercase().as_str() {
450            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
451            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
452            "hybrid" => Some(CacheTtl::Hybrid),
453            _ => None,
454        }
455    }
456}
457
458/// `tools.activation_confirm` — host policy for model-initiated
459/// `activate_tools` (progressive tool disclosure).
460#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
461pub enum ActivationConfirm {
462    /// Authorize model-initiated activation without prompting (default).
463    #[default]
464    Auto,
465    /// Ask the host: the y/n "Confirm tool activation" dialog; only an
466    /// explicit y/yes authorizes (fail-closed).
467    Prompt,
468    /// Never authorize model-initiated activation; no prompt is raised.
469    Deny,
470}
471
472impl ActivationConfirm {
473    pub fn parse(val: &str) -> Option<Self> {
474        match val.trim().to_ascii_lowercase().as_str() {
475            "auto" => Some(Self::Auto),
476            "prompt" => Some(Self::Prompt),
477            "deny" => Some(Self::Deny),
478            _ => None,
479        }
480    }
481
482    pub fn as_str(self) -> &'static str {
483        match self {
484            Self::Auto => "auto",
485            Self::Prompt => "prompt",
486            Self::Deny => "deny",
487        }
488    }
489}
490
491/// Animated theme-transition mode, parsed from `theme_transition`:
492/// - `"on"` (default): cross-fade theme changes over the default 350 ms.
493/// - `"off"`: instant snap — accessibility, and mercy on tmux-over-ssh.
494/// - integer milliseconds: cross-fade over that duration (clamped to
495///   0–2000 ms; `0` behaves like `off`).
496///
497/// Purely a TUI presentation knob: it changes *how* a new palette is
498/// applied, never *which* palette wins.
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
500pub enum ThemeTransitionMode {
501    #[default]
502    On,
503    Off,
504    Ms(u64),
505}
506
507impl ThemeTransitionMode {
508    /// Default cross-fade duration for `On` (and the fallback when a live
509    /// palette update carries no advisory duration of its own).
510    pub const DEFAULT_MS: u64 = 350;
511    /// Duration ceiling — both this knob and MXC wire `fade_ms` clamp here.
512    pub const MAX_MS: u64 = 2000;
513
514    /// Parse a config value (case-insensitive). Returns `None` for unknown
515    /// values so the caller can warn and fall back to the default.
516    pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
517        match val.to_ascii_lowercase().as_str() {
518            "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
519            "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
520            other => other
521                .parse::<u64>()
522                .ok()
523                .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
524        }
525    }
526
527    /// Effective default duration in milliseconds; `0` means "snap".
528    pub fn duration_ms(self) -> u64 {
529        match self {
530            ThemeTransitionMode::On => Self::DEFAULT_MS,
531            ThemeTransitionMode::Off => 0,
532            ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
533        }
534    }
535}
536
537/// Runtime event routing configuration parsed from `events.*` keys.
538///
539/// Controls how runtime events (from the `EventQueue`) are delivered in
540/// server and RPC modes.
541#[derive(Debug, Clone)]
542pub struct EventsConfig {
543    /// When `true` (default), the server/RPC/daemon session automatically
544    /// triggers a model turn when runtime events arrive while idle.  Set
545    /// `events.auto_turn = false` (or `0` / `no` / `off`) to opt out.
546    /// Unrecognised values fail safe to `false` with a warning.
547    /// The consecutive-turn cap (`auto_turn_cap`) still applies regardless.
548    pub auto_turn: bool,
549    /// Maximum number of consecutive auto-triggered model turns before the
550    /// engine parks and waits for real user input.  Set via
551    /// `events.auto_turn_cap = N`.  Default **5**.
552    ///
553    /// `0` (or `unlimited` / `inf` / `infinite`) disables the cap entirely —
554    /// "to infinity and beyond".  Unparseable values warn and keep the default.
555    pub auto_turn_cap: u32,
556}
557
558/// Default for `events.auto_turn_cap` — mirrors `engine::reactor::AUTO_TURN_CAP`.
559pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
560
561impl Default for EventsConfig {
562    fn default() -> Self {
563        Self {
564            auto_turn: true,
565            auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
566        }
567    }
568}
569
570/// Parse a `events.auto_turn_cap` value.  Returns `None` on garbage.
571///
572/// Accepts a non-negative integer (`0` = unlimited) or one of the aliases
573/// `unlimited` / `inf` / `infinite` / `infinity` / `none` (all → `0`).
574pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
575    let normalised = val.trim().to_lowercase();
576    match normalised.as_str() {
577        "unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
578        n => n.parse::<u32>().ok(),
579    }
580}
581
582/// Parse `events.*` configuration keys.
583fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
584    if key == "events.auto_turn" {
585        let normalised = val.trim().to_lowercase();
586        cfg.auto_turn = match normalised.as_str() {
587            // Explicit true values.
588            "true" | "1" | "yes" | "on" => true,
589            // Explicit false values.
590            "false" | "0" | "no" | "off" => false,
591            // Unrecognised: fail safe to false and warn so the user knows.
592            other => {
593                eprintln!(
594                    "warning: config: unrecognised value for events.auto_turn = {:?}; \
595                     expected true/false/yes/no/on/off/1/0 — defaulting to false",
596                    other
597                );
598                false
599            }
600        };
601    } else if key == "events.auto_turn_cap" {
602        match parse_auto_turn_cap(val) {
603            Some(cap) => cfg.auto_turn_cap = cap,
604            None => {
605                eprintln!(
606                    "warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
607                     expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
608                     — keeping default {}",
609                    val.trim(),
610                    DEFAULT_AUTO_TURN_CAP
611                );
612            }
613        }
614    } // unknown events.* keys ignored
615}
616
617/// Typed per-role turn-budget overrides (Task 23, spec §8.1). Every field
618/// is optional: unset fields keep the role's compiled default. Values are
619/// parsed from `turn_budget.<role>.<field>` config keys.
620#[derive(Debug, Clone, Default, PartialEq)]
621pub struct TurnBudgetOverrides {
622    pub max_provider_rounds: Option<u32>,
623    pub max_tool_calls: Option<u32>,
624    pub max_elapsed_secs: Option<u64>,
625    pub max_accumulated_tool_result_bytes: Option<usize>,
626    pub max_context_tokens: Option<u64>,
627    pub max_cost_usd: Option<f64>,
628    /// Bounded auto-renewals of the provider-round allowance after it is
629    /// exhausted mid-turn (spec §8.1). Each renewal resets the round counter;
630    /// round renewals never reset wall-clock (durable context successors do). `0`
631    /// disables graceful continuation (hard stop at the first exhaustion).
632    pub max_round_renewals: Option<u32>,
633}
634
635/// Per-role turn budgets (foreground, autonomous/watcher, worker).
636#[derive(Debug, Clone, Default, PartialEq)]
637pub struct TurnBudgetsConfig {
638    pub foreground: TurnBudgetOverrides,
639    pub autonomous: TurnBudgetOverrides,
640    pub worker: TurnBudgetOverrides,
641}
642
643/// Parse `turn_budget.<role>.<field>` keys. Invalid values warn and keep
644/// the default (parity with shell.* parsing).
645fn parse_turn_budget_config_key(budgets: &mut TurnBudgetsConfig, key: &str, val: &str) {
646    let Some(rest) = key.strip_prefix("turn_budget.") else {
647        return;
648    };
649    let Some((role, field)) = rest.split_once('.') else {
650        eprintln!("Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)");
651        return;
652    };
653    let overrides = match role {
654        "foreground" => &mut budgets.foreground,
655        "autonomous" => &mut budgets.autonomous,
656        "worker" => &mut budgets.worker,
657        other => {
658            eprintln!("Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)");
659            return;
660        }
661    };
662    macro_rules! set {
663        ($slot:expr, $ty:ty) => {
664            match val.parse::<$ty>() {
665                Ok(parsed) => $slot = Some(parsed),
666                Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
667            }
668        };
669    }
670    match field {
671        "max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
672        "max_tool_calls" => set!(overrides.max_tool_calls, u32),
673        "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
674        "max_accumulated_tool_result_bytes" => {
675            set!(overrides.max_accumulated_tool_result_bytes, usize)
676        }
677        "max_context_tokens" => set!(overrides.max_context_tokens, u64),
678        "max_cost_usd" => set!(overrides.max_cost_usd, f64),
679        "max_round_renewals" => set!(overrides.max_round_renewals, u32),
680        other => eprintln!("Warning: unknown turn_budget field '{other}'"),
681    }
682}
683
684/// Continuous-memory settings surface (Task A2, spec §12). Every field is
685/// fail-closed: unknown or invalid values warn and keep the compiled default.
686///
687/// This struct contains continuous-memory policy; backend selection lives in
688/// the separate `MemoryBackendConfig`.
689/// Secret handling, retention, and project-scope rules are deliberately NOT
690/// fields here — they are not configurable at all (spec §12: "Secret,
691/// retention, and project rules are not configurable to fail open").
692#[derive(Debug, Clone, PartialEq)]
693pub struct MemoryConfig {
694    /// Durable default memory mode. "off" unless the operator explicitly
695    /// consents via `memory.default_mode_confirmed = true` in the same
696    /// config parse pass (spec §12 operator-consent rule). Known values:
697    /// off | recall_once | recall_each_prompt | capture_only |
698    /// capture_and_recall (spec §6.1).
699    pub default_mode: String,
700    /// Operator-consent latch for `default_mode` (spec §12). Must be set to
701    /// `true` in the same parse pass for a non-"off" `default_mode` to take
702    /// effect; otherwise `default_mode` reverts to "off" with a warning.
703    pub default_mode_confirmed: bool,
704    /// Max records surfaced per recall (spec §10.3 budget).
705    pub recall_max_records: u32,
706    /// Max tokens surfaced per recall (spec §10.3 budget).
707    pub recall_max_tokens: u32,
708    /// Recall time budget in milliseconds (spec §16).
709    pub recall_timeout_ms: u64,
710    /// Tool-result capture policy: off | summary_only (spec §11).
711    pub capture_tools: String,
712    /// Capture assistant turns (spec §8.2).
713    pub capture_assistant: bool,
714    /// Capture user turns (spec §8.2).
715    pub capture_user: bool,
716    /// Background consolidation: off | on (spec §11.3, explicit opt-in).
717    pub auto_consolidate: String,
718    /// Local embedding models: off | on (spec §11.2, never implicit).
719    pub local_embeddings: String,
720    /// GLiNER entity extraction: off | on (spec §11.2, never implicit).
721    pub gliner: String,
722}
723
724impl Default for MemoryConfig {
725    fn default() -> Self {
726        Self {
727            default_mode: "off".to_string(),
728            default_mode_confirmed: false,
729            recall_max_records: 8,
730            recall_max_tokens: 4096,
731            recall_timeout_ms: 150,
732            capture_tools: "summary_only".to_string(),
733            capture_assistant: true,
734            capture_user: true,
735            auto_consolidate: "off".to_string(),
736            local_embeddings: "off".to_string(),
737            gliner: "off".to_string(),
738        }
739    }
740}
741
742/// Memory mode strings accepted by `memory.default_mode` (spec §6.1,
743/// snake_case of `MemoryContextMode`). Anything else fails closed to the
744/// current value — `default_mode` can never silently become a non-off mode
745/// through an unrecognised string.
746const KNOWN_MEMORY_MODES: &[&str] = &[
747    "off",
748    "recall_once",
749    "recall_each_prompt",
750    "capture_only",
751    "capture_and_recall",
752];
753
754/// Parse `memory.<field>` keys (Task A2, spec §12). Invalid values warn and
755/// keep the default (parity with turn_budget.* parsing) — never fail open.
756fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) {
757    let Some(field) = key.strip_prefix("memory.") else {
758        return;
759    };
760    /// Strict boolean: recognised forms parse; anything else warns and
761    /// keeps the current (default) value.
762    fn parse_bool(slot: &mut bool, key: &str, val: &str) {
763        match val {
764            "true" | "1" | "yes" | "on" => *slot = true,
765            "false" | "0" | "no" | "off" => *slot = false,
766            other => eprintln!(
767                "Warning: invalid value for {key}: '{other}', expected true/false — using default"
768            ),
769        }
770    }
771    /// Closed string-enum: value must be in `allowed`, else warn and keep
772    /// the current (default) value.
773    fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) {
774        if allowed.contains(&val) {
775            *slot = val.to_string();
776        } else {
777            eprintln!(
778                "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
779                allowed.join("|")
780            );
781        }
782    }
783    macro_rules! set_num {
784        ($slot:expr, $ty:ty) => {
785            match val.parse::<$ty>() {
786                Ok(parsed) => $slot = parsed,
787                Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
788            }
789        };
790    }
791    match field {
792        "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
793        "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
794        "recall_max_records" => set_num!(memory.recall_max_records, u32),
795        "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
796        "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
797        "capture_tools" => parse_enum(
798            &mut memory.capture_tools,
799            key,
800            val,
801            &["off", "summary_only"],
802        ),
803        "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
804        "capture_user" => parse_bool(&mut memory.capture_user, key, val),
805        "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
806        "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
807        "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
808        other => eprintln!("Warning: unknown memory field '{other}'"),
809    }
810}
811
812/// Host-owned note storage selection, independent of continuous-memory consent.
813#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
814pub enum MemoryBackendKind {
815    #[default]
816    Legacy,
817    Axel,
818    /// Invalid configuration must never select legacy storage implicitly.
819    Unavailable,
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Default)]
823pub struct MemoryBackendConfig {
824    pub kind: MemoryBackendKind,
825    /// Explicit absolute executable path; never resolved through PATH.
826    pub executable: Option<PathBuf>,
827    /// Optional absolute shared Axel brain path; absent uses the private user default.
828    pub brain: Option<PathBuf>,
829    /// Explicit host opt-in to user-wide notes. Never grants capture/history consent.
830    pub user_scope: bool,
831}
832
833/// Parsed configuration from the config file.
834#[derive(Debug, Clone)]
835pub struct SynapsConfig {
836    pub model: Option<String>,
837    pub thinking_budget: Option<u32>,
838    /// Named reasoning level, parsed from the same `thinking = …` key.
839    /// When `Some`, this is the authoritative level. When `None`, fall back
840    /// to `thinking_budget` for legacy numeric-only values.
841    pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
842    pub context_window: Option<u64>, // override auto-detected context window (tokens)
843    pub context_management: ContextManagementConfig,
844    /// First-turn / cold-start behaviour (`startup.*`).
845    pub startup: StartupConfig,
846    /// Daemon lifetime knobs (`daemon.*`); env vars still win.
847    pub daemon: DaemonConfig,
848    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
849    /// Where compaction summarization runs (spec §9.4): remote provider or
850    /// local-only (zero network construction).
851    pub compaction_mode: crate::core::compaction::CompactionMode,
852    /// Content classes excluded from remote compaction disclosure.
853    pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
854    pub max_tool_output: usize,  // default 30000
855    pub bash_timeout: u64,       // default 30
856    pub bash_max_timeout: u64,   // default 300
857    pub subagent_timeout: u64,   // default 300
858    pub api_retries: u32,        // default 3
859    pub refusal_retries: u32,    // default 2 — retries on stop_reason=refusal
860    pub telemetry: String,       // off | basic | full (default off)
861    pub cache_diagnostics: bool, // opt into cache-diagnosis beta (default false)
862    /// Prompt-cache TTL strategy: "5m" (default) | "1h" | "hybrid".
863    pub cache_ttl: CacheTtl,
864    /// Max TUI redraw rate in frames/sec — caps streaming redraws (e.g. 60,
865    /// 144, 240). User input always redraws immediately regardless. Default
866    /// 60. Range 1–1000. The frame budget is `1000 / max_fps` ms.
867    pub max_fps: u32,
868    /// Lines to scroll per mouse-wheel event. Different terminal emulators
869    /// emit 1–9+ scroll events per physical notch; set this to compensate.
870    /// Default: 3. Range 1–20.
871    pub scroll_lines: Option<u16>,
872    pub theme: Option<String>,
873    /// Animated theme transitions: `"on"` (350 ms cross-fade, default),
874    /// `"off"` (instant snap), or integer milliseconds (0–2000).
875    pub theme_transition: ThemeTransitionMode,
876    /// Whether the TUI paints its own opaque background. `false` preserves the terminal background.
877    pub tui_background_opaque: bool,
878    pub agent_name: Option<String>,
879    pub identity: Option<String>,
880    pub disabled_plugins: Vec<String>,
881    pub favorite_models: Vec<String>,
882    pub disabled_skills: Vec<String>,
883    /// Opt-in progressive tool disclosure (Task 18). When false, providers
884    /// receive the existing full tool schema byte-for-byte. When true, each
885    /// stream starts with the small essential local core plus discovery and
886    /// authorization gateways; exact activations are added per session.
887    pub progressive_tool_disclosure: bool,
888    /// Host confirmation policy for MODEL-INITIATED `activate_tools`
889    /// (`tools.activation_confirm`). `Auto` (default) authorizes without
890    /// prompting; `Prompt` asks the host (y/n confirm dialog); `Deny` never
891    /// authorizes (locked-down hosts). `server.auto_approve_confirms = true`
892    /// still authorizes regardless of this key.
893    pub tools_activation_confirm: ActivationConfirm,
894    /// Opt-in session persistence strategy (Task 35, spec §9.8). `Json`
895    /// (default) is the unchanged legacy full-rewrite path; `Journal` adds
896    /// an append-only delta journal with periodic atomic snapshots. See
897    /// docs/decisions/T35-session-journal-opt-in.md.
898    pub session_persistence: crate::core::session_journal::SessionPersistence,
899    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
900    /// from the registry at boot so they're never offered to the model.
901    pub disabled_tools: Vec<String>,
902    pub shell: ShellConfig,
903    pub server: ServerConfig,
904    pub bridge: BridgeConfig,
905    pub auth: AuthConfig,
906    pub events: EventsConfig,
907    pub provider_keys: BTreeMap<String, String>,
908    pub keybinds: std::collections::HashMap<String, String>,
909    /// Typed per-role turn budgets (Task 23, spec §8.1).
910    pub turn_budgets: TurnBudgetsConfig,
911    /// Continuous-memory settings surface (Task A2, spec §12).
912    pub memory: MemoryConfig,
913    /// Host-owned backend; selecting it does not enable recall or capture.
914    pub memory_backend: MemoryBackendConfig,
915    /// Non-fatal problems found while parsing the config file (unknown keys,
916    /// unparseable values). Surfaced once at startup — never block boot.
917    pub warnings: Vec<String>,
918}
919
920impl Default for SynapsConfig {
921    fn default() -> Self {
922        Self {
923            model: None,
924            thinking_budget: None,
925            thinking_level: None,
926            context_window: None,
927            context_management: ContextManagementConfig::default(),
928            startup: StartupConfig::default(),
929            daemon: DaemonConfig::default(),
930            compaction_model: None,
931            compaction_mode: crate::core::compaction::CompactionMode::default(),
932            compaction_exclude: Vec::new(),
933            max_tool_output: 30000,
934            bash_timeout: 30,
935            bash_max_timeout: 300,
936            subagent_timeout: 300,
937            api_retries: 3,
938            refusal_retries: 2,
939            telemetry: "off".to_string(),
940            cache_diagnostics: false,
941            cache_ttl: CacheTtl::default(),
942            max_fps: 60,
943            scroll_lines: None,
944            theme: None,
945            theme_transition: ThemeTransitionMode::default(),
946            tui_background_opaque: true,
947            agent_name: None,
948            identity: None,
949            disabled_plugins: Vec::new(),
950            favorite_models: Vec::new(),
951            disabled_skills: Vec::new(),
952            progressive_tool_disclosure: false,
953            tools_activation_confirm: ActivationConfirm::default(),
954            session_persistence: crate::core::session_journal::SessionPersistence::default(),
955            disabled_tools: Vec::new(),
956            shell: ShellConfig::default(),
957            server: ServerConfig::default(),
958            bridge: BridgeConfig::default(),
959            auth: AuthConfig::default(),
960            events: EventsConfig::default(),
961            provider_keys: BTreeMap::new(),
962            keybinds: std::collections::HashMap::new(),
963            turn_budgets: TurnBudgetsConfig::default(),
964            memory: MemoryConfig::default(),
965            memory_backend: MemoryBackendConfig::default(),
966            warnings: Vec::new(),
967        }
968    }
969}
970
971/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
972const KNOWN_CONFIG_KEYS: &[&str] = &[
973    "model",
974    "thinking",
975    "compaction_model",
976    "context_window",
977    "context_management.mode",
978    "context_management.pressure_tokens",
979    "context_management.rollover_tokens",
980    "context_management.reserve_tokens",
981    "context_management.finish_rounds",
982    "startup.quick_start",
983    "startup.extensions_ready_timeout_secs",
984    "daemon.idle_exit_secs",
985    "daemon.prompt_abandon_secs",
986    "daemon.parked_evict_secs",
987    "max_tool_output",
988    "bash_timeout",
989    "bash_max_timeout",
990    "subagent_timeout",
991    "api_retries",
992    "refusal_retries",
993    "telemetry",
994    "cache_diagnostics",
995    "cache_ttl",
996    "max_fps",
997    "scroll_lines",
998    "theme",
999    "theme_transition",
1000    "tui_background_opaque",
1001    "agent_name",
1002    "identity",
1003    "disabled_plugins",
1004    "favorite_models",
1005    "disabled_skills",
1006    "disabled_tools",
1007    "progressive_tool_disclosure",
1008    "tools.activation_confirm",
1009    "session_persistence",
1010];
1011
1012/// Simple Levenshtein distance for did-you-mean suggestions.
1013fn levenshtein(a: &str, b: &str) -> usize {
1014    let a: Vec<char> = a.chars().collect();
1015    let b: Vec<char> = b.chars().collect();
1016    let mut prev: Vec<usize> = (0..=b.len()).collect();
1017    let mut cur = vec![0; b.len() + 1];
1018    for (i, ca) in a.iter().enumerate() {
1019        cur[0] = i + 1;
1020        for (j, cb) in b.iter().enumerate() {
1021            let cost = if ca == cb { 0 } else { 1 };
1022            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1023        }
1024        std::mem::swap(&mut prev, &mut cur);
1025    }
1026    prev[b.len()]
1027}
1028
1029/// Closest known key within edit distance 2, for typo suggestions.
1030fn did_you_mean(key: &str) -> Option<&'static str> {
1031    KNOWN_CONFIG_KEYS
1032        .iter()
1033        .map(|k| (*k, levenshtein(key, k)))
1034        .filter(|(_, d)| *d <= 2)
1035        .min_by_key(|(_, d)| *d)
1036        .map(|(k, _)| k)
1037}
1038
1039// parse_thinking_budget replaced by ThinkingSpec::parse in apply_config_content.
1040
1041fn parse_comma_list(val: &str) -> Vec<String> {
1042    val.split(',')
1043        .map(|s| s.trim().to_string())
1044        .filter(|s| !s.is_empty())
1045        .collect()
1046}
1047
1048fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1049    write_config_value_locked(key, &values.join(", "))
1050}
1051
1052/// Parse shell.* configuration keys and update the ShellConfig.
1053fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
1054    match key {
1055        "shell.max_sessions" => {
1056            if let Ok(sessions) = val.parse::<usize>() {
1057                shell_config.max_sessions = sessions;
1058            } else {
1059                eprintln!(
1060                    "Warning: invalid value for shell.max_sessions: '{}', using default",
1061                    val
1062                );
1063            }
1064        }
1065        "shell.idle_timeout" => {
1066            if let Ok(timeout) = val.parse::<u64>() {
1067                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1068            } else {
1069                eprintln!(
1070                    "Warning: invalid value for shell.idle_timeout: '{}', using default",
1071                    val
1072                );
1073            }
1074        }
1075        "shell.readiness_timeout_ms" => {
1076            if let Ok(timeout) = val.parse::<u64>() {
1077                shell_config.readiness_timeout_ms = timeout;
1078            } else {
1079                eprintln!(
1080                    "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
1081                    val
1082                );
1083            }
1084        }
1085        "shell.max_readiness_timeout_ms" => {
1086            if let Ok(timeout) = val.parse::<u64>() {
1087                shell_config.max_readiness_timeout_ms = timeout;
1088            } else {
1089                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
1090            }
1091        }
1092        "shell.default_rows" => {
1093            if let Ok(rows) = val.parse::<u16>() {
1094                shell_config.default_rows = rows;
1095            } else {
1096                eprintln!(
1097                    "Warning: invalid value for shell.default_rows: '{}', using default",
1098                    val
1099                );
1100            }
1101        }
1102        "shell.default_cols" => {
1103            if let Ok(cols) = val.parse::<u16>() {
1104                shell_config.default_cols = cols;
1105            } else {
1106                eprintln!(
1107                    "Warning: invalid value for shell.default_cols: '{}', using default",
1108                    val
1109                );
1110            }
1111        }
1112        "shell.readiness_strategy" => {
1113            let val_lower = val.to_lowercase();
1114            match val_lower.as_str() {
1115                "timeout" | "prompt" | "hybrid" => {
1116                    shell_config.readiness_strategy = val.to_string();
1117                }
1118                _ => {
1119                    eprintln!(
1120                        "Warning: invalid value for shell.readiness_strategy: '{}', using default",
1121                        val
1122                    );
1123                }
1124            }
1125        }
1126        "shell.max_output" => {
1127            if let Ok(max_output) = val.parse::<usize>() {
1128                shell_config.max_output = max_output;
1129            } else {
1130                eprintln!(
1131                    "Warning: invalid value for shell.max_output: '{}', using default",
1132                    val
1133                );
1134            }
1135        }
1136        _ => {
1137            // Unknown shell.* keys are preserved (not rejected)
1138        }
1139    }
1140}
1141
1142/// Parse server.* configuration keys and update the ServerConfig.
1143#[allow(clippy::collapsible_match)]
1144fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1145    match key {
1146        "server.allowed_origins" => {
1147            server_config.allowed_origins = parse_comma_list(val);
1148        }
1149        "server.token" => {
1150            if !val.is_empty() {
1151                server_config.token = Some(val.to_string());
1152            }
1153        }
1154        "server.auto_approve_confirms" => {
1155            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1156        }
1157        "server.max_message_size" => {
1158            if let Ok(size) = val.parse::<usize>() {
1159                server_config.max_message_size = Some(size);
1160            } else {
1161                eprintln!(
1162                    "Warning: invalid value for server.max_message_size: '{}', ignored",
1163                    val
1164                );
1165            }
1166        }
1167        _ => {
1168            // Unknown server.* keys preserved (not rejected)
1169        }
1170    }
1171}
1172
1173/// Parse bridge.* configuration keys and update the BridgeConfig.
1174fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1175    match key {
1176        "bridge.uds_path" => {
1177            if val.is_empty() {
1178                bridge_config.uds_path = None;
1179            } else {
1180                bridge_config.uds_path = Some(PathBuf::from(val));
1181            }
1182        }
1183        "bridge.heartbeat_mirror" => {
1184            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
1185        }
1186        "bridge.heartbeat_timeout_ms" => {
1187            if let Ok(ms) = val.parse::<u64>() {
1188                bridge_config.heartbeat_timeout_ms = ms;
1189            } else {
1190                eprintln!(
1191                    "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
1192                    val
1193                );
1194            }
1195        }
1196        _ => {
1197            // Unknown bridge.* keys preserved (not rejected)
1198        }
1199    }
1200}
1201
1202/// Parse auth.* configuration keys and update the AuthConfig.
1203fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1204    let v = val.trim();
1205    match key {
1206        "auth.remote_endpoint" => {
1207            auth_config.remote_endpoint = if v.is_empty() {
1208                None
1209            } else {
1210                Some(v.to_string())
1211            };
1212        }
1213        "auth.machine_token" => {
1214            auth_config.machine_token = if v.is_empty() {
1215                None
1216            } else {
1217                Some(v.to_string())
1218            };
1219        }
1220        _ => {
1221            // Unknown auth.* keys preserved (not rejected)
1222        }
1223    }
1224}
1225
1226/// Parse config from a raw string — useful for tests and embedded harnesses.
1227/// Does NOT write to `PROVIDER_KEYS` or `IDENTITY` OnceLocks.
1228pub fn load_config_from_str(content: &str) -> SynapsConfig {
1229    let mut config = SynapsConfig::default();
1230    apply_config_content(&mut config, content);
1231    config
1232}
1233
1234/// Parse the config file at ~/.synaps-cli/config (or profile variant).
1235/// Returns default config if file doesn't exist or can't be read.
1236pub fn load_config() -> SynapsConfig {
1237    let path = resolve_read_path("config");
1238    let mut config = SynapsConfig::default();
1239
1240    let Ok(content) = std::fs::read_to_string(&path) else {
1241        return config;
1242    };
1243
1244    apply_config_content(&mut config, &content);
1245
1246    // Publish provider keys to the process-wide cache for the API router.
1247    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
1248    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1249
1250    // Publish identity to the process-wide cache for API system prompt preamble.
1251    let identity_val = config
1252        .identity
1253        .clone()
1254        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1255    let _ = IDENTITY.set(identity_val);
1256
1257    config
1258}
1259
1260/// Apply key=value config lines from `content` into `config`.
1261/// Shared by `load_config` (file path) and `load_config_from_str` (test helper).
1262fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1263    let mut invalid_context_management = false;
1264    let mut invalid_memory_backend = false;
1265    for line in content.lines() {
1266        let line = line.trim();
1267        if line.is_empty() || line.starts_with('#') {
1268            continue;
1269        }
1270        let Some((key, val)) = line.split_once('=') else {
1271            continue;
1272        };
1273        let key = key.trim();
1274        let val = val.trim();
1275        match key {
1276            "model" => config.model = Some(val.to_string()),
1277            "thinking" => {
1278                use crate::core::reasoning::ThinkingSpec;
1279                match ThinkingSpec::parse(val) {
1280                    Some(ThinkingSpec::Named(level)) => {
1281                        config.thinking_level = Some(level);
1282                        config.thinking_budget = level.to_legacy_budget();
1283                    }
1284                    Some(ThinkingSpec::Budget(budget)) => {
1285                        // Preserve exact legacy budgets; do not make the derived
1286                        // bucket authoritative over the user's token count.
1287                        config.thinking_level = None;
1288                        config.thinking_budget = Some(budget);
1289                    }
1290                    None => {
1291                        config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
1292                    }
1293                }
1294            }
1295            "compaction_model" => config.compaction_model = Some(val.to_string()),
1296            "compaction_mode" => match val {
1297                "remote" => {
1298                    config.compaction_mode = crate::core::compaction::CompactionMode::Remote
1299                }
1300                "local" | "local_only" | "local-only" => {
1301                    config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
1302                }
1303                _ => {
1304                    config.warnings.push(format!(
1305                        "compaction_mode = {val} — expected remote or local; ignored"
1306                    ));
1307                }
1308            },
1309            "compaction_exclude" => {
1310                let mut classes = Vec::new();
1311                for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1312                    match crate::core::compaction::ContentClass::parse(part) {
1313                        Some(class) => {
1314                            if !classes.contains(&class) {
1315                                classes.push(class);
1316                            }
1317                        }
1318                        None => config.warnings.push(format!(
1319                            "compaction_exclude — unknown content class '{part}'; \
1320                             expected one of: user_text, assistant_text, thinking, \
1321                             tool_calls, tool_results, file_paths, event_data"
1322                        )),
1323                    }
1324                }
1325                config.compaction_exclude = classes;
1326            }
1327            "context_window" => {
1328                let parsed = match val {
1329                    "200k" | "200K" => Some(200_000),
1330                    "1m" | "1M" => Some(1_000_000),
1331                    _ => val.parse::<u64>().ok(),
1332                };
1333                if parsed.is_none() {
1334                    config.warnings.push(format!(
1335                        "context_window = {val} — expected 200k, 1m, or a token count; ignored"
1336                    ));
1337                }
1338                config.context_window = parsed;
1339            }
1340            "max_tool_output" => match val.parse::<usize>() {
1341                Ok(size) => config.max_tool_output = size,
1342                Err(_) => config.warnings.push(format!(
1343                    "max_tool_output = {val} — not a number; using {}",
1344                    config.max_tool_output
1345                )),
1346            },
1347            "bash_timeout" => match val.parse::<u64>() {
1348                Ok(t) if t >= 1 => config.bash_timeout = t,
1349                Ok(_) => config.warnings.push(format!(
1350                    "bash_timeout = {val} — below minimum (1s); using {}",
1351                    config.bash_timeout
1352                )),
1353                Err(_) => config.warnings.push(format!(
1354                    "bash_timeout = {val} — not a number; using {}",
1355                    config.bash_timeout
1356                )),
1357            },
1358            "bash_max_timeout" => {
1359                if let Ok(timeout) = val.parse::<u64>() {
1360                    config.bash_max_timeout = timeout;
1361                }
1362            }
1363            "subagent_timeout" => {
1364                if let Ok(timeout) = val.parse::<u64>() {
1365                    config.subagent_timeout = timeout;
1366                }
1367            }
1368            "api_retries" => {
1369                if let Ok(retries) = val.parse::<u32>() {
1370                    config.api_retries = retries;
1371                }
1372            }
1373            "refusal_retries" => {
1374                if let Ok(retries) = val.parse::<u32>() {
1375                    config.refusal_retries = retries;
1376                }
1377            }
1378            "telemetry" => config.telemetry = val.to_string(),
1379            "cache_diagnostics" => {
1380                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1381            }
1382            "cache_ttl" => match CacheTtl::parse(val) {
1383                Some(ttl) => config.cache_ttl = ttl,
1384                None => config.warnings.push(format!(
1385                    "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1386                )),
1387            },
1388            "max_fps" => match val.parse::<u32>() {
1389                Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1390                Ok(_) => config.warnings.push(format!(
1391                    "max_fps = {val} — expected 1–1000; using {}",
1392                    config.max_fps
1393                )),
1394                Err(_) => config.warnings.push(format!(
1395                    "max_fps = {val} — not a number; using {}",
1396                    config.max_fps
1397                )),
1398            },
1399            "scroll_lines" => match val.parse::<u16>() {
1400                Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1401                Ok(_) => config
1402                    .warnings
1403                    .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1404                Err(_) => config
1405                    .warnings
1406                    .push(format!("scroll_lines = {val} — not a number; ignoring")),
1407            },
1408            "theme" => config.theme = Some(val.to_string()),
1409            "theme_transition" => match ThemeTransitionMode::parse(val) {
1410                Some(mode) => config.theme_transition = mode,
1411                None => config.warnings.push(format!(
1412                    "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1413                )),
1414            },
1415            "tui_background_opaque" => match val {
1416                "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1417                "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1418                _ => config.warnings.push(format!(
1419                    "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1420                    if config.tui_background_opaque {
1421                        "opaque"
1422                    } else {
1423                        "invisible"
1424                    }
1425                )),
1426            },
1427            "agent_name" => config.agent_name = Some(val.to_string()),
1428            "identity" => config.identity = Some(val.to_string()),
1429            "disabled_plugins" => {
1430                config.disabled_plugins = parse_comma_list(val);
1431            }
1432            "favorite_models" => {
1433                config.favorite_models = parse_comma_list(val);
1434            }
1435            "disabled_skills" => {
1436                config.disabled_skills = parse_comma_list(val);
1437            }
1438            "progressive_tool_disclosure" => {
1439                config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1440            }
1441            "tools.activation_confirm" => match ActivationConfirm::parse(val) {
1442                Some(mode) => config.tools_activation_confirm = mode,
1443                None => config.warnings.push(format!(
1444                    "tools.activation_confirm = {val} — expected auto, prompt or deny; \
1445                     keeping the default (auto)"
1446                )),
1447            },
1448            "session_persistence" => {
1449                match crate::core::session_journal::SessionPersistence::parse(val) {
1450                    Some(mode) => config.session_persistence = mode,
1451                    None => config.warnings.push(format!(
1452                        "session_persistence = {val} — expected json or journal; \
1453                         keeping the default (json)"
1454                    )),
1455                }
1456            }
1457            "disabled_tools" => {
1458                config.disabled_tools = parse_comma_list(val);
1459            }
1460            "memory.backend" => {
1461                config.memory_backend.kind = match val {
1462                    "legacy" => MemoryBackendKind::Legacy,
1463                    "axel" => MemoryBackendKind::Axel,
1464                    _ => {
1465                        invalid_memory_backend = true;
1466                        config.warnings.push(format!(
1467                            "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
1468                        ));
1469                        MemoryBackendKind::Unavailable
1470                    }
1471                };
1472            }
1473            "memory.user_scope" => match val {
1474                "true" => config.memory_backend.user_scope = true,
1475                "false" => config.memory_backend.user_scope = false,
1476                _ => {
1477                    invalid_memory_backend = true;
1478                    config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
1479                }
1480            },
1481            "memory.axel.executable" | "memory.axel.brain" => {
1482                let path = PathBuf::from(val);
1483                if !path.is_absolute() {
1484                    invalid_memory_backend = true;
1485                    config.warnings.push(format!(
1486                        "{key} — expected an absolute path; memory backend unavailable"
1487                    ));
1488                }
1489                // Retain invalid paths too: direct config consumers must not
1490                // mistake an invalid explicit path for an absent default.
1491                if key == "memory.axel.executable" {
1492                    config.memory_backend.executable = Some(path);
1493                } else {
1494                    config.memory_backend.brain = Some(path);
1495                }
1496            }
1497            _ => {
1498                // Handle namespaced keys
1499                if key.starts_with("shell.") {
1500                    parse_shell_config_key(&mut config.shell, key, val);
1501                } else if key.starts_with("server.") {
1502                    parse_server_config_key(&mut config.server, key, val);
1503                } else if key.starts_with("bridge.") {
1504                    parse_bridge_config_key(&mut config.bridge, key, val);
1505                } else if key.starts_with("auth.") {
1506                    parse_auth_config_key(&mut config.auth, key, val);
1507                } else if key.starts_with("events.") {
1508                    parse_events_config_key(&mut config.events, key, val);
1509                } else if key.starts_with("turn_budget.") {
1510                    parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1511                } else if key.starts_with("memory.") {
1512                    parse_memory_config_key(&mut config.memory, key, val);
1513                } else if key.starts_with("startup.") {
1514                    if let Err(reason) =
1515                        parse_startup_config_key(&mut config.startup, key, val)
1516                    {
1517                        config.warnings.push(format!("{key} — {reason}"));
1518                    }
1519                } else if key.starts_with("daemon.") {
1520                    if let Err(reason) =
1521                        parse_daemon_config_key(&mut config.daemon, key, val)
1522                    {
1523                        config.warnings.push(format!("{key} — {reason}"));
1524                    }
1525                } else if key.starts_with("context_management.") {
1526                    if let Err(reason) = parse_context_management_config_key(
1527                        &mut config.context_management, key, val,
1528                    ) {
1529                        invalid_context_management = true;
1530                        config.warnings.push(format!("{key} — {reason}"));
1531                    }
1532                } else if let Some(provider_key) = key.strip_prefix("provider.") {
1533                    config
1534                        .provider_keys
1535                        .insert(provider_key.to_string(), val.to_string());
1536                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1537                    config
1538                        .keybinds
1539                        .insert(keybind_key.to_string(), val.to_string());
1540                } else if key.contains('.') {
1541                    // Dotted keys are namespaced (plugin/extension config, e.g.
1542                    // `knowledge.jawz_notes`). Plugins define their own keys —
1543                    // not ours to police. Silently preserved.
1544                } else {
1545                    // Unknown top-level key — warn with a did-you-mean if close.
1546                    match did_you_mean(key) {
1547                        Some(suggestion) => config.warnings.push(format!(
1548                            "unknown key '{key}' (did you mean '{suggestion}'?)"
1549                        )),
1550                        None => config
1551                            .warnings
1552                            .push(format!("unknown key '{key}' — ignored")),
1553                    }
1554                }
1555            }
1556        }
1557    }
1558
1559    // Never leave a partially parsed automatic policy enabled. Syntax errors
1560    // invalidate this whole parse pass, even if a later duplicate is valid.
1561    let context_validation = match config.context_window {
1562        Some(window) => config.context_management.validate_for_window(window),
1563        None => config.context_management.validate(),
1564    };
1565    if let Err(reason) = context_validation {
1566        invalid_context_management = true;
1567        config
1568            .warnings
1569            .push(format!("context_management — {reason}"));
1570    }
1571    if invalid_context_management {
1572        config.context_management = ContextManagementConfig::default();
1573        config.warnings.push(
1574            "invalid context_management configuration — using defaults (mode off)".to_string(),
1575        );
1576    }
1577
1578    // Any invalid backend setting poisons this parse pass, regardless of key
1579    // order or later duplicate selectors. Never silently route to legacy.
1580    if invalid_memory_backend {
1581        config.memory_backend.kind = MemoryBackendKind::Unavailable;
1582    }
1583
1584    // Fail-closed operator-consent rule (Task A2, spec §12): a non-"off"
1585    // `memory.default_mode` takes effect only when the operator also set
1586    // `memory.default_mode_confirmed = true` in this same parse pass.
1587    // Checked after the loop so key order never matters.
1588    if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1589        config.warnings.push(format!(
1590            "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1591             in the same config; reverting to off",
1592            config.memory.default_mode
1593        ));
1594        config.memory.default_mode = "off".to_string();
1595    }
1596
1597    // Derive max_message_size from context_window if not explicitly set.
1598    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
1599    if config.server.max_message_size.is_none() {
1600        if let Some(ctx_tokens) = config.context_window {
1601            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1602        }
1603    }
1604}
1605
1606/// Read a single config value by exact key from the active config file.
1607pub fn read_config_value(key: &str) -> Option<String> {
1608    let path = resolve_read_path("config");
1609    let content = std::fs::read_to_string(&path).ok()?;
1610    for line in content.lines() {
1611        let line = line.trim();
1612        if line.is_empty() || line.starts_with('#') {
1613            continue;
1614        }
1615        let Some((k, v)) = line.split_once('=') else {
1616            continue;
1617        };
1618        if k.trim() == key.trim() {
1619            return Some(v.trim().to_string());
1620        }
1621    }
1622    None
1623}
1624
1625/// Exclusive advisory lock on `<config>.lock`, held around every
1626/// read-modify-write of the config file (same pattern as `auth.json`).
1627/// Two writers (`/settings` in two processes, a `/model` favorite toggle
1628/// racing a settings edit) no longer lose each other's updates.
1629///
1630/// Held for the RMW only (< 1 ms); readers (`load_config`) never take it —
1631/// they already tolerate the atomic rename. `SYNAPS_CONFIG_LOCK=0` bypasses.
1632///
1633/// NOT reentrant: flock on a fresh fd of the same file from the same process
1634/// conflicts on Linux. Callers that already hold the guard must use the
1635/// `_locked` inner fns.
1636struct ConfigLock {
1637    _file: Option<std::fs::File>,
1638}
1639
1640impl ConfigLock {
1641    fn acquire() -> std::io::Result<Self> {
1642        if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
1643            return Ok(Self { _file: None });
1644        }
1645        use fs4::fs_std::FileExt;
1646        let lock_path = resolve_write_path("config").with_extension("lock");
1647        if let Some(parent) = lock_path.parent() {
1648            std::fs::create_dir_all(parent)?;
1649        }
1650        let mut open = std::fs::OpenOptions::new();
1651        open.create(true).write(true).truncate(false);
1652        #[cfg(unix)]
1653        {
1654            use std::os::unix::fs::OpenOptionsExt;
1655            open.mode(0o600);
1656        }
1657        let file = open.open(&lock_path)?;
1658        FileExt::lock_exclusive(&file)?;
1659        Ok(Self { _file: Some(file) })
1660    }
1661}
1662
1663/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
1664/// Replaces the first existing line that matches the key, or appends if absent.
1665/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
1666pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1667    let _guard = ConfigLock::acquire()?;
1668    write_config_value_locked(key, value)
1669}
1670
1671/// The RMW body; caller holds [`ConfigLock`].
1672fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
1673    let path = resolve_write_path("config");
1674    let existing = std::fs::read_to_string(&path).unwrap_or_default();
1675
1676    let key_trimmed = key.trim();
1677    let replacement = format!("{} = {}", key_trimmed, value);
1678
1679    let mut found = false;
1680    let mut new_lines: Vec<String> = existing
1681        .lines()
1682        .map(|line| {
1683            if found {
1684                return line.to_string();
1685            }
1686            let t = line.trim_start();
1687            if t.starts_with('#') || t.is_empty() {
1688                return line.to_string();
1689            }
1690            if let Some((k, _)) = t.split_once('=') {
1691                if k.trim() == key_trimmed {
1692                    found = true;
1693                    return replacement.clone();
1694                }
1695            }
1696            line.to_string()
1697        })
1698        .collect();
1699
1700    if !found {
1701        new_lines.push(replacement);
1702    }
1703
1704    let mut out = new_lines.join("\n");
1705    if !out.ends_with('\n') {
1706        out.push('\n');
1707    }
1708
1709    let tmp = path.with_extension("tmp");
1710    std::fs::write(&tmp, out)?;
1711    // Config may contain API keys — restrict to owner-only
1712    #[cfg(unix)]
1713    {
1714        use std::os::unix::fs::PermissionsExt;
1715        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1716    }
1717    std::fs::rename(&tmp, &path)?;
1718    Ok(())
1719}
1720
1721/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
1722pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1723    let trimmed = id.trim();
1724    if trimmed.is_empty() {
1725        return Ok(());
1726    }
1727    let _guard = ConfigLock::acquire()?;
1728    let mut values = load_config().favorite_models;
1729    if !values.iter().any(|v| v == trimmed) {
1730        values.push(trimmed.to_string());
1731        values.sort();
1732    }
1733    write_comma_list_locked("favorite_models", &values)
1734}
1735
1736/// Remove a favorite model id (`provider/model`) from config.
1737pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1738    let _guard = ConfigLock::acquire()?;
1739    let mut values = load_config().favorite_models;
1740    values.retain(|v| v != id.trim());
1741    write_comma_list_locked("favorite_models", &values)
1742}
1743
1744/// Return whether a model id is marked as favorite.
1745pub fn is_favorite_model(id: &str) -> bool {
1746    load_config().favorite_models.iter().any(|v| v == id.trim())
1747}
1748
1749/// Whether a key matches the secret denylist (case-insensitive).
1750/// Used by the journal save path to ensure secret values never reach disk,
1751/// and by the client env capture to strip secrets before `Hello`.
1752pub fn is_secret_key(key: &str) -> bool {
1753    let upper = key.to_ascii_uppercase();
1754    upper.ends_with("_KEY")
1755        || upper.ends_with("_TOKEN")
1756        || upper.ends_with("_CREDENTIALS")
1757        || upper.ends_with("_API_KEY")
1758        || upper.contains("SECRET")
1759        || upper.contains("PASSWORD")
1760        || upper.contains("PASSWD")
1761        || upper.ends_with("_PAT")
1762        || upper.ends_with("_DSN")
1763}
1764
1765/// Whether an env VALUE carries a credential regardless of its key name:
1766/// any URL with userinfo (`scheme://user:pass@host`, `scheme://key@host`),
1767/// anywhere in the value — `DATABASE_URL`, `HTTPS_PROXY`, a Sentry DSN or a
1768/// JDBC option string all qualify. The key-name denylist alone missed these,
1769/// so their credentials reached the daemon and the session files on disk.
1770/// A username-only URL (`ssh://git@host/…`) is treated as a secret too:
1771/// rare in an environment, and failing safe beats a guess.
1772pub fn is_secret_env_value(value: &str) -> bool {
1773    let mut rest = value;
1774    while let Some(i) = rest.find("://") {
1775        let scheme_ok = rest[..i]
1776            .chars()
1777            .next_back()
1778            .is_some_and(|c| c.is_ascii_alphanumeric());
1779        let after = &rest[i + 3..];
1780        // The authority ends at the path, query or fragment, or at a list /
1781        // option separator when several URLs share one value.
1782        let end = after
1783            .find(|c: char| {
1784                matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
1785            })
1786            .unwrap_or(after.len());
1787        if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
1788            return true;
1789        }
1790        rest = after;
1791    }
1792    false
1793}
1794
1795/// Whether an env pair must never reach the daemon or disk: a secret key
1796/// name OR a value carrying credentials (see [`is_secret_env_value`]).
1797pub fn is_secret_env(key: &str, value: &str) -> bool {
1798    is_secret_key(key) || is_secret_env_value(value)
1799}
1800
1801/// Resolve the system prompt from CLI flag, config file, or default.
1802/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
1803pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1804    #[cfg(windows)]
1805    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
1806        You have access to powershell, bash, read, and write tools. \
1807        Prefer the powershell tool for system commands and filesystem operations — it is native \
1808        to Windows and avoids quoting/path translation issues. \
1809        Use bash only when the task specifically requires WSL or a Unix environment. \
1810        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1811    #[cfg(not(windows))]
1812    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1813        You have access to bash, read, and write tools. \
1814        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1815
1816    if let Some(val) = explicit {
1817        let path = std::path::Path::new(val);
1818        if path.exists() && path.is_file() {
1819            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1820        }
1821        return val.to_string();
1822    }
1823
1824    let system_path = resolve_read_path("system.md");
1825    if system_path.exists() {
1826        return std::fs::read_to_string(&system_path).unwrap_or_default();
1827    }
1828
1829    DEFAULT_PROMPT.to_string()
1830}
1831
1832#[cfg(test)]
1833mod tests {
1834    #[test]
1835    fn secret_env_value_detects_credential_urls() {
1836        use super::is_secret_env_value as v;
1837        // user:password in the authority
1838        assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
1839        assert!(v("redis://:pw@cache:6379/0"));
1840        assert!(v("http://user:pass@proxy.corp:3128"));
1841        assert!(v(
1842            "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
1843        ));
1844        // a key as the username, no password (Sentry DSN)
1845        assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
1846        // anywhere in the value, not only at the start; lists
1847        assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
1848        assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
1849        // no userinfo → not a secret
1850        assert!(!v("https://api.example.com/v1/items?q=a@b"));
1851        assert!(!v("http://host/path/with@sign"));
1852        assert!(!v("https://@host/empty-userinfo"));
1853        assert!(!v("git@github.com:org/repo.git"));
1854        assert!(!v("mailto:someone@example.com"));
1855        assert!(!v("someone@example.com"));
1856        assert!(!v("/usr/local/bin:/usr/bin"));
1857        assert!(!v(""));
1858    }
1859
1860    #[test]
1861    fn secret_key_covers_pat_and_dsn() {
1862        assert!(super::is_secret_key("GITHUB_PAT"));
1863        assert!(super::is_secret_key("gh_pat"));
1864        assert!(super::is_secret_key("SENTRY_DSN"));
1865        assert!(!super::is_secret_key("PATH"));
1866        assert!(!super::is_secret_key("PATTERN_DIR"));
1867    }
1868
1869    #[test]
1870    fn secret_env_is_key_or_value() {
1871        assert!(super::is_secret_env(
1872            "DATABASE_URL",
1873            "postgres://app:hunter2@db/prod"
1874        ));
1875        assert!(super::is_secret_env("GH_TOKEN", "anything"));
1876        assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
1877        assert!(!super::is_secret_env("HOME", "/home/u"));
1878    }
1879    #[test]
1880    fn startup_and_daemon_defaults() {
1881        let c = super::load_config_from_str("");
1882        assert!(c.startup.quick_start, "quick_start defaults ON");
1883        assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
1884        assert_eq!(c.daemon.idle_exit_secs, 10);
1885        assert_eq!(c.daemon.prompt_abandon_secs, 3600);
1886        assert_eq!(c.daemon.parked_evict_secs, 3600);
1887        // No warnings for the absent keys.
1888        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
1889    }
1890
1891    #[test]
1892    fn startup_and_daemon_parse_all_keys() {
1893        let c = super::load_config_from_str(concat!(
1894            "startup.quick_start = off\n",
1895            "startup.extensions_ready_timeout_secs = 45\n",
1896            "daemon.idle_exit_secs = 20\n",
1897            "daemon.prompt_abandon_secs = 0\n",
1898            "daemon.parked_evict_secs = 120\n",
1899        ));
1900        assert!(!c.startup.quick_start);
1901        assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
1902        assert_eq!(c.daemon.idle_exit_secs, 20);
1903        assert_eq!(c.daemon.prompt_abandon_secs, 0);
1904        assert_eq!(c.daemon.parked_evict_secs, 120);
1905        assert!(
1906            !c.warnings.iter().any(|w| w.contains("unknown")),
1907            "no unknown-key warning: {:?}",
1908            c.warnings
1909        );
1910    }
1911
1912    #[test]
1913    fn startup_quick_start_accepts_on() {
1914        let c = super::load_config_from_str("startup.quick_start = on\n");
1915        assert!(c.startup.quick_start);
1916    }
1917
1918    #[test]
1919    fn startup_and_daemon_keys_are_known() {
1920        for k in [
1921            "startup.quick_start",
1922            "startup.extensions_ready_timeout_secs",
1923            "daemon.idle_exit_secs",
1924            "daemon.prompt_abandon_secs",
1925            "daemon.parked_evict_secs",
1926        ] {
1927            assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
1928        }
1929    }
1930
1931    #[test]
1932    fn startup_daemon_bad_values_warn_and_keep_defaults() {
1933        let c = super::load_config_from_str(concat!(
1934            "startup.quick_start = maybe\n",
1935            "daemon.idle_exit_secs = soon\n",
1936        ));
1937        assert!(c.startup.quick_start, "invalid bool keeps default");
1938        assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
1939        assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
1940        assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
1941    }
1942
1943    #[test]
1944    fn context_management_defaults_off_and_parses_all_keys() {
1945        use super::{ContextManagementConfig, ContextManagementMode};
1946        assert_eq!(
1947            super::load_config_from_str("").context_management,
1948            ContextManagementConfig::default()
1949        );
1950        let parsed = super::load_config_from_str(
1951            "context_management.mode = AUTO\n\
1952             context_management.pressure_tokens = 250000\n\
1953             context_management.rollover_tokens = 400000\n\
1954             context_management.reserve_tokens = 24000\n\
1955             context_management.finish_rounds = 3\n\
1956             context_window = 1m\n",
1957        );
1958        assert_eq!(
1959            parsed.context_management,
1960            ContextManagementConfig {
1961                mode: ContextManagementMode::Auto,
1962                pressure_tokens: Some(250_000),
1963                rollover_tokens: Some(400_000),
1964                reserve_tokens: 24_000,
1965                finish_rounds: 3,
1966            }
1967        );
1968        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
1969        assert!(parsed.provider_keys.is_empty());
1970        assert!(super::load_config_from_str("context_management.mode = off")
1971            .warnings
1972            .is_empty());
1973    }
1974
1975    #[test]
1976    fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
1977        for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
1978            let parsed = super::load_config_from_str(&format!(
1979                "context_management.mode = auto\n\
1980                 context_management.pressure_tokens = 300000\n\
1981                 context_management.rollover_tokens = 450000\n\
1982                 context_management.pressure_tokens = auto\n\
1983                 context_management.rollover_tokens = AUTO\n\
1984                 context_management.finish_rounds = {rounds}\n"
1985            ));
1986            assert_eq!(parsed.context_management.pressure_tokens, None);
1987            assert_eq!(parsed.context_management.rollover_tokens, None);
1988            assert_eq!(parsed.context_management.finish_rounds, rounds);
1989            assert!(parsed.warnings.is_empty());
1990        }
1991    }
1992
1993    #[test]
1994    fn context_management_invalid_values_reset_entire_policy() {
1995        for (key, value) in [
1996            ("mode", "on"),
1997            ("pressure_tokens", "0"),
1998            ("pressure_tokens", "-1"),
1999            ("pressure_tokens", "250k"),
2000            ("pressure_tokens", "18446744073709551616"),
2001            ("rollover_tokens", "1"),
2002            ("rollover_tokens", "nope"),
2003            ("reserve_tokens", "0"),
2004            ("reserve_tokens", "1000001"),
2005            ("reserve_tokens", "auto"),
2006            ("finish_rounds", "9"),
2007            ("finish_rounds", "-1"),
2008            ("finish_rounds", "4294967296"),
2009            ("finish_rounds", ""),
2010            ("mod", "auto"),
2011        ] {
2012            let parsed = super::load_config_from_str(&format!(
2013                "context_management.mode = auto\ncontext_management.{key} = {value}\n"
2014            ));
2015            assert_eq!(
2016                parsed.context_management,
2017                super::ContextManagementConfig::default(),
2018                "{key}={value}"
2019            );
2020            assert!(!parsed.warnings.is_empty(), "{key}={value}");
2021            assert!(parsed.provider_keys.is_empty());
2022        }
2023        let duplicate = super::load_config_from_str(
2024            "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
2025        );
2026        assert_eq!(
2027            duplicate.context_management,
2028            super::ContextManagementConfig::default()
2029        );
2030    }
2031
2032    #[test]
2033    fn context_management_validates_final_threshold_order_and_window() {
2034        for fields in [
2035            "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
2036            "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
2037            "pressure_tokens = 1000000",
2038            "rollover_tokens = 1000001",
2039        ] {
2040            for window_first in [false, true] {
2041                let policy =
2042                    format!("context_management.mode = auto\ncontext_management.{fields}\n");
2043                let content = if window_first {
2044                    format!("context_window = 1m\n{policy}")
2045                } else {
2046                    format!("{policy}context_window = 1m\n")
2047                };
2048                let parsed = super::load_config_from_str(&content);
2049                assert_eq!(
2050                    parsed.context_management,
2051                    super::ContextManagementConfig::default()
2052                );
2053                assert!(!parsed.warnings.is_empty());
2054            }
2055        }
2056        // Temporarily reversed overrides are valid once the whole file is parsed.
2057        let parsed = super::load_config_from_str(
2058            "context_management.mode = auto\n\
2059             context_management.rollover_tokens = 100000\n\
2060             context_management.pressure_tokens = 300000\n\
2061             context_management.rollover_tokens = 450000\ncontext_window = 1m",
2062        );
2063        assert!(parsed.warnings.is_empty());
2064        assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
2065    }
2066
2067    #[test]
2068    fn context_management_parsed_finish_override_bounds_rollover_extension() {
2069        use crate::core::context_policy::{
2070            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2071        };
2072        let config = super::load_config_from_str(
2073            "context_management.mode = auto\n\
2074             context_management.finish_rounds = 1\ncontext_window = 1m",
2075        );
2076        let budget = ContextBudget {
2077            context_window_tokens: 1_000_000,
2078            used_tokens: 400_000,
2079            hard_remaining_tokens: 600_000,
2080            required_next_round_tokens: 8_000,
2081        };
2082        let mut state = ContextState::default();
2083        state.report_phase(WorkPhase::Plan);
2084        let first = assess_context(&config.context_management, &state, budget);
2085        assert_eq!(
2086            first.action,
2087            ContextAction::FinishBounded {
2088                rounds_remaining: 0
2089            }
2090        );
2091        state = first.next_state;
2092        state.report_phase(WorkPhase::WrapUp);
2093        assert_eq!(
2094            assess_context(&config.context_management, &state, budget).action,
2095            ContextAction::Rollover
2096        );
2097    }
2098
2099    #[test]
2100    fn context_management_keys_are_known_for_suggestions() {
2101        for key in [
2102            "mode",
2103            "pressure_tokens",
2104            "rollover_tokens",
2105            "reserve_tokens",
2106            "finish_rounds",
2107        ] {
2108            let full = format!("context_management.{key}");
2109            assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
2110            assert_eq!(
2111                super::did_you_mean(&format!("{full}x")),
2112                Some(full.as_str())
2113            );
2114        }
2115    }
2116
2117    #[test]
2118    fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
2119        use crate::core::context_policy::{
2120            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2121        };
2122        let config =
2123            super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
2124        let budget = ContextBudget {
2125            context_window_tokens: config.context_window.unwrap(),
2126            used_tokens: 350_000,
2127            hard_remaining_tokens: 650_000,
2128            required_next_round_tokens: 8_000,
2129        };
2130        let mut state = ContextState::default();
2131        state.report_phase(WorkPhase::Plan);
2132        let plan = assess_context(&config.context_management, &state, budget);
2133        assert_eq!(plan.action, ContextAction::Advisory);
2134        state = plan.next_state;
2135        state.report_phase(WorkPhase::Execute);
2136        assert_eq!(
2137            assess_context(&config.context_management, &state, budget).action,
2138            ContextAction::Rollover
2139        );
2140    }
2141
2142    #[test]
2143    fn tui_background_opaque_defaults_and_parses_supported_values() {
2144        assert!(super::load_config_from_str("").tui_background_opaque);
2145        assert!(
2146            super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
2147        );
2148        assert!(
2149            !super::load_config_from_str("tui_background_opaque = invisible\n")
2150                .tui_background_opaque
2151        );
2152        let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
2153        assert!(invalid.tui_background_opaque);
2154        assert!(invalid
2155            .warnings
2156            .iter()
2157            .any(|w| w.contains("tui_background_opaque")));
2158    }
2159
2160    #[test]
2161    fn turn_budget_keys_parse_typed_per_role() {
2162        let config = super::load_config_from_str(
2163            "turn_budget.worker.max_provider_rounds = 3\n\
2164             turn_budget.worker.max_cost_usd = 0.25\n\
2165             turn_budget.autonomous.max_elapsed_secs = 90\n\
2166             turn_budget.foreground.max_tool_calls = 7\n\
2167             turn_budget.foreground.max_round_renewals = 12\n\
2168             turn_budget.worker.max_provider_rounds_bogus = 1\n\
2169             turn_budget.nosuchrole.max_tool_calls = 1\n",
2170        );
2171        assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
2172        assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
2173        assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
2174        assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
2175        assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
2176        // Unknown fields/roles warn and change nothing.
2177        assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
2178        // Invalid values keep the default (None).
2179        let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
2180        assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
2181    }
2182
2183    #[test]
2184    fn memory_keys_all_parse_with_consent() {
2185        let config = super::load_config_from_str(
2186            "memory.default_mode = capture_and_recall\n\
2187             memory.default_mode_confirmed = true\n\
2188             memory.recall_max_records = 12\n\
2189             memory.recall_max_tokens = 2048\n\
2190             memory.recall_timeout_ms = 250\n\
2191             memory.capture_tools = off\n\
2192             memory.capture_assistant = false\n\
2193             memory.capture_user = false\n\
2194             memory.auto_consolidate = on\n\
2195             memory.local_embeddings = on\n\
2196             memory.gliner = on\n",
2197        );
2198        assert_eq!(config.memory.default_mode, "capture_and_recall");
2199        assert!(config.memory.default_mode_confirmed);
2200        assert_eq!(config.memory.recall_max_records, 12);
2201        assert_eq!(config.memory.recall_max_tokens, 2048);
2202        assert_eq!(config.memory.recall_timeout_ms, 250);
2203        assert_eq!(config.memory.capture_tools, "off");
2204        assert!(!config.memory.capture_assistant);
2205        assert!(!config.memory.capture_user);
2206        assert_eq!(config.memory.auto_consolidate, "on");
2207        assert_eq!(config.memory.local_embeddings, "on");
2208        assert_eq!(config.memory.gliner, "on");
2209    }
2210
2211    #[test]
2212    fn memory_defaults_match_spec_12() {
2213        let config = super::load_config_from_str("");
2214        assert_eq!(config.memory, super::MemoryConfig::default());
2215        assert_eq!(config.memory.default_mode, "off");
2216        assert!(!config.memory.default_mode_confirmed);
2217        assert_eq!(config.memory.recall_max_records, 8);
2218        assert_eq!(config.memory.recall_max_tokens, 4096);
2219        assert_eq!(config.memory.recall_timeout_ms, 150);
2220        assert_eq!(config.memory.capture_tools, "summary_only");
2221        assert!(config.memory.capture_assistant);
2222        assert!(config.memory.capture_user);
2223        assert_eq!(config.memory.auto_consolidate, "off");
2224        assert_eq!(config.memory.local_embeddings, "off");
2225        assert_eq!(config.memory.gliner, "off");
2226    }
2227
2228    #[test]
2229    fn memory_unknown_values_fail_closed_to_defaults() {
2230        let config = super::load_config_from_str(
2231            "memory.default_mode = recall_everything_forever\n\
2232             memory.default_mode_confirmed = true\n\
2233             memory.recall_max_records = lots\n\
2234             memory.recall_max_tokens = -5\n\
2235             memory.recall_timeout_ms = fast\n\
2236             memory.capture_tools = full\n\
2237             memory.capture_assistant = maybe\n\
2238             memory.capture_user = sometimes\n\
2239             memory.auto_consolidate = aggressive\n\
2240             memory.local_embeddings = auto\n\
2241             memory.gliner = download\n\
2242             memory.nosuchfield = 1\n",
2243        );
2244        // Unrecognised mode string must stay "off" — never a non-off mode.
2245        assert_eq!(config.memory.default_mode, "off");
2246        assert_eq!(config.memory.recall_max_records, 8);
2247        assert_eq!(config.memory.recall_max_tokens, 4096);
2248        assert_eq!(config.memory.recall_timeout_ms, 150);
2249        assert_eq!(config.memory.capture_tools, "summary_only");
2250        assert!(config.memory.capture_assistant);
2251        assert!(config.memory.capture_user);
2252        assert_eq!(config.memory.auto_consolidate, "off");
2253        assert_eq!(config.memory.local_embeddings, "off");
2254        assert_eq!(config.memory.gliner, "off");
2255    }
2256
2257    #[test]
2258    fn memory_default_mode_without_consent_reverts_to_off() {
2259        let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
2260        assert_eq!(config.memory.default_mode, "off");
2261        assert!(
2262            config
2263                .warnings
2264                .iter()
2265                .any(|w| w.contains("memory.default_mode_confirmed")),
2266            "reversion must surface a warning; got: {:?}",
2267            config.warnings
2268        );
2269
2270        // Explicit false consent is not consent.
2271        let denied = super::load_config_from_str(
2272            "memory.default_mode = capture_only\n\
2273             memory.default_mode_confirmed = false\n",
2274        );
2275        assert_eq!(denied.memory.default_mode, "off");
2276
2277        // Garbage consent value fails closed — not consent.
2278        let garbage = super::load_config_from_str(
2279            "memory.default_mode = capture_only\n\
2280             memory.default_mode_confirmed = definitely\n",
2281        );
2282        assert_eq!(garbage.memory.default_mode, "off");
2283    }
2284
2285    #[test]
2286    fn memory_consent_is_order_independent() {
2287        // Consent key before the mode key.
2288        let before = super::load_config_from_str(
2289            "memory.default_mode_confirmed = true\n\
2290             memory.default_mode = recall_once\n",
2291        );
2292        assert_eq!(before.memory.default_mode, "recall_once");
2293        // Consent key after the mode key.
2294        let after = super::load_config_from_str(
2295            "memory.default_mode = recall_once\n\
2296             memory.default_mode_confirmed = true\n",
2297        );
2298        assert_eq!(after.memory.default_mode, "recall_once");
2299    }
2300
2301    #[test]
2302    fn memory_backend_defaults_are_independent_of_consent() {
2303        let config = super::load_config_from_str("");
2304        assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
2305        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
2306        let axel = super::load_config_from_str("memory.backend = axel\n");
2307        assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
2308        assert_eq!(axel.memory, config.memory);
2309        assert!(axel.warnings.is_empty());
2310    }
2311
2312    #[test]
2313    fn user_wide_notes_require_explicit_valid_opt_in() {
2314        let default = super::load_config_from_str("memory.backend = axel\n");
2315        assert!(!default.memory_backend.user_scope);
2316        let enabled =
2317            super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
2318        assert!(enabled.memory_backend.user_scope);
2319        assert_eq!(enabled.memory, default.memory);
2320        for bad in ["yes", "", "1", "TRUE"] {
2321            let config = super::load_config_from_str(&format!(
2322                "memory.user_scope = {bad}\nmemory.backend = axel\n"
2323            ));
2324            assert_eq!(
2325                config.memory_backend.kind,
2326                super::MemoryBackendKind::Unavailable
2327            );
2328        }
2329    }
2330
2331    #[test]
2332    fn memory_backend_invalid_selector_fails_closed() {
2333        for content in [
2334            "memory.backend = typo\n",
2335            "memory.backend = \n",
2336            "memory.backend = unavailable\n",
2337            "memory.backend = axel\nmemory.backend = typo\n",
2338            "memory.backend = typo\nmemory.backend = legacy\n",
2339        ] {
2340            let config = super::load_config_from_str(content);
2341            assert_eq!(
2342                config.memory_backend.kind,
2343                super::MemoryBackendKind::Unavailable
2344            );
2345            assert!(config
2346                .warnings
2347                .iter()
2348                .any(|w| w.contains("memory backend unavailable")));
2349        }
2350    }
2351
2352    #[test]
2353    fn memory_backend_absolute_paths_are_preserved() {
2354        let root = std::env::current_dir().unwrap();
2355        let executable = root.join("fixture bin/axel");
2356        let brain = root.join("fixture brain");
2357        let config = super::load_config_from_str(&format!(
2358            "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
2359            brain.display(),
2360            executable.display()
2361        ));
2362        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
2363        assert_eq!(config.memory_backend.executable, Some(executable));
2364        assert_eq!(config.memory_backend.brain, Some(brain));
2365        assert!(config.warnings.is_empty());
2366    }
2367
2368    #[test]
2369    fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
2370        for key in ["memory.axel.executable", "memory.axel.brain"] {
2371            for path in ["relative/path", "~/brain", ""] {
2372                for content in [
2373                    format!("memory.backend = axel\n{key} = {path}\n"),
2374                    format!("{key} = {path}\nmemory.backend = legacy\n"),
2375                ] {
2376                    let config = super::load_config_from_str(&content);
2377                    assert_eq!(
2378                        config.memory_backend.kind,
2379                        super::MemoryBackendKind::Unavailable
2380                    );
2381                    assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
2382                }
2383            }
2384        }
2385    }
2386
2387    #[test]
2388    fn memory_config_surface_is_closed_by_construction() {
2389        // Exhaustive struct literal — no `..Default::default()` spread. This
2390        // fails to COMPILE if any field is added to or removed from
2391        // MemoryConfig, pinning the exact memory.* config surface. Secret,
2392        // retention, and project-scope semantics have no field here, so no
2393        // `memory.secret*` / `memory.retention*` / `memory.project*` config
2394        // key can exist (spec §12: those rules are not configurable to fail
2395        // open). Asserted by construction, not by test disproof.
2396        let _closed_surface = super::MemoryConfig {
2397            default_mode: "off".to_string(),
2398            default_mode_confirmed: false,
2399            recall_max_records: 8,
2400            recall_max_tokens: 4096,
2401            recall_timeout_ms: 150,
2402            capture_tools: "summary_only".to_string(),
2403            capture_assistant: true,
2404            capture_user: true,
2405            auto_consolidate: "off".to_string(),
2406            local_embeddings: "off".to_string(),
2407            gliner: "off".to_string(),
2408        };
2409    }
2410
2411    use super::*;
2412    use serial_test::serial;
2413
2414    #[test]
2415    fn test_levenshtein_basics() {
2416        assert_eq!(levenshtein("model", "model"), 0);
2417        assert_eq!(levenshtein("modle", "model"), 2);
2418        assert_eq!(levenshtein("them", "theme"), 1);
2419    }
2420
2421    #[test]
2422    fn test_did_you_mean_close_typos() {
2423        assert_eq!(did_you_mean("modle"), Some("model"));
2424        assert_eq!(did_you_mean("them"), Some("theme"));
2425        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
2426        assert_eq!(did_you_mean("completely_unrelated_key"), None);
2427    }
2428
2429    #[test]
2430    #[serial]
2431    fn test_config_warnings_unknown_key_and_bad_values() {
2432        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
2433        let dir = home.join(".synaps-cli");
2434        std::fs::create_dir_all(&dir).unwrap();
2435        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();
2436
2437        with_home(&home, || {
2438            let config = load_config();
2439            // Dotted (namespaced) keys must NOT warn — plugins own those.
2440            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
2441            assert!(
2442                !config.warnings.iter().any(|w| w.contains("knowledge")),
2443                "{:?}",
2444                config.warnings
2445            );
2446            assert!(
2447                config
2448                    .warnings
2449                    .iter()
2450                    .any(|w| w.contains("did you mean 'model'")),
2451                "{:?}",
2452                config.warnings
2453            );
2454            assert!(
2455                config.warnings.iter().any(|w| w.contains("thinking")),
2456                "{:?}",
2457                config.warnings
2458            );
2459            assert!(
2460                config.warnings.iter().any(|w| w.contains("below minimum")),
2461                "{:?}",
2462                config.warnings
2463            );
2464            // Bad values fall back to defaults
2465            assert_eq!(config.bash_timeout, 30);
2466            assert_eq!(config.thinking_budget, None);
2467        });
2468        let _ = std::fs::remove_dir_all(&home);
2469    }
2470
2471    // ── cache_ttl parse table (spec §3.1) ──
2472
2473    #[test]
2474    fn test_cache_ttl_parse_table() {
2475        // 5m aliases
2476        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
2477        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
2478        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
2479        // 1h aliases
2480        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
2481        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
2482        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
2483        // hybrid
2484        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
2485        // case-insensitive
2486        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
2487        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
2488        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
2489        // garbage → None (caller warns + defaults)
2490        assert_eq!(CacheTtl::parse("2h"), None);
2491        assert_eq!(CacheTtl::parse(""), None);
2492        assert_eq!(CacheTtl::parse("forever"), None);
2493    }
2494
2495    #[test]
2496    fn test_cache_ttl_default_is_five_minutes() {
2497        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
2498        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
2499    }
2500
2501    #[test]
2502    fn test_max_fps_default_is_60() {
2503        assert_eq!(SynapsConfig::default().max_fps, 60);
2504    }
2505
2506    // ── theme_transition parse table (animated theme transitions) ──
2507
2508    #[test]
2509    fn test_theme_transition_parse_table() {
2510        use ThemeTransitionMode as M;
2511        assert_eq!(M::parse("on"), Some(M::On));
2512        assert_eq!(M::parse("ON"), Some(M::On));
2513        assert_eq!(M::parse("off"), Some(M::Off));
2514        assert_eq!(M::parse("Off"), Some(M::Off));
2515        // integer ms, clamped to 0..=2000
2516        assert_eq!(M::parse("500"), Some(M::Ms(500)));
2517        assert_eq!(M::parse("0"), Some(M::Ms(0)));
2518        assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
2519        assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
2520        // garbage → None (caller warns + keeps default)
2521        assert_eq!(M::parse("fast"), None);
2522        assert_eq!(M::parse(""), None);
2523        assert_eq!(M::parse("-5"), None);
2524        assert_eq!(M::parse("1.5"), None);
2525    }
2526
2527    #[test]
2528    fn test_theme_transition_duration_ms() {
2529        use ThemeTransitionMode as M;
2530        assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
2531        assert_eq!(M::DEFAULT_MS, 350);
2532        assert_eq!(M::Off.duration_ms(), 0);
2533        assert_eq!(M::Ms(120).duration_ms(), 120);
2534        assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
2535        assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
2536    }
2537
2538    #[test]
2539    fn test_theme_transition_config_key_parses_and_defaults() {
2540        use ThemeTransitionMode as M;
2541        assert_eq!(SynapsConfig::default().theme_transition, M::On);
2542        assert_eq!(
2543            super::load_config_from_str("theme_transition = off\n").theme_transition,
2544            M::Off
2545        );
2546        assert_eq!(
2547            super::load_config_from_str("theme_transition = 750\n").theme_transition,
2548            M::Ms(750)
2549        );
2550        let bad = super::load_config_from_str("theme_transition = warp\n");
2551        assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
2552        assert!(
2553            bad.warnings.iter().any(|w| w.contains("theme_transition")),
2554            "{:?}",
2555            bad.warnings
2556        );
2557    }
2558
2559    #[test]
2560    #[serial]
2561    fn test_max_fps_config_parse_and_validation() {
2562        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
2563        let dir = home.join(".synaps-cli");
2564        std::fs::create_dir_all(&dir).unwrap();
2565
2566        // Valid high-refresh value parses, no warning.
2567        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
2568        with_home(&home, || {
2569            let config = load_config();
2570            assert_eq!(config.max_fps, 144);
2571            assert!(
2572                config.warnings.is_empty(),
2573                "warnings: {:?}",
2574                config.warnings
2575            );
2576        });
2577
2578        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
2579        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
2580        with_home(&home, || {
2581            let config = load_config();
2582            assert_eq!(config.max_fps, 60);
2583            assert!(
2584                config.warnings.iter().any(|w| w.contains("max_fps")),
2585                "warnings: {:?}",
2586                config.warnings
2587            );
2588        });
2589
2590        // Non-numeric → default 60 + warning.
2591        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
2592        with_home(&home, || {
2593            let config = load_config();
2594            assert_eq!(config.max_fps, 60);
2595            assert!(
2596                config.warnings.iter().any(|w| w.contains("max_fps")),
2597                "warnings: {:?}",
2598                config.warnings
2599            );
2600        });
2601
2602        let _ = std::fs::remove_dir_all(&home);
2603    }
2604
2605    #[test]
2606    #[serial]
2607    fn test_cache_ttl_config_parse_and_garbage_warning() {
2608        let home =
2609            std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
2610        let dir = home.join(".synaps-cli");
2611        std::fs::create_dir_all(&dir).unwrap();
2612
2613        // Valid value parses, no warning.
2614        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
2615        with_home(&home, || {
2616            let config = load_config();
2617            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
2618            assert!(
2619                config.warnings.is_empty(),
2620                "warnings: {:?}",
2621                config.warnings
2622            );
2623        });
2624
2625        // Garbage value → 5m default + boot warning (never blocks boot).
2626        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
2627        with_home(&home, || {
2628            let config = load_config();
2629            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
2630            assert!(
2631                config.warnings.iter().any(|w| w.contains("cache_ttl")),
2632                "warnings: {:?}",
2633                config.warnings
2634            );
2635        });
2636
2637        let _ = std::fs::remove_dir_all(&home);
2638    }
2639
2640    #[test]
2641    fn test_parse_thinking_budget() {
2642        use crate::core::reasoning::ThinkingSpec;
2643        // ThinkingSpec::parse replaced parse_thinking_budget; verify budget values.
2644        assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
2645        assert_eq!(
2646            ThinkingSpec::parse("medium").unwrap().to_budget(),
2647            Some(4096)
2648        );
2649        assert_eq!(
2650            ThinkingSpec::parse("high").unwrap().to_budget(),
2651            Some(16384)
2652        );
2653        assert_eq!(
2654            ThinkingSpec::parse("xhigh").unwrap().to_budget(),
2655            Some(32768)
2656        );
2657        assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
2658        let config = load_config_from_str("thinking = 8192\n");
2659        assert_eq!(config.thinking_level, None);
2660        assert_eq!(config.thinking_budget, Some(8192));
2661        assert_eq!(ThinkingSpec::parse("invalid"), None);
2662    }
2663
2664    #[test]
2665    fn test_base_dir() {
2666        let path = base_dir();
2667        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
2668    }
2669
2670    #[test]
2671    fn test_resolve_system_prompt_explicit() {
2672        let result = resolve_system_prompt(Some("test prompt"));
2673        assert_eq!(result, "test prompt");
2674    }
2675
2676    #[test]
2677    fn test_resolve_system_prompt_none() {
2678        let result = resolve_system_prompt(None);
2679        assert!(result.contains("helpful AI agent"));
2680    }
2681
2682    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
2683    // is not thread-safe and races with shell config tests. Coverage provided
2684    // by shell::config::tests::test_shell_config_from_file.
2685
2686    #[test]
2687    fn test_synaps_config_default() {
2688        let config = SynapsConfig::default();
2689        assert_eq!(config.model, None);
2690        assert_eq!(config.thinking_budget, None);
2691        assert_eq!(config.max_tool_output, 30000);
2692        assert_eq!(config.bash_timeout, 30);
2693        assert_eq!(config.bash_max_timeout, 300);
2694        assert_eq!(config.subagent_timeout, 300);
2695        assert_eq!(config.api_retries, 3);
2696        assert_eq!(config.theme, None);
2697        assert!(config.disabled_plugins.is_empty());
2698        assert!(config.favorite_models.is_empty());
2699        assert!(config.disabled_skills.is_empty());
2700        assert!(!config.progressive_tool_disclosure);
2701        assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
2702        assert_eq!(config.shell.max_sessions, 5);
2703        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
2704        // Server config defaults
2705        assert!(config.server.allowed_origins.is_empty());
2706        assert_eq!(config.server.token, None);
2707        assert!(!config.server.auto_approve_confirms);
2708        assert_eq!(config.server.max_message_size, None);
2709        // Bridge config defaults
2710        assert!(config.bridge.uds_path.is_none());
2711        assert!(!config.bridge.heartbeat_mirror);
2712        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2713    }
2714
2715    #[test]
2716    #[serial]
2717    fn test_load_config_tools_activation_confirm_modes() {
2718        for (raw, expected, warns) in [
2719            ("prompt", ActivationConfirm::Prompt, false),
2720            ("deny", ActivationConfirm::Deny, false),
2721            ("AUTO", ActivationConfirm::Auto, false),
2722            ("garbage", ActivationConfirm::Auto, true),
2723        ] {
2724            let home = make_test_home(&format!("activation-confirm-{raw}"));
2725            let cfg = home.join(".synaps-cli/config");
2726            std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
2727            with_home(&home, || {
2728                let config = load_config();
2729                assert_eq!(config.tools_activation_confirm, expected, "{raw}");
2730                assert_eq!(
2731                    config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
2732                    warns,
2733                    "{raw}: {:?}",
2734                    config.warnings
2735                );
2736            });
2737            let _ = std::fs::remove_dir_all(&home);
2738        }
2739    }
2740
2741    #[test]
2742    #[serial]
2743    fn test_load_config_progressive_tool_disclosure_is_opt_in() {
2744        let home = make_test_home("progressive-tool-disclosure");
2745        let cfg = home.join(".synaps-cli/config");
2746        std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
2747
2748        with_home(&home, || {
2749            let config = load_config();
2750            assert!(config.progressive_tool_disclosure);
2751            assert!(config.warnings.is_empty());
2752        });
2753
2754        let _ = std::fs::remove_dir_all(&home);
2755    }
2756
2757    #[test]
2758    #[serial]
2759    fn test_load_config_bridge_keys() {
2760        let home = make_test_home("bridge-keys");
2761        let cfg = home.join(".synaps-cli/config");
2762        std::fs::write(
2763            &cfg,
2764            "\
2765bridge.uds_path = /tmp/some/control.sock\n\
2766bridge.heartbeat_mirror = true\n\
2767bridge.heartbeat_timeout_ms = 750\n\
2768",
2769        )
2770        .unwrap();
2771
2772        with_home(&home, || {
2773            let config = load_config();
2774            assert_eq!(
2775                config.bridge.uds_path,
2776                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
2777            );
2778            assert!(config.bridge.heartbeat_mirror);
2779            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
2780            assert_eq!(
2781                config.bridge.resolved_uds_path(),
2782                std::path::PathBuf::from("/tmp/some/control.sock"),
2783            );
2784        });
2785
2786        let _ = std::fs::remove_dir_all(&home);
2787    }
2788
2789    #[test]
2790    fn test_bridge_config_defaults() {
2791        let cfg = BridgeConfig::default();
2792        assert!(cfg.uds_path.is_none());
2793        assert!(!cfg.heartbeat_mirror);
2794        assert_eq!(cfg.heartbeat_timeout_ms, 250);
2795        // resolved path falls under base_dir()/bridge/control.sock
2796        let resolved = cfg.resolved_uds_path();
2797        assert!(resolved.ends_with("bridge/control.sock"));
2798    }
2799
2800    #[test]
2801    #[serial]
2802    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
2803        let home = make_test_home("bridge-default-off");
2804        let cfg = home.join(".synaps-cli/config");
2805        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
2806
2807        with_home(&home, || {
2808            let config = load_config();
2809            assert!(!config.bridge.heartbeat_mirror);
2810            assert!(config.bridge.uds_path.is_none());
2811            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2812        });
2813
2814        let _ = std::fs::remove_dir_all(&home);
2815    }
2816
2817    #[test]
2818    fn compaction_disclosure_keys_parse_with_typed_warnings() {
2819        let config = load_config_from_str(
2820            "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
2821        );
2822        assert_eq!(
2823            config.compaction_mode,
2824            crate::core::compaction::CompactionMode::LocalOnly
2825        );
2826        assert_eq!(
2827            config.compaction_exclude,
2828            vec![
2829                crate::core::compaction::ContentClass::Thinking,
2830                crate::core::compaction::ContentClass::ToolResults,
2831            ]
2832        );
2833        assert!(
2834            config.warnings.iter().any(|w| w.contains("bogus")),
2835            "unknown class must warn: {:?}",
2836            config.warnings
2837        );
2838
2839        let defaults = load_config_from_str("");
2840        assert_eq!(
2841            defaults.compaction_mode,
2842            crate::core::compaction::CompactionMode::Remote
2843        );
2844        assert!(defaults.compaction_exclude.is_empty());
2845
2846        let bad_mode = load_config_from_str("compaction_mode = cloud\n");
2847        assert_eq!(
2848            bad_mode.compaction_mode,
2849            crate::core::compaction::CompactionMode::Remote
2850        );
2851        assert!(bad_mode
2852            .warnings
2853            .iter()
2854            .any(|w| w.contains("compaction_mode")));
2855    }
2856
2857    #[test]
2858    #[serial]
2859    fn test_load_config_server_keys() {
2860        let home = make_test_home("server-keys");
2861        let cfg = home.join(".synaps-cli/config");
2862        std::fs::write(
2863            &cfg,
2864            "\
2865server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
2866server.token = my-secret-token\n\
2867server.auto_approve_confirms = true\n\
2868server.max_message_size = 65536\n\
2869context_window = 200k\n\
2870",
2871        )
2872        .unwrap();
2873
2874        with_home(&home, || {
2875            let config = load_config();
2876            assert_eq!(
2877                config.server.allowed_origins,
2878                vec![
2879                    "http://localhost:3000".to_string(),
2880                    "http://localhost:5193".to_string(),
2881                ]
2882            );
2883            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
2884            assert!(config.server.auto_approve_confirms);
2885            // Explicit max_message_size takes precedence over context_window derivation
2886            assert_eq!(config.server.max_message_size, Some(65536));
2887        });
2888
2889        let _ = std::fs::remove_dir_all(&home);
2890    }
2891
2892    #[test]
2893    #[serial]
2894    fn test_server_max_message_size_derived_from_context_window() {
2895        let home = make_test_home("server-derive");
2896        let cfg = home.join(".synaps-cli/config");
2897        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
2898
2899        with_home(&home, || {
2900            let config = load_config();
2901            // 200_000 tokens * 4 bytes/token = 800_000 bytes
2902            assert_eq!(config.server.max_message_size, Some(800_000));
2903        });
2904
2905        let _ = std::fs::remove_dir_all(&home);
2906    }
2907
2908    fn make_test_home(subdir: &str) -> std::path::PathBuf {
2909        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
2910        let _ = std::fs::remove_dir_all(&dir);
2911        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
2912        dir
2913    }
2914
2915    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
2916        let original = std::env::var("HOME").ok();
2917        std::env::set_var("HOME", home);
2918        f();
2919        if let Some(h) = original {
2920            std::env::set_var("HOME", h);
2921        } else {
2922            std::env::remove_var("HOME");
2923        }
2924    }
2925
2926    #[test]
2927    #[serial]
2928    fn write_config_value_replaces_existing_key() {
2929        let home = make_test_home("replace");
2930        let cfg = home.join(".synaps-cli/config");
2931        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
2932
2933        with_home(&home, || {
2934            write_config_value("model", "claude-sonnet-4-6").unwrap();
2935        });
2936
2937        let contents = std::fs::read_to_string(&cfg).unwrap();
2938        assert!(contents.contains("model = claude-sonnet-4-6"));
2939        assert!(contents.contains("thinking = low"));
2940        let _ = std::fs::remove_dir_all(&home);
2941    }
2942
2943    #[test]
2944    #[serial]
2945    fn write_config_value_appends_when_missing() {
2946        let home = make_test_home("append");
2947        let cfg = home.join(".synaps-cli/config");
2948        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
2949
2950        with_home(&home, || {
2951            write_config_value("theme", "dracula").unwrap();
2952        });
2953
2954        let contents = std::fs::read_to_string(&cfg).unwrap();
2955        assert!(contents.contains("model = claude-opus-4-6"));
2956        assert!(contents.contains("theme = dracula"));
2957        let _ = std::fs::remove_dir_all(&home);
2958    }
2959
2960    #[test]
2961    #[serial]
2962    fn write_config_value_concurrent_writers_lose_nothing() {
2963        let home = make_test_home("concurrent");
2964        let cfg = home.join(".synaps-cli/config");
2965        std::fs::write(&cfg, "# keep\n").unwrap();
2966
2967        // HOME is process-global; set it for the whole test, not per thread.
2968        with_home(&home, || {
2969            let a = std::thread::spawn(|| {
2970                for i in 0..200 {
2971                    write_config_value("a_key", &i.to_string()).unwrap();
2972                }
2973            });
2974            let b = std::thread::spawn(|| {
2975                for i in 0..200 {
2976                    write_config_value("b_key", &i.to_string()).unwrap();
2977                }
2978            });
2979            a.join().unwrap();
2980            b.join().unwrap();
2981        });
2982
2983        let contents = std::fs::read_to_string(&cfg).unwrap();
2984        assert!(contents.contains("# keep"), "{contents}");
2985        assert!(contents.contains("a_key = 199"), "{contents}");
2986        assert!(contents.contains("b_key = 199"), "{contents}");
2987        assert_eq!(contents.matches("a_key").count(), 1);
2988        assert_eq!(contents.matches("b_key").count(), 1);
2989
2990        #[cfg(unix)]
2991        {
2992            use std::os::unix::fs::PermissionsExt;
2993            let lock = home.join(".synaps-cli/config.lock");
2994            let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
2995            assert_eq!(mode, 0o600);
2996        }
2997        let _ = std::fs::remove_dir_all(&home);
2998    }
2999
3000    #[test]
3001    #[serial]
3002    fn config_lock_kill_switch_still_writes() {
3003        let home = make_test_home("lock-off");
3004        let cfg = home.join(".synaps-cli/config");
3005        std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
3006        with_home(&home, || {
3007            write_config_value("model", "claude-sonnet-4-6").unwrap();
3008        });
3009        std::env::remove_var("SYNAPS_CONFIG_LOCK");
3010        let contents = std::fs::read_to_string(&cfg).unwrap();
3011        assert!(contents.contains("model = claude-sonnet-4-6"));
3012        assert!(!home.join(".synaps-cli/config.lock").exists());
3013        let _ = std::fs::remove_dir_all(&home);
3014    }
3015
3016    #[test]
3017    #[serial]
3018    fn add_favorite_model_end_to_end_does_not_deadlock() {
3019        let home = make_test_home("favorite-lock");
3020        let cfg = home.join(".synaps-cli/config");
3021        with_home(&home, || {
3022            add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3023            add_favorite_model("openai/gpt-5").unwrap();
3024            remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3025        });
3026        let contents = std::fs::read_to_string(&cfg).unwrap();
3027        assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
3028        let _ = std::fs::remove_dir_all(&home);
3029    }
3030
3031    #[test]
3032    #[serial]
3033    fn write_config_value_preserves_comments() {
3034        let home = make_test_home("comments");
3035        let cfg = home.join(".synaps-cli/config");
3036        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
3037
3038        with_home(&home, || {
3039            write_config_value("model", "claude-sonnet-4-6").unwrap();
3040        });
3041
3042        let contents = std::fs::read_to_string(&cfg).unwrap();
3043        assert!(contents.contains("# user comment"));
3044        assert!(contents.contains("# another"));
3045        assert!(contents.contains("model = claude-sonnet-4-6"));
3046        let _ = std::fs::remove_dir_all(&home);
3047    }
3048
3049    #[test]
3050    #[serial]
3051    fn write_config_value_preserves_unknown_keys() {
3052        let home = make_test_home("unknown");
3053        let cfg = home.join(".synaps-cli/config");
3054        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
3055
3056        with_home(&home, || {
3057            write_config_value("model", "claude-sonnet-4-6").unwrap();
3058        });
3059
3060        let contents = std::fs::read_to_string(&cfg).unwrap();
3061        assert!(contents.contains("custom_thing = 42"));
3062        let _ = std::fs::remove_dir_all(&home);
3063    }
3064
3065    #[test]
3066    #[serial]
3067    fn write_config_value_creates_file_if_absent() {
3068        let home = make_test_home("create");
3069        let cfg = home.join(".synaps-cli/config");
3070        assert!(!cfg.exists());
3071
3072        with_home(&home, || {
3073            write_config_value("model", "claude-sonnet-4-6").unwrap();
3074        });
3075
3076        let contents = std::fs::read_to_string(&cfg).unwrap();
3077        assert!(contents.contains("model = claude-sonnet-4-6"));
3078        let _ = std::fs::remove_dir_all(&home);
3079    }
3080
3081    #[test]
3082    #[serial]
3083    fn load_config_parses_theme_key() {
3084        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
3085        let _ = std::fs::create_dir_all(&dir);
3086        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
3087
3088        let original_home = std::env::var("HOME").ok();
3089        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
3090
3091        let config = load_config();
3092
3093        if let Some(home) = original_home {
3094            std::env::set_var("HOME", home);
3095        } else {
3096            std::env::remove_var("HOME");
3097        }
3098        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
3099
3100        assert_eq!(config.theme.as_deref(), Some("dracula"));
3101    }
3102
3103    #[test]
3104    #[serial]
3105    fn test_load_config_disable_lists() {
3106        let test_dir =
3107            std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
3108        let _ = std::fs::create_dir_all(&test_dir);
3109        let config_path = test_dir.join("config");
3110
3111        let config_content = r#"
3112# Test config with disable lists
3113favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
3114
3115disabled_plugins = foo, bar
3116disabled_skills = baz, plug:qual
3117"#;
3118        std::fs::write(&config_path, config_content).unwrap();
3119
3120        let original_home = std::env::var("HOME").ok();
3121        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
3122
3123        let config = load_config();
3124
3125        if let Some(home) = original_home {
3126            std::env::set_var("HOME", home);
3127        } else {
3128            std::env::remove_var("HOME");
3129        }
3130
3131        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
3132
3133        assert_eq!(
3134            config.disabled_plugins,
3135            vec!["foo".to_string(), "bar".to_string()]
3136        );
3137        assert_eq!(
3138            config.favorite_models,
3139            vec![
3140                "claude/claude-opus-4-7".to_string(),
3141                "groq/llama-3.3-70b-versatile".to_string(),
3142            ]
3143        );
3144        assert_eq!(
3145            config.disabled_skills,
3146            vec!["baz".to_string(), "plug:qual".to_string()]
3147        );
3148    }
3149
3150    #[test]
3151    #[serial]
3152    fn favorite_model_helpers_round_trip_through_config_file() {
3153        let home = make_test_home("favorite-models");
3154        let cfg = home.join(".synaps-cli/config");
3155        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
3156
3157        with_home(&home, || {
3158            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3159            add_favorite_model("claude/claude-opus-4-7").unwrap();
3160            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3161            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
3162            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3163            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
3164            assert!(is_favorite_model("claude/claude-opus-4-7"));
3165        });
3166
3167        let contents = std::fs::read_to_string(&cfg).unwrap();
3168        assert!(contents.contains("model = claude-opus-4-7"));
3169        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
3170        let _ = std::fs::remove_dir_all(&home);
3171    }
3172
3173    #[test]
3174    #[serial]
3175    fn test_load_config_new_keys() {
3176        // Create a temporary config directory with the new keys
3177        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
3178        let _ = std::fs::create_dir_all(&test_dir);
3179        let config_path = test_dir.join("config");
3180
3181        let config_content = r#"
3182# Test config with new keys
3183model = claude-haiku
3184thinking = medium
3185max_tool_output = 50000
3186bash_timeout = 45
3187bash_max_timeout = 600
3188subagent_timeout = 120
3189api_retries = 5
3190"#;
3191        std::fs::write(&config_path, config_content).unwrap();
3192
3193        // Temporarily override the config path for this test
3194        let original_home = std::env::var("HOME").ok();
3195        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
3196
3197        let config = load_config();
3198
3199        // Restore original HOME
3200        if let Some(home) = original_home {
3201            std::env::set_var("HOME", home);
3202        } else {
3203            std::env::remove_var("HOME");
3204        }
3205
3206        // Cleanup
3207        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
3208
3209        assert_eq!(config.model, Some("claude-haiku".to_string()));
3210        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
3211        assert_eq!(config.max_tool_output, 50000);
3212        assert_eq!(config.bash_timeout, 45);
3213        assert_eq!(config.bash_max_timeout, 600);
3214        assert_eq!(config.subagent_timeout, 120);
3215        assert_eq!(config.api_retries, 5);
3216    }
3217
3218    // ── auth.* config + credential source (#157) ──
3219
3220    // ── events.auto_turn parser ──────────────────────────────────────────────
3221
3222    #[test]
3223    fn events_auto_turn_explicit_true_values() {
3224        for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
3225            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3226            assert!(
3227                cfg.events.auto_turn,
3228                "expected true for events.auto_turn = {val}"
3229            );
3230        }
3231    }
3232
3233    #[test]
3234    fn events_auto_turn_explicit_false_values() {
3235        for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
3236            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3237            assert!(
3238                !cfg.events.auto_turn,
3239                "expected false for events.auto_turn = {val}"
3240            );
3241        }
3242    }
3243
3244    #[test]
3245    fn events_auto_turn_typo_fails_safe_false() {
3246        // Unrecognised value should fail safe to false (with a warning on stderr).
3247        let cfg = load_config_from_str("events.auto_turn = fales");
3248        assert!(
3249            !cfg.events.auto_turn,
3250            "typo 'fales' must fail safe to false"
3251        );
3252    }
3253
3254    // ── events.auto_turn_cap parser ──────────────────────────────────────────
3255
3256    #[test]
3257    fn events_auto_turn_cap_default_is_five() {
3258        let cfg = load_config_from_str("");
3259        assert_eq!(cfg.events.auto_turn_cap, 5);
3260        assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
3261    }
3262
3263    #[test]
3264    fn events_auto_turn_cap_zero_is_unlimited() {
3265        let cfg = load_config_from_str("events.auto_turn_cap = 0");
3266        assert_eq!(cfg.events.auto_turn_cap, 0);
3267    }
3268
3269    #[test]
3270    fn events_auto_turn_cap_unlimited_aliases() {
3271        for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
3272            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3273            assert_eq!(
3274                cfg.events.auto_turn_cap, 0,
3275                "expected 0 for events.auto_turn_cap = {val:?}"
3276            );
3277        }
3278    }
3279
3280    #[test]
3281    fn events_auto_turn_cap_explicit_number() {
3282        let cfg = load_config_from_str("events.auto_turn_cap = 12");
3283        assert_eq!(cfg.events.auto_turn_cap, 12);
3284    }
3285
3286    #[test]
3287    fn events_auto_turn_cap_garbage_keeps_default() {
3288        for val in ["banana", "-3", "5.5", "", "true"] {
3289            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3290            assert_eq!(
3291                cfg.events.auto_turn_cap, 5,
3292                "garbage {val:?} must keep default 5"
3293            );
3294        }
3295    }
3296
3297    #[test]
3298    fn events_auto_turn_cap_independent_of_auto_turn() {
3299        let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
3300        assert!(!cfg.events.auto_turn);
3301        assert_eq!(cfg.events.auto_turn_cap, 0);
3302    }
3303
3304    #[test]
3305    fn events_auto_turn_default_is_true() {
3306        let cfg = load_config_from_str("model = claude-haiku");
3307        assert!(cfg.events.auto_turn, "default must be true when key absent");
3308    }
3309
3310    #[test]
3311    #[serial]
3312    fn test_auth_config_parses_endpoint_and_token() {
3313        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
3314        let dir = home.join(".synaps-cli");
3315        std::fs::create_dir_all(&dir).unwrap();
3316        std::fs::write(
3317            dir.join("config"),
3318            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
3319        )
3320        .unwrap();
3321        with_home(&home, || {
3322            let config = load_config();
3323            assert_eq!(
3324                config.auth.remote_endpoint.as_deref(),
3325                Some("https://jade.jade:8181")
3326            );
3327            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
3328            // auth.* are namespaced -> must NOT warn as unknown keys
3329            assert!(
3330                !config.warnings.iter().any(|w| w.contains("auth.")),
3331                "{:?}",
3332                config.warnings
3333            );
3334        });
3335        let _ = std::fs::remove_dir_all(&home);
3336    }
3337
3338    #[test]
3339    #[serial]
3340    fn test_credential_source_local_by_default() {
3341        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3342        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3343        assert!(!AuthConfig::default().credential_source().is_remote());
3344    }
3345
3346    #[test]
3347    #[serial]
3348    fn test_credential_source_remote_from_config() {
3349        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3350        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3351        let auth = AuthConfig {
3352            remote_endpoint: Some("https://b".into()),
3353            machine_token: Some("m".into()),
3354        };
3355        assert_eq!(
3356            auth.credential_source(),
3357            crate::core::auth::CredentialSource::Remote {
3358                endpoint: "https://b".into(),
3359                machine_token: "m".into()
3360            }
3361        );
3362    }
3363
3364    #[test]
3365    #[serial]
3366    fn test_credential_source_env_overrides_config() {
3367        let auth = AuthConfig {
3368            remote_endpoint: Some("https://config-host".into()),
3369            machine_token: Some("config-tok".into()),
3370        };
3371        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
3372        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
3373        let src = auth.credential_source();
3374        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3375        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3376        assert_eq!(
3377            src,
3378            crate::core::auth::CredentialSource::Remote {
3379                endpoint: "https://env-host".into(),
3380                machine_token: "env-tok".into()
3381            }
3382        );
3383    }
3384    #[test]
3385    fn config_parses_ultracode_as_distinct_canonical_level() {
3386        let cfg = load_config_from_str("thinking = ultracode\n");
3387        assert_eq!(
3388            cfg.thinking_level,
3389            Some(crate::reasoning::ReasoningLevel::UltraCode)
3390        );
3391        assert_eq!(cfg.thinking_budget, None);
3392        for other in [
3393            crate::reasoning::ReasoningLevel::Ultra,
3394            crate::reasoning::ReasoningLevel::Max,
3395            crate::reasoning::ReasoningLevel::XHigh,
3396        ] {
3397            assert_ne!(cfg.thinking_level, Some(other));
3398        }
3399    }
3400}