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    /// Whether the prompt shows a glow sweeping across it while a turn streams. Default off.
879    pub tui_streaming_glow: bool,
880    pub agent_name: Option<String>,
881    pub identity: Option<String>,
882    pub disabled_plugins: Vec<String>,
883    pub favorite_models: Vec<String>,
884    pub disabled_skills: Vec<String>,
885    /// Opt-in progressive tool disclosure (Task 18). When false, providers
886    /// receive the existing full tool schema byte-for-byte. When true, each
887    /// stream starts with the small essential local core plus discovery and
888    /// authorization gateways; exact activations are added per session.
889    pub progressive_tool_disclosure: bool,
890    /// Host confirmation policy for MODEL-INITIATED `activate_tools`
891    /// (`tools.activation_confirm`). `Auto` (default) authorizes without
892    /// prompting; `Prompt` asks the host (y/n confirm dialog); `Deny` never
893    /// authorizes (locked-down hosts). `server.auto_approve_confirms = true`
894    /// still authorizes regardless of this key.
895    pub tools_activation_confirm: ActivationConfirm,
896    /// Opt-in session persistence strategy (Task 35, spec §9.8). `Json`
897    /// (default) is the unchanged legacy full-rewrite path; `Journal` adds
898    /// an append-only delta journal with periodic atomic snapshots. See
899    /// docs/decisions/T35-session-journal-opt-in.md.
900    pub session_persistence: crate::core::session_journal::SessionPersistence,
901    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
902    /// from the registry at boot so they're never offered to the model.
903    pub disabled_tools: Vec<String>,
904    pub shell: ShellConfig,
905    pub server: ServerConfig,
906    pub bridge: BridgeConfig,
907    pub auth: AuthConfig,
908    pub events: EventsConfig,
909    pub provider_keys: BTreeMap<String, String>,
910    pub keybinds: std::collections::HashMap<String, String>,
911    /// Typed per-role turn budgets (Task 23, spec §8.1).
912    pub turn_budgets: TurnBudgetsConfig,
913    /// Continuous-memory settings surface (Task A2, spec §12).
914    pub memory: MemoryConfig,
915    /// Host-owned backend; selecting it does not enable recall or capture.
916    pub memory_backend: MemoryBackendConfig,
917    /// Non-fatal problems found while parsing the config file (unknown keys,
918    /// unparseable values). Surfaced once at startup — never block boot.
919    pub warnings: Vec<String>,
920}
921
922impl Default for SynapsConfig {
923    fn default() -> Self {
924        Self {
925            model: None,
926            thinking_budget: None,
927            thinking_level: None,
928            context_window: None,
929            context_management: ContextManagementConfig::default(),
930            startup: StartupConfig::default(),
931            daemon: DaemonConfig::default(),
932            compaction_model: None,
933            compaction_mode: crate::core::compaction::CompactionMode::default(),
934            compaction_exclude: Vec::new(),
935            max_tool_output: 30000,
936            bash_timeout: 30,
937            bash_max_timeout: 300,
938            subagent_timeout: 300,
939            api_retries: 3,
940            refusal_retries: 2,
941            telemetry: "off".to_string(),
942            cache_diagnostics: false,
943            cache_ttl: CacheTtl::default(),
944            max_fps: 60,
945            scroll_lines: None,
946            theme: None,
947            theme_transition: ThemeTransitionMode::default(),
948            tui_background_opaque: true,
949            tui_streaming_glow: false,
950            agent_name: None,
951            identity: None,
952            disabled_plugins: Vec::new(),
953            favorite_models: Vec::new(),
954            disabled_skills: Vec::new(),
955            progressive_tool_disclosure: false,
956            tools_activation_confirm: ActivationConfirm::default(),
957            session_persistence: crate::core::session_journal::SessionPersistence::default(),
958            disabled_tools: Vec::new(),
959            shell: ShellConfig::default(),
960            server: ServerConfig::default(),
961            bridge: BridgeConfig::default(),
962            auth: AuthConfig::default(),
963            events: EventsConfig::default(),
964            provider_keys: BTreeMap::new(),
965            keybinds: std::collections::HashMap::new(),
966            turn_budgets: TurnBudgetsConfig::default(),
967            memory: MemoryConfig::default(),
968            memory_backend: MemoryBackendConfig::default(),
969            warnings: Vec::new(),
970        }
971    }
972}
973
974/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
975const KNOWN_CONFIG_KEYS: &[&str] = &[
976    "model",
977    "thinking",
978    "compaction_model",
979    "context_window",
980    "context_management.mode",
981    "context_management.pressure_tokens",
982    "context_management.rollover_tokens",
983    "context_management.reserve_tokens",
984    "context_management.finish_rounds",
985    "startup.quick_start",
986    "startup.extensions_ready_timeout_secs",
987    "daemon.idle_exit_secs",
988    "daemon.prompt_abandon_secs",
989    "daemon.parked_evict_secs",
990    "max_tool_output",
991    "bash_timeout",
992    "bash_max_timeout",
993    "subagent_timeout",
994    "api_retries",
995    "refusal_retries",
996    "telemetry",
997    "cache_diagnostics",
998    "cache_ttl",
999    "max_fps",
1000    "scroll_lines",
1001    "theme",
1002    "theme_transition",
1003    "tui_background_opaque",
1004    "tui_streaming_glow",
1005    "agent_name",
1006    "identity",
1007    "disabled_plugins",
1008    "favorite_models",
1009    "disabled_skills",
1010    "disabled_tools",
1011    "progressive_tool_disclosure",
1012    "tools.activation_confirm",
1013    "session_persistence",
1014];
1015
1016/// Simple Levenshtein distance for did-you-mean suggestions.
1017fn levenshtein(a: &str, b: &str) -> usize {
1018    let a: Vec<char> = a.chars().collect();
1019    let b: Vec<char> = b.chars().collect();
1020    let mut prev: Vec<usize> = (0..=b.len()).collect();
1021    let mut cur = vec![0; b.len() + 1];
1022    for (i, ca) in a.iter().enumerate() {
1023        cur[0] = i + 1;
1024        for (j, cb) in b.iter().enumerate() {
1025            let cost = if ca == cb { 0 } else { 1 };
1026            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1027        }
1028        std::mem::swap(&mut prev, &mut cur);
1029    }
1030    prev[b.len()]
1031}
1032
1033/// Closest known key within edit distance 2, for typo suggestions.
1034fn did_you_mean(key: &str) -> Option<&'static str> {
1035    KNOWN_CONFIG_KEYS
1036        .iter()
1037        .map(|k| (*k, levenshtein(key, k)))
1038        .filter(|(_, d)| *d <= 2)
1039        .min_by_key(|(_, d)| *d)
1040        .map(|(k, _)| k)
1041}
1042
1043// parse_thinking_budget replaced by ThinkingSpec::parse in apply_config_content.
1044
1045fn parse_comma_list(val: &str) -> Vec<String> {
1046    val.split(',')
1047        .map(|s| s.trim().to_string())
1048        .filter(|s| !s.is_empty())
1049        .collect()
1050}
1051
1052fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1053    write_config_value_locked(key, &values.join(", "))
1054}
1055
1056/// Parse shell.* configuration keys and update the ShellConfig.
1057fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
1058    match key {
1059        "shell.max_sessions" => {
1060            if let Ok(sessions) = val.parse::<usize>() {
1061                shell_config.max_sessions = sessions;
1062            } else {
1063                eprintln!(
1064                    "Warning: invalid value for shell.max_sessions: '{}', using default",
1065                    val
1066                );
1067            }
1068        }
1069        "shell.idle_timeout" => {
1070            if let Ok(timeout) = val.parse::<u64>() {
1071                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1072            } else {
1073                eprintln!(
1074                    "Warning: invalid value for shell.idle_timeout: '{}', using default",
1075                    val
1076                );
1077            }
1078        }
1079        "shell.readiness_timeout_ms" => {
1080            if let Ok(timeout) = val.parse::<u64>() {
1081                shell_config.readiness_timeout_ms = timeout;
1082            } else {
1083                eprintln!(
1084                    "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
1085                    val
1086                );
1087            }
1088        }
1089        "shell.max_readiness_timeout_ms" => {
1090            if let Ok(timeout) = val.parse::<u64>() {
1091                shell_config.max_readiness_timeout_ms = timeout;
1092            } else {
1093                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
1094            }
1095        }
1096        "shell.default_rows" => {
1097            if let Ok(rows) = val.parse::<u16>() {
1098                shell_config.default_rows = rows;
1099            } else {
1100                eprintln!(
1101                    "Warning: invalid value for shell.default_rows: '{}', using default",
1102                    val
1103                );
1104            }
1105        }
1106        "shell.default_cols" => {
1107            if let Ok(cols) = val.parse::<u16>() {
1108                shell_config.default_cols = cols;
1109            } else {
1110                eprintln!(
1111                    "Warning: invalid value for shell.default_cols: '{}', using default",
1112                    val
1113                );
1114            }
1115        }
1116        "shell.readiness_strategy" => {
1117            let val_lower = val.to_lowercase();
1118            match val_lower.as_str() {
1119                "timeout" | "prompt" | "hybrid" => {
1120                    shell_config.readiness_strategy = val.to_string();
1121                }
1122                _ => {
1123                    eprintln!(
1124                        "Warning: invalid value for shell.readiness_strategy: '{}', using default",
1125                        val
1126                    );
1127                }
1128            }
1129        }
1130        "shell.max_output" => {
1131            if let Ok(max_output) = val.parse::<usize>() {
1132                shell_config.max_output = max_output;
1133            } else {
1134                eprintln!(
1135                    "Warning: invalid value for shell.max_output: '{}', using default",
1136                    val
1137                );
1138            }
1139        }
1140        _ => {
1141            // Unknown shell.* keys are preserved (not rejected)
1142        }
1143    }
1144}
1145
1146/// Parse server.* configuration keys and update the ServerConfig.
1147#[allow(clippy::collapsible_match)]
1148fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1149    match key {
1150        "server.allowed_origins" => {
1151            server_config.allowed_origins = parse_comma_list(val);
1152        }
1153        "server.token" => {
1154            if !val.is_empty() {
1155                server_config.token = Some(val.to_string());
1156            }
1157        }
1158        "server.auto_approve_confirms" => {
1159            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1160        }
1161        "server.max_message_size" => {
1162            if let Ok(size) = val.parse::<usize>() {
1163                server_config.max_message_size = Some(size);
1164            } else {
1165                eprintln!(
1166                    "Warning: invalid value for server.max_message_size: '{}', ignored",
1167                    val
1168                );
1169            }
1170        }
1171        _ => {
1172            // Unknown server.* keys preserved (not rejected)
1173        }
1174    }
1175}
1176
1177/// Parse bridge.* configuration keys and update the BridgeConfig.
1178fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1179    match key {
1180        "bridge.uds_path" => {
1181            if val.is_empty() {
1182                bridge_config.uds_path = None;
1183            } else {
1184                bridge_config.uds_path = Some(PathBuf::from(val));
1185            }
1186        }
1187        "bridge.heartbeat_mirror" => {
1188            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
1189        }
1190        "bridge.heartbeat_timeout_ms" => {
1191            if let Ok(ms) = val.parse::<u64>() {
1192                bridge_config.heartbeat_timeout_ms = ms;
1193            } else {
1194                eprintln!(
1195                    "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
1196                    val
1197                );
1198            }
1199        }
1200        _ => {
1201            // Unknown bridge.* keys preserved (not rejected)
1202        }
1203    }
1204}
1205
1206/// Parse auth.* configuration keys and update the AuthConfig.
1207fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1208    let v = val.trim();
1209    match key {
1210        "auth.remote_endpoint" => {
1211            auth_config.remote_endpoint = if v.is_empty() {
1212                None
1213            } else {
1214                Some(v.to_string())
1215            };
1216        }
1217        "auth.machine_token" => {
1218            auth_config.machine_token = if v.is_empty() {
1219                None
1220            } else {
1221                Some(v.to_string())
1222            };
1223        }
1224        _ => {
1225            // Unknown auth.* keys preserved (not rejected)
1226        }
1227    }
1228}
1229
1230/// Parse config from a raw string — useful for tests and embedded harnesses.
1231/// Does NOT write to `PROVIDER_KEYS` or `IDENTITY` OnceLocks.
1232pub fn load_config_from_str(content: &str) -> SynapsConfig {
1233    let mut config = SynapsConfig::default();
1234    apply_config_content(&mut config, content);
1235    config
1236}
1237
1238/// Parse the config file at ~/.synaps-cli/config (or profile variant).
1239/// Returns default config if file doesn't exist or can't be read.
1240pub fn load_config() -> SynapsConfig {
1241    let path = resolve_read_path("config");
1242    let mut config = SynapsConfig::default();
1243
1244    let Ok(content) = std::fs::read_to_string(&path) else {
1245        return config;
1246    };
1247
1248    apply_config_content(&mut config, &content);
1249
1250    // Publish provider keys to the process-wide cache for the API router.
1251    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
1252    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1253
1254    // Publish identity to the process-wide cache for API system prompt preamble.
1255    let identity_val = config
1256        .identity
1257        .clone()
1258        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1259    let _ = IDENTITY.set(identity_val);
1260
1261    config
1262}
1263
1264/// Apply key=value config lines from `content` into `config`.
1265/// Shared by `load_config` (file path) and `load_config_from_str` (test helper).
1266fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1267    let mut invalid_context_management = false;
1268    let mut invalid_memory_backend = false;
1269    for line in content.lines() {
1270        let line = line.trim();
1271        if line.is_empty() || line.starts_with('#') {
1272            continue;
1273        }
1274        let Some((key, val)) = line.split_once('=') else {
1275            continue;
1276        };
1277        let key = key.trim();
1278        let val = val.trim();
1279        match key {
1280            "model" => config.model = Some(val.to_string()),
1281            "thinking" => {
1282                use crate::core::reasoning::ThinkingSpec;
1283                match ThinkingSpec::parse(val) {
1284                    Some(ThinkingSpec::Named(level)) => {
1285                        config.thinking_level = Some(level);
1286                        config.thinking_budget = level.to_legacy_budget();
1287                    }
1288                    Some(ThinkingSpec::Budget(budget)) => {
1289                        // Preserve exact legacy budgets; do not make the derived
1290                        // bucket authoritative over the user's token count.
1291                        config.thinking_level = None;
1292                        config.thinking_budget = Some(budget);
1293                    }
1294                    None => {
1295                        config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
1296                    }
1297                }
1298            }
1299            "compaction_model" => config.compaction_model = Some(val.to_string()),
1300            "compaction_mode" => match val {
1301                "remote" => {
1302                    config.compaction_mode = crate::core::compaction::CompactionMode::Remote
1303                }
1304                "local" | "local_only" | "local-only" => {
1305                    config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
1306                }
1307                _ => {
1308                    config.warnings.push(format!(
1309                        "compaction_mode = {val} — expected remote or local; ignored"
1310                    ));
1311                }
1312            },
1313            "compaction_exclude" => {
1314                let mut classes = Vec::new();
1315                for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1316                    match crate::core::compaction::ContentClass::parse(part) {
1317                        Some(class) => {
1318                            if !classes.contains(&class) {
1319                                classes.push(class);
1320                            }
1321                        }
1322                        None => config.warnings.push(format!(
1323                            "compaction_exclude — unknown content class '{part}'; \
1324                             expected one of: user_text, assistant_text, thinking, \
1325                             tool_calls, tool_results, file_paths, event_data"
1326                        )),
1327                    }
1328                }
1329                config.compaction_exclude = classes;
1330            }
1331            "context_window" => {
1332                let parsed = match val {
1333                    "200k" | "200K" => Some(200_000),
1334                    "1m" | "1M" => Some(1_000_000),
1335                    _ => val.parse::<u64>().ok(),
1336                };
1337                if parsed.is_none() {
1338                    config.warnings.push(format!(
1339                        "context_window = {val} — expected 200k, 1m, or a token count; ignored"
1340                    ));
1341                }
1342                config.context_window = parsed;
1343            }
1344            "max_tool_output" => match val.parse::<usize>() {
1345                Ok(size) => config.max_tool_output = size,
1346                Err(_) => config.warnings.push(format!(
1347                    "max_tool_output = {val} — not a number; using {}",
1348                    config.max_tool_output
1349                )),
1350            },
1351            "bash_timeout" => match val.parse::<u64>() {
1352                Ok(t) if t >= 1 => config.bash_timeout = t,
1353                Ok(_) => config.warnings.push(format!(
1354                    "bash_timeout = {val} — below minimum (1s); using {}",
1355                    config.bash_timeout
1356                )),
1357                Err(_) => config.warnings.push(format!(
1358                    "bash_timeout = {val} — not a number; using {}",
1359                    config.bash_timeout
1360                )),
1361            },
1362            "bash_max_timeout" => {
1363                if let Ok(timeout) = val.parse::<u64>() {
1364                    config.bash_max_timeout = timeout;
1365                }
1366            }
1367            "subagent_timeout" => {
1368                if let Ok(timeout) = val.parse::<u64>() {
1369                    config.subagent_timeout = timeout;
1370                }
1371            }
1372            "api_retries" => {
1373                if let Ok(retries) = val.parse::<u32>() {
1374                    config.api_retries = retries;
1375                }
1376            }
1377            "refusal_retries" => {
1378                if let Ok(retries) = val.parse::<u32>() {
1379                    config.refusal_retries = retries;
1380                }
1381            }
1382            "telemetry" => config.telemetry = val.to_string(),
1383            "cache_diagnostics" => {
1384                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1385            }
1386            "cache_ttl" => match CacheTtl::parse(val) {
1387                Some(ttl) => config.cache_ttl = ttl,
1388                None => config.warnings.push(format!(
1389                    "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1390                )),
1391            },
1392            "max_fps" => match val.parse::<u32>() {
1393                Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1394                Ok(_) => config.warnings.push(format!(
1395                    "max_fps = {val} — expected 1–1000; using {}",
1396                    config.max_fps
1397                )),
1398                Err(_) => config.warnings.push(format!(
1399                    "max_fps = {val} — not a number; using {}",
1400                    config.max_fps
1401                )),
1402            },
1403            "scroll_lines" => match val.parse::<u16>() {
1404                Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1405                Ok(_) => config
1406                    .warnings
1407                    .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1408                Err(_) => config
1409                    .warnings
1410                    .push(format!("scroll_lines = {val} — not a number; ignoring")),
1411            },
1412            "theme" => config.theme = Some(val.to_string()),
1413            "theme_transition" => match ThemeTransitionMode::parse(val) {
1414                Some(mode) => config.theme_transition = mode,
1415                None => config.warnings.push(format!(
1416                    "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1417                )),
1418            },
1419            "tui_background_opaque" => match val {
1420                "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1421                "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1422                _ => config.warnings.push(format!(
1423                    "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1424                    if config.tui_background_opaque {
1425                        "opaque"
1426                    } else {
1427                        "invisible"
1428                    }
1429                )),
1430            },
1431            "tui_streaming_glow" => match val {
1432                "true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
1433                "false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
1434                _ => config.warnings.push(format!(
1435                    "tui_streaming_glow = {val} — expected on or off; using off"
1436                )),
1437            },
1438            "agent_name" => config.agent_name = Some(val.to_string()),
1439            "identity" => config.identity = Some(val.to_string()),
1440            "disabled_plugins" => {
1441                config.disabled_plugins = parse_comma_list(val);
1442            }
1443            "favorite_models" => {
1444                config.favorite_models = parse_comma_list(val);
1445            }
1446            "disabled_skills" => {
1447                config.disabled_skills = parse_comma_list(val);
1448            }
1449            "progressive_tool_disclosure" => {
1450                config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1451            }
1452            "tools.activation_confirm" => match ActivationConfirm::parse(val) {
1453                Some(mode) => config.tools_activation_confirm = mode,
1454                None => config.warnings.push(format!(
1455                    "tools.activation_confirm = {val} — expected auto, prompt or deny; \
1456                     keeping the default (auto)"
1457                )),
1458            },
1459            "session_persistence" => {
1460                match crate::core::session_journal::SessionPersistence::parse(val) {
1461                    Some(mode) => config.session_persistence = mode,
1462                    None => config.warnings.push(format!(
1463                        "session_persistence = {val} — expected json or journal; \
1464                         keeping the default (json)"
1465                    )),
1466                }
1467            }
1468            "disabled_tools" => {
1469                config.disabled_tools = parse_comma_list(val);
1470            }
1471            "memory.backend" => {
1472                config.memory_backend.kind = match val {
1473                    "legacy" => MemoryBackendKind::Legacy,
1474                    "axel" => MemoryBackendKind::Axel,
1475                    _ => {
1476                        invalid_memory_backend = true;
1477                        config.warnings.push(format!(
1478                            "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
1479                        ));
1480                        MemoryBackendKind::Unavailable
1481                    }
1482                };
1483            }
1484            "memory.user_scope" => match val {
1485                "true" => config.memory_backend.user_scope = true,
1486                "false" => config.memory_backend.user_scope = false,
1487                _ => {
1488                    invalid_memory_backend = true;
1489                    config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
1490                }
1491            },
1492            "memory.axel.executable" | "memory.axel.brain" => {
1493                let path = PathBuf::from(val);
1494                if !path.is_absolute() {
1495                    invalid_memory_backend = true;
1496                    config.warnings.push(format!(
1497                        "{key} — expected an absolute path; memory backend unavailable"
1498                    ));
1499                }
1500                // Retain invalid paths too: direct config consumers must not
1501                // mistake an invalid explicit path for an absent default.
1502                if key == "memory.axel.executable" {
1503                    config.memory_backend.executable = Some(path);
1504                } else {
1505                    config.memory_backend.brain = Some(path);
1506                }
1507            }
1508            _ => {
1509                // Handle namespaced keys
1510                if key.starts_with("shell.") {
1511                    parse_shell_config_key(&mut config.shell, key, val);
1512                } else if key.starts_with("server.") {
1513                    parse_server_config_key(&mut config.server, key, val);
1514                } else if key.starts_with("bridge.") {
1515                    parse_bridge_config_key(&mut config.bridge, key, val);
1516                } else if key.starts_with("auth.") {
1517                    parse_auth_config_key(&mut config.auth, key, val);
1518                } else if key.starts_with("events.") {
1519                    parse_events_config_key(&mut config.events, key, val);
1520                } else if key.starts_with("turn_budget.") {
1521                    parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1522                } else if key.starts_with("memory.") {
1523                    parse_memory_config_key(&mut config.memory, key, val);
1524                } else if key.starts_with("startup.") {
1525                    if let Err(reason) =
1526                        parse_startup_config_key(&mut config.startup, key, val)
1527                    {
1528                        config.warnings.push(format!("{key} — {reason}"));
1529                    }
1530                } else if key.starts_with("daemon.") {
1531                    if let Err(reason) =
1532                        parse_daemon_config_key(&mut config.daemon, key, val)
1533                    {
1534                        config.warnings.push(format!("{key} — {reason}"));
1535                    }
1536                } else if key.starts_with("context_management.") {
1537                    if let Err(reason) = parse_context_management_config_key(
1538                        &mut config.context_management, key, val,
1539                    ) {
1540                        invalid_context_management = true;
1541                        config.warnings.push(format!("{key} — {reason}"));
1542                    }
1543                } else if let Some(provider_key) = key.strip_prefix("provider.") {
1544                    config
1545                        .provider_keys
1546                        .insert(provider_key.to_string(), val.to_string());
1547                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1548                    config
1549                        .keybinds
1550                        .insert(keybind_key.to_string(), val.to_string());
1551                } else if key.contains('.') {
1552                    // Dotted keys are namespaced (plugin/extension config, e.g.
1553                    // `knowledge.jawz_notes`). Plugins define their own keys —
1554                    // not ours to police. Silently preserved.
1555                } else {
1556                    // Unknown top-level key — warn with a did-you-mean if close.
1557                    match did_you_mean(key) {
1558                        Some(suggestion) => config.warnings.push(format!(
1559                            "unknown key '{key}' (did you mean '{suggestion}'?)"
1560                        )),
1561                        None => config
1562                            .warnings
1563                            .push(format!("unknown key '{key}' — ignored")),
1564                    }
1565                }
1566            }
1567        }
1568    }
1569
1570    // Never leave a partially parsed automatic policy enabled. Syntax errors
1571    // invalidate this whole parse pass, even if a later duplicate is valid.
1572    let context_validation = match config.context_window {
1573        Some(window) => config.context_management.validate_for_window(window),
1574        None => config.context_management.validate(),
1575    };
1576    if let Err(reason) = context_validation {
1577        invalid_context_management = true;
1578        config
1579            .warnings
1580            .push(format!("context_management — {reason}"));
1581    }
1582    if invalid_context_management {
1583        config.context_management = ContextManagementConfig::default();
1584        config.warnings.push(
1585            "invalid context_management configuration — using defaults (mode off)".to_string(),
1586        );
1587    }
1588
1589    // Any invalid backend setting poisons this parse pass, regardless of key
1590    // order or later duplicate selectors. Never silently route to legacy.
1591    if invalid_memory_backend {
1592        config.memory_backend.kind = MemoryBackendKind::Unavailable;
1593    }
1594
1595    // Fail-closed operator-consent rule (Task A2, spec §12): a non-"off"
1596    // `memory.default_mode` takes effect only when the operator also set
1597    // `memory.default_mode_confirmed = true` in this same parse pass.
1598    // Checked after the loop so key order never matters.
1599    if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1600        config.warnings.push(format!(
1601            "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1602             in the same config; reverting to off",
1603            config.memory.default_mode
1604        ));
1605        config.memory.default_mode = "off".to_string();
1606    }
1607
1608    // Derive max_message_size from context_window if not explicitly set.
1609    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
1610    if config.server.max_message_size.is_none() {
1611        if let Some(ctx_tokens) = config.context_window {
1612            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1613        }
1614    }
1615}
1616
1617/// Read a single config value by exact key from the active config file.
1618pub fn read_config_value(key: &str) -> Option<String> {
1619    let path = resolve_read_path("config");
1620    let content = std::fs::read_to_string(&path).ok()?;
1621    for line in content.lines() {
1622        let line = line.trim();
1623        if line.is_empty() || line.starts_with('#') {
1624            continue;
1625        }
1626        let Some((k, v)) = line.split_once('=') else {
1627            continue;
1628        };
1629        if k.trim() == key.trim() {
1630            return Some(v.trim().to_string());
1631        }
1632    }
1633    None
1634}
1635
1636/// Exclusive advisory lock on `<config>.lock`, held around every
1637/// read-modify-write of the config file (same pattern as `auth.json`).
1638/// Two writers (`/settings` in two processes, a `/model` favorite toggle
1639/// racing a settings edit) no longer lose each other's updates.
1640///
1641/// Held for the RMW only (< 1 ms); readers (`load_config`) never take it —
1642/// they already tolerate the atomic rename. `SYNAPS_CONFIG_LOCK=0` bypasses.
1643///
1644/// NOT reentrant: flock on a fresh fd of the same file from the same process
1645/// conflicts on Linux. Callers that already hold the guard must use the
1646/// `_locked` inner fns.
1647struct ConfigLock {
1648    _file: Option<std::fs::File>,
1649}
1650
1651impl ConfigLock {
1652    fn acquire() -> std::io::Result<Self> {
1653        if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
1654            return Ok(Self { _file: None });
1655        }
1656        use fs4::fs_std::FileExt;
1657        let lock_path = resolve_write_path("config").with_extension("lock");
1658        if let Some(parent) = lock_path.parent() {
1659            std::fs::create_dir_all(parent)?;
1660        }
1661        let mut open = std::fs::OpenOptions::new();
1662        open.create(true).write(true).truncate(false);
1663        #[cfg(unix)]
1664        {
1665            use std::os::unix::fs::OpenOptionsExt;
1666            open.mode(0o600);
1667        }
1668        let file = open.open(&lock_path)?;
1669        FileExt::lock_exclusive(&file)?;
1670        Ok(Self { _file: Some(file) })
1671    }
1672}
1673
1674/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
1675/// Replaces the first existing line that matches the key, or appends if absent.
1676/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
1677pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1678    let _guard = ConfigLock::acquire()?;
1679    write_config_value_locked(key, value)
1680}
1681
1682/// The RMW body; caller holds [`ConfigLock`].
1683fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
1684    let path = resolve_write_path("config");
1685    let existing = std::fs::read_to_string(&path).unwrap_or_default();
1686
1687    let key_trimmed = key.trim();
1688    let replacement = format!("{} = {}", key_trimmed, value);
1689
1690    let mut found = false;
1691    let mut new_lines: Vec<String> = existing
1692        .lines()
1693        .map(|line| {
1694            if found {
1695                return line.to_string();
1696            }
1697            let t = line.trim_start();
1698            if t.starts_with('#') || t.is_empty() {
1699                return line.to_string();
1700            }
1701            if let Some((k, _)) = t.split_once('=') {
1702                if k.trim() == key_trimmed {
1703                    found = true;
1704                    return replacement.clone();
1705                }
1706            }
1707            line.to_string()
1708        })
1709        .collect();
1710
1711    if !found {
1712        new_lines.push(replacement);
1713    }
1714
1715    let mut out = new_lines.join("\n");
1716    if !out.ends_with('\n') {
1717        out.push('\n');
1718    }
1719
1720    let tmp = path.with_extension("tmp");
1721    std::fs::write(&tmp, out)?;
1722    // Config may contain API keys — restrict to owner-only
1723    #[cfg(unix)]
1724    {
1725        use std::os::unix::fs::PermissionsExt;
1726        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1727    }
1728    std::fs::rename(&tmp, &path)?;
1729    Ok(())
1730}
1731
1732/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
1733pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1734    let trimmed = id.trim();
1735    if trimmed.is_empty() {
1736        return Ok(());
1737    }
1738    let _guard = ConfigLock::acquire()?;
1739    let mut values = load_config().favorite_models;
1740    if !values.iter().any(|v| v == trimmed) {
1741        values.push(trimmed.to_string());
1742        values.sort();
1743    }
1744    write_comma_list_locked("favorite_models", &values)
1745}
1746
1747/// Remove a favorite model id (`provider/model`) from config.
1748pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1749    let _guard = ConfigLock::acquire()?;
1750    let mut values = load_config().favorite_models;
1751    values.retain(|v| v != id.trim());
1752    write_comma_list_locked("favorite_models", &values)
1753}
1754
1755/// Return whether a model id is marked as favorite.
1756pub fn is_favorite_model(id: &str) -> bool {
1757    load_config().favorite_models.iter().any(|v| v == id.trim())
1758}
1759
1760/// Whether a key matches the secret denylist (case-insensitive).
1761/// Used by the journal save path to ensure secret values never reach disk,
1762/// and by the client env capture to strip secrets before `Hello`.
1763pub fn is_secret_key(key: &str) -> bool {
1764    let upper = key.to_ascii_uppercase();
1765    upper.ends_with("_KEY")
1766        || upper.ends_with("_TOKEN")
1767        || upper.ends_with("_CREDENTIALS")
1768        || upper.ends_with("_API_KEY")
1769        || upper.contains("SECRET")
1770        || upper.contains("PASSWORD")
1771        || upper.contains("PASSWD")
1772        || upper.ends_with("_PAT")
1773        || upper.ends_with("_DSN")
1774}
1775
1776/// Whether an env VALUE carries a credential regardless of its key name:
1777/// any URL with userinfo (`scheme://user:pass@host`, `scheme://key@host`),
1778/// anywhere in the value — `DATABASE_URL`, `HTTPS_PROXY`, a Sentry DSN or a
1779/// JDBC option string all qualify. The key-name denylist alone missed these,
1780/// so their credentials reached the daemon and the session files on disk.
1781/// A username-only URL (`ssh://git@host/…`) is treated as a secret too:
1782/// rare in an environment, and failing safe beats a guess.
1783pub fn is_secret_env_value(value: &str) -> bool {
1784    let mut rest = value;
1785    while let Some(i) = rest.find("://") {
1786        let scheme_ok = rest[..i]
1787            .chars()
1788            .next_back()
1789            .is_some_and(|c| c.is_ascii_alphanumeric());
1790        let after = &rest[i + 3..];
1791        // The authority ends at the path, query or fragment, or at a list /
1792        // option separator when several URLs share one value.
1793        let end = after
1794            .find(|c: char| {
1795                matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
1796            })
1797            .unwrap_or(after.len());
1798        if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
1799            return true;
1800        }
1801        rest = after;
1802    }
1803    false
1804}
1805
1806/// Whether an env pair must never reach the daemon or disk: a secret key
1807/// name OR a value carrying credentials (see [`is_secret_env_value`]).
1808pub fn is_secret_env(key: &str, value: &str) -> bool {
1809    is_secret_key(key) || is_secret_env_value(value)
1810}
1811
1812/// Resolve the system prompt from CLI flag, config file, or default.
1813/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
1814pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1815    #[cfg(windows)]
1816    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
1817        You have access to powershell, bash, read, and write tools. \
1818        Prefer the powershell tool for system commands and filesystem operations — it is native \
1819        to Windows and avoids quoting/path translation issues. \
1820        Use bash only when the task specifically requires WSL or a Unix environment. \
1821        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1822    #[cfg(not(windows))]
1823    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1824        You have access to bash, read, and write tools. \
1825        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1826
1827    if let Some(val) = explicit {
1828        let path = std::path::Path::new(val);
1829        if path.exists() && path.is_file() {
1830            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1831        }
1832        return val.to_string();
1833    }
1834
1835    let system_path = resolve_read_path("system.md");
1836    if system_path.exists() {
1837        return std::fs::read_to_string(&system_path).unwrap_or_default();
1838    }
1839
1840    DEFAULT_PROMPT.to_string()
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845    #[test]
1846    fn secret_env_value_detects_credential_urls() {
1847        use super::is_secret_env_value as v;
1848        // user:password in the authority
1849        assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
1850        assert!(v("redis://:pw@cache:6379/0"));
1851        assert!(v("http://user:pass@proxy.corp:3128"));
1852        assert!(v(
1853            "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
1854        ));
1855        // a key as the username, no password (Sentry DSN)
1856        assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
1857        // anywhere in the value, not only at the start; lists
1858        assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
1859        assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
1860        // no userinfo → not a secret
1861        assert!(!v("https://api.example.com/v1/items?q=a@b"));
1862        assert!(!v("http://host/path/with@sign"));
1863        assert!(!v("https://@host/empty-userinfo"));
1864        assert!(!v("git@github.com:org/repo.git"));
1865        assert!(!v("mailto:someone@example.com"));
1866        assert!(!v("someone@example.com"));
1867        assert!(!v("/usr/local/bin:/usr/bin"));
1868        assert!(!v(""));
1869    }
1870
1871    #[test]
1872    fn secret_key_covers_pat_and_dsn() {
1873        assert!(super::is_secret_key("GITHUB_PAT"));
1874        assert!(super::is_secret_key("gh_pat"));
1875        assert!(super::is_secret_key("SENTRY_DSN"));
1876        assert!(!super::is_secret_key("PATH"));
1877        assert!(!super::is_secret_key("PATTERN_DIR"));
1878    }
1879
1880    #[test]
1881    fn secret_env_is_key_or_value() {
1882        assert!(super::is_secret_env(
1883            "DATABASE_URL",
1884            "postgres://app:hunter2@db/prod"
1885        ));
1886        assert!(super::is_secret_env("GH_TOKEN", "anything"));
1887        assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
1888        assert!(!super::is_secret_env("HOME", "/home/u"));
1889    }
1890    #[test]
1891    fn startup_and_daemon_defaults() {
1892        let c = super::load_config_from_str("");
1893        assert!(c.startup.quick_start, "quick_start defaults ON");
1894        assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
1895        assert_eq!(c.daemon.idle_exit_secs, 10);
1896        assert_eq!(c.daemon.prompt_abandon_secs, 3600);
1897        assert_eq!(c.daemon.parked_evict_secs, 3600);
1898        // No warnings for the absent keys.
1899        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
1900    }
1901
1902    #[test]
1903    fn startup_and_daemon_parse_all_keys() {
1904        let c = super::load_config_from_str(concat!(
1905            "startup.quick_start = off\n",
1906            "startup.extensions_ready_timeout_secs = 45\n",
1907            "daemon.idle_exit_secs = 20\n",
1908            "daemon.prompt_abandon_secs = 0\n",
1909            "daemon.parked_evict_secs = 120\n",
1910        ));
1911        assert!(!c.startup.quick_start);
1912        assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
1913        assert_eq!(c.daemon.idle_exit_secs, 20);
1914        assert_eq!(c.daemon.prompt_abandon_secs, 0);
1915        assert_eq!(c.daemon.parked_evict_secs, 120);
1916        assert!(
1917            !c.warnings.iter().any(|w| w.contains("unknown")),
1918            "no unknown-key warning: {:?}",
1919            c.warnings
1920        );
1921    }
1922
1923    #[test]
1924    fn startup_quick_start_accepts_on() {
1925        let c = super::load_config_from_str("startup.quick_start = on\n");
1926        assert!(c.startup.quick_start);
1927    }
1928
1929    #[test]
1930    fn startup_and_daemon_keys_are_known() {
1931        for k in [
1932            "startup.quick_start",
1933            "startup.extensions_ready_timeout_secs",
1934            "daemon.idle_exit_secs",
1935            "daemon.prompt_abandon_secs",
1936            "daemon.parked_evict_secs",
1937        ] {
1938            assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
1939        }
1940    }
1941
1942    #[test]
1943    fn startup_daemon_bad_values_warn_and_keep_defaults() {
1944        let c = super::load_config_from_str(concat!(
1945            "startup.quick_start = maybe\n",
1946            "daemon.idle_exit_secs = soon\n",
1947        ));
1948        assert!(c.startup.quick_start, "invalid bool keeps default");
1949        assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
1950        assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
1951        assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
1952    }
1953
1954    #[test]
1955    fn context_management_defaults_off_and_parses_all_keys() {
1956        use super::{ContextManagementConfig, ContextManagementMode};
1957        assert_eq!(
1958            super::load_config_from_str("").context_management,
1959            ContextManagementConfig::default()
1960        );
1961        let parsed = super::load_config_from_str(
1962            "context_management.mode = AUTO\n\
1963             context_management.pressure_tokens = 250000\n\
1964             context_management.rollover_tokens = 400000\n\
1965             context_management.reserve_tokens = 24000\n\
1966             context_management.finish_rounds = 3\n\
1967             context_window = 1m\n",
1968        );
1969        assert_eq!(
1970            parsed.context_management,
1971            ContextManagementConfig {
1972                mode: ContextManagementMode::Auto,
1973                pressure_tokens: Some(250_000),
1974                rollover_tokens: Some(400_000),
1975                reserve_tokens: 24_000,
1976                finish_rounds: 3,
1977            }
1978        );
1979        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
1980        assert!(parsed.provider_keys.is_empty());
1981        assert!(super::load_config_from_str("context_management.mode = off")
1982            .warnings
1983            .is_empty());
1984    }
1985
1986    #[test]
1987    fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
1988        for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
1989            let parsed = super::load_config_from_str(&format!(
1990                "context_management.mode = auto\n\
1991                 context_management.pressure_tokens = 300000\n\
1992                 context_management.rollover_tokens = 450000\n\
1993                 context_management.pressure_tokens = auto\n\
1994                 context_management.rollover_tokens = AUTO\n\
1995                 context_management.finish_rounds = {rounds}\n"
1996            ));
1997            assert_eq!(parsed.context_management.pressure_tokens, None);
1998            assert_eq!(parsed.context_management.rollover_tokens, None);
1999            assert_eq!(parsed.context_management.finish_rounds, rounds);
2000            assert!(parsed.warnings.is_empty());
2001        }
2002    }
2003
2004    #[test]
2005    fn context_management_invalid_values_reset_entire_policy() {
2006        for (key, value) in [
2007            ("mode", "on"),
2008            ("pressure_tokens", "0"),
2009            ("pressure_tokens", "-1"),
2010            ("pressure_tokens", "250k"),
2011            ("pressure_tokens", "18446744073709551616"),
2012            ("rollover_tokens", "1"),
2013            ("rollover_tokens", "nope"),
2014            ("reserve_tokens", "0"),
2015            ("reserve_tokens", "1000001"),
2016            ("reserve_tokens", "auto"),
2017            ("finish_rounds", "9"),
2018            ("finish_rounds", "-1"),
2019            ("finish_rounds", "4294967296"),
2020            ("finish_rounds", ""),
2021            ("mod", "auto"),
2022        ] {
2023            let parsed = super::load_config_from_str(&format!(
2024                "context_management.mode = auto\ncontext_management.{key} = {value}\n"
2025            ));
2026            assert_eq!(
2027                parsed.context_management,
2028                super::ContextManagementConfig::default(),
2029                "{key}={value}"
2030            );
2031            assert!(!parsed.warnings.is_empty(), "{key}={value}");
2032            assert!(parsed.provider_keys.is_empty());
2033        }
2034        let duplicate = super::load_config_from_str(
2035            "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
2036        );
2037        assert_eq!(
2038            duplicate.context_management,
2039            super::ContextManagementConfig::default()
2040        );
2041    }
2042
2043    #[test]
2044    fn context_management_validates_final_threshold_order_and_window() {
2045        for fields in [
2046            "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
2047            "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
2048            "pressure_tokens = 1000000",
2049            "rollover_tokens = 1000001",
2050        ] {
2051            for window_first in [false, true] {
2052                let policy =
2053                    format!("context_management.mode = auto\ncontext_management.{fields}\n");
2054                let content = if window_first {
2055                    format!("context_window = 1m\n{policy}")
2056                } else {
2057                    format!("{policy}context_window = 1m\n")
2058                };
2059                let parsed = super::load_config_from_str(&content);
2060                assert_eq!(
2061                    parsed.context_management,
2062                    super::ContextManagementConfig::default()
2063                );
2064                assert!(!parsed.warnings.is_empty());
2065            }
2066        }
2067        // Temporarily reversed overrides are valid once the whole file is parsed.
2068        let parsed = super::load_config_from_str(
2069            "context_management.mode = auto\n\
2070             context_management.rollover_tokens = 100000\n\
2071             context_management.pressure_tokens = 300000\n\
2072             context_management.rollover_tokens = 450000\ncontext_window = 1m",
2073        );
2074        assert!(parsed.warnings.is_empty());
2075        assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
2076    }
2077
2078    #[test]
2079    fn context_management_parsed_finish_override_bounds_rollover_extension() {
2080        use crate::core::context_policy::{
2081            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2082        };
2083        let config = super::load_config_from_str(
2084            "context_management.mode = auto\n\
2085             context_management.finish_rounds = 1\ncontext_window = 1m",
2086        );
2087        let budget = ContextBudget {
2088            context_window_tokens: 1_000_000,
2089            used_tokens: 400_000,
2090            hard_remaining_tokens: 600_000,
2091            required_next_round_tokens: 8_000,
2092        };
2093        let mut state = ContextState::default();
2094        state.report_phase(WorkPhase::Plan);
2095        let first = assess_context(&config.context_management, &state, budget);
2096        assert_eq!(
2097            first.action,
2098            ContextAction::FinishBounded {
2099                rounds_remaining: 0
2100            }
2101        );
2102        state = first.next_state;
2103        state.report_phase(WorkPhase::WrapUp);
2104        assert_eq!(
2105            assess_context(&config.context_management, &state, budget).action,
2106            ContextAction::Rollover
2107        );
2108    }
2109
2110    #[test]
2111    fn context_management_keys_are_known_for_suggestions() {
2112        for key in [
2113            "mode",
2114            "pressure_tokens",
2115            "rollover_tokens",
2116            "reserve_tokens",
2117            "finish_rounds",
2118        ] {
2119            let full = format!("context_management.{key}");
2120            assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
2121            assert_eq!(
2122                super::did_you_mean(&format!("{full}x")),
2123                Some(full.as_str())
2124            );
2125        }
2126    }
2127
2128    #[test]
2129    fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
2130        use crate::core::context_policy::{
2131            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2132        };
2133        let config =
2134            super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
2135        let budget = ContextBudget {
2136            context_window_tokens: config.context_window.unwrap(),
2137            used_tokens: 350_000,
2138            hard_remaining_tokens: 650_000,
2139            required_next_round_tokens: 8_000,
2140        };
2141        let mut state = ContextState::default();
2142        state.report_phase(WorkPhase::Plan);
2143        let plan = assess_context(&config.context_management, &state, budget);
2144        assert_eq!(plan.action, ContextAction::Advisory);
2145        state = plan.next_state;
2146        state.report_phase(WorkPhase::Execute);
2147        assert_eq!(
2148            assess_context(&config.context_management, &state, budget).action,
2149            ContextAction::Rollover
2150        );
2151    }
2152
2153    #[test]
2154    fn tui_streaming_glow_defaults_off_and_parses() {
2155        assert!(!super::load_config_from_str("").tui_streaming_glow);
2156        assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
2157        assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
2158        let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
2159        assert!(
2160            !invalid.tui_streaming_glow,
2161            "invalid keeps the default (off)"
2162        );
2163        assert!(invalid
2164            .warnings
2165            .iter()
2166            .any(|w| w.contains("tui_streaming_glow")));
2167    }
2168
2169    #[test]
2170    fn tui_background_opaque_defaults_and_parses_supported_values() {
2171        assert!(super::load_config_from_str("").tui_background_opaque);
2172        assert!(
2173            super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
2174        );
2175        assert!(
2176            !super::load_config_from_str("tui_background_opaque = invisible\n")
2177                .tui_background_opaque
2178        );
2179        let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
2180        assert!(invalid.tui_background_opaque);
2181        assert!(invalid
2182            .warnings
2183            .iter()
2184            .any(|w| w.contains("tui_background_opaque")));
2185    }
2186
2187    #[test]
2188    fn turn_budget_keys_parse_typed_per_role() {
2189        let config = super::load_config_from_str(
2190            "turn_budget.worker.max_provider_rounds = 3\n\
2191             turn_budget.worker.max_cost_usd = 0.25\n\
2192             turn_budget.autonomous.max_elapsed_secs = 90\n\
2193             turn_budget.foreground.max_tool_calls = 7\n\
2194             turn_budget.foreground.max_round_renewals = 12\n\
2195             turn_budget.worker.max_provider_rounds_bogus = 1\n\
2196             turn_budget.nosuchrole.max_tool_calls = 1\n",
2197        );
2198        assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
2199        assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
2200        assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
2201        assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
2202        assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
2203        // Unknown fields/roles warn and change nothing.
2204        assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
2205        // Invalid values keep the default (None).
2206        let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
2207        assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
2208    }
2209
2210    #[test]
2211    fn memory_keys_all_parse_with_consent() {
2212        let config = super::load_config_from_str(
2213            "memory.default_mode = capture_and_recall\n\
2214             memory.default_mode_confirmed = true\n\
2215             memory.recall_max_records = 12\n\
2216             memory.recall_max_tokens = 2048\n\
2217             memory.recall_timeout_ms = 250\n\
2218             memory.capture_tools = off\n\
2219             memory.capture_assistant = false\n\
2220             memory.capture_user = false\n\
2221             memory.auto_consolidate = on\n\
2222             memory.local_embeddings = on\n\
2223             memory.gliner = on\n",
2224        );
2225        assert_eq!(config.memory.default_mode, "capture_and_recall");
2226        assert!(config.memory.default_mode_confirmed);
2227        assert_eq!(config.memory.recall_max_records, 12);
2228        assert_eq!(config.memory.recall_max_tokens, 2048);
2229        assert_eq!(config.memory.recall_timeout_ms, 250);
2230        assert_eq!(config.memory.capture_tools, "off");
2231        assert!(!config.memory.capture_assistant);
2232        assert!(!config.memory.capture_user);
2233        assert_eq!(config.memory.auto_consolidate, "on");
2234        assert_eq!(config.memory.local_embeddings, "on");
2235        assert_eq!(config.memory.gliner, "on");
2236    }
2237
2238    #[test]
2239    fn memory_defaults_match_spec_12() {
2240        let config = super::load_config_from_str("");
2241        assert_eq!(config.memory, super::MemoryConfig::default());
2242        assert_eq!(config.memory.default_mode, "off");
2243        assert!(!config.memory.default_mode_confirmed);
2244        assert_eq!(config.memory.recall_max_records, 8);
2245        assert_eq!(config.memory.recall_max_tokens, 4096);
2246        assert_eq!(config.memory.recall_timeout_ms, 150);
2247        assert_eq!(config.memory.capture_tools, "summary_only");
2248        assert!(config.memory.capture_assistant);
2249        assert!(config.memory.capture_user);
2250        assert_eq!(config.memory.auto_consolidate, "off");
2251        assert_eq!(config.memory.local_embeddings, "off");
2252        assert_eq!(config.memory.gliner, "off");
2253    }
2254
2255    #[test]
2256    fn memory_unknown_values_fail_closed_to_defaults() {
2257        let config = super::load_config_from_str(
2258            "memory.default_mode = recall_everything_forever\n\
2259             memory.default_mode_confirmed = true\n\
2260             memory.recall_max_records = lots\n\
2261             memory.recall_max_tokens = -5\n\
2262             memory.recall_timeout_ms = fast\n\
2263             memory.capture_tools = full\n\
2264             memory.capture_assistant = maybe\n\
2265             memory.capture_user = sometimes\n\
2266             memory.auto_consolidate = aggressive\n\
2267             memory.local_embeddings = auto\n\
2268             memory.gliner = download\n\
2269             memory.nosuchfield = 1\n",
2270        );
2271        // Unrecognised mode string must stay "off" — never a non-off mode.
2272        assert_eq!(config.memory.default_mode, "off");
2273        assert_eq!(config.memory.recall_max_records, 8);
2274        assert_eq!(config.memory.recall_max_tokens, 4096);
2275        assert_eq!(config.memory.recall_timeout_ms, 150);
2276        assert_eq!(config.memory.capture_tools, "summary_only");
2277        assert!(config.memory.capture_assistant);
2278        assert!(config.memory.capture_user);
2279        assert_eq!(config.memory.auto_consolidate, "off");
2280        assert_eq!(config.memory.local_embeddings, "off");
2281        assert_eq!(config.memory.gliner, "off");
2282    }
2283
2284    #[test]
2285    fn memory_default_mode_without_consent_reverts_to_off() {
2286        let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
2287        assert_eq!(config.memory.default_mode, "off");
2288        assert!(
2289            config
2290                .warnings
2291                .iter()
2292                .any(|w| w.contains("memory.default_mode_confirmed")),
2293            "reversion must surface a warning; got: {:?}",
2294            config.warnings
2295        );
2296
2297        // Explicit false consent is not consent.
2298        let denied = super::load_config_from_str(
2299            "memory.default_mode = capture_only\n\
2300             memory.default_mode_confirmed = false\n",
2301        );
2302        assert_eq!(denied.memory.default_mode, "off");
2303
2304        // Garbage consent value fails closed — not consent.
2305        let garbage = super::load_config_from_str(
2306            "memory.default_mode = capture_only\n\
2307             memory.default_mode_confirmed = definitely\n",
2308        );
2309        assert_eq!(garbage.memory.default_mode, "off");
2310    }
2311
2312    #[test]
2313    fn memory_consent_is_order_independent() {
2314        // Consent key before the mode key.
2315        let before = super::load_config_from_str(
2316            "memory.default_mode_confirmed = true\n\
2317             memory.default_mode = recall_once\n",
2318        );
2319        assert_eq!(before.memory.default_mode, "recall_once");
2320        // Consent key after the mode key.
2321        let after = super::load_config_from_str(
2322            "memory.default_mode = recall_once\n\
2323             memory.default_mode_confirmed = true\n",
2324        );
2325        assert_eq!(after.memory.default_mode, "recall_once");
2326    }
2327
2328    #[test]
2329    fn memory_backend_defaults_are_independent_of_consent() {
2330        let config = super::load_config_from_str("");
2331        assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
2332        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
2333        let axel = super::load_config_from_str("memory.backend = axel\n");
2334        assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
2335        assert_eq!(axel.memory, config.memory);
2336        assert!(axel.warnings.is_empty());
2337    }
2338
2339    #[test]
2340    fn user_wide_notes_require_explicit_valid_opt_in() {
2341        let default = super::load_config_from_str("memory.backend = axel\n");
2342        assert!(!default.memory_backend.user_scope);
2343        let enabled =
2344            super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
2345        assert!(enabled.memory_backend.user_scope);
2346        assert_eq!(enabled.memory, default.memory);
2347        for bad in ["yes", "", "1", "TRUE"] {
2348            let config = super::load_config_from_str(&format!(
2349                "memory.user_scope = {bad}\nmemory.backend = axel\n"
2350            ));
2351            assert_eq!(
2352                config.memory_backend.kind,
2353                super::MemoryBackendKind::Unavailable
2354            );
2355        }
2356    }
2357
2358    #[test]
2359    fn memory_backend_invalid_selector_fails_closed() {
2360        for content in [
2361            "memory.backend = typo\n",
2362            "memory.backend = \n",
2363            "memory.backend = unavailable\n",
2364            "memory.backend = axel\nmemory.backend = typo\n",
2365            "memory.backend = typo\nmemory.backend = legacy\n",
2366        ] {
2367            let config = super::load_config_from_str(content);
2368            assert_eq!(
2369                config.memory_backend.kind,
2370                super::MemoryBackendKind::Unavailable
2371            );
2372            assert!(config
2373                .warnings
2374                .iter()
2375                .any(|w| w.contains("memory backend unavailable")));
2376        }
2377    }
2378
2379    #[test]
2380    fn memory_backend_absolute_paths_are_preserved() {
2381        let root = std::env::current_dir().unwrap();
2382        let executable = root.join("fixture bin/axel");
2383        let brain = root.join("fixture brain");
2384        let config = super::load_config_from_str(&format!(
2385            "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
2386            brain.display(),
2387            executable.display()
2388        ));
2389        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
2390        assert_eq!(config.memory_backend.executable, Some(executable));
2391        assert_eq!(config.memory_backend.brain, Some(brain));
2392        assert!(config.warnings.is_empty());
2393    }
2394
2395    #[test]
2396    fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
2397        for key in ["memory.axel.executable", "memory.axel.brain"] {
2398            for path in ["relative/path", "~/brain", ""] {
2399                for content in [
2400                    format!("memory.backend = axel\n{key} = {path}\n"),
2401                    format!("{key} = {path}\nmemory.backend = legacy\n"),
2402                ] {
2403                    let config = super::load_config_from_str(&content);
2404                    assert_eq!(
2405                        config.memory_backend.kind,
2406                        super::MemoryBackendKind::Unavailable
2407                    );
2408                    assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
2409                }
2410            }
2411        }
2412    }
2413
2414    #[test]
2415    fn memory_config_surface_is_closed_by_construction() {
2416        // Exhaustive struct literal — no `..Default::default()` spread. This
2417        // fails to COMPILE if any field is added to or removed from
2418        // MemoryConfig, pinning the exact memory.* config surface. Secret,
2419        // retention, and project-scope semantics have no field here, so no
2420        // `memory.secret*` / `memory.retention*` / `memory.project*` config
2421        // key can exist (spec §12: those rules are not configurable to fail
2422        // open). Asserted by construction, not by test disproof.
2423        let _closed_surface = super::MemoryConfig {
2424            default_mode: "off".to_string(),
2425            default_mode_confirmed: false,
2426            recall_max_records: 8,
2427            recall_max_tokens: 4096,
2428            recall_timeout_ms: 150,
2429            capture_tools: "summary_only".to_string(),
2430            capture_assistant: true,
2431            capture_user: true,
2432            auto_consolidate: "off".to_string(),
2433            local_embeddings: "off".to_string(),
2434            gliner: "off".to_string(),
2435        };
2436    }
2437
2438    use super::*;
2439    use serial_test::serial;
2440
2441    #[test]
2442    fn test_levenshtein_basics() {
2443        assert_eq!(levenshtein("model", "model"), 0);
2444        assert_eq!(levenshtein("modle", "model"), 2);
2445        assert_eq!(levenshtein("them", "theme"), 1);
2446    }
2447
2448    #[test]
2449    fn test_did_you_mean_close_typos() {
2450        assert_eq!(did_you_mean("modle"), Some("model"));
2451        assert_eq!(did_you_mean("them"), Some("theme"));
2452        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
2453        assert_eq!(did_you_mean("completely_unrelated_key"), None);
2454    }
2455
2456    #[test]
2457    #[serial]
2458    fn test_config_warnings_unknown_key_and_bad_values() {
2459        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
2460        let dir = home.join(".synaps-cli");
2461        std::fs::create_dir_all(&dir).unwrap();
2462        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();
2463
2464        with_home(&home, || {
2465            let config = load_config();
2466            // Dotted (namespaced) keys must NOT warn — plugins own those.
2467            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
2468            assert!(
2469                !config.warnings.iter().any(|w| w.contains("knowledge")),
2470                "{:?}",
2471                config.warnings
2472            );
2473            assert!(
2474                config
2475                    .warnings
2476                    .iter()
2477                    .any(|w| w.contains("did you mean 'model'")),
2478                "{:?}",
2479                config.warnings
2480            );
2481            assert!(
2482                config.warnings.iter().any(|w| w.contains("thinking")),
2483                "{:?}",
2484                config.warnings
2485            );
2486            assert!(
2487                config.warnings.iter().any(|w| w.contains("below minimum")),
2488                "{:?}",
2489                config.warnings
2490            );
2491            // Bad values fall back to defaults
2492            assert_eq!(config.bash_timeout, 30);
2493            assert_eq!(config.thinking_budget, None);
2494        });
2495        let _ = std::fs::remove_dir_all(&home);
2496    }
2497
2498    // ── cache_ttl parse table (spec §3.1) ──
2499
2500    #[test]
2501    fn test_cache_ttl_parse_table() {
2502        // 5m aliases
2503        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
2504        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
2505        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
2506        // 1h aliases
2507        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
2508        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
2509        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
2510        // hybrid
2511        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
2512        // case-insensitive
2513        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
2514        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
2515        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
2516        // garbage → None (caller warns + defaults)
2517        assert_eq!(CacheTtl::parse("2h"), None);
2518        assert_eq!(CacheTtl::parse(""), None);
2519        assert_eq!(CacheTtl::parse("forever"), None);
2520    }
2521
2522    #[test]
2523    fn test_cache_ttl_default_is_five_minutes() {
2524        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
2525        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
2526    }
2527
2528    #[test]
2529    fn test_max_fps_default_is_60() {
2530        assert_eq!(SynapsConfig::default().max_fps, 60);
2531    }
2532
2533    // ── theme_transition parse table (animated theme transitions) ──
2534
2535    #[test]
2536    fn test_theme_transition_parse_table() {
2537        use ThemeTransitionMode as M;
2538        assert_eq!(M::parse("on"), Some(M::On));
2539        assert_eq!(M::parse("ON"), Some(M::On));
2540        assert_eq!(M::parse("off"), Some(M::Off));
2541        assert_eq!(M::parse("Off"), Some(M::Off));
2542        // integer ms, clamped to 0..=2000
2543        assert_eq!(M::parse("500"), Some(M::Ms(500)));
2544        assert_eq!(M::parse("0"), Some(M::Ms(0)));
2545        assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
2546        assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
2547        // garbage → None (caller warns + keeps default)
2548        assert_eq!(M::parse("fast"), None);
2549        assert_eq!(M::parse(""), None);
2550        assert_eq!(M::parse("-5"), None);
2551        assert_eq!(M::parse("1.5"), None);
2552    }
2553
2554    #[test]
2555    fn test_theme_transition_duration_ms() {
2556        use ThemeTransitionMode as M;
2557        assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
2558        assert_eq!(M::DEFAULT_MS, 350);
2559        assert_eq!(M::Off.duration_ms(), 0);
2560        assert_eq!(M::Ms(120).duration_ms(), 120);
2561        assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
2562        assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
2563    }
2564
2565    #[test]
2566    fn test_theme_transition_config_key_parses_and_defaults() {
2567        use ThemeTransitionMode as M;
2568        assert_eq!(SynapsConfig::default().theme_transition, M::On);
2569        assert_eq!(
2570            super::load_config_from_str("theme_transition = off\n").theme_transition,
2571            M::Off
2572        );
2573        assert_eq!(
2574            super::load_config_from_str("theme_transition = 750\n").theme_transition,
2575            M::Ms(750)
2576        );
2577        let bad = super::load_config_from_str("theme_transition = warp\n");
2578        assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
2579        assert!(
2580            bad.warnings.iter().any(|w| w.contains("theme_transition")),
2581            "{:?}",
2582            bad.warnings
2583        );
2584    }
2585
2586    #[test]
2587    #[serial]
2588    fn test_max_fps_config_parse_and_validation() {
2589        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
2590        let dir = home.join(".synaps-cli");
2591        std::fs::create_dir_all(&dir).unwrap();
2592
2593        // Valid high-refresh value parses, no warning.
2594        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
2595        with_home(&home, || {
2596            let config = load_config();
2597            assert_eq!(config.max_fps, 144);
2598            assert!(
2599                config.warnings.is_empty(),
2600                "warnings: {:?}",
2601                config.warnings
2602            );
2603        });
2604
2605        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
2606        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
2607        with_home(&home, || {
2608            let config = load_config();
2609            assert_eq!(config.max_fps, 60);
2610            assert!(
2611                config.warnings.iter().any(|w| w.contains("max_fps")),
2612                "warnings: {:?}",
2613                config.warnings
2614            );
2615        });
2616
2617        // Non-numeric → default 60 + warning.
2618        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
2619        with_home(&home, || {
2620            let config = load_config();
2621            assert_eq!(config.max_fps, 60);
2622            assert!(
2623                config.warnings.iter().any(|w| w.contains("max_fps")),
2624                "warnings: {:?}",
2625                config.warnings
2626            );
2627        });
2628
2629        let _ = std::fs::remove_dir_all(&home);
2630    }
2631
2632    #[test]
2633    #[serial]
2634    fn test_cache_ttl_config_parse_and_garbage_warning() {
2635        let home =
2636            std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
2637        let dir = home.join(".synaps-cli");
2638        std::fs::create_dir_all(&dir).unwrap();
2639
2640        // Valid value parses, no warning.
2641        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
2642        with_home(&home, || {
2643            let config = load_config();
2644            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
2645            assert!(
2646                config.warnings.is_empty(),
2647                "warnings: {:?}",
2648                config.warnings
2649            );
2650        });
2651
2652        // Garbage value → 5m default + boot warning (never blocks boot).
2653        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
2654        with_home(&home, || {
2655            let config = load_config();
2656            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
2657            assert!(
2658                config.warnings.iter().any(|w| w.contains("cache_ttl")),
2659                "warnings: {:?}",
2660                config.warnings
2661            );
2662        });
2663
2664        let _ = std::fs::remove_dir_all(&home);
2665    }
2666
2667    #[test]
2668    fn test_parse_thinking_budget() {
2669        use crate::core::reasoning::ThinkingSpec;
2670        // ThinkingSpec::parse replaced parse_thinking_budget; verify budget values.
2671        assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
2672        assert_eq!(
2673            ThinkingSpec::parse("medium").unwrap().to_budget(),
2674            Some(4096)
2675        );
2676        assert_eq!(
2677            ThinkingSpec::parse("high").unwrap().to_budget(),
2678            Some(16384)
2679        );
2680        assert_eq!(
2681            ThinkingSpec::parse("xhigh").unwrap().to_budget(),
2682            Some(32768)
2683        );
2684        assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
2685        let config = load_config_from_str("thinking = 8192\n");
2686        assert_eq!(config.thinking_level, None);
2687        assert_eq!(config.thinking_budget, Some(8192));
2688        assert_eq!(ThinkingSpec::parse("invalid"), None);
2689    }
2690
2691    #[test]
2692    fn test_base_dir() {
2693        let path = base_dir();
2694        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
2695    }
2696
2697    #[test]
2698    fn test_resolve_system_prompt_explicit() {
2699        let result = resolve_system_prompt(Some("test prompt"));
2700        assert_eq!(result, "test prompt");
2701    }
2702
2703    #[test]
2704    fn test_resolve_system_prompt_none() {
2705        let result = resolve_system_prompt(None);
2706        assert!(result.contains("helpful AI agent"));
2707    }
2708
2709    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
2710    // is not thread-safe and races with shell config tests. Coverage provided
2711    // by shell::config::tests::test_shell_config_from_file.
2712
2713    #[test]
2714    fn test_synaps_config_default() {
2715        let config = SynapsConfig::default();
2716        assert_eq!(config.model, None);
2717        assert_eq!(config.thinking_budget, None);
2718        assert_eq!(config.max_tool_output, 30000);
2719        assert_eq!(config.bash_timeout, 30);
2720        assert_eq!(config.bash_max_timeout, 300);
2721        assert_eq!(config.subagent_timeout, 300);
2722        assert_eq!(config.api_retries, 3);
2723        assert_eq!(config.theme, None);
2724        assert!(config.disabled_plugins.is_empty());
2725        assert!(config.favorite_models.is_empty());
2726        assert!(config.disabled_skills.is_empty());
2727        assert!(!config.progressive_tool_disclosure);
2728        assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
2729        assert_eq!(config.shell.max_sessions, 5);
2730        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
2731        // Server config defaults
2732        assert!(config.server.allowed_origins.is_empty());
2733        assert_eq!(config.server.token, None);
2734        assert!(!config.server.auto_approve_confirms);
2735        assert_eq!(config.server.max_message_size, None);
2736        // Bridge config defaults
2737        assert!(config.bridge.uds_path.is_none());
2738        assert!(!config.bridge.heartbeat_mirror);
2739        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2740    }
2741
2742    #[test]
2743    #[serial]
2744    fn test_load_config_tools_activation_confirm_modes() {
2745        for (raw, expected, warns) in [
2746            ("prompt", ActivationConfirm::Prompt, false),
2747            ("deny", ActivationConfirm::Deny, false),
2748            ("AUTO", ActivationConfirm::Auto, false),
2749            ("garbage", ActivationConfirm::Auto, true),
2750        ] {
2751            let home = make_test_home(&format!("activation-confirm-{raw}"));
2752            let cfg = home.join(".synaps-cli/config");
2753            std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
2754            with_home(&home, || {
2755                let config = load_config();
2756                assert_eq!(config.tools_activation_confirm, expected, "{raw}");
2757                assert_eq!(
2758                    config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
2759                    warns,
2760                    "{raw}: {:?}",
2761                    config.warnings
2762                );
2763            });
2764            let _ = std::fs::remove_dir_all(&home);
2765        }
2766    }
2767
2768    #[test]
2769    #[serial]
2770    fn test_load_config_progressive_tool_disclosure_is_opt_in() {
2771        let home = make_test_home("progressive-tool-disclosure");
2772        let cfg = home.join(".synaps-cli/config");
2773        std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
2774
2775        with_home(&home, || {
2776            let config = load_config();
2777            assert!(config.progressive_tool_disclosure);
2778            assert!(config.warnings.is_empty());
2779        });
2780
2781        let _ = std::fs::remove_dir_all(&home);
2782    }
2783
2784    #[test]
2785    #[serial]
2786    fn test_load_config_bridge_keys() {
2787        let home = make_test_home("bridge-keys");
2788        let cfg = home.join(".synaps-cli/config");
2789        std::fs::write(
2790            &cfg,
2791            "\
2792bridge.uds_path = /tmp/some/control.sock\n\
2793bridge.heartbeat_mirror = true\n\
2794bridge.heartbeat_timeout_ms = 750\n\
2795",
2796        )
2797        .unwrap();
2798
2799        with_home(&home, || {
2800            let config = load_config();
2801            assert_eq!(
2802                config.bridge.uds_path,
2803                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
2804            );
2805            assert!(config.bridge.heartbeat_mirror);
2806            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
2807            assert_eq!(
2808                config.bridge.resolved_uds_path(),
2809                std::path::PathBuf::from("/tmp/some/control.sock"),
2810            );
2811        });
2812
2813        let _ = std::fs::remove_dir_all(&home);
2814    }
2815
2816    #[test]
2817    fn test_bridge_config_defaults() {
2818        let cfg = BridgeConfig::default();
2819        assert!(cfg.uds_path.is_none());
2820        assert!(!cfg.heartbeat_mirror);
2821        assert_eq!(cfg.heartbeat_timeout_ms, 250);
2822        // resolved path falls under base_dir()/bridge/control.sock
2823        let resolved = cfg.resolved_uds_path();
2824        assert!(resolved.ends_with("bridge/control.sock"));
2825    }
2826
2827    #[test]
2828    #[serial]
2829    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
2830        let home = make_test_home("bridge-default-off");
2831        let cfg = home.join(".synaps-cli/config");
2832        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
2833
2834        with_home(&home, || {
2835            let config = load_config();
2836            assert!(!config.bridge.heartbeat_mirror);
2837            assert!(config.bridge.uds_path.is_none());
2838            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
2839        });
2840
2841        let _ = std::fs::remove_dir_all(&home);
2842    }
2843
2844    #[test]
2845    fn compaction_disclosure_keys_parse_with_typed_warnings() {
2846        let config = load_config_from_str(
2847            "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
2848        );
2849        assert_eq!(
2850            config.compaction_mode,
2851            crate::core::compaction::CompactionMode::LocalOnly
2852        );
2853        assert_eq!(
2854            config.compaction_exclude,
2855            vec![
2856                crate::core::compaction::ContentClass::Thinking,
2857                crate::core::compaction::ContentClass::ToolResults,
2858            ]
2859        );
2860        assert!(
2861            config.warnings.iter().any(|w| w.contains("bogus")),
2862            "unknown class must warn: {:?}",
2863            config.warnings
2864        );
2865
2866        let defaults = load_config_from_str("");
2867        assert_eq!(
2868            defaults.compaction_mode,
2869            crate::core::compaction::CompactionMode::Remote
2870        );
2871        assert!(defaults.compaction_exclude.is_empty());
2872
2873        let bad_mode = load_config_from_str("compaction_mode = cloud\n");
2874        assert_eq!(
2875            bad_mode.compaction_mode,
2876            crate::core::compaction::CompactionMode::Remote
2877        );
2878        assert!(bad_mode
2879            .warnings
2880            .iter()
2881            .any(|w| w.contains("compaction_mode")));
2882    }
2883
2884    #[test]
2885    #[serial]
2886    fn test_load_config_server_keys() {
2887        let home = make_test_home("server-keys");
2888        let cfg = home.join(".synaps-cli/config");
2889        std::fs::write(
2890            &cfg,
2891            "\
2892server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
2893server.token = my-secret-token\n\
2894server.auto_approve_confirms = true\n\
2895server.max_message_size = 65536\n\
2896context_window = 200k\n\
2897",
2898        )
2899        .unwrap();
2900
2901        with_home(&home, || {
2902            let config = load_config();
2903            assert_eq!(
2904                config.server.allowed_origins,
2905                vec![
2906                    "http://localhost:3000".to_string(),
2907                    "http://localhost:5193".to_string(),
2908                ]
2909            );
2910            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
2911            assert!(config.server.auto_approve_confirms);
2912            // Explicit max_message_size takes precedence over context_window derivation
2913            assert_eq!(config.server.max_message_size, Some(65536));
2914        });
2915
2916        let _ = std::fs::remove_dir_all(&home);
2917    }
2918
2919    #[test]
2920    #[serial]
2921    fn test_server_max_message_size_derived_from_context_window() {
2922        let home = make_test_home("server-derive");
2923        let cfg = home.join(".synaps-cli/config");
2924        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
2925
2926        with_home(&home, || {
2927            let config = load_config();
2928            // 200_000 tokens * 4 bytes/token = 800_000 bytes
2929            assert_eq!(config.server.max_message_size, Some(800_000));
2930        });
2931
2932        let _ = std::fs::remove_dir_all(&home);
2933    }
2934
2935    fn make_test_home(subdir: &str) -> std::path::PathBuf {
2936        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
2937        let _ = std::fs::remove_dir_all(&dir);
2938        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
2939        dir
2940    }
2941
2942    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
2943        // `base_dir()` reads SYNAPS_BASE_DIR before HOME: pin it too, or a value
2944        // another test left set redirects the write away from `home`.
2945        let original = std::env::var("HOME").ok();
2946        let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
2947        std::env::set_var("HOME", home);
2948        std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
2949        f();
2950        if let Some(h) = original {
2951            std::env::set_var("HOME", h);
2952        } else {
2953            std::env::remove_var("HOME");
2954        }
2955        if let Some(b) = original_base {
2956            std::env::set_var("SYNAPS_BASE_DIR", b);
2957        } else {
2958            std::env::remove_var("SYNAPS_BASE_DIR");
2959        }
2960    }
2961
2962    #[test]
2963    #[serial]
2964    fn write_config_value_replaces_existing_key() {
2965        let home = make_test_home("replace");
2966        let cfg = home.join(".synaps-cli/config");
2967        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
2968
2969        with_home(&home, || {
2970            write_config_value("model", "claude-sonnet-4-6").unwrap();
2971        });
2972
2973        let contents = std::fs::read_to_string(&cfg).unwrap();
2974        assert!(contents.contains("model = claude-sonnet-4-6"));
2975        assert!(contents.contains("thinking = low"));
2976        let _ = std::fs::remove_dir_all(&home);
2977    }
2978
2979    #[test]
2980    #[serial]
2981    fn write_config_value_appends_when_missing() {
2982        let home = make_test_home("append");
2983        let cfg = home.join(".synaps-cli/config");
2984        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
2985
2986        with_home(&home, || {
2987            write_config_value("theme", "dracula").unwrap();
2988        });
2989
2990        let contents = std::fs::read_to_string(&cfg).unwrap();
2991        assert!(contents.contains("model = claude-opus-4-6"));
2992        assert!(contents.contains("theme = dracula"));
2993        let _ = std::fs::remove_dir_all(&home);
2994    }
2995
2996    #[test]
2997    #[serial]
2998    fn write_config_value_concurrent_writers_lose_nothing() {
2999        let home = make_test_home("concurrent");
3000        let cfg = home.join(".synaps-cli/config");
3001        std::fs::write(&cfg, "# keep\n").unwrap();
3002
3003        // HOME is process-global; set it for the whole test, not per thread.
3004        with_home(&home, || {
3005            let a = std::thread::spawn(|| {
3006                for i in 0..200 {
3007                    write_config_value("a_key", &i.to_string()).unwrap();
3008                }
3009            });
3010            let b = std::thread::spawn(|| {
3011                for i in 0..200 {
3012                    write_config_value("b_key", &i.to_string()).unwrap();
3013                }
3014            });
3015            a.join().unwrap();
3016            b.join().unwrap();
3017        });
3018
3019        let contents = std::fs::read_to_string(&cfg).unwrap();
3020        assert!(contents.contains("# keep"), "{contents}");
3021        assert!(contents.contains("a_key = 199"), "{contents}");
3022        assert!(contents.contains("b_key = 199"), "{contents}");
3023        assert_eq!(contents.matches("a_key").count(), 1);
3024        assert_eq!(contents.matches("b_key").count(), 1);
3025
3026        #[cfg(unix)]
3027        {
3028            use std::os::unix::fs::PermissionsExt;
3029            let lock = home.join(".synaps-cli/config.lock");
3030            let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
3031            assert_eq!(mode, 0o600);
3032        }
3033        let _ = std::fs::remove_dir_all(&home);
3034    }
3035
3036    #[test]
3037    #[serial]
3038    fn config_lock_kill_switch_still_writes() {
3039        let home = make_test_home("lock-off");
3040        let cfg = home.join(".synaps-cli/config");
3041        std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
3042        with_home(&home, || {
3043            write_config_value("model", "claude-sonnet-4-6").unwrap();
3044        });
3045        std::env::remove_var("SYNAPS_CONFIG_LOCK");
3046        let contents = std::fs::read_to_string(&cfg).unwrap();
3047        assert!(contents.contains("model = claude-sonnet-4-6"));
3048        assert!(!home.join(".synaps-cli/config.lock").exists());
3049        let _ = std::fs::remove_dir_all(&home);
3050    }
3051
3052    #[test]
3053    #[serial]
3054    fn add_favorite_model_end_to_end_does_not_deadlock() {
3055        let home = make_test_home("favorite-lock");
3056        let cfg = home.join(".synaps-cli/config");
3057        with_home(&home, || {
3058            add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3059            add_favorite_model("openai/gpt-5").unwrap();
3060            remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3061        });
3062        let contents = std::fs::read_to_string(&cfg).unwrap();
3063        assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
3064        let _ = std::fs::remove_dir_all(&home);
3065    }
3066
3067    #[test]
3068    #[serial]
3069    fn write_config_value_preserves_comments() {
3070        let home = make_test_home("comments");
3071        let cfg = home.join(".synaps-cli/config");
3072        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
3073
3074        with_home(&home, || {
3075            write_config_value("model", "claude-sonnet-4-6").unwrap();
3076        });
3077
3078        let contents = std::fs::read_to_string(&cfg).unwrap();
3079        assert!(contents.contains("# user comment"));
3080        assert!(contents.contains("# another"));
3081        assert!(contents.contains("model = claude-sonnet-4-6"));
3082        let _ = std::fs::remove_dir_all(&home);
3083    }
3084
3085    #[test]
3086    #[serial]
3087    fn write_config_value_preserves_unknown_keys() {
3088        let home = make_test_home("unknown");
3089        let cfg = home.join(".synaps-cli/config");
3090        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
3091
3092        with_home(&home, || {
3093            write_config_value("model", "claude-sonnet-4-6").unwrap();
3094        });
3095
3096        let contents = std::fs::read_to_string(&cfg).unwrap();
3097        assert!(contents.contains("custom_thing = 42"));
3098        let _ = std::fs::remove_dir_all(&home);
3099    }
3100
3101    #[test]
3102    #[serial]
3103    fn write_config_value_creates_file_if_absent() {
3104        let home = make_test_home("create");
3105        let cfg = home.join(".synaps-cli/config");
3106        assert!(!cfg.exists());
3107
3108        with_home(&home, || {
3109            write_config_value("model", "claude-sonnet-4-6").unwrap();
3110        });
3111
3112        let contents = std::fs::read_to_string(&cfg).unwrap();
3113        assert!(contents.contains("model = claude-sonnet-4-6"));
3114        let _ = std::fs::remove_dir_all(&home);
3115    }
3116
3117    #[test]
3118    #[serial]
3119    fn load_config_parses_theme_key() {
3120        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
3121        let _ = std::fs::create_dir_all(&dir);
3122        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
3123
3124        let original_home = std::env::var("HOME").ok();
3125        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
3126
3127        let config = load_config();
3128
3129        if let Some(home) = original_home {
3130            std::env::set_var("HOME", home);
3131        } else {
3132            std::env::remove_var("HOME");
3133        }
3134        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
3135
3136        assert_eq!(config.theme.as_deref(), Some("dracula"));
3137    }
3138
3139    #[test]
3140    #[serial]
3141    fn test_load_config_disable_lists() {
3142        let test_dir =
3143            std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
3144        let _ = std::fs::create_dir_all(&test_dir);
3145        let config_path = test_dir.join("config");
3146
3147        let config_content = r#"
3148# Test config with disable lists
3149favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
3150
3151disabled_plugins = foo, bar
3152disabled_skills = baz, plug:qual
3153"#;
3154        std::fs::write(&config_path, config_content).unwrap();
3155
3156        let original_home = std::env::var("HOME").ok();
3157        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
3158
3159        let config = load_config();
3160
3161        if let Some(home) = original_home {
3162            std::env::set_var("HOME", home);
3163        } else {
3164            std::env::remove_var("HOME");
3165        }
3166
3167        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
3168
3169        assert_eq!(
3170            config.disabled_plugins,
3171            vec!["foo".to_string(), "bar".to_string()]
3172        );
3173        assert_eq!(
3174            config.favorite_models,
3175            vec![
3176                "claude/claude-opus-4-7".to_string(),
3177                "groq/llama-3.3-70b-versatile".to_string(),
3178            ]
3179        );
3180        assert_eq!(
3181            config.disabled_skills,
3182            vec!["baz".to_string(), "plug:qual".to_string()]
3183        );
3184    }
3185
3186    #[test]
3187    #[serial]
3188    fn favorite_model_helpers_round_trip_through_config_file() {
3189        let home = make_test_home("favorite-models");
3190        let cfg = home.join(".synaps-cli/config");
3191        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
3192
3193        with_home(&home, || {
3194            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3195            add_favorite_model("claude/claude-opus-4-7").unwrap();
3196            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3197            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
3198            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3199            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
3200            assert!(is_favorite_model("claude/claude-opus-4-7"));
3201        });
3202
3203        let contents = std::fs::read_to_string(&cfg).unwrap();
3204        assert!(contents.contains("model = claude-opus-4-7"));
3205        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
3206        let _ = std::fs::remove_dir_all(&home);
3207    }
3208
3209    #[test]
3210    #[serial]
3211    fn test_load_config_new_keys() {
3212        // Create a temporary config directory with the new keys
3213        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
3214        let _ = std::fs::create_dir_all(&test_dir);
3215        let config_path = test_dir.join("config");
3216
3217        let config_content = r#"
3218# Test config with new keys
3219model = claude-haiku
3220thinking = medium
3221max_tool_output = 50000
3222bash_timeout = 45
3223bash_max_timeout = 600
3224subagent_timeout = 120
3225api_retries = 5
3226"#;
3227        std::fs::write(&config_path, config_content).unwrap();
3228
3229        // Temporarily override the config path for this test
3230        let original_home = std::env::var("HOME").ok();
3231        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
3232
3233        let config = load_config();
3234
3235        // Restore original HOME
3236        if let Some(home) = original_home {
3237            std::env::set_var("HOME", home);
3238        } else {
3239            std::env::remove_var("HOME");
3240        }
3241
3242        // Cleanup
3243        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
3244
3245        assert_eq!(config.model, Some("claude-haiku".to_string()));
3246        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
3247        assert_eq!(config.max_tool_output, 50000);
3248        assert_eq!(config.bash_timeout, 45);
3249        assert_eq!(config.bash_max_timeout, 600);
3250        assert_eq!(config.subagent_timeout, 120);
3251        assert_eq!(config.api_retries, 5);
3252    }
3253
3254    // ── auth.* config + credential source (#157) ──
3255
3256    // ── events.auto_turn parser ──────────────────────────────────────────────
3257
3258    #[test]
3259    fn events_auto_turn_explicit_true_values() {
3260        for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
3261            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3262            assert!(
3263                cfg.events.auto_turn,
3264                "expected true for events.auto_turn = {val}"
3265            );
3266        }
3267    }
3268
3269    #[test]
3270    fn events_auto_turn_explicit_false_values() {
3271        for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
3272            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3273            assert!(
3274                !cfg.events.auto_turn,
3275                "expected false for events.auto_turn = {val}"
3276            );
3277        }
3278    }
3279
3280    #[test]
3281    fn events_auto_turn_typo_fails_safe_false() {
3282        // Unrecognised value should fail safe to false (with a warning on stderr).
3283        let cfg = load_config_from_str("events.auto_turn = fales");
3284        assert!(
3285            !cfg.events.auto_turn,
3286            "typo 'fales' must fail safe to false"
3287        );
3288    }
3289
3290    // ── events.auto_turn_cap parser ──────────────────────────────────────────
3291
3292    #[test]
3293    fn events_auto_turn_cap_default_is_five() {
3294        let cfg = load_config_from_str("");
3295        assert_eq!(cfg.events.auto_turn_cap, 5);
3296        assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
3297    }
3298
3299    #[test]
3300    fn events_auto_turn_cap_zero_is_unlimited() {
3301        let cfg = load_config_from_str("events.auto_turn_cap = 0");
3302        assert_eq!(cfg.events.auto_turn_cap, 0);
3303    }
3304
3305    #[test]
3306    fn events_auto_turn_cap_unlimited_aliases() {
3307        for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
3308            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3309            assert_eq!(
3310                cfg.events.auto_turn_cap, 0,
3311                "expected 0 for events.auto_turn_cap = {val:?}"
3312            );
3313        }
3314    }
3315
3316    #[test]
3317    fn events_auto_turn_cap_explicit_number() {
3318        let cfg = load_config_from_str("events.auto_turn_cap = 12");
3319        assert_eq!(cfg.events.auto_turn_cap, 12);
3320    }
3321
3322    #[test]
3323    fn events_auto_turn_cap_garbage_keeps_default() {
3324        for val in ["banana", "-3", "5.5", "", "true"] {
3325            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3326            assert_eq!(
3327                cfg.events.auto_turn_cap, 5,
3328                "garbage {val:?} must keep default 5"
3329            );
3330        }
3331    }
3332
3333    #[test]
3334    fn events_auto_turn_cap_independent_of_auto_turn() {
3335        let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
3336        assert!(!cfg.events.auto_turn);
3337        assert_eq!(cfg.events.auto_turn_cap, 0);
3338    }
3339
3340    #[test]
3341    fn events_auto_turn_default_is_true() {
3342        let cfg = load_config_from_str("model = claude-haiku");
3343        assert!(cfg.events.auto_turn, "default must be true when key absent");
3344    }
3345
3346    #[test]
3347    #[serial]
3348    fn test_auth_config_parses_endpoint_and_token() {
3349        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
3350        let dir = home.join(".synaps-cli");
3351        std::fs::create_dir_all(&dir).unwrap();
3352        std::fs::write(
3353            dir.join("config"),
3354            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
3355        )
3356        .unwrap();
3357        with_home(&home, || {
3358            let config = load_config();
3359            assert_eq!(
3360                config.auth.remote_endpoint.as_deref(),
3361                Some("https://jade.jade:8181")
3362            );
3363            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
3364            // auth.* are namespaced -> must NOT warn as unknown keys
3365            assert!(
3366                !config.warnings.iter().any(|w| w.contains("auth.")),
3367                "{:?}",
3368                config.warnings
3369            );
3370        });
3371        let _ = std::fs::remove_dir_all(&home);
3372    }
3373
3374    #[test]
3375    #[serial]
3376    fn test_credential_source_local_by_default() {
3377        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3378        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3379        assert!(!AuthConfig::default().credential_source().is_remote());
3380    }
3381
3382    #[test]
3383    #[serial]
3384    fn test_credential_source_remote_from_config() {
3385        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3386        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3387        let auth = AuthConfig {
3388            remote_endpoint: Some("https://b".into()),
3389            machine_token: Some("m".into()),
3390        };
3391        assert_eq!(
3392            auth.credential_source(),
3393            crate::core::auth::CredentialSource::Remote {
3394                endpoint: "https://b".into(),
3395                machine_token: "m".into()
3396            }
3397        );
3398    }
3399
3400    #[test]
3401    #[serial]
3402    fn test_credential_source_env_overrides_config() {
3403        let auth = AuthConfig {
3404            remote_endpoint: Some("https://config-host".into()),
3405            machine_token: Some("config-tok".into()),
3406        };
3407        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
3408        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
3409        let src = auth.credential_source();
3410        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3411        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3412        assert_eq!(
3413            src,
3414            crate::core::auth::CredentialSource::Remote {
3415                endpoint: "https://env-host".into(),
3416                machine_token: "env-tok".into()
3417            }
3418        );
3419    }
3420    #[test]
3421    fn config_parses_ultracode_as_distinct_canonical_level() {
3422        let cfg = load_config_from_str("thinking = ultracode\n");
3423        assert_eq!(
3424            cfg.thinking_level,
3425            Some(crate::reasoning::ReasoningLevel::UltraCode)
3426        );
3427        assert_eq!(cfg.thinking_budget, None);
3428        for other in [
3429            crate::reasoning::ReasoningLevel::Ultra,
3430            crate::reasoning::ReasoningLevel::Max,
3431            crate::reasoning::ReasoningLevel::XHigh,
3432        ] {
3433            assert_ne!(cfg.thinking_level, Some(other));
3434        }
3435    }
3436}