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/// Subagent fan-out limits (`subagent.*` keys). They apply to the whole
308/// delegation tree of one session: workers started by workers count against
309/// the same limits as workers started by the foreground. A prompt manifest
310/// with an explicit delegation policy sets its own concurrency and total
311/// limits; `max_depth` applies either way.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct SubagentLimits {
314    /// Workers running at the same time. Default: 16.
315    pub max_concurrent: usize,
316    /// Workers dispatched and not yet collected with `reconciled=true`
317    /// (running, finished, or collected without reconciling). Default: 64.
318    pub max_total: usize,
319    /// How deep workers may nest: 1 = only the foreground delegates,
320    /// 2 = its workers may delegate once more, and so on. Default: 4.
321    pub max_depth: u16,
322}
323
324impl Default for SubagentLimits {
325    fn default() -> Self {
326        Self {
327            max_concurrent: 16,
328            max_total: 64,
329            max_depth: 4,
330        }
331    }
332}
333
334/// Parse `subagent.*` keys. Unknown keys and invalid values return an error
335/// (caller warns and keeps the default).
336fn parse_subagent_config_key(
337    config: &mut SubagentLimits,
338    key: &str,
339    value: &str,
340) -> Result<(), &'static str> {
341    let positive = || match value.trim().parse::<usize>() {
342        Ok(n) if n > 0 => Ok(n),
343        _ => Err("expected a whole number of at least 1"),
344    };
345    match key {
346        "subagent.max_concurrent" => config.max_concurrent = positive()?,
347        "subagent.max_total" => config.max_total = positive()?,
348        "subagent.max_depth" => {
349            config.max_depth =
350                u16::try_from(positive()?).map_err(|_| "expected a whole number from 1 to 65535")?
351        }
352        _ => return Err("unknown subagent key"),
353    }
354    Ok(())
355}
356
357/// Parse `startup.*` keys. Unknown keys return an error (caller warns).
358fn parse_startup_config_key(
359    config: &mut StartupConfig,
360    key: &str,
361    value: &str,
362) -> Result<(), &'static str> {
363    match key {
364        "startup.quick_start" => {
365            config.quick_start = match value.trim() {
366                "true" | "1" | "on" | "yes" => true,
367                "false" | "0" | "off" | "no" => false,
368                _ => return Err("expected on or off"),
369            };
370        }
371        "startup.extensions_ready_timeout_secs" => {
372            config.extensions_ready_timeout_secs =
373                value.trim().parse().map_err(|_| "expected an unsigned second count")?;
374        }
375        _ => return Err("unknown startup key"),
376    }
377    Ok(())
378}
379
380/// Parse `daemon.*` keys. Unknown keys return an error (caller warns).
381fn parse_daemon_config_key(
382    config: &mut DaemonConfig,
383    key: &str,
384    value: &str,
385) -> Result<(), &'static str> {
386    let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
387    match key {
388        "daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
389        "daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
390        "daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
391        _ => return Err("unknown daemon key"),
392    }
393    Ok(())
394}
395
396/// Server security configuration parsed from `server.*` keys.
397#[derive(Debug, Clone, Default)]
398pub struct ServerConfig {
399    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
400    pub allowed_origins: Vec<String>,
401    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
402    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
403    pub token: Option<String>,
404    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
405    pub auto_approve_confirms: bool,
406    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
407    /// None means no artificial cap.
408    pub max_message_size: Option<usize>,
409}
410
411/// Bridge daemon configuration parsed from `bridge.*` keys.
412///
413/// Controls best-effort mirroring of watcher heartbeats over the bridge
414/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
415/// and the feature is OFF by default.
416#[derive(Debug, Clone)]
417pub struct BridgeConfig {
418    /// Path to the bridge daemon's UDS control socket.
419    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
420    pub uds_path: Option<PathBuf>,
421    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
422    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
423    pub heartbeat_mirror: bool,
424    /// Per-call timeout in milliseconds (covers connect + write + read).
425    pub heartbeat_timeout_ms: u64,
426}
427
428impl Default for BridgeConfig {
429    fn default() -> Self {
430        Self {
431            uds_path: None,
432            heartbeat_mirror: false,
433            heartbeat_timeout_ms: 250,
434        }
435    }
436}
437
438impl BridgeConfig {
439    /// Resolve the UDS path, falling back to the default under `base_dir()`.
440    pub fn resolved_uds_path(&self) -> PathBuf {
441        self.uds_path
442            .clone()
443            .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
444    }
445}
446
447/// Auth configuration parsed from `auth.*` keys. Controls whether this client
448/// resolves provider tokens from the local `auth.json` (default) or from a
449/// remote credential broker. See task #157 / `auth::credential_source`.
450#[derive(Debug, Clone, Default)]
451pub struct AuthConfig {
452    /// Broker base URL. When set (here, or via `SYNAPS_AUTH_ENDPOINT`), the
453    /// client fetches short-lived access tokens from the broker instead of
454    /// reading/refreshing the local `auth.json`.
455    pub remote_endpoint: Option<String>,
456    /// Per-machine bearer presented to the broker (or `SYNAPS_MACHINE_TOKEN`).
457    /// This is the machine's own identity, never the provider credential.
458    pub machine_token: Option<String>,
459}
460
461impl AuthConfig {
462    /// Resolve the credential source. Environment variables take precedence over
463    /// config-file values: `SYNAPS_AUTH_ENDPOINT` / `SYNAPS_MACHINE_TOKEN`.
464    /// Returns `Remote` iff an endpoint is set (env or config), else `Local`.
465    pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
466        let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
467            .ok()
468            .filter(|s| !s.trim().is_empty())
469            .or_else(|| self.remote_endpoint.clone());
470        let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
471            .ok()
472            .filter(|s| !s.trim().is_empty())
473            .or_else(|| self.machine_token.clone());
474        crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
475    }
476}
477
478/// Upper bound for the `<N>h` form of `cache_keepwarm`. The ski-rental bound
479/// already caps an abandoned session's spend at about one prefix rewrite; the
480/// hour cap is an extra, user-chosen wall on top of it.
481pub const MAX_CACHE_KEEPWARM_HOURS: u32 = 48;
482
483/// Default for `cache_keepwarm_auto_max_hours`: the longest an `auto` idle
484/// period may run. Without it the ski-rental bound alone lets a 0.05×
485/// cache-read model (Opus 5.5, Sonnet 5.5) ping ~39 times over ~36h and a
486/// 0.025× model (Fable 5.1, Mythos 5.1) ~79 times over ~72h under a 1h TTL,
487/// keeping an abandoned session unparked for days. 24h: a user who steps
488/// away for a working day or overnight still comes back to a warm cache; a
489/// session left over a weekend parks. (Owner decision, task #444.)
490pub const DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS: u32 = 24;
491
492/// Prompt-cache keep-warm policy (`cache_keepwarm`, task #444). See
493/// docs/specs/cache-ttl-spec.md §7.
494///
495/// - `Off` (default): never ping.
496/// - `Auto`: ping while the measured ping spend of the current idle period
497///   stays below the cost of the cache rewrite it prevents (ski-rental rule:
498///   at most 2× the optimal spend, at most ~one rewrite on an abandoned
499///   session), and for at most `cache_keepwarm_auto_max_hours` (default
500///   [`DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS`]) after the session went idle.
501/// - `Hours(n)`: the same bound, and additionally stop `n` hours after the
502///   session went idle — whichever comes first.
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
504pub enum CacheKeepWarm {
505    #[default]
506    Off,
507    Auto,
508    Hours(u32),
509}
510
511impl CacheKeepWarm {
512    /// Parse `off` | `auto` | `<N>h` (case-insensitive; `0h` = off).
513    /// `None` for anything else, including hours above
514    /// [`MAX_CACHE_KEEPWARM_HOURS`].
515    pub fn parse(val: &str) -> Option<CacheKeepWarm> {
516        let v = val.trim().to_ascii_lowercase();
517        match v.as_str() {
518            "off" | "false" | "no" | "0" => return Some(CacheKeepWarm::Off),
519            "auto" | "on" => return Some(CacheKeepWarm::Auto),
520            _ => {}
521        }
522        let digits = v.strip_suffix('h')?;
523        match digits.parse::<u32>().ok()? {
524            0 => Some(CacheKeepWarm::Off),
525            h if h <= MAX_CACHE_KEEPWARM_HOURS => Some(CacheKeepWarm::Hours(h)),
526            _ => None,
527        }
528    }
529
530    pub fn is_off(self) -> bool {
531        self == CacheKeepWarm::Off
532    }
533}
534
535impl std::fmt::Display for CacheKeepWarm {
536    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537        match self {
538            CacheKeepWarm::Off => f.write_str("off"),
539            CacheKeepWarm::Auto => f.write_str("auto"),
540            CacheKeepWarm::Hours(h) => write!(f, "{h}h"),
541        }
542    }
543}
544
545/// Prompt-cache TTL strategy for Anthropic requests.
546///
547/// Controls the `cache_control` value emitted at every cache marker site:
548/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
549///   to historical payloads; the default path can never invalidate existing
550///   cached prefixes.
551/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
552/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
553///   bare 5m on the message-tail marker (written every turn).
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
555pub enum CacheTtl {
556    #[default]
557    FiveMinutes,
558    OneHour,
559    Hybrid,
560}
561
562impl CacheTtl {
563    /// Parse a config value (case-insensitive). Returns `None` for unknown
564    /// values so the caller can warn and fall back to the default.
565    pub fn parse(val: &str) -> Option<CacheTtl> {
566        match val.to_ascii_lowercase().as_str() {
567            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
568            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
569            "hybrid" => Some(CacheTtl::Hybrid),
570            _ => None,
571        }
572    }
573}
574
575/// `tools.activation_confirm` — host policy for model-initiated
576/// `activate_tools` (progressive tool disclosure).
577#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
578pub enum ActivationConfirm {
579    /// Authorize model-initiated activation without prompting (default).
580    #[default]
581    Auto,
582    /// Ask the host: the y/n "Confirm tool activation" dialog; only an
583    /// explicit y/yes authorizes (fail-closed).
584    Prompt,
585    /// Never authorize model-initiated activation; no prompt is raised.
586    Deny,
587}
588
589impl ActivationConfirm {
590    pub fn parse(val: &str) -> Option<Self> {
591        match val.trim().to_ascii_lowercase().as_str() {
592            "auto" => Some(Self::Auto),
593            "prompt" => Some(Self::Prompt),
594            "deny" => Some(Self::Deny),
595            _ => None,
596        }
597    }
598
599    pub fn as_str(self) -> &'static str {
600        match self {
601            Self::Auto => "auto",
602            Self::Prompt => "prompt",
603            Self::Deny => "deny",
604        }
605    }
606}
607
608/// Animated theme-transition mode, parsed from `theme_transition`:
609/// - `"on"` (default): cross-fade theme changes over the default 350 ms.
610/// - `"off"`: instant snap — accessibility, and mercy on tmux-over-ssh.
611/// - integer milliseconds: cross-fade over that duration (clamped to
612///   0–2000 ms; `0` behaves like `off`).
613///
614/// Purely a TUI presentation knob: it changes *how* a new palette is
615/// applied, never *which* palette wins.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
617pub enum ThemeTransitionMode {
618    #[default]
619    On,
620    Off,
621    Ms(u64),
622}
623
624impl ThemeTransitionMode {
625    /// Default cross-fade duration for `On` (and the fallback when a live
626    /// palette update carries no advisory duration of its own).
627    pub const DEFAULT_MS: u64 = 350;
628    /// Duration ceiling — both this knob and MXC wire `fade_ms` clamp here.
629    pub const MAX_MS: u64 = 2000;
630
631    /// Parse a config value (case-insensitive). Returns `None` for unknown
632    /// values so the caller can warn and fall back to the default.
633    pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
634        match val.to_ascii_lowercase().as_str() {
635            "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
636            "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
637            other => other
638                .parse::<u64>()
639                .ok()
640                .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
641        }
642    }
643
644    /// Effective default duration in milliseconds; `0` means "snap".
645    pub fn duration_ms(self) -> u64 {
646        match self {
647            ThemeTransitionMode::On => Self::DEFAULT_MS,
648            ThemeTransitionMode::Off => 0,
649            ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
650        }
651    }
652}
653
654/// Runtime event routing configuration parsed from `events.*` keys.
655///
656/// Controls how runtime events (from the `EventQueue`) are delivered in
657/// server and RPC modes.
658#[derive(Debug, Clone)]
659pub struct EventsConfig {
660    /// When `true` (default), the server/RPC/daemon session automatically
661    /// triggers a model turn when runtime events arrive while idle.  Set
662    /// `events.auto_turn = false` (or `0` / `no` / `off`) to opt out.
663    /// Unrecognised values fail safe to `false` with a warning.
664    /// The consecutive-turn cap (`auto_turn_cap`) still applies regardless.
665    pub auto_turn: bool,
666    /// Maximum number of consecutive auto-triggered model turns before the
667    /// engine parks and waits for real user input.  Set via
668    /// `events.auto_turn_cap = N`.  Default **5**.
669    ///
670    /// `0` (or `unlimited` / `inf` / `infinite`) disables the cap entirely —
671    /// "to infinity and beyond".  Unparseable values warn and keep the default.
672    pub auto_turn_cap: u32,
673}
674
675/// Default for `events.auto_turn_cap` — mirrors `engine::reactor::AUTO_TURN_CAP`.
676pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
677
678impl Default for EventsConfig {
679    fn default() -> Self {
680        Self {
681            auto_turn: true,
682            auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
683        }
684    }
685}
686
687/// Parse a `events.auto_turn_cap` value.  Returns `None` on garbage.
688///
689/// Accepts a non-negative integer (`0` = unlimited) or one of the aliases
690/// `unlimited` / `inf` / `infinite` / `infinity` / `none` (all → `0`).
691pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
692    let normalised = val.trim().to_lowercase();
693    match normalised.as_str() {
694        "unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
695        n => n.parse::<u32>().ok(),
696    }
697}
698
699/// Parse `events.*` configuration keys. `Err` carries the diagnostic the
700/// caller prints (stderr, unchanged); the value side effects are the same
701/// either way (an unrecognised `auto_turn` still fails safe to `false`).
702fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) -> Result<(), String> {
703    if key == "events.auto_turn" {
704        let normalised = val.trim().to_lowercase();
705        match normalised.as_str() {
706            // Explicit true values.
707            "true" | "1" | "yes" | "on" => cfg.auto_turn = true,
708            // Explicit false values.
709            "false" | "0" | "no" | "off" => cfg.auto_turn = false,
710            // Unrecognised: fail safe to false and warn so the user knows.
711            other => {
712                cfg.auto_turn = false;
713                return Err(format!(
714                    "warning: config: unrecognised value for events.auto_turn = {:?}; \
715                     expected true/false/yes/no/on/off/1/0 — defaulting to false",
716                    other
717                ));
718            }
719        }
720    } else if key == "events.auto_turn_cap" {
721        match parse_auto_turn_cap(val) {
722            Some(cap) => cfg.auto_turn_cap = cap,
723            None => {
724                return Err(format!(
725                    "warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
726                     expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
727                     — keeping default {}",
728                    val.trim(),
729                    DEFAULT_AUTO_TURN_CAP
730                ));
731            }
732        }
733    } // unknown events.* keys ignored
734    Ok(())
735}
736
737/// Typed per-role turn-budget overrides (Task 23, spec §8.1). Every field
738/// is optional: unset fields keep the role's compiled default. Values are
739/// parsed from `turn_budget.<role>.<field>` config keys.
740#[derive(Debug, Clone, Default, PartialEq)]
741pub struct TurnBudgetOverrides {
742    pub max_provider_rounds: Option<u32>,
743    pub max_tool_calls: Option<u32>,
744    /// Wall-clock limit for one turn, in seconds. Unset = no limit (the
745    /// default for every role); `0` also means no limit.
746    pub max_elapsed_secs: Option<u64>,
747    pub max_accumulated_tool_result_bytes: Option<usize>,
748    pub max_context_tokens: Option<u64>,
749    pub max_cost_usd: Option<f64>,
750    /// Bounded auto-renewals of the provider-round allowance after it is
751    /// exhausted mid-turn (spec §8.1). Each renewal resets the round counter;
752    /// round renewals never reset wall-clock (durable context successors do). `0`
753    /// disables graceful continuation (hard stop at the first exhaustion).
754    pub max_round_renewals: Option<u32>,
755}
756
757/// Per-role turn budgets (foreground, autonomous/watcher, worker).
758#[derive(Debug, Clone, Default, PartialEq)]
759pub struct TurnBudgetsConfig {
760    pub foreground: TurnBudgetOverrides,
761    pub autonomous: TurnBudgetOverrides,
762    pub worker: TurnBudgetOverrides,
763}
764
765/// Parse `turn_budget.<role>.<field>` keys. Invalid values warn and keep
766/// the default (parity with shell.* parsing). `Err` carries the diagnostic
767/// the caller prints.
768fn parse_turn_budget_config_key(
769    budgets: &mut TurnBudgetsConfig,
770    key: &str,
771    val: &str,
772) -> Result<(), String> {
773    let Some(rest) = key.strip_prefix("turn_budget.") else {
774        return Ok(());
775    };
776    let Some((role, field)) = rest.split_once('.') else {
777        return Err(format!(
778            "Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)"
779        ));
780    };
781    let overrides = match role {
782        "foreground" => &mut budgets.foreground,
783        "autonomous" => &mut budgets.autonomous,
784        "worker" => &mut budgets.worker,
785        other => {
786            return Err(format!(
787                "Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)"
788            ));
789        }
790    };
791    macro_rules! set {
792        ($slot:expr, $ty:ty) => {
793            match val.parse::<$ty>() {
794                Ok(parsed) => $slot = Some(parsed),
795                Err(_) => {
796                    return Err(format!("Warning: invalid value for {key}: '{val}', using default"))
797                }
798            }
799        };
800    }
801    match field {
802        "max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
803        "max_tool_calls" => set!(overrides.max_tool_calls, u32),
804        "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
805        "max_accumulated_tool_result_bytes" => {
806            set!(overrides.max_accumulated_tool_result_bytes, usize)
807        }
808        "max_context_tokens" => set!(overrides.max_context_tokens, u64),
809        "max_cost_usd" => set!(overrides.max_cost_usd, f64),
810        "max_round_renewals" => set!(overrides.max_round_renewals, u32),
811        other => return Err(format!("Warning: unknown turn_budget field '{other}'")),
812    }
813    Ok(())
814}
815
816/// Continuous-memory settings surface (Task A2, spec §12). Every field is
817/// fail-closed: unknown or invalid values warn and keep the compiled default.
818///
819/// This struct contains continuous-memory policy; backend selection lives in
820/// the separate `MemoryBackendConfig`.
821/// Secret handling, retention, and project-scope rules are deliberately NOT
822/// fields here — they are not configurable at all (spec §12: "Secret,
823/// retention, and project rules are not configurable to fail open").
824#[derive(Debug, Clone, PartialEq)]
825pub struct MemoryConfig {
826    /// Durable default memory mode. "off" unless the operator explicitly
827    /// consents via `memory.default_mode_confirmed = true` in the same
828    /// config parse pass (spec §12 operator-consent rule). Known values:
829    /// off | recall_once | recall_each_prompt | capture_only |
830    /// capture_and_recall (spec §6.1).
831    pub default_mode: String,
832    /// Operator-consent latch for `default_mode` (spec §12). Must be set to
833    /// `true` in the same parse pass for a non-"off" `default_mode` to take
834    /// effect; otherwise `default_mode` reverts to "off" with a warning.
835    pub default_mode_confirmed: bool,
836    /// Max records surfaced per recall (spec §10.3 budget).
837    pub recall_max_records: u32,
838    /// Max tokens surfaced per recall (spec §10.3 budget).
839    pub recall_max_tokens: u32,
840    /// Recall time budget in milliseconds (spec §16).
841    pub recall_timeout_ms: u64,
842    /// Tool-result capture policy: off | summary_only (spec §11).
843    pub capture_tools: String,
844    /// Capture assistant turns (spec §8.2).
845    pub capture_assistant: bool,
846    /// Capture user turns (spec §8.2).
847    pub capture_user: bool,
848    /// Background consolidation: off | on (spec §11.3, explicit opt-in).
849    pub auto_consolidate: String,
850    /// Local embedding models: off | on (spec §11.2, never implicit).
851    pub local_embeddings: String,
852    /// GLiNER entity extraction: off | on (spec §11.2, never implicit).
853    pub gliner: String,
854}
855
856impl Default for MemoryConfig {
857    fn default() -> Self {
858        Self {
859            default_mode: "off".to_string(),
860            default_mode_confirmed: false,
861            recall_max_records: 8,
862            recall_max_tokens: 4096,
863            recall_timeout_ms: 150,
864            capture_tools: "summary_only".to_string(),
865            capture_assistant: true,
866            capture_user: true,
867            auto_consolidate: "off".to_string(),
868            local_embeddings: "off".to_string(),
869            gliner: "off".to_string(),
870        }
871    }
872}
873
874/// Memory mode strings accepted by `memory.default_mode` (spec §6.1,
875/// snake_case of `MemoryContextMode`). Anything else fails closed to the
876/// current value — `default_mode` can never silently become a non-off mode
877/// through an unrecognised string.
878const KNOWN_MEMORY_MODES: &[&str] = &[
879    "off",
880    "recall_once",
881    "recall_each_prompt",
882    "capture_only",
883    "capture_and_recall",
884];
885
886/// Parse `memory.<field>` keys (Task A2, spec §12). Invalid values warn and
887/// keep the default (parity with turn_budget.* parsing) — never fail open.
888/// `Err` carries the diagnostic the caller prints.
889fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) -> Result<(), String> {
890    let Some(field) = key.strip_prefix("memory.") else {
891        return Ok(());
892    };
893    /// Strict boolean: recognised forms parse; anything else warns and
894    /// keeps the current (default) value.
895    fn parse_bool(slot: &mut bool, key: &str, val: &str) -> Result<(), String> {
896        match val {
897            "true" | "1" | "yes" | "on" => *slot = true,
898            "false" | "0" | "no" | "off" => *slot = false,
899            other => {
900                return Err(format!(
901                    "Warning: invalid value for {key}: '{other}', expected true/false — using default"
902                ))
903            }
904        }
905        Ok(())
906    }
907    /// Closed string-enum: value must be in `allowed`, else warn and keep
908    /// the current (default) value.
909    fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) -> Result<(), String> {
910        if allowed.contains(&val) {
911            *slot = val.to_string();
912            Ok(())
913        } else {
914            Err(format!(
915                "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
916                allowed.join("|")
917            ))
918        }
919    }
920    macro_rules! set_num {
921        ($slot:expr, $ty:ty) => {
922            match val.parse::<$ty>() {
923                Ok(parsed) => {
924                    $slot = parsed;
925                    Ok(())
926                }
927                Err(_) => Err(format!("Warning: invalid value for {key}: '{val}', using default")),
928            }
929        };
930    }
931    match field {
932        "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
933        "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
934        "recall_max_records" => set_num!(memory.recall_max_records, u32),
935        "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
936        "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
937        "capture_tools" => parse_enum(
938            &mut memory.capture_tools,
939            key,
940            val,
941            &["off", "summary_only"],
942        ),
943        "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
944        "capture_user" => parse_bool(&mut memory.capture_user, key, val),
945        "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
946        "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
947        "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
948        other => Err(format!("Warning: unknown memory field '{other}'")),
949    }
950}
951
952/// Host-owned note storage selection, independent of continuous-memory consent.
953#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
954pub enum MemoryBackendKind {
955    #[default]
956    Legacy,
957    Axel,
958    /// Invalid configuration must never select legacy storage implicitly.
959    Unavailable,
960}
961
962#[derive(Debug, Clone, PartialEq, Eq, Default)]
963pub struct MemoryBackendConfig {
964    pub kind: MemoryBackendKind,
965    /// Explicit absolute executable path; never resolved through PATH.
966    pub executable: Option<PathBuf>,
967    /// Optional absolute shared Axel brain path; absent uses the private user default.
968    pub brain: Option<PathBuf>,
969    /// Explicit host opt-in to user-wide notes. Never grants capture/history consent.
970    pub user_scope: bool,
971}
972
973/// Parsed configuration from the config file.
974#[derive(Debug, Clone)]
975pub struct SynapsConfig {
976    pub model: Option<String>,
977    pub thinking_budget: Option<u32>,
978    /// Named reasoning level, parsed from the same `thinking = …` key.
979    /// When `Some`, this is the authoritative level. When `None`, fall back
980    /// to `thinking_budget` for legacy numeric-only values.
981    pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
982    pub context_window: Option<u64>, // override auto-detected context window (tokens)
983    pub context_management: ContextManagementConfig,
984    /// First-turn / cold-start behaviour (`startup.*`).
985    pub startup: StartupConfig,
986    /// Daemon lifetime knobs (`daemon.*`); env vars still win.
987    pub daemon: DaemonConfig,
988    /// Subagent fan-out limits (`subagent.*`).
989    pub subagent: SubagentLimits,
990    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
991    /// Where compaction summarization runs (spec §9.4): remote provider or
992    /// local-only (zero network construction).
993    pub compaction_mode: crate::core::compaction::CompactionMode,
994    /// Content classes excluded from remote compaction disclosure.
995    pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
996    pub max_tool_output: usize,  // default 30000
997    pub bash_timeout: u64,       // default 30
998    pub bash_max_timeout: u64,   // default 300
999    pub subagent_timeout: u64,   // default 300
1000    pub api_retries: u32,        // default 3
1001    pub refusal_retries: u32,    // default 2 — retries on stop_reason=refusal
1002    pub telemetry: String,       // off | basic | full (default off)
1003    pub cache_diagnostics: bool, // opt into cache-diagnosis beta (default false)
1004    /// Prompt-cache TTL strategy: "5m" (default) | "1h" | "hybrid".
1005    pub cache_ttl: CacheTtl,
1006    /// Prompt-cache keep-warm while idle (`cache_keepwarm`): `off` (default)
1007    /// | `auto` | `<N>h`. See [`CacheKeepWarm`] and
1008    /// docs/specs/cache-ttl-spec.md §7.
1009    pub cache_keepwarm: CacheKeepWarm,
1010    /// Longest an `auto` keep-warm idle period may run, in hours
1011    /// (`cache_keepwarm_auto_max_hours`, 1–48, default
1012    /// [`DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS`]). An explicit `<N>h` mode
1013    /// is its own window and ignores this.
1014    pub cache_keepwarm_auto_max_hours: u32,
1015    /// Max TUI redraw rate in frames/sec — caps streaming redraws (e.g. 60,
1016    /// 144, 240). User input always redraws immediately regardless. Default
1017    /// 60. Range 1–1000. The frame budget is `1000 / max_fps` ms.
1018    pub max_fps: u32,
1019    /// Lines to scroll per mouse-wheel event. Different terminal emulators
1020    /// emit 1–9+ scroll events per physical notch; set this to compensate.
1021    /// Default: 3. Range 1–20.
1022    pub scroll_lines: Option<u16>,
1023    pub theme: Option<String>,
1024    /// Animated theme transitions: `"on"` (350 ms cross-fade, default),
1025    /// `"off"` (instant snap), or integer milliseconds (0–2000).
1026    pub theme_transition: ThemeTransitionMode,
1027    /// Whether the TUI paints its own opaque background. `false` preserves the terminal background.
1028    pub tui_background_opaque: bool,
1029    /// Whether the prompt shows a glow sweeping across it while a turn streams. Default off.
1030    pub tui_streaming_glow: bool,
1031    /// Run the daemon, bash tool commands and PTY shells each in their own
1032    /// systemd scope (Linux, when a systemd user manager is reachable), so the
1033    /// out-of-memory killer can stop one runaway command instead of the whole
1034    /// terminal scope with the daemon in it. `process_scopes = off` disables;
1035    /// env `SYNAPS_PROCESS_SCOPES` overrides. Default on.
1036    pub process_scopes: bool,
1037    pub agent_name: Option<String>,
1038    pub identity: Option<String>,
1039    pub disabled_plugins: Vec<String>,
1040    pub favorite_models: Vec<String>,
1041    pub disabled_skills: Vec<String>,
1042    /// Opt-in progressive tool disclosure (Task 18). When false, providers
1043    /// receive the existing full tool schema byte-for-byte. When true, each
1044    /// stream starts with the small essential local core plus discovery and
1045    /// authorization gateways; exact activations are added per session.
1046    pub progressive_tool_disclosure: bool,
1047    /// Host confirmation policy for MODEL-INITIATED `activate_tools`
1048    /// (`tools.activation_confirm`). `Auto` (default) authorizes without
1049    /// prompting; `Prompt` asks the host (y/n confirm dialog); `Deny` never
1050    /// authorizes (locked-down hosts). `server.auto_approve_confirms = true`
1051    /// still authorizes regardless of this key.
1052    pub tools_activation_confirm: ActivationConfirm,
1053    /// `tools.deferred_loading` (default on). With progressive disclosure on
1054    /// the native Anthropic transport, every activatable tool is sent from the
1055    /// first request with `defer_loading: true` and `activate_tools` answers
1056    /// with `tool_reference` blocks, so an activation never changes the cached
1057    /// prompt prefix. `off` restores the per-activation tools-array change.
1058    pub tools_deferred_loading: bool,
1059    /// Opt-in session persistence strategy (Task 35, spec §9.8). `Json`
1060    /// (default) is the unchanged legacy full-rewrite path; `Journal` adds
1061    /// an append-only delta journal with periodic atomic snapshots. See
1062    /// docs/decisions/T35-session-journal-opt-in.md.
1063    pub session_persistence: crate::core::session_journal::SessionPersistence,
1064    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
1065    /// from the registry at boot so they're never offered to the model.
1066    pub disabled_tools: Vec<String>,
1067    pub shell: ShellConfig,
1068    pub server: ServerConfig,
1069    pub bridge: BridgeConfig,
1070    pub auth: AuthConfig,
1071    pub events: EventsConfig,
1072    pub provider_keys: BTreeMap<String, String>,
1073    pub keybinds: std::collections::HashMap<String, String>,
1074    /// Typed per-role turn budgets (Task 23, spec §8.1).
1075    pub turn_budgets: TurnBudgetsConfig,
1076    /// Continuous-memory settings surface (Task A2, spec §12).
1077    pub memory: MemoryConfig,
1078    /// Host-owned backend; selecting it does not enable recall or capture.
1079    pub memory_backend: MemoryBackendConfig,
1080    /// Non-fatal problems found while parsing the config file (unknown keys,
1081    /// unparseable values). Surfaced once at startup — never block boot.
1082    pub warnings: Vec<String>,
1083}
1084
1085impl Default for SynapsConfig {
1086    fn default() -> Self {
1087        Self {
1088            model: None,
1089            thinking_budget: None,
1090            thinking_level: None,
1091            context_window: None,
1092            context_management: ContextManagementConfig::default(),
1093            startup: StartupConfig::default(),
1094            daemon: DaemonConfig::default(),
1095            subagent: SubagentLimits::default(),
1096            compaction_model: None,
1097            compaction_mode: crate::core::compaction::CompactionMode::default(),
1098            compaction_exclude: Vec::new(),
1099            max_tool_output: 30000,
1100            bash_timeout: 30,
1101            bash_max_timeout: 300,
1102            subagent_timeout: 300,
1103            api_retries: 3,
1104            refusal_retries: 2,
1105            telemetry: "off".to_string(),
1106            cache_diagnostics: false,
1107            cache_ttl: CacheTtl::default(),
1108            cache_keepwarm: CacheKeepWarm::Off,
1109            cache_keepwarm_auto_max_hours: DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS,
1110            max_fps: 60,
1111            scroll_lines: None,
1112            theme: None,
1113            theme_transition: ThemeTransitionMode::default(),
1114            tui_background_opaque: true,
1115            tui_streaming_glow: false,
1116            process_scopes: true,
1117            agent_name: None,
1118            identity: None,
1119            disabled_plugins: Vec::new(),
1120            favorite_models: Vec::new(),
1121            disabled_skills: Vec::new(),
1122            progressive_tool_disclosure: false,
1123            tools_activation_confirm: ActivationConfirm::default(),
1124            tools_deferred_loading: true,
1125            session_persistence: crate::core::session_journal::SessionPersistence::default(),
1126            disabled_tools: Vec::new(),
1127            shell: ShellConfig::default(),
1128            server: ServerConfig::default(),
1129            bridge: BridgeConfig::default(),
1130            auth: AuthConfig::default(),
1131            events: EventsConfig::default(),
1132            provider_keys: BTreeMap::new(),
1133            keybinds: std::collections::HashMap::new(),
1134            turn_budgets: TurnBudgetsConfig::default(),
1135            memory: MemoryConfig::default(),
1136            memory_backend: MemoryBackendConfig::default(),
1137            warnings: Vec::new(),
1138        }
1139    }
1140}
1141
1142/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
1143/// Public so the /settings coverage test can require a settings row (or an
1144/// explicit exclusion) for every key here.
1145pub const KNOWN_CONFIG_KEYS: &[&str] = &[
1146    "model",
1147    "thinking",
1148    "compaction_model",
1149    "compaction_mode",
1150    "compaction_exclude",
1151    "context_window",
1152    "context_management.mode",
1153    "context_management.pressure_tokens",
1154    "context_management.rollover_tokens",
1155    "context_management.reserve_tokens",
1156    "context_management.finish_rounds",
1157    "startup.quick_start",
1158    "startup.extensions_ready_timeout_secs",
1159    "daemon.idle_exit_secs",
1160    "daemon.prompt_abandon_secs",
1161    "daemon.parked_evict_secs",
1162    "max_tool_output",
1163    "bash_timeout",
1164    "bash_max_timeout",
1165    "subagent_timeout",
1166    "subagent.max_concurrent",
1167    "subagent.max_total",
1168    "subagent.max_depth",
1169    "api_retries",
1170    "refusal_retries",
1171    "telemetry",
1172    "cache_diagnostics",
1173    "cache_ttl",
1174    "cache_keepwarm",
1175    "cache_keepwarm_auto_max_hours",
1176    "max_fps",
1177    "scroll_lines",
1178    "theme",
1179    "theme_transition",
1180    "tui_background_opaque",
1181    "tui_streaming_glow",
1182    "process_scopes",
1183    "agent_name",
1184    "identity",
1185    "disabled_plugins",
1186    "favorite_models",
1187    "disabled_skills",
1188    "disabled_tools",
1189    "progressive_tool_disclosure",
1190    "tools.activation_confirm",
1191    "tools.deferred_loading",
1192    "session_persistence",
1193    "sidecar_toggle_key",
1194];
1195
1196/// Every key accepted by the namespaced family parsers that is NOT in
1197/// [`KNOWN_CONFIG_KEYS`] (`shell.*`, `events.*`, `turn_budget.*`,
1198/// `memory.*`, `server.*`, `bridge.*`, `auth.*`). Open-ended families whose
1199/// suffix is user-chosen are listed as `prefix.*`. Keep this in sync when a
1200/// parser learns a key: the /settings coverage test requires each entry to
1201/// have a settings row or an explicit exclusion.
1202pub const FAMILY_CONFIG_KEYS: &[&str] = &[
1203    // parse_shell_config_key
1204    "shell.max_sessions",
1205    "shell.idle_timeout",
1206    "shell.readiness_timeout_ms",
1207    "shell.max_readiness_timeout_ms",
1208    "shell.default_rows",
1209    "shell.default_cols",
1210    "shell.readiness_strategy",
1211    "shell.max_output",
1212    // parse_events_config_key
1213    "events.auto_turn",
1214    "events.auto_turn_cap",
1215    // parse_turn_budget_config_key: turn_budget.<role>.<field>
1216    "turn_budget.foreground.max_provider_rounds",
1217    "turn_budget.foreground.max_tool_calls",
1218    "turn_budget.foreground.max_elapsed_secs",
1219    "turn_budget.foreground.max_accumulated_tool_result_bytes",
1220    "turn_budget.foreground.max_context_tokens",
1221    "turn_budget.foreground.max_cost_usd",
1222    "turn_budget.foreground.max_round_renewals",
1223    "turn_budget.autonomous.max_provider_rounds",
1224    "turn_budget.autonomous.max_tool_calls",
1225    "turn_budget.autonomous.max_elapsed_secs",
1226    "turn_budget.autonomous.max_accumulated_tool_result_bytes",
1227    "turn_budget.autonomous.max_context_tokens",
1228    "turn_budget.autonomous.max_cost_usd",
1229    "turn_budget.autonomous.max_round_renewals",
1230    "turn_budget.worker.max_provider_rounds",
1231    "turn_budget.worker.max_tool_calls",
1232    "turn_budget.worker.max_elapsed_secs",
1233    "turn_budget.worker.max_accumulated_tool_result_bytes",
1234    "turn_budget.worker.max_context_tokens",
1235    "turn_budget.worker.max_cost_usd",
1236    "turn_budget.worker.max_round_renewals",
1237    // memory backend (top-level arms of apply_config_content)
1238    "memory.backend",
1239    "memory.user_scope",
1240    "memory.axel.executable",
1241    "memory.axel.brain",
1242    // parse_memory_config_key
1243    "memory.default_mode",
1244    "memory.default_mode_confirmed",
1245    "memory.recall_max_records",
1246    "memory.recall_max_tokens",
1247    "memory.recall_timeout_ms",
1248    "memory.capture_tools",
1249    "memory.capture_assistant",
1250    "memory.capture_user",
1251    "memory.auto_consolidate",
1252    "memory.local_embeddings",
1253    "memory.gliner",
1254    // parse_server_config_key
1255    "server.allowed_origins",
1256    "server.token",
1257    "server.auto_approve_confirms",
1258    "server.max_message_size",
1259    // parse_bridge_config_key
1260    "bridge.uds_path",
1261    "bridge.heartbeat_mirror",
1262    "bridge.heartbeat_timeout_ms",
1263    // parse_auth_config_key
1264    "auth.remote_endpoint",
1265    "auth.machine_token",
1266    // open-ended families
1267    "provider.*",
1268    "keybind.*",
1269];
1270
1271/// Simple Levenshtein distance for did-you-mean suggestions.
1272fn levenshtein(a: &str, b: &str) -> usize {
1273    let a: Vec<char> = a.chars().collect();
1274    let b: Vec<char> = b.chars().collect();
1275    let mut prev: Vec<usize> = (0..=b.len()).collect();
1276    let mut cur = vec![0; b.len() + 1];
1277    for (i, ca) in a.iter().enumerate() {
1278        cur[0] = i + 1;
1279        for (j, cb) in b.iter().enumerate() {
1280            let cost = if ca == cb { 0 } else { 1 };
1281            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1282        }
1283        std::mem::swap(&mut prev, &mut cur);
1284    }
1285    prev[b.len()]
1286}
1287
1288/// Closest known key within edit distance 2, for typo suggestions.
1289fn did_you_mean(key: &str) -> Option<&'static str> {
1290    KNOWN_CONFIG_KEYS
1291        .iter()
1292        .map(|k| (*k, levenshtein(key, k)))
1293        .filter(|(_, d)| *d <= 2)
1294        .min_by_key(|(_, d)| *d)
1295        .map(|(k, _)| k)
1296}
1297
1298// parse_thinking_budget replaced by ThinkingSpec::parse in apply_config_content.
1299
1300fn parse_comma_list(val: &str) -> Vec<String> {
1301    val.split(',')
1302        .map(|s| s.trim().to_string())
1303        .filter(|s| !s.is_empty())
1304        .collect()
1305}
1306
1307fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1308    write_config_value_locked(key, &values.join(", "))
1309}
1310
1311/// Parse shell.* configuration keys and update the ShellConfig. `Err`
1312/// carries the diagnostic the caller prints.
1313fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) -> Result<(), String> {
1314    let invalid = || format!("Warning: invalid value for {key}: '{val}', using default");
1315    match key {
1316        "shell.max_sessions" => {
1317            shell_config.max_sessions = val.parse::<usize>().map_err(|_| invalid())?;
1318        }
1319        "shell.idle_timeout" => {
1320            let timeout = val.parse::<u64>().map_err(|_| invalid())?;
1321            shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1322        }
1323        "shell.readiness_timeout_ms" => {
1324            shell_config.readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1325        }
1326        "shell.max_readiness_timeout_ms" => {
1327            shell_config.max_readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1328        }
1329        "shell.default_rows" => {
1330            shell_config.default_rows = val.parse::<u16>().map_err(|_| invalid())?;
1331        }
1332        "shell.default_cols" => {
1333            shell_config.default_cols = val.parse::<u16>().map_err(|_| invalid())?;
1334        }
1335        "shell.readiness_strategy" => {
1336            let val_lower = val.to_lowercase();
1337            match val_lower.as_str() {
1338                "timeout" | "prompt" | "hybrid" => {
1339                    shell_config.readiness_strategy = val.to_string();
1340                }
1341                _ => return Err(invalid()),
1342            }
1343        }
1344        "shell.max_output" => {
1345            shell_config.max_output = val.parse::<usize>().map_err(|_| invalid())?;
1346        }
1347        _ => {
1348            // Unknown shell.* keys are preserved (not rejected)
1349        }
1350    }
1351    Ok(())
1352}
1353
1354/// Parse server.* configuration keys and update the ServerConfig.
1355#[allow(clippy::collapsible_match)]
1356fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1357    match key {
1358        "server.allowed_origins" => {
1359            server_config.allowed_origins = parse_comma_list(val);
1360        }
1361        "server.token" => {
1362            if !val.is_empty() {
1363                server_config.token = Some(val.to_string());
1364            }
1365        }
1366        "server.auto_approve_confirms" => {
1367            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1368        }
1369        "server.max_message_size" => {
1370            if let Ok(size) = val.parse::<usize>() {
1371                server_config.max_message_size = Some(size);
1372            } else {
1373                eprintln!(
1374                    "Warning: invalid value for server.max_message_size: '{}', ignored",
1375                    val
1376                );
1377            }
1378        }
1379        _ => {
1380            // Unknown server.* keys preserved (not rejected)
1381        }
1382    }
1383}
1384
1385/// Parse bridge.* configuration keys and update the BridgeConfig.
1386fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1387    match key {
1388        "bridge.uds_path" => {
1389            if val.is_empty() {
1390                bridge_config.uds_path = None;
1391            } else {
1392                bridge_config.uds_path = Some(PathBuf::from(val));
1393            }
1394        }
1395        "bridge.heartbeat_mirror" => {
1396            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
1397        }
1398        "bridge.heartbeat_timeout_ms" => {
1399            if let Ok(ms) = val.parse::<u64>() {
1400                bridge_config.heartbeat_timeout_ms = ms;
1401            } else {
1402                eprintln!(
1403                    "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
1404                    val
1405                );
1406            }
1407        }
1408        _ => {
1409            // Unknown bridge.* keys preserved (not rejected)
1410        }
1411    }
1412}
1413
1414/// Parse auth.* configuration keys and update the AuthConfig.
1415fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1416    let v = val.trim();
1417    match key {
1418        "auth.remote_endpoint" => {
1419            auth_config.remote_endpoint = if v.is_empty() {
1420                None
1421            } else {
1422                Some(v.to_string())
1423            };
1424        }
1425        "auth.machine_token" => {
1426            auth_config.machine_token = if v.is_empty() {
1427                None
1428            } else {
1429                Some(v.to_string())
1430            };
1431        }
1432        _ => {
1433            // Unknown auth.* keys preserved (not rejected)
1434        }
1435    }
1436}
1437
1438/// Parse config from a raw string — useful for tests and embedded harnesses.
1439/// Does NOT write to `PROVIDER_KEYS` or `IDENTITY` OnceLocks.
1440pub fn load_config_from_str(content: &str) -> SynapsConfig {
1441    let mut config = SynapsConfig::default();
1442    apply_config_content(&mut config, content);
1443    config
1444}
1445
1446/// Every problem the parser reports for `content`: the `warnings` surfaced
1447/// at startup plus the stderr-only diagnostics of the `shell.*`, `events.*`,
1448/// `turn_budget.*` and `memory.*` families. Pure — prints nothing and
1449/// touches no process state.
1450pub fn config_content_problems(content: &str) -> Vec<String> {
1451    let mut config = SynapsConfig::default();
1452    let mut problems = Vec::new();
1453    apply_config_content_with(&mut config, content, &mut |diagnostic: String| problems.push(diagnostic));
1454    problems.extend(config.warnings);
1455    problems
1456}
1457
1458/// Check a `key = value` edit before it is written (the /settings modal).
1459///
1460/// Runs the real parser over the config file as it would look after
1461/// [`write_config_value`] and returns `Err` with the parser's own messages
1462/// for any problem the edit introduces — invalid values, and cross-key
1463/// rules such as `context_management` threshold ordering, `subagent`
1464/// concurrency vs total, or `memory.default_mode` without operator consent.
1465/// Problems already present before the edit are not blamed on it.
1466pub fn validate_config_edit(key: &str, value: &str) -> Result<(), String> {
1467    if key.contains(['=', '\n', '\r'])
1468        || value.contains(['\n', '\r'])
1469    {
1470        return Err("value must be a single line".to_string());
1471    }
1472    // The file the edit lands in (profile-aware, like `write_config_value`),
1473    // computed without `resolve_write_path`'s directory creation.
1474    let target = get_active_config_dir().join("config");
1475    let existing = std::fs::read_to_string(&target).unwrap_or_default();
1476    let before = config_content_problems(&existing);
1477    let after = config_content_problems(&replace_config_line(&existing, key, value));
1478    let introduced: Vec<String> = after.into_iter().filter(|p| !before.contains(p)).collect();
1479    if introduced.is_empty() {
1480        Ok(())
1481    } else {
1482        Err(introduced.join("; "))
1483    }
1484}
1485
1486/// Parse the config file at ~/.synaps-cli/config (or profile variant).
1487/// Returns default config if file doesn't exist or can't be read.
1488pub fn load_config() -> SynapsConfig {
1489    let path = resolve_read_path("config");
1490    let mut config = SynapsConfig::default();
1491
1492    let Ok(content) = std::fs::read_to_string(&path) else {
1493        return config;
1494    };
1495
1496    apply_config_content(&mut config, &content);
1497
1498    // Publish provider keys to the process-wide cache for the API router.
1499    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
1500    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1501
1502    // Publish identity to the process-wide cache for API system prompt preamble.
1503    let identity_val = config
1504        .identity
1505        .clone()
1506        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1507    let _ = IDENTITY.set(identity_val);
1508
1509    config
1510}
1511
1512/// Apply key=value config lines from `content` into `config`.
1513/// Shared by `load_config` (file path) and `load_config_from_str` (test helper).
1514fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1515    // The shell/events/turn_budget/memory families report to stderr (not
1516    // `config.warnings`) — unchanged behaviour.
1517    apply_config_content_with(config, content, &mut |diagnostic: String| eprintln!("{diagnostic}"));
1518}
1519
1520/// [`apply_config_content`] with the stderr-only family diagnostics routed
1521/// to `family_diagnostic` instead, so [`config_content_problems`] can see
1522/// every value the parser rejects.
1523fn apply_config_content_with(
1524    config: &mut SynapsConfig,
1525    content: &str,
1526    family_diagnostic: &mut dyn FnMut(String),
1527) {
1528    let mut invalid_context_management = false;
1529    let mut invalid_memory_backend = false;
1530    for line in content.lines() {
1531        let line = line.trim();
1532        if line.is_empty() || line.starts_with('#') {
1533            continue;
1534        }
1535        let Some((key, val)) = line.split_once('=') else {
1536            continue;
1537        };
1538        let key = key.trim();
1539        let val = val.trim();
1540        match key {
1541            "model" => config.model = Some(val.to_string()),
1542            "thinking" => {
1543                use crate::core::reasoning::ThinkingSpec;
1544                match ThinkingSpec::parse(val) {
1545                    Some(ThinkingSpec::Named(level)) => {
1546                        config.thinking_level = Some(level);
1547                        config.thinking_budget = level.to_legacy_budget();
1548                    }
1549                    Some(ThinkingSpec::Budget(budget)) => {
1550                        // Preserve exact legacy budgets; do not make the derived
1551                        // bucket authoritative over the user's token count.
1552                        config.thinking_level = None;
1553                        config.thinking_budget = Some(budget);
1554                    }
1555                    None => {
1556                        config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
1557                    }
1558                }
1559            }
1560            "compaction_model" => config.compaction_model = Some(val.to_string()),
1561            "compaction_mode" => match val {
1562                "remote" => {
1563                    config.compaction_mode = crate::core::compaction::CompactionMode::Remote
1564                }
1565                "local" | "local_only" | "local-only" => {
1566                    config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
1567                }
1568                _ => {
1569                    config.warnings.push(format!(
1570                        "compaction_mode = {val} — expected remote or local; ignored"
1571                    ));
1572                }
1573            },
1574            "compaction_exclude" => {
1575                let mut classes = Vec::new();
1576                for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1577                    match crate::core::compaction::ContentClass::parse(part) {
1578                        Some(class) => {
1579                            if !classes.contains(&class) {
1580                                classes.push(class);
1581                            }
1582                        }
1583                        None => config.warnings.push(format!(
1584                            "compaction_exclude — unknown content class '{part}'; \
1585                             expected one of: user_text, assistant_text, thinking, \
1586                             tool_calls, tool_results, file_paths, event_data"
1587                        )),
1588                    }
1589                }
1590                config.compaction_exclude = classes;
1591            }
1592            // `auto` = model default (what /settings and `/context auto`
1593            // write): clears any override, no warning.
1594            "context_window" if val.eq_ignore_ascii_case("auto") => config.context_window = None,
1595            "context_window" => {
1596                let parsed = match val {
1597                    "200k" | "200K" => Some(200_000),
1598                    "1m" | "1M" => Some(1_000_000),
1599                    _ => val.parse::<u64>().ok(),
1600                };
1601                if parsed.is_none() {
1602                    config.warnings.push(format!(
1603                        "context_window = {val} — expected 200k, 1m, auto, or a token count; ignored"
1604                    ));
1605                }
1606                config.context_window = parsed;
1607            }
1608            "max_tool_output" => match val.parse::<usize>() {
1609                Ok(size) => config.max_tool_output = size,
1610                Err(_) => config.warnings.push(format!(
1611                    "max_tool_output = {val} — not a number; using {}",
1612                    config.max_tool_output
1613                )),
1614            },
1615            "bash_timeout" => match val.parse::<u64>() {
1616                Ok(t) if t >= 1 => config.bash_timeout = t,
1617                Ok(_) => config.warnings.push(format!(
1618                    "bash_timeout = {val} — below minimum (1s); using {}",
1619                    config.bash_timeout
1620                )),
1621                Err(_) => config.warnings.push(format!(
1622                    "bash_timeout = {val} — not a number; using {}",
1623                    config.bash_timeout
1624                )),
1625            },
1626            "bash_max_timeout" => {
1627                if let Ok(timeout) = val.parse::<u64>() {
1628                    config.bash_max_timeout = timeout;
1629                }
1630            }
1631            "subagent_timeout" => {
1632                if let Ok(timeout) = val.parse::<u64>() {
1633                    config.subagent_timeout = timeout;
1634                }
1635            }
1636            "api_retries" => {
1637                if let Ok(retries) = val.parse::<u32>() {
1638                    config.api_retries = retries;
1639                }
1640            }
1641            "refusal_retries" => {
1642                if let Ok(retries) = val.parse::<u32>() {
1643                    config.refusal_retries = retries;
1644                }
1645            }
1646            "telemetry" => config.telemetry = val.to_string(),
1647            "cache_diagnostics" => {
1648                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1649            }
1650            "cache_ttl" => match CacheTtl::parse(val) {
1651                Some(ttl) => config.cache_ttl = ttl,
1652                None => config.warnings.push(format!(
1653                    "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1654                )),
1655            },
1656            "cache_keepwarm" => match CacheKeepWarm::parse(val) {
1657                Some(mode) => config.cache_keepwarm = mode,
1658                None => config.warnings.push(format!(
1659                    "cache_keepwarm = {val} — expected off, auto, or 1h–{MAX_CACHE_KEEPWARM_HOURS}h; keep-warm stays off"
1660                )),
1661            },
1662            "cache_keepwarm_auto_max_hours" => {
1663                match val.trim().trim_end_matches(['h', 'H']).parse::<u32>() {
1664                    Ok(h) if (1..=MAX_CACHE_KEEPWARM_HOURS).contains(&h) => {
1665                        config.cache_keepwarm_auto_max_hours = h
1666                    }
1667                    _ => config.warnings.push(format!(
1668                        "cache_keepwarm_auto_max_hours = {val} — expected 1–{MAX_CACHE_KEEPWARM_HOURS}; using {DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS}"
1669                    )),
1670                }
1671            }
1672            "max_fps" => match val.parse::<u32>() {
1673                Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1674                Ok(_) => config.warnings.push(format!(
1675                    "max_fps = {val} — expected 1–1000; using {}",
1676                    config.max_fps
1677                )),
1678                Err(_) => config.warnings.push(format!(
1679                    "max_fps = {val} — not a number; using {}",
1680                    config.max_fps
1681                )),
1682            },
1683            "scroll_lines" => match val.parse::<u16>() {
1684                Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1685                Ok(_) => config
1686                    .warnings
1687                    .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1688                Err(_) => config
1689                    .warnings
1690                    .push(format!("scroll_lines = {val} — not a number; ignoring")),
1691            },
1692            "theme" => config.theme = Some(val.to_string()),
1693            "theme_transition" => match ThemeTransitionMode::parse(val) {
1694                Some(mode) => config.theme_transition = mode,
1695                None => config.warnings.push(format!(
1696                    "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1697                )),
1698            },
1699            "tui_background_opaque" => match val {
1700                "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1701                "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1702                _ => config.warnings.push(format!(
1703                    "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1704                    if config.tui_background_opaque {
1705                        "opaque"
1706                    } else {
1707                        "invisible"
1708                    }
1709                )),
1710            },
1711            "tui_streaming_glow" => match val {
1712                "true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
1713                "false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
1714                _ => config.warnings.push(format!(
1715                    "tui_streaming_glow = {val} — expected on or off; using off"
1716                )),
1717            },
1718            "process_scopes" => match val {
1719                "true" | "1" | "on" | "yes" | "auto" => config.process_scopes = true,
1720                "false" | "0" | "off" | "no" => config.process_scopes = false,
1721                _ => config.warnings.push(format!(
1722                    "process_scopes = {val} — expected auto or off; using auto"
1723                )),
1724            },
1725            // Read directly by the keybind registry (`read_config_value`);
1726            // recognised here so the /settings row never trips the
1727            // unknown-key warning.
1728            "sidecar_toggle_key" => {}
1729            "agent_name" => config.agent_name = Some(val.to_string()),
1730            "identity" => config.identity = Some(val.to_string()),
1731            "disabled_plugins" => {
1732                config.disabled_plugins = parse_comma_list(val);
1733            }
1734            "favorite_models" => {
1735                config.favorite_models = parse_comma_list(val);
1736            }
1737            "disabled_skills" => {
1738                config.disabled_skills = parse_comma_list(val);
1739            }
1740            "progressive_tool_disclosure" => {
1741                config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1742            }
1743            "tools.deferred_loading" => match val {
1744                "on" | "true" | "1" | "yes" => config.tools_deferred_loading = true,
1745                "off" | "false" | "0" | "no" => config.tools_deferred_loading = false,
1746                _ => config.warnings.push(format!(
1747                    "tools.deferred_loading = {val} — expected on or off; keeping the default (on)"
1748                )),
1749            },
1750            "tools.activation_confirm" => match ActivationConfirm::parse(val) {
1751                Some(mode) => config.tools_activation_confirm = mode,
1752                None => config.warnings.push(format!(
1753                    "tools.activation_confirm = {val} — expected auto, prompt or deny; \
1754                     keeping the default (auto)"
1755                )),
1756            },
1757            "session_persistence" => {
1758                match crate::core::session_journal::SessionPersistence::parse(val) {
1759                    Some(mode) => config.session_persistence = mode,
1760                    None => config.warnings.push(format!(
1761                        "session_persistence = {val} — expected json or journal; \
1762                         keeping the default (json)"
1763                    )),
1764                }
1765            }
1766            "disabled_tools" => {
1767                config.disabled_tools = parse_comma_list(val);
1768            }
1769            "memory.backend" => {
1770                config.memory_backend.kind = match val {
1771                    "legacy" => MemoryBackendKind::Legacy,
1772                    "axel" => MemoryBackendKind::Axel,
1773                    _ => {
1774                        invalid_memory_backend = true;
1775                        config.warnings.push(format!(
1776                            "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
1777                        ));
1778                        MemoryBackendKind::Unavailable
1779                    }
1780                };
1781            }
1782            "memory.user_scope" => match val {
1783                "true" => config.memory_backend.user_scope = true,
1784                "false" => config.memory_backend.user_scope = false,
1785                _ => {
1786                    invalid_memory_backend = true;
1787                    config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
1788                }
1789            },
1790            "memory.axel.executable" | "memory.axel.brain" => {
1791                let path = PathBuf::from(val);
1792                if !path.is_absolute() {
1793                    invalid_memory_backend = true;
1794                    config.warnings.push(format!(
1795                        "{key} — expected an absolute path; memory backend unavailable"
1796                    ));
1797                }
1798                // Retain invalid paths too: direct config consumers must not
1799                // mistake an invalid explicit path for an absent default.
1800                if key == "memory.axel.executable" {
1801                    config.memory_backend.executable = Some(path);
1802                } else {
1803                    config.memory_backend.brain = Some(path);
1804                }
1805            }
1806            _ => {
1807                // Handle namespaced keys
1808                if key.starts_with("shell.") {
1809                    if let Err(diagnostic) = parse_shell_config_key(&mut config.shell, key, val) {
1810                        family_diagnostic(diagnostic);
1811                    }
1812                } else if key.starts_with("server.") {
1813                    parse_server_config_key(&mut config.server, key, val);
1814                } else if key.starts_with("bridge.") {
1815                    parse_bridge_config_key(&mut config.bridge, key, val);
1816                } else if key.starts_with("auth.") {
1817                    parse_auth_config_key(&mut config.auth, key, val);
1818                } else if key.starts_with("events.") {
1819                    if let Err(diagnostic) = parse_events_config_key(&mut config.events, key, val)
1820                    {
1821                        family_diagnostic(diagnostic);
1822                    }
1823                } else if key.starts_with("turn_budget.") {
1824                    if let Err(diagnostic) =
1825                        parse_turn_budget_config_key(&mut config.turn_budgets, key, val)
1826                    {
1827                        family_diagnostic(diagnostic);
1828                    }
1829                } else if key.starts_with("memory.") {
1830                    if let Err(diagnostic) = parse_memory_config_key(&mut config.memory, key, val)
1831                    {
1832                        family_diagnostic(diagnostic);
1833                    }
1834                } else if key.starts_with("startup.") {
1835                    if let Err(reason) =
1836                        parse_startup_config_key(&mut config.startup, key, val)
1837                    {
1838                        config.warnings.push(format!("{key} — {reason}"));
1839                    }
1840                } else if key.starts_with("daemon.") {
1841                    if let Err(reason) =
1842                        parse_daemon_config_key(&mut config.daemon, key, val)
1843                    {
1844                        config.warnings.push(format!("{key} — {reason}"));
1845                    }
1846                } else if key.starts_with("subagent.") {
1847                    if let Err(reason) =
1848                        parse_subagent_config_key(&mut config.subagent, key, val)
1849                    {
1850                        config.warnings.push(format!("{key} — {reason}"));
1851                    }
1852                } else if key.starts_with("context_management.") {
1853                    if let Err(reason) = parse_context_management_config_key(
1854                        &mut config.context_management, key, val,
1855                    ) {
1856                        invalid_context_management = true;
1857                        config.warnings.push(format!("{key} — {reason}"));
1858                    }
1859                } else if let Some(provider_key) = key.strip_prefix("provider.") {
1860                    config
1861                        .provider_keys
1862                        .insert(provider_key.to_string(), val.to_string());
1863                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1864                    config
1865                        .keybinds
1866                        .insert(keybind_key.to_string(), val.to_string());
1867                } else if key.contains('.') {
1868                    // Dotted keys are namespaced (plugin/extension config, e.g.
1869                    // `knowledge.jawz_notes`). Plugins define their own keys —
1870                    // not ours to police. Silently preserved.
1871                } else {
1872                    // Unknown top-level key — warn with a did-you-mean if close.
1873                    match did_you_mean(key) {
1874                        Some(suggestion) => config.warnings.push(format!(
1875                            "unknown key '{key}' (did you mean '{suggestion}'?)"
1876                        )),
1877                        None => config
1878                            .warnings
1879                            .push(format!("unknown key '{key}' — ignored")),
1880                    }
1881                }
1882            }
1883        }
1884    }
1885
1886    // Never leave a partially parsed automatic policy enabled. Syntax errors
1887    // invalidate this whole parse pass, even if a later duplicate is valid.
1888    let context_validation = match config.context_window {
1889        Some(window) => config.context_management.validate_for_window(window),
1890        None => config.context_management.validate(),
1891    };
1892    if let Err(reason) = context_validation {
1893        invalid_context_management = true;
1894        config
1895            .warnings
1896            .push(format!("context_management — {reason}"));
1897    }
1898    // The delegation policy refuses concurrent > total, which would take
1899    // subagents away entirely. Keep the stricter number for both. Checked
1900    // after the loop so key order never matters.
1901    if config.subagent.max_concurrent > config.subagent.max_total {
1902        config.warnings.push(format!(
1903            "subagent.max_concurrent ({}) is above subagent.max_total ({}); using {} for both",
1904            config.subagent.max_concurrent, config.subagent.max_total, config.subagent.max_total
1905        ));
1906        config.subagent.max_concurrent = config.subagent.max_total;
1907    }
1908    if invalid_context_management {
1909        config.context_management = ContextManagementConfig::default();
1910        config.warnings.push(
1911            "invalid context_management configuration — using defaults (mode off)".to_string(),
1912        );
1913    }
1914
1915    // Any invalid backend setting poisons this parse pass, regardless of key
1916    // order or later duplicate selectors. Never silently route to legacy.
1917    if invalid_memory_backend {
1918        config.memory_backend.kind = MemoryBackendKind::Unavailable;
1919    }
1920
1921    // Fail-closed operator-consent rule (Task A2, spec §12): a non-"off"
1922    // `memory.default_mode` takes effect only when the operator also set
1923    // `memory.default_mode_confirmed = true` in this same parse pass.
1924    // Checked after the loop so key order never matters.
1925    if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1926        config.warnings.push(format!(
1927            "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1928             in the same config; reverting to off",
1929            config.memory.default_mode
1930        ));
1931        config.memory.default_mode = "off".to_string();
1932    }
1933
1934    // Derive max_message_size from context_window if not explicitly set.
1935    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
1936    if config.server.max_message_size.is_none() {
1937        if let Some(ctx_tokens) = config.context_window {
1938            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1939        }
1940    }
1941}
1942
1943/// Read a single config value by exact key from the active config file.
1944pub fn read_config_value(key: &str) -> Option<String> {
1945    let path = resolve_read_path("config");
1946    let content = std::fs::read_to_string(&path).ok()?;
1947    for line in content.lines() {
1948        let line = line.trim();
1949        if line.is_empty() || line.starts_with('#') {
1950            continue;
1951        }
1952        let Some((k, v)) = line.split_once('=') else {
1953            continue;
1954        };
1955        if k.trim() == key.trim() {
1956            return Some(v.trim().to_string());
1957        }
1958    }
1959    None
1960}
1961
1962/// Exclusive advisory lock on `<config>.lock`, held around every
1963/// read-modify-write of the config file (same pattern as `auth.json`).
1964/// Two writers (`/settings` in two processes, a `/model` favorite toggle
1965/// racing a settings edit) no longer lose each other's updates.
1966///
1967/// Held for the RMW only (< 1 ms); readers (`load_config`) never take it —
1968/// they already tolerate the atomic rename. `SYNAPS_CONFIG_LOCK=0` bypasses.
1969///
1970/// NOT reentrant: flock on a fresh fd of the same file from the same process
1971/// conflicts on Linux. Callers that already hold the guard must use the
1972/// `_locked` inner fns.
1973struct ConfigLock {
1974    _file: Option<std::fs::File>,
1975}
1976
1977impl ConfigLock {
1978    fn acquire() -> std::io::Result<Self> {
1979        if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
1980            return Ok(Self { _file: None });
1981        }
1982        use fs4::fs_std::FileExt;
1983        let lock_path = resolve_write_path("config").with_extension("lock");
1984        if let Some(parent) = lock_path.parent() {
1985            std::fs::create_dir_all(parent)?;
1986        }
1987        let mut open = std::fs::OpenOptions::new();
1988        open.create(true).write(true).truncate(false);
1989        #[cfg(unix)]
1990        {
1991            use std::os::unix::fs::OpenOptionsExt;
1992            open.mode(0o600);
1993        }
1994        let file = open.open(&lock_path)?;
1995        FileExt::lock_exclusive(&file)?;
1996        Ok(Self { _file: Some(file) })
1997    }
1998}
1999
2000/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
2001/// Replaces the first existing line that matches the key, or appends if absent.
2002/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
2003pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
2004    let _guard = ConfigLock::acquire()?;
2005    write_config_value_locked(key, value)
2006}
2007
2008/// The RMW body; caller holds [`ConfigLock`].
2009fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
2010    let path = resolve_write_path("config");
2011    let existing = std::fs::read_to_string(&path).unwrap_or_default();
2012    let out = replace_config_line(&existing, key, value);
2013
2014    let tmp = path.with_extension("tmp");
2015    std::fs::write(&tmp, out)?;
2016    // Config may contain API keys — restrict to owner-only
2017    #[cfg(unix)]
2018    {
2019        use std::os::unix::fs::PermissionsExt;
2020        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
2021    }
2022    std::fs::rename(&tmp, &path)?;
2023    Ok(())
2024}
2025
2026/// `existing` with the first `key = …` line replaced by `key = value` (or
2027/// the pair appended), comments and unknown keys preserved — exactly what
2028/// [`write_config_value`] writes.
2029fn replace_config_line(existing: &str, key: &str, value: &str) -> String {
2030    let key_trimmed = key.trim();
2031    let replacement = format!("{} = {}", key_trimmed, value);
2032
2033    let mut found = false;
2034    let mut new_lines: Vec<String> = existing
2035        .lines()
2036        .map(|line| {
2037            if found {
2038                return line.to_string();
2039            }
2040            let t = line.trim_start();
2041            if t.starts_with('#') || t.is_empty() {
2042                return line.to_string();
2043            }
2044            if let Some((k, _)) = t.split_once('=') {
2045                if k.trim() == key_trimmed {
2046                    found = true;
2047                    return replacement.clone();
2048                }
2049            }
2050            line.to_string()
2051        })
2052        .collect();
2053
2054    if !found {
2055        new_lines.push(replacement);
2056    }
2057
2058    let mut out = new_lines.join("\n");
2059    if !out.ends_with('\n') {
2060        out.push('\n');
2061    }
2062    out
2063}
2064
2065/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
2066pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
2067    let trimmed = id.trim();
2068    if trimmed.is_empty() {
2069        return Ok(());
2070    }
2071    let _guard = ConfigLock::acquire()?;
2072    let mut values = load_config().favorite_models;
2073    if !values.iter().any(|v| v == trimmed) {
2074        values.push(trimmed.to_string());
2075        values.sort();
2076    }
2077    write_comma_list_locked("favorite_models", &values)
2078}
2079
2080/// Remove a favorite model id (`provider/model`) from config.
2081pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
2082    let _guard = ConfigLock::acquire()?;
2083    let mut values = load_config().favorite_models;
2084    values.retain(|v| v != id.trim());
2085    write_comma_list_locked("favorite_models", &values)
2086}
2087
2088/// Return whether a model id is marked as favorite.
2089pub fn is_favorite_model(id: &str) -> bool {
2090    load_config().favorite_models.iter().any(|v| v == id.trim())
2091}
2092
2093/// Whether a key matches the secret denylist (case-insensitive).
2094/// Used by the journal save path to ensure secret values never reach disk,
2095/// and by the client env capture to strip secrets before `Hello`.
2096pub fn is_secret_key(key: &str) -> bool {
2097    let upper = key.to_ascii_uppercase();
2098    upper.ends_with("_KEY")
2099        || upper.ends_with("_TOKEN")
2100        || upper.ends_with("_CREDENTIALS")
2101        || upper.ends_with("_API_KEY")
2102        || upper.contains("SECRET")
2103        || upper.contains("PASSWORD")
2104        || upper.contains("PASSWD")
2105        || upper.ends_with("_PAT")
2106        || upper.ends_with("_DSN")
2107}
2108
2109/// Whether an env VALUE carries a credential regardless of its key name:
2110/// any URL with userinfo (`scheme://user:pass@host`, `scheme://key@host`),
2111/// anywhere in the value — `DATABASE_URL`, `HTTPS_PROXY`, a Sentry DSN or a
2112/// JDBC option string all qualify. The key-name denylist alone missed these,
2113/// so their credentials reached the daemon and the session files on disk.
2114/// A username-only URL (`ssh://git@host/…`) is treated as a secret too:
2115/// rare in an environment, and failing safe beats a guess.
2116pub fn is_secret_env_value(value: &str) -> bool {
2117    let mut rest = value;
2118    while let Some(i) = rest.find("://") {
2119        let scheme_ok = rest[..i]
2120            .chars()
2121            .next_back()
2122            .is_some_and(|c| c.is_ascii_alphanumeric());
2123        let after = &rest[i + 3..];
2124        // The authority ends at the path, query or fragment, or at a list /
2125        // option separator when several URLs share one value.
2126        let end = after
2127            .find(|c: char| {
2128                matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
2129            })
2130            .unwrap_or(after.len());
2131        if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
2132            return true;
2133        }
2134        rest = after;
2135    }
2136    false
2137}
2138
2139/// Whether an env pair must never reach the daemon or disk: a secret key
2140/// name OR a value carrying credentials (see [`is_secret_env_value`]).
2141pub fn is_secret_env(key: &str, value: &str) -> bool {
2142    is_secret_key(key) || is_secret_env_value(value)
2143}
2144
2145/// Resolve the system prompt from CLI flag, config file, or default.
2146/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
2147pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
2148    #[cfg(windows)]
2149    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
2150        You have access to powershell, bash, read, and write tools. \
2151        Prefer the powershell tool for system commands and filesystem operations — it is native \
2152        to Windows and avoids quoting/path translation issues. \
2153        Use bash only when the task specifically requires WSL or a Unix environment. \
2154        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
2155    #[cfg(not(windows))]
2156    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
2157        You have access to bash, read, and write tools. \
2158        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
2159
2160    if let Some(val) = explicit {
2161        let path = std::path::Path::new(val);
2162        if path.exists() && path.is_file() {
2163            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
2164        }
2165        return val.to_string();
2166    }
2167
2168    let system_path = resolve_read_path("system.md");
2169    if system_path.exists() {
2170        return std::fs::read_to_string(&system_path).unwrap_or_default();
2171    }
2172
2173    DEFAULT_PROMPT.to_string()
2174}
2175
2176#[cfg(test)]
2177mod tests {
2178    #[test]
2179    fn tools_deferred_loading_defaults_on_and_parses() {
2180        assert!(super::load_config_from_str("").tools_deferred_loading);
2181        assert!(
2182            !super::load_config_from_str("tools.deferred_loading = off\n").tools_deferred_loading
2183        );
2184        let bad = super::load_config_from_str("tools.deferred_loading = maybe\n");
2185        assert!(
2186            bad.tools_deferred_loading,
2187            "invalid value keeps the default"
2188        );
2189        assert!(bad
2190            .warnings
2191            .iter()
2192            .any(|w| w.contains("tools.deferred_loading")));
2193    }
2194
2195    #[test]
2196    fn secret_env_value_detects_credential_urls() {
2197        use super::is_secret_env_value as v;
2198        // user:password in the authority
2199        assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
2200        assert!(v("redis://:pw@cache:6379/0"));
2201        assert!(v("http://user:pass@proxy.corp:3128"));
2202        assert!(v(
2203            "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
2204        ));
2205        // a key as the username, no password (Sentry DSN)
2206        assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
2207        // anywhere in the value, not only at the start; lists
2208        assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
2209        assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
2210        // no userinfo → not a secret
2211        assert!(!v("https://api.example.com/v1/items?q=a@b"));
2212        assert!(!v("http://host/path/with@sign"));
2213        assert!(!v("https://@host/empty-userinfo"));
2214        assert!(!v("git@github.com:org/repo.git"));
2215        assert!(!v("mailto:someone@example.com"));
2216        assert!(!v("someone@example.com"));
2217        assert!(!v("/usr/local/bin:/usr/bin"));
2218        assert!(!v(""));
2219    }
2220
2221    #[test]
2222    fn secret_key_covers_pat_and_dsn() {
2223        assert!(super::is_secret_key("GITHUB_PAT"));
2224        assert!(super::is_secret_key("gh_pat"));
2225        assert!(super::is_secret_key("SENTRY_DSN"));
2226        assert!(!super::is_secret_key("PATH"));
2227        assert!(!super::is_secret_key("PATTERN_DIR"));
2228    }
2229
2230    #[test]
2231    fn secret_env_is_key_or_value() {
2232        assert!(super::is_secret_env(
2233            "DATABASE_URL",
2234            "postgres://app:hunter2@db/prod"
2235        ));
2236        assert!(super::is_secret_env("GH_TOKEN", "anything"));
2237        assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
2238        assert!(!super::is_secret_env("HOME", "/home/u"));
2239    }
2240    #[test]
2241    fn startup_and_daemon_defaults() {
2242        let c = super::load_config_from_str("");
2243        assert!(c.startup.quick_start, "quick_start defaults ON");
2244        assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
2245        assert_eq!(c.daemon.idle_exit_secs, 10);
2246        assert_eq!(c.daemon.prompt_abandon_secs, 3600);
2247        assert_eq!(c.daemon.parked_evict_secs, 3600);
2248        // No warnings for the absent keys.
2249        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2250    }
2251
2252    #[test]
2253    fn startup_and_daemon_parse_all_keys() {
2254        let c = super::load_config_from_str(concat!(
2255            "startup.quick_start = off\n",
2256            "startup.extensions_ready_timeout_secs = 45\n",
2257            "daemon.idle_exit_secs = 20\n",
2258            "daemon.prompt_abandon_secs = 0\n",
2259            "daemon.parked_evict_secs = 120\n",
2260        ));
2261        assert!(!c.startup.quick_start);
2262        assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
2263        assert_eq!(c.daemon.idle_exit_secs, 20);
2264        assert_eq!(c.daemon.prompt_abandon_secs, 0);
2265        assert_eq!(c.daemon.parked_evict_secs, 120);
2266        assert!(
2267            !c.warnings.iter().any(|w| w.contains("unknown")),
2268            "no unknown-key warning: {:?}",
2269            c.warnings
2270        );
2271    }
2272
2273    #[test]
2274    fn subagent_limits_default_to_16_64_4() {
2275        let c = super::load_config_from_str("");
2276        assert_eq!(c.subagent, super::SubagentLimits::default());
2277        assert_eq!(c.subagent.max_concurrent, 16);
2278        assert_eq!(c.subagent.max_total, 64);
2279        assert_eq!(c.subagent.max_depth, 4);
2280        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2281    }
2282
2283    #[test]
2284    fn subagent_limits_parse_and_are_known_keys() {
2285        let c = super::load_config_from_str(concat!(
2286            "subagent.max_concurrent = 24\n",
2287            "subagent.max_total = 100\n",
2288            "subagent.max_depth = 2\n",
2289            "subagent_timeout = 600\n",
2290        ));
2291        assert_eq!(c.subagent.max_concurrent, 24);
2292        assert_eq!(c.subagent.max_total, 100);
2293        assert_eq!(c.subagent.max_depth, 2);
2294        assert_eq!(c.subagent_timeout, 600, "the old flat key is unaffected");
2295        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2296        for k in [
2297            "subagent.max_concurrent",
2298            "subagent.max_total",
2299            "subagent.max_depth",
2300        ] {
2301            assert!(
2302                super::KNOWN_CONFIG_KEYS.contains(&k),
2303                "{k} not in KNOWN_CONFIG_KEYS"
2304            );
2305        }
2306    }
2307
2308    #[test]
2309    fn subagent_limits_reject_zero_and_junk_and_keep_defaults() {
2310        let c = super::load_config_from_str(concat!(
2311            "subagent.max_concurrent = 0\n",
2312            "subagent.max_total = lots\n",
2313            "subagent.max_depth = 70000\n",
2314            "subagent.max_children = 4\n",
2315        ));
2316        assert_eq!(c.subagent, super::SubagentLimits::default());
2317        for key in [
2318            "subagent.max_concurrent",
2319            "subagent.max_total",
2320            "subagent.max_depth",
2321            "subagent.max_children",
2322        ] {
2323            assert!(
2324                c.warnings.iter().any(|w| w.starts_with(key)),
2325                "expected a warning for {key}: {:?}",
2326                c.warnings
2327            );
2328        }
2329    }
2330
2331    #[test]
2332    fn subagent_concurrency_above_total_uses_the_stricter_number() {
2333        // Either key order: the check runs after the whole file is parsed.
2334        for text in [
2335            "subagent.max_total = 10\nsubagent.max_concurrent = 32\n",
2336            "subagent.max_concurrent = 32\nsubagent.max_total = 10\n",
2337        ] {
2338            let c = super::load_config_from_str(text);
2339            assert_eq!(c.subagent.max_concurrent, 10);
2340            assert_eq!(c.subagent.max_total, 10);
2341            assert!(
2342                c.warnings
2343                    .iter()
2344                    .any(|w| w.contains("subagent.max_concurrent (32)")),
2345                "{:?}",
2346                c.warnings
2347            );
2348        }
2349        // Lowering only the total below the default concurrency also clamps.
2350        let c = super::load_config_from_str("subagent.max_total = 5\n");
2351        assert_eq!(c.subagent.max_concurrent, 5);
2352    }
2353
2354    #[test]
2355    fn process_scopes_defaults_on_and_parses_auto_and_off() {
2356        assert!(super::load_config_from_str("").process_scopes);
2357        assert!(super::load_config_from_str("process_scopes = auto\n").process_scopes);
2358        let off = super::load_config_from_str("process_scopes = off\n");
2359        assert!(!off.process_scopes);
2360        assert!(off.warnings.is_empty(), "{:?}", off.warnings);
2361        let bad = super::load_config_from_str("process_scopes = sometimes\n");
2362        assert!(bad.process_scopes, "an invalid value keeps the default");
2363        assert!(bad.warnings.iter().any(|w| w.starts_with("process_scopes")));
2364        assert!(super::KNOWN_CONFIG_KEYS.contains(&"process_scopes"));
2365    }
2366
2367    #[test]
2368    fn startup_quick_start_accepts_on() {
2369        let c = super::load_config_from_str("startup.quick_start = on\n");
2370        assert!(c.startup.quick_start);
2371    }
2372
2373    #[test]
2374    fn startup_and_daemon_keys_are_known() {
2375        for k in [
2376            "startup.quick_start",
2377            "startup.extensions_ready_timeout_secs",
2378            "daemon.idle_exit_secs",
2379            "daemon.prompt_abandon_secs",
2380            "daemon.parked_evict_secs",
2381        ] {
2382            assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
2383        }
2384    }
2385
2386    #[test]
2387    fn startup_daemon_bad_values_warn_and_keep_defaults() {
2388        let c = super::load_config_from_str(concat!(
2389            "startup.quick_start = maybe\n",
2390            "daemon.idle_exit_secs = soon\n",
2391        ));
2392        assert!(c.startup.quick_start, "invalid bool keeps default");
2393        assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
2394        assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
2395        assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
2396    }
2397
2398    #[test]
2399    fn context_management_defaults_off_and_parses_all_keys() {
2400        use super::{ContextManagementConfig, ContextManagementMode};
2401        assert_eq!(
2402            super::load_config_from_str("").context_management,
2403            ContextManagementConfig::default()
2404        );
2405        let parsed = super::load_config_from_str(
2406            "context_management.mode = AUTO\n\
2407             context_management.pressure_tokens = 250000\n\
2408             context_management.rollover_tokens = 400000\n\
2409             context_management.reserve_tokens = 24000\n\
2410             context_management.finish_rounds = 3\n\
2411             context_window = 1m\n",
2412        );
2413        assert_eq!(
2414            parsed.context_management,
2415            ContextManagementConfig {
2416                mode: ContextManagementMode::Auto,
2417                pressure_tokens: Some(250_000),
2418                rollover_tokens: Some(400_000),
2419                reserve_tokens: 24_000,
2420                finish_rounds: 3,
2421            }
2422        );
2423        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
2424        assert!(parsed.provider_keys.is_empty());
2425        assert!(super::load_config_from_str("context_management.mode = off")
2426            .warnings
2427            .is_empty());
2428    }
2429
2430    #[test]
2431    fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
2432        for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
2433            let parsed = super::load_config_from_str(&format!(
2434                "context_management.mode = auto\n\
2435                 context_management.pressure_tokens = 300000\n\
2436                 context_management.rollover_tokens = 450000\n\
2437                 context_management.pressure_tokens = auto\n\
2438                 context_management.rollover_tokens = AUTO\n\
2439                 context_management.finish_rounds = {rounds}\n"
2440            ));
2441            assert_eq!(parsed.context_management.pressure_tokens, None);
2442            assert_eq!(parsed.context_management.rollover_tokens, None);
2443            assert_eq!(parsed.context_management.finish_rounds, rounds);
2444            assert!(parsed.warnings.is_empty());
2445        }
2446    }
2447
2448    #[test]
2449    fn context_management_invalid_values_reset_entire_policy() {
2450        for (key, value) in [
2451            ("mode", "on"),
2452            ("pressure_tokens", "0"),
2453            ("pressure_tokens", "-1"),
2454            ("pressure_tokens", "250k"),
2455            ("pressure_tokens", "18446744073709551616"),
2456            ("rollover_tokens", "1"),
2457            ("rollover_tokens", "nope"),
2458            ("reserve_tokens", "0"),
2459            ("reserve_tokens", "1000001"),
2460            ("reserve_tokens", "auto"),
2461            ("finish_rounds", "9"),
2462            ("finish_rounds", "-1"),
2463            ("finish_rounds", "4294967296"),
2464            ("finish_rounds", ""),
2465            ("mod", "auto"),
2466        ] {
2467            let parsed = super::load_config_from_str(&format!(
2468                "context_management.mode = auto\ncontext_management.{key} = {value}\n"
2469            ));
2470            assert_eq!(
2471                parsed.context_management,
2472                super::ContextManagementConfig::default(),
2473                "{key}={value}"
2474            );
2475            assert!(!parsed.warnings.is_empty(), "{key}={value}");
2476            assert!(parsed.provider_keys.is_empty());
2477        }
2478        let duplicate = super::load_config_from_str(
2479            "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
2480        );
2481        assert_eq!(
2482            duplicate.context_management,
2483            super::ContextManagementConfig::default()
2484        );
2485    }
2486
2487    #[test]
2488    fn context_management_validates_final_threshold_order_and_window() {
2489        for fields in [
2490            "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
2491            "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
2492            "pressure_tokens = 1000000",
2493            "rollover_tokens = 1000001",
2494        ] {
2495            for window_first in [false, true] {
2496                let policy =
2497                    format!("context_management.mode = auto\ncontext_management.{fields}\n");
2498                let content = if window_first {
2499                    format!("context_window = 1m\n{policy}")
2500                } else {
2501                    format!("{policy}context_window = 1m\n")
2502                };
2503                let parsed = super::load_config_from_str(&content);
2504                assert_eq!(
2505                    parsed.context_management,
2506                    super::ContextManagementConfig::default()
2507                );
2508                assert!(!parsed.warnings.is_empty());
2509            }
2510        }
2511        // Temporarily reversed overrides are valid once the whole file is parsed.
2512        let parsed = super::load_config_from_str(
2513            "context_management.mode = auto\n\
2514             context_management.rollover_tokens = 100000\n\
2515             context_management.pressure_tokens = 300000\n\
2516             context_management.rollover_tokens = 450000\ncontext_window = 1m",
2517        );
2518        assert!(parsed.warnings.is_empty());
2519        assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
2520    }
2521
2522    #[test]
2523    fn context_management_parsed_finish_override_bounds_rollover_extension() {
2524        use crate::core::context_policy::{
2525            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2526        };
2527        let config = super::load_config_from_str(
2528            "context_management.mode = auto\n\
2529             context_management.finish_rounds = 1\ncontext_window = 1m",
2530        );
2531        let budget = ContextBudget {
2532            context_window_tokens: 1_000_000,
2533            used_tokens: 400_000,
2534            hard_remaining_tokens: 600_000,
2535            required_next_round_tokens: 8_000,
2536        };
2537        let mut state = ContextState::default();
2538        state.report_phase(WorkPhase::Plan);
2539        let first = assess_context(&config.context_management, &state, budget);
2540        assert_eq!(
2541            first.action,
2542            ContextAction::FinishBounded {
2543                rounds_remaining: 0
2544            }
2545        );
2546        state = first.next_state;
2547        state.report_phase(WorkPhase::WrapUp);
2548        assert_eq!(
2549            assess_context(&config.context_management, &state, budget).action,
2550            ContextAction::Rollover
2551        );
2552    }
2553
2554    #[test]
2555    fn context_management_keys_are_known_for_suggestions() {
2556        for key in [
2557            "mode",
2558            "pressure_tokens",
2559            "rollover_tokens",
2560            "reserve_tokens",
2561            "finish_rounds",
2562        ] {
2563            let full = format!("context_management.{key}");
2564            assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
2565            assert_eq!(
2566                super::did_you_mean(&format!("{full}x")),
2567                Some(full.as_str())
2568            );
2569        }
2570    }
2571
2572    #[test]
2573    fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
2574        use crate::core::context_policy::{
2575            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2576        };
2577        let config =
2578            super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
2579        let budget = ContextBudget {
2580            context_window_tokens: config.context_window.unwrap(),
2581            used_tokens: 350_000,
2582            hard_remaining_tokens: 650_000,
2583            required_next_round_tokens: 8_000,
2584        };
2585        let mut state = ContextState::default();
2586        state.report_phase(WorkPhase::Plan);
2587        let plan = assess_context(&config.context_management, &state, budget);
2588        assert_eq!(plan.action, ContextAction::Advisory);
2589        state = plan.next_state;
2590        state.report_phase(WorkPhase::Execute);
2591        assert_eq!(
2592            assess_context(&config.context_management, &state, budget).action,
2593            ContextAction::Rollover
2594        );
2595    }
2596
2597    #[test]
2598    fn tui_streaming_glow_defaults_off_and_parses() {
2599        assert!(!super::load_config_from_str("").tui_streaming_glow);
2600        assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
2601        assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
2602        let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
2603        assert!(
2604            !invalid.tui_streaming_glow,
2605            "invalid keeps the default (off)"
2606        );
2607        assert!(invalid
2608            .warnings
2609            .iter()
2610            .any(|w| w.contains("tui_streaming_glow")));
2611    }
2612
2613    #[test]
2614    fn tui_background_opaque_defaults_and_parses_supported_values() {
2615        assert!(super::load_config_from_str("").tui_background_opaque);
2616        assert!(
2617            super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
2618        );
2619        assert!(
2620            !super::load_config_from_str("tui_background_opaque = invisible\n")
2621                .tui_background_opaque
2622        );
2623        let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
2624        assert!(invalid.tui_background_opaque);
2625        assert!(invalid
2626            .warnings
2627            .iter()
2628            .any(|w| w.contains("tui_background_opaque")));
2629    }
2630
2631    #[test]
2632    fn turn_budget_keys_parse_typed_per_role() {
2633        let config = super::load_config_from_str(
2634            "turn_budget.worker.max_provider_rounds = 3\n\
2635             turn_budget.worker.max_cost_usd = 0.25\n\
2636             turn_budget.autonomous.max_elapsed_secs = 90\n\
2637             turn_budget.foreground.max_tool_calls = 7\n\
2638             turn_budget.foreground.max_round_renewals = 12\n\
2639             turn_budget.worker.max_provider_rounds_bogus = 1\n\
2640             turn_budget.nosuchrole.max_tool_calls = 1\n",
2641        );
2642        assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
2643        assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
2644        assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
2645        assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
2646        assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
2647        // Unknown fields/roles warn and change nothing.
2648        assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
2649        // Invalid values keep the default (None).
2650        let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
2651        assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
2652    }
2653
2654    #[test]
2655    fn memory_keys_all_parse_with_consent() {
2656        let config = super::load_config_from_str(
2657            "memory.default_mode = capture_and_recall\n\
2658             memory.default_mode_confirmed = true\n\
2659             memory.recall_max_records = 12\n\
2660             memory.recall_max_tokens = 2048\n\
2661             memory.recall_timeout_ms = 250\n\
2662             memory.capture_tools = off\n\
2663             memory.capture_assistant = false\n\
2664             memory.capture_user = false\n\
2665             memory.auto_consolidate = on\n\
2666             memory.local_embeddings = on\n\
2667             memory.gliner = on\n",
2668        );
2669        assert_eq!(config.memory.default_mode, "capture_and_recall");
2670        assert!(config.memory.default_mode_confirmed);
2671        assert_eq!(config.memory.recall_max_records, 12);
2672        assert_eq!(config.memory.recall_max_tokens, 2048);
2673        assert_eq!(config.memory.recall_timeout_ms, 250);
2674        assert_eq!(config.memory.capture_tools, "off");
2675        assert!(!config.memory.capture_assistant);
2676        assert!(!config.memory.capture_user);
2677        assert_eq!(config.memory.auto_consolidate, "on");
2678        assert_eq!(config.memory.local_embeddings, "on");
2679        assert_eq!(config.memory.gliner, "on");
2680    }
2681
2682    #[test]
2683    fn memory_defaults_match_spec_12() {
2684        let config = super::load_config_from_str("");
2685        assert_eq!(config.memory, super::MemoryConfig::default());
2686        assert_eq!(config.memory.default_mode, "off");
2687        assert!(!config.memory.default_mode_confirmed);
2688        assert_eq!(config.memory.recall_max_records, 8);
2689        assert_eq!(config.memory.recall_max_tokens, 4096);
2690        assert_eq!(config.memory.recall_timeout_ms, 150);
2691        assert_eq!(config.memory.capture_tools, "summary_only");
2692        assert!(config.memory.capture_assistant);
2693        assert!(config.memory.capture_user);
2694        assert_eq!(config.memory.auto_consolidate, "off");
2695        assert_eq!(config.memory.local_embeddings, "off");
2696        assert_eq!(config.memory.gliner, "off");
2697    }
2698
2699    #[test]
2700    fn memory_unknown_values_fail_closed_to_defaults() {
2701        let config = super::load_config_from_str(
2702            "memory.default_mode = recall_everything_forever\n\
2703             memory.default_mode_confirmed = true\n\
2704             memory.recall_max_records = lots\n\
2705             memory.recall_max_tokens = -5\n\
2706             memory.recall_timeout_ms = fast\n\
2707             memory.capture_tools = full\n\
2708             memory.capture_assistant = maybe\n\
2709             memory.capture_user = sometimes\n\
2710             memory.auto_consolidate = aggressive\n\
2711             memory.local_embeddings = auto\n\
2712             memory.gliner = download\n\
2713             memory.nosuchfield = 1\n",
2714        );
2715        // Unrecognised mode string must stay "off" — never a non-off mode.
2716        assert_eq!(config.memory.default_mode, "off");
2717        assert_eq!(config.memory.recall_max_records, 8);
2718        assert_eq!(config.memory.recall_max_tokens, 4096);
2719        assert_eq!(config.memory.recall_timeout_ms, 150);
2720        assert_eq!(config.memory.capture_tools, "summary_only");
2721        assert!(config.memory.capture_assistant);
2722        assert!(config.memory.capture_user);
2723        assert_eq!(config.memory.auto_consolidate, "off");
2724        assert_eq!(config.memory.local_embeddings, "off");
2725        assert_eq!(config.memory.gliner, "off");
2726    }
2727
2728    #[test]
2729    fn memory_default_mode_without_consent_reverts_to_off() {
2730        let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
2731        assert_eq!(config.memory.default_mode, "off");
2732        assert!(
2733            config
2734                .warnings
2735                .iter()
2736                .any(|w| w.contains("memory.default_mode_confirmed")),
2737            "reversion must surface a warning; got: {:?}",
2738            config.warnings
2739        );
2740
2741        // Explicit false consent is not consent.
2742        let denied = super::load_config_from_str(
2743            "memory.default_mode = capture_only\n\
2744             memory.default_mode_confirmed = false\n",
2745        );
2746        assert_eq!(denied.memory.default_mode, "off");
2747
2748        // Garbage consent value fails closed — not consent.
2749        let garbage = super::load_config_from_str(
2750            "memory.default_mode = capture_only\n\
2751             memory.default_mode_confirmed = definitely\n",
2752        );
2753        assert_eq!(garbage.memory.default_mode, "off");
2754    }
2755
2756    #[test]
2757    fn memory_consent_is_order_independent() {
2758        // Consent key before the mode key.
2759        let before = super::load_config_from_str(
2760            "memory.default_mode_confirmed = true\n\
2761             memory.default_mode = recall_once\n",
2762        );
2763        assert_eq!(before.memory.default_mode, "recall_once");
2764        // Consent key after the mode key.
2765        let after = super::load_config_from_str(
2766            "memory.default_mode = recall_once\n\
2767             memory.default_mode_confirmed = true\n",
2768        );
2769        assert_eq!(after.memory.default_mode, "recall_once");
2770    }
2771
2772    #[test]
2773    fn memory_backend_defaults_are_independent_of_consent() {
2774        let config = super::load_config_from_str("");
2775        assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
2776        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
2777        let axel = super::load_config_from_str("memory.backend = axel\n");
2778        assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
2779        assert_eq!(axel.memory, config.memory);
2780        assert!(axel.warnings.is_empty());
2781    }
2782
2783    #[test]
2784    fn user_wide_notes_require_explicit_valid_opt_in() {
2785        let default = super::load_config_from_str("memory.backend = axel\n");
2786        assert!(!default.memory_backend.user_scope);
2787        let enabled =
2788            super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
2789        assert!(enabled.memory_backend.user_scope);
2790        assert_eq!(enabled.memory, default.memory);
2791        for bad in ["yes", "", "1", "TRUE"] {
2792            let config = super::load_config_from_str(&format!(
2793                "memory.user_scope = {bad}\nmemory.backend = axel\n"
2794            ));
2795            assert_eq!(
2796                config.memory_backend.kind,
2797                super::MemoryBackendKind::Unavailable
2798            );
2799        }
2800    }
2801
2802    #[test]
2803    fn memory_backend_invalid_selector_fails_closed() {
2804        for content in [
2805            "memory.backend = typo\n",
2806            "memory.backend = \n",
2807            "memory.backend = unavailable\n",
2808            "memory.backend = axel\nmemory.backend = typo\n",
2809            "memory.backend = typo\nmemory.backend = legacy\n",
2810        ] {
2811            let config = super::load_config_from_str(content);
2812            assert_eq!(
2813                config.memory_backend.kind,
2814                super::MemoryBackendKind::Unavailable
2815            );
2816            assert!(config
2817                .warnings
2818                .iter()
2819                .any(|w| w.contains("memory backend unavailable")));
2820        }
2821    }
2822
2823    #[test]
2824    fn memory_backend_absolute_paths_are_preserved() {
2825        let root = std::env::current_dir().unwrap();
2826        let executable = root.join("fixture bin/axel");
2827        let brain = root.join("fixture brain");
2828        let config = super::load_config_from_str(&format!(
2829            "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
2830            brain.display(),
2831            executable.display()
2832        ));
2833        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
2834        assert_eq!(config.memory_backend.executable, Some(executable));
2835        assert_eq!(config.memory_backend.brain, Some(brain));
2836        assert!(config.warnings.is_empty());
2837    }
2838
2839    #[test]
2840    fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
2841        for key in ["memory.axel.executable", "memory.axel.brain"] {
2842            for path in ["relative/path", "~/brain", ""] {
2843                for content in [
2844                    format!("memory.backend = axel\n{key} = {path}\n"),
2845                    format!("{key} = {path}\nmemory.backend = legacy\n"),
2846                ] {
2847                    let config = super::load_config_from_str(&content);
2848                    assert_eq!(
2849                        config.memory_backend.kind,
2850                        super::MemoryBackendKind::Unavailable
2851                    );
2852                    assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
2853                }
2854            }
2855        }
2856    }
2857
2858    #[test]
2859    fn memory_config_surface_is_closed_by_construction() {
2860        // Exhaustive struct literal — no `..Default::default()` spread. This
2861        // fails to COMPILE if any field is added to or removed from
2862        // MemoryConfig, pinning the exact memory.* config surface. Secret,
2863        // retention, and project-scope semantics have no field here, so no
2864        // `memory.secret*` / `memory.retention*` / `memory.project*` config
2865        // key can exist (spec §12: those rules are not configurable to fail
2866        // open). Asserted by construction, not by test disproof.
2867        let _closed_surface = super::MemoryConfig {
2868            default_mode: "off".to_string(),
2869            default_mode_confirmed: false,
2870            recall_max_records: 8,
2871            recall_max_tokens: 4096,
2872            recall_timeout_ms: 150,
2873            capture_tools: "summary_only".to_string(),
2874            capture_assistant: true,
2875            capture_user: true,
2876            auto_consolidate: "off".to_string(),
2877            local_embeddings: "off".to_string(),
2878            gliner: "off".to_string(),
2879        };
2880    }
2881
2882    use super::*;
2883    use serial_test::serial;
2884
2885    #[test]
2886    fn test_levenshtein_basics() {
2887        assert_eq!(levenshtein("model", "model"), 0);
2888        assert_eq!(levenshtein("modle", "model"), 2);
2889        assert_eq!(levenshtein("them", "theme"), 1);
2890    }
2891
2892    #[test]
2893    fn test_did_you_mean_close_typos() {
2894        assert_eq!(did_you_mean("modle"), Some("model"));
2895        assert_eq!(did_you_mean("them"), Some("theme"));
2896        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
2897        assert_eq!(did_you_mean("completely_unrelated_key"), None);
2898    }
2899
2900    #[test]
2901    #[serial]
2902    fn test_config_warnings_unknown_key_and_bad_values() {
2903        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
2904        let dir = home.join(".synaps-cli");
2905        std::fs::create_dir_all(&dir).unwrap();
2906        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();
2907
2908        with_home(&home, || {
2909            let config = load_config();
2910            // Dotted (namespaced) keys must NOT warn — plugins own those.
2911            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
2912            assert!(
2913                !config.warnings.iter().any(|w| w.contains("knowledge")),
2914                "{:?}",
2915                config.warnings
2916            );
2917            assert!(
2918                config
2919                    .warnings
2920                    .iter()
2921                    .any(|w| w.contains("did you mean 'model'")),
2922                "{:?}",
2923                config.warnings
2924            );
2925            assert!(
2926                config.warnings.iter().any(|w| w.contains("thinking")),
2927                "{:?}",
2928                config.warnings
2929            );
2930            assert!(
2931                config.warnings.iter().any(|w| w.contains("below minimum")),
2932                "{:?}",
2933                config.warnings
2934            );
2935            // Bad values fall back to defaults
2936            assert_eq!(config.bash_timeout, 30);
2937            assert_eq!(config.thinking_budget, None);
2938        });
2939        let _ = std::fs::remove_dir_all(&home);
2940    }
2941
2942    // ── cache_keepwarm (task #444) ──
2943
2944    #[test]
2945    fn test_cache_keepwarm_is_off_by_default() {
2946        assert_eq!(SynapsConfig::default().cache_keepwarm, CacheKeepWarm::Off);
2947        assert_eq!(load_config_from_str("").cache_keepwarm, CacheKeepWarm::Off);
2948    }
2949
2950    #[test]
2951    fn test_cache_keepwarm_parse_table() {
2952        for (raw, want) in [
2953            ("off", Some(CacheKeepWarm::Off)),
2954            ("OFF", Some(CacheKeepWarm::Off)),
2955            ("0h", Some(CacheKeepWarm::Off)),
2956            ("auto", Some(CacheKeepWarm::Auto)),
2957            (" Auto ", Some(CacheKeepWarm::Auto)),
2958            ("1h", Some(CacheKeepWarm::Hours(1))),
2959            ("12H", Some(CacheKeepWarm::Hours(12))),
2960            ("48h", Some(CacheKeepWarm::Hours(MAX_CACHE_KEEPWARM_HOURS))),
2961            ("49h", None),
2962            ("12", None),
2963            ("h", None),
2964            ("-1h", None),
2965            ("lots", None),
2966        ] {
2967            assert_eq!(CacheKeepWarm::parse(raw), want, "{raw:?}");
2968        }
2969        for mode in [CacheKeepWarm::Off, CacheKeepWarm::Auto, CacheKeepWarm::Hours(6)] {
2970            assert_eq!(CacheKeepWarm::parse(&mode.to_string()), Some(mode));
2971        }
2972    }
2973
2974    #[test]
2975    fn test_cache_keepwarm_config_key() {
2976        let c = load_config_from_str("cache_keepwarm = auto\ncache_ttl = 5m\n");
2977        assert_eq!(c.cache_keepwarm, CacheKeepWarm::Auto);
2978        assert_eq!(c.cache_ttl, CacheTtl::FiveMinutes);
2979        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2980
2981        let c = load_config_from_str("cache_keepwarm = 6h\n");
2982        assert_eq!(c.cache_keepwarm, CacheKeepWarm::Hours(6));
2983
2984        assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "default auto cap");
2985
2986        let c = load_config_from_str("cache_keepwarm_auto_max_hours = 8\n");
2987        assert_eq!(c.cache_keepwarm_auto_max_hours, 8);
2988        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2989        let c = load_config_from_str("cache_keepwarm_auto_max_hours = 12h\n");
2990        assert_eq!(c.cache_keepwarm_auto_max_hours, 12);
2991        for bad in ["0", "49", "lots"] {
2992            let c = load_config_from_str(&format!("cache_keepwarm_auto_max_hours = {bad}\n"));
2993            assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "{bad}");
2994            assert!(
2995                c.warnings.iter().any(|w| w.contains("auto_max_hours")),
2996                "{bad}"
2997            );
2998        }
2999
3000        let c = load_config_from_str("cache_keepwarm = forever\n");
3001        assert_eq!(c.cache_keepwarm, CacheKeepWarm::Off, "garbage keeps keep-warm off");
3002        assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm")));
3003    }
3004
3005    // ── cache_ttl parse table (spec §3.1) ──
3006
3007    #[test]
3008    fn test_cache_ttl_parse_table() {
3009        // 5m aliases
3010        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
3011        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
3012        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
3013        // 1h aliases
3014        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
3015        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
3016        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
3017        // hybrid
3018        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
3019        // case-insensitive
3020        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
3021        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
3022        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
3023        // garbage → None (caller warns + defaults)
3024        assert_eq!(CacheTtl::parse("2h"), None);
3025        assert_eq!(CacheTtl::parse(""), None);
3026        assert_eq!(CacheTtl::parse("forever"), None);
3027    }
3028
3029    #[test]
3030    fn test_cache_ttl_default_is_five_minutes() {
3031        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
3032        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
3033    }
3034
3035    #[test]
3036    fn test_max_fps_default_is_60() {
3037        assert_eq!(SynapsConfig::default().max_fps, 60);
3038    }
3039
3040    // ── theme_transition parse table (animated theme transitions) ──
3041
3042    #[test]
3043    fn test_theme_transition_parse_table() {
3044        use ThemeTransitionMode as M;
3045        assert_eq!(M::parse("on"), Some(M::On));
3046        assert_eq!(M::parse("ON"), Some(M::On));
3047        assert_eq!(M::parse("off"), Some(M::Off));
3048        assert_eq!(M::parse("Off"), Some(M::Off));
3049        // integer ms, clamped to 0..=2000
3050        assert_eq!(M::parse("500"), Some(M::Ms(500)));
3051        assert_eq!(M::parse("0"), Some(M::Ms(0)));
3052        assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
3053        assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
3054        // garbage → None (caller warns + keeps default)
3055        assert_eq!(M::parse("fast"), None);
3056        assert_eq!(M::parse(""), None);
3057        assert_eq!(M::parse("-5"), None);
3058        assert_eq!(M::parse("1.5"), None);
3059    }
3060
3061    #[test]
3062    fn test_theme_transition_duration_ms() {
3063        use ThemeTransitionMode as M;
3064        assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
3065        assert_eq!(M::DEFAULT_MS, 350);
3066        assert_eq!(M::Off.duration_ms(), 0);
3067        assert_eq!(M::Ms(120).duration_ms(), 120);
3068        assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
3069        assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
3070    }
3071
3072    #[test]
3073    fn test_theme_transition_config_key_parses_and_defaults() {
3074        use ThemeTransitionMode as M;
3075        assert_eq!(SynapsConfig::default().theme_transition, M::On);
3076        assert_eq!(
3077            super::load_config_from_str("theme_transition = off\n").theme_transition,
3078            M::Off
3079        );
3080        assert_eq!(
3081            super::load_config_from_str("theme_transition = 750\n").theme_transition,
3082            M::Ms(750)
3083        );
3084        let bad = super::load_config_from_str("theme_transition = warp\n");
3085        assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
3086        assert!(
3087            bad.warnings.iter().any(|w| w.contains("theme_transition")),
3088            "{:?}",
3089            bad.warnings
3090        );
3091    }
3092
3093    #[test]
3094    #[serial]
3095    fn test_max_fps_config_parse_and_validation() {
3096        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
3097        let dir = home.join(".synaps-cli");
3098        std::fs::create_dir_all(&dir).unwrap();
3099
3100        // Valid high-refresh value parses, no warning.
3101        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
3102        with_home(&home, || {
3103            let config = load_config();
3104            assert_eq!(config.max_fps, 144);
3105            assert!(
3106                config.warnings.is_empty(),
3107                "warnings: {:?}",
3108                config.warnings
3109            );
3110        });
3111
3112        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
3113        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
3114        with_home(&home, || {
3115            let config = load_config();
3116            assert_eq!(config.max_fps, 60);
3117            assert!(
3118                config.warnings.iter().any(|w| w.contains("max_fps")),
3119                "warnings: {:?}",
3120                config.warnings
3121            );
3122        });
3123
3124        // Non-numeric → default 60 + warning.
3125        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
3126        with_home(&home, || {
3127            let config = load_config();
3128            assert_eq!(config.max_fps, 60);
3129            assert!(
3130                config.warnings.iter().any(|w| w.contains("max_fps")),
3131                "warnings: {:?}",
3132                config.warnings
3133            );
3134        });
3135
3136        let _ = std::fs::remove_dir_all(&home);
3137    }
3138
3139    #[test]
3140    #[serial]
3141    fn test_cache_ttl_config_parse_and_garbage_warning() {
3142        let home =
3143            std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
3144        let dir = home.join(".synaps-cli");
3145        std::fs::create_dir_all(&dir).unwrap();
3146
3147        // Valid value parses, no warning.
3148        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
3149        with_home(&home, || {
3150            let config = load_config();
3151            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
3152            assert!(
3153                config.warnings.is_empty(),
3154                "warnings: {:?}",
3155                config.warnings
3156            );
3157        });
3158
3159        // Garbage value → 5m default + boot warning (never blocks boot).
3160        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
3161        with_home(&home, || {
3162            let config = load_config();
3163            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
3164            assert!(
3165                config.warnings.iter().any(|w| w.contains("cache_ttl")),
3166                "warnings: {:?}",
3167                config.warnings
3168            );
3169        });
3170
3171        let _ = std::fs::remove_dir_all(&home);
3172    }
3173
3174    #[test]
3175    fn test_parse_thinking_budget() {
3176        use crate::core::reasoning::ThinkingSpec;
3177        // ThinkingSpec::parse replaced parse_thinking_budget; verify budget values.
3178        assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
3179        assert_eq!(
3180            ThinkingSpec::parse("medium").unwrap().to_budget(),
3181            Some(4096)
3182        );
3183        assert_eq!(
3184            ThinkingSpec::parse("high").unwrap().to_budget(),
3185            Some(16384)
3186        );
3187        assert_eq!(
3188            ThinkingSpec::parse("xhigh").unwrap().to_budget(),
3189            Some(32768)
3190        );
3191        assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
3192        let config = load_config_from_str("thinking = 8192\n");
3193        assert_eq!(config.thinking_level, None);
3194        assert_eq!(config.thinking_budget, Some(8192));
3195        assert_eq!(ThinkingSpec::parse("invalid"), None);
3196    }
3197
3198    #[test]
3199    fn test_base_dir() {
3200        let path = base_dir();
3201        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
3202    }
3203
3204    #[test]
3205    fn test_resolve_system_prompt_explicit() {
3206        let result = resolve_system_prompt(Some("test prompt"));
3207        assert_eq!(result, "test prompt");
3208    }
3209
3210    #[test]
3211    fn test_resolve_system_prompt_none() {
3212        let result = resolve_system_prompt(None);
3213        assert!(result.contains("helpful AI agent"));
3214    }
3215
3216    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
3217    // is not thread-safe and races with shell config tests. Coverage provided
3218    // by shell::config::tests::test_shell_config_from_file.
3219
3220    #[test]
3221    fn test_synaps_config_default() {
3222        let config = SynapsConfig::default();
3223        assert_eq!(config.model, None);
3224        assert_eq!(config.thinking_budget, None);
3225        assert_eq!(config.max_tool_output, 30000);
3226        assert_eq!(config.bash_timeout, 30);
3227        assert_eq!(config.bash_max_timeout, 300);
3228        assert_eq!(config.subagent_timeout, 300);
3229        assert_eq!(config.api_retries, 3);
3230        assert_eq!(config.theme, None);
3231        assert!(config.disabled_plugins.is_empty());
3232        assert!(config.favorite_models.is_empty());
3233        assert!(config.disabled_skills.is_empty());
3234        assert!(!config.progressive_tool_disclosure);
3235        assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
3236        assert_eq!(config.shell.max_sessions, 5);
3237        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
3238        // Server config defaults
3239        assert!(config.server.allowed_origins.is_empty());
3240        assert_eq!(config.server.token, None);
3241        assert!(!config.server.auto_approve_confirms);
3242        assert_eq!(config.server.max_message_size, None);
3243        // Bridge config defaults
3244        assert!(config.bridge.uds_path.is_none());
3245        assert!(!config.bridge.heartbeat_mirror);
3246        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
3247    }
3248
3249    #[test]
3250    #[serial]
3251    fn test_load_config_tools_activation_confirm_modes() {
3252        for (raw, expected, warns) in [
3253            ("prompt", ActivationConfirm::Prompt, false),
3254            ("deny", ActivationConfirm::Deny, false),
3255            ("AUTO", ActivationConfirm::Auto, false),
3256            ("garbage", ActivationConfirm::Auto, true),
3257        ] {
3258            let home = make_test_home(&format!("activation-confirm-{raw}"));
3259            let cfg = home.join(".synaps-cli/config");
3260            std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
3261            with_home(&home, || {
3262                let config = load_config();
3263                assert_eq!(config.tools_activation_confirm, expected, "{raw}");
3264                assert_eq!(
3265                    config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
3266                    warns,
3267                    "{raw}: {:?}",
3268                    config.warnings
3269                );
3270            });
3271            let _ = std::fs::remove_dir_all(&home);
3272        }
3273    }
3274
3275    #[test]
3276    #[serial]
3277    fn test_load_config_progressive_tool_disclosure_is_opt_in() {
3278        let home = make_test_home("progressive-tool-disclosure");
3279        let cfg = home.join(".synaps-cli/config");
3280        std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
3281
3282        with_home(&home, || {
3283            let config = load_config();
3284            assert!(config.progressive_tool_disclosure);
3285            assert!(config.warnings.is_empty());
3286        });
3287
3288        let _ = std::fs::remove_dir_all(&home);
3289    }
3290
3291    #[test]
3292    #[serial]
3293    fn test_load_config_bridge_keys() {
3294        let home = make_test_home("bridge-keys");
3295        let cfg = home.join(".synaps-cli/config");
3296        std::fs::write(
3297            &cfg,
3298            "\
3299bridge.uds_path = /tmp/some/control.sock\n\
3300bridge.heartbeat_mirror = true\n\
3301bridge.heartbeat_timeout_ms = 750\n\
3302",
3303        )
3304        .unwrap();
3305
3306        with_home(&home, || {
3307            let config = load_config();
3308            assert_eq!(
3309                config.bridge.uds_path,
3310                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
3311            );
3312            assert!(config.bridge.heartbeat_mirror);
3313            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
3314            assert_eq!(
3315                config.bridge.resolved_uds_path(),
3316                std::path::PathBuf::from("/tmp/some/control.sock"),
3317            );
3318        });
3319
3320        let _ = std::fs::remove_dir_all(&home);
3321    }
3322
3323    #[test]
3324    fn test_bridge_config_defaults() {
3325        let cfg = BridgeConfig::default();
3326        assert!(cfg.uds_path.is_none());
3327        assert!(!cfg.heartbeat_mirror);
3328        assert_eq!(cfg.heartbeat_timeout_ms, 250);
3329        // resolved path falls under base_dir()/bridge/control.sock
3330        let resolved = cfg.resolved_uds_path();
3331        assert!(resolved.ends_with("bridge/control.sock"));
3332    }
3333
3334    #[test]
3335    #[serial]
3336    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
3337        let home = make_test_home("bridge-default-off");
3338        let cfg = home.join(".synaps-cli/config");
3339        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
3340
3341        with_home(&home, || {
3342            let config = load_config();
3343            assert!(!config.bridge.heartbeat_mirror);
3344            assert!(config.bridge.uds_path.is_none());
3345            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
3346        });
3347
3348        let _ = std::fs::remove_dir_all(&home);
3349    }
3350
3351    #[test]
3352    fn compaction_disclosure_keys_parse_with_typed_warnings() {
3353        let config = load_config_from_str(
3354            "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
3355        );
3356        assert_eq!(
3357            config.compaction_mode,
3358            crate::core::compaction::CompactionMode::LocalOnly
3359        );
3360        assert_eq!(
3361            config.compaction_exclude,
3362            vec![
3363                crate::core::compaction::ContentClass::Thinking,
3364                crate::core::compaction::ContentClass::ToolResults,
3365            ]
3366        );
3367        assert!(
3368            config.warnings.iter().any(|w| w.contains("bogus")),
3369            "unknown class must warn: {:?}",
3370            config.warnings
3371        );
3372
3373        let defaults = load_config_from_str("");
3374        assert_eq!(
3375            defaults.compaction_mode,
3376            crate::core::compaction::CompactionMode::Remote
3377        );
3378        assert!(defaults.compaction_exclude.is_empty());
3379
3380        let bad_mode = load_config_from_str("compaction_mode = cloud\n");
3381        assert_eq!(
3382            bad_mode.compaction_mode,
3383            crate::core::compaction::CompactionMode::Remote
3384        );
3385        assert!(bad_mode
3386            .warnings
3387            .iter()
3388            .any(|w| w.contains("compaction_mode")));
3389    }
3390
3391    #[test]
3392    #[serial]
3393    fn test_load_config_server_keys() {
3394        let home = make_test_home("server-keys");
3395        let cfg = home.join(".synaps-cli/config");
3396        std::fs::write(
3397            &cfg,
3398            "\
3399server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
3400server.token = my-secret-token\n\
3401server.auto_approve_confirms = true\n\
3402server.max_message_size = 65536\n\
3403context_window = 200k\n\
3404",
3405        )
3406        .unwrap();
3407
3408        with_home(&home, || {
3409            let config = load_config();
3410            assert_eq!(
3411                config.server.allowed_origins,
3412                vec![
3413                    "http://localhost:3000".to_string(),
3414                    "http://localhost:5193".to_string(),
3415                ]
3416            );
3417            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
3418            assert!(config.server.auto_approve_confirms);
3419            // Explicit max_message_size takes precedence over context_window derivation
3420            assert_eq!(config.server.max_message_size, Some(65536));
3421        });
3422
3423        let _ = std::fs::remove_dir_all(&home);
3424    }
3425
3426    #[test]
3427    #[serial]
3428    fn test_server_max_message_size_derived_from_context_window() {
3429        let home = make_test_home("server-derive");
3430        let cfg = home.join(".synaps-cli/config");
3431        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
3432
3433        with_home(&home, || {
3434            let config = load_config();
3435            // 200_000 tokens * 4 bytes/token = 800_000 bytes
3436            assert_eq!(config.server.max_message_size, Some(800_000));
3437        });
3438
3439        let _ = std::fs::remove_dir_all(&home);
3440    }
3441
3442    fn make_test_home(subdir: &str) -> std::path::PathBuf {
3443        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
3444        let _ = std::fs::remove_dir_all(&dir);
3445        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
3446        dir
3447    }
3448
3449    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
3450        // `base_dir()` reads SYNAPS_BASE_DIR before HOME: pin it too, or a value
3451        // another test left set redirects the write away from `home`.
3452        let original = std::env::var("HOME").ok();
3453        let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
3454        std::env::set_var("HOME", home);
3455        std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
3456        f();
3457        if let Some(h) = original {
3458            std::env::set_var("HOME", h);
3459        } else {
3460            std::env::remove_var("HOME");
3461        }
3462        if let Some(b) = original_base {
3463            std::env::set_var("SYNAPS_BASE_DIR", b);
3464        } else {
3465            std::env::remove_var("SYNAPS_BASE_DIR");
3466        }
3467    }
3468
3469    #[test]
3470    #[serial]
3471    fn write_config_value_replaces_existing_key() {
3472        let home = make_test_home("replace");
3473        let cfg = home.join(".synaps-cli/config");
3474        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
3475
3476        with_home(&home, || {
3477            write_config_value("model", "claude-sonnet-4-6").unwrap();
3478        });
3479
3480        let contents = std::fs::read_to_string(&cfg).unwrap();
3481        assert!(contents.contains("model = claude-sonnet-4-6"));
3482        assert!(contents.contains("thinking = low"));
3483        let _ = std::fs::remove_dir_all(&home);
3484    }
3485
3486    #[test]
3487    #[serial]
3488    fn write_config_value_appends_when_missing() {
3489        let home = make_test_home("append");
3490        let cfg = home.join(".synaps-cli/config");
3491        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
3492
3493        with_home(&home, || {
3494            write_config_value("theme", "dracula").unwrap();
3495        });
3496
3497        let contents = std::fs::read_to_string(&cfg).unwrap();
3498        assert!(contents.contains("model = claude-opus-4-6"));
3499        assert!(contents.contains("theme = dracula"));
3500        let _ = std::fs::remove_dir_all(&home);
3501    }
3502
3503    #[test]
3504    #[serial]
3505    fn write_config_value_concurrent_writers_lose_nothing() {
3506        let home = make_test_home("concurrent");
3507        let cfg = home.join(".synaps-cli/config");
3508        std::fs::write(&cfg, "# keep\n").unwrap();
3509
3510        // HOME is process-global; set it for the whole test, not per thread.
3511        with_home(&home, || {
3512            let a = std::thread::spawn(|| {
3513                for i in 0..200 {
3514                    write_config_value("a_key", &i.to_string()).unwrap();
3515                }
3516            });
3517            let b = std::thread::spawn(|| {
3518                for i in 0..200 {
3519                    write_config_value("b_key", &i.to_string()).unwrap();
3520                }
3521            });
3522            a.join().unwrap();
3523            b.join().unwrap();
3524        });
3525
3526        let contents = std::fs::read_to_string(&cfg).unwrap();
3527        assert!(contents.contains("# keep"), "{contents}");
3528        assert!(contents.contains("a_key = 199"), "{contents}");
3529        assert!(contents.contains("b_key = 199"), "{contents}");
3530        assert_eq!(contents.matches("a_key").count(), 1);
3531        assert_eq!(contents.matches("b_key").count(), 1);
3532
3533        #[cfg(unix)]
3534        {
3535            use std::os::unix::fs::PermissionsExt;
3536            let lock = home.join(".synaps-cli/config.lock");
3537            let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
3538            assert_eq!(mode, 0o600);
3539        }
3540        let _ = std::fs::remove_dir_all(&home);
3541    }
3542
3543    #[test]
3544    #[serial]
3545    fn config_lock_kill_switch_still_writes() {
3546        let home = make_test_home("lock-off");
3547        let cfg = home.join(".synaps-cli/config");
3548        std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
3549        with_home(&home, || {
3550            write_config_value("model", "claude-sonnet-4-6").unwrap();
3551        });
3552        std::env::remove_var("SYNAPS_CONFIG_LOCK");
3553        let contents = std::fs::read_to_string(&cfg).unwrap();
3554        assert!(contents.contains("model = claude-sonnet-4-6"));
3555        assert!(!home.join(".synaps-cli/config.lock").exists());
3556        let _ = std::fs::remove_dir_all(&home);
3557    }
3558
3559    #[test]
3560    #[serial]
3561    fn add_favorite_model_end_to_end_does_not_deadlock() {
3562        let home = make_test_home("favorite-lock");
3563        let cfg = home.join(".synaps-cli/config");
3564        with_home(&home, || {
3565            add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3566            add_favorite_model("openai/gpt-5").unwrap();
3567            remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
3568        });
3569        let contents = std::fs::read_to_string(&cfg).unwrap();
3570        assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
3571        let _ = std::fs::remove_dir_all(&home);
3572    }
3573
3574    #[test]
3575    #[serial]
3576    fn write_config_value_preserves_comments() {
3577        let home = make_test_home("comments");
3578        let cfg = home.join(".synaps-cli/config");
3579        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
3580
3581        with_home(&home, || {
3582            write_config_value("model", "claude-sonnet-4-6").unwrap();
3583        });
3584
3585        let contents = std::fs::read_to_string(&cfg).unwrap();
3586        assert!(contents.contains("# user comment"));
3587        assert!(contents.contains("# another"));
3588        assert!(contents.contains("model = claude-sonnet-4-6"));
3589        let _ = std::fs::remove_dir_all(&home);
3590    }
3591
3592    #[test]
3593    #[serial]
3594    fn write_config_value_preserves_unknown_keys() {
3595        let home = make_test_home("unknown");
3596        let cfg = home.join(".synaps-cli/config");
3597        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
3598
3599        with_home(&home, || {
3600            write_config_value("model", "claude-sonnet-4-6").unwrap();
3601        });
3602
3603        let contents = std::fs::read_to_string(&cfg).unwrap();
3604        assert!(contents.contains("custom_thing = 42"));
3605        let _ = std::fs::remove_dir_all(&home);
3606    }
3607
3608    #[test]
3609    #[serial]
3610    fn write_config_value_creates_file_if_absent() {
3611        let home = make_test_home("create");
3612        let cfg = home.join(".synaps-cli/config");
3613        assert!(!cfg.exists());
3614
3615        with_home(&home, || {
3616            write_config_value("model", "claude-sonnet-4-6").unwrap();
3617        });
3618
3619        let contents = std::fs::read_to_string(&cfg).unwrap();
3620        assert!(contents.contains("model = claude-sonnet-4-6"));
3621        let _ = std::fs::remove_dir_all(&home);
3622    }
3623
3624    #[test]
3625    #[serial]
3626    fn load_config_parses_theme_key() {
3627        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
3628        let _ = std::fs::create_dir_all(&dir);
3629        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
3630
3631        let original_home = std::env::var("HOME").ok();
3632        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
3633
3634        let config = load_config();
3635
3636        if let Some(home) = original_home {
3637            std::env::set_var("HOME", home);
3638        } else {
3639            std::env::remove_var("HOME");
3640        }
3641        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
3642
3643        assert_eq!(config.theme.as_deref(), Some("dracula"));
3644    }
3645
3646    #[test]
3647    #[serial]
3648    fn test_load_config_disable_lists() {
3649        let test_dir =
3650            std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
3651        let _ = std::fs::create_dir_all(&test_dir);
3652        let config_path = test_dir.join("config");
3653
3654        let config_content = r#"
3655# Test config with disable lists
3656favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
3657
3658disabled_plugins = foo, bar
3659disabled_skills = baz, plug:qual
3660"#;
3661        std::fs::write(&config_path, config_content).unwrap();
3662
3663        let original_home = std::env::var("HOME").ok();
3664        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
3665
3666        let config = load_config();
3667
3668        if let Some(home) = original_home {
3669            std::env::set_var("HOME", home);
3670        } else {
3671            std::env::remove_var("HOME");
3672        }
3673
3674        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
3675
3676        assert_eq!(
3677            config.disabled_plugins,
3678            vec!["foo".to_string(), "bar".to_string()]
3679        );
3680        assert_eq!(
3681            config.favorite_models,
3682            vec![
3683                "claude/claude-opus-4-7".to_string(),
3684                "groq/llama-3.3-70b-versatile".to_string(),
3685            ]
3686        );
3687        assert_eq!(
3688            config.disabled_skills,
3689            vec!["baz".to_string(), "plug:qual".to_string()]
3690        );
3691    }
3692
3693    #[test]
3694    #[serial]
3695    fn favorite_model_helpers_round_trip_through_config_file() {
3696        let home = make_test_home("favorite-models");
3697        let cfg = home.join(".synaps-cli/config");
3698        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
3699
3700        with_home(&home, || {
3701            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3702            add_favorite_model("claude/claude-opus-4-7").unwrap();
3703            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3704            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
3705            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
3706            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
3707            assert!(is_favorite_model("claude/claude-opus-4-7"));
3708        });
3709
3710        let contents = std::fs::read_to_string(&cfg).unwrap();
3711        assert!(contents.contains("model = claude-opus-4-7"));
3712        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
3713        let _ = std::fs::remove_dir_all(&home);
3714    }
3715
3716    #[test]
3717    #[serial]
3718    fn test_load_config_new_keys() {
3719        // Create a temporary config directory with the new keys
3720        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
3721        let _ = std::fs::create_dir_all(&test_dir);
3722        let config_path = test_dir.join("config");
3723
3724        let config_content = r#"
3725# Test config with new keys
3726model = claude-haiku
3727thinking = medium
3728max_tool_output = 50000
3729bash_timeout = 45
3730bash_max_timeout = 600
3731subagent_timeout = 120
3732api_retries = 5
3733"#;
3734        std::fs::write(&config_path, config_content).unwrap();
3735
3736        // Temporarily override the config path for this test
3737        let original_home = std::env::var("HOME").ok();
3738        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
3739
3740        let config = load_config();
3741
3742        // Restore original HOME
3743        if let Some(home) = original_home {
3744            std::env::set_var("HOME", home);
3745        } else {
3746            std::env::remove_var("HOME");
3747        }
3748
3749        // Cleanup
3750        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
3751
3752        assert_eq!(config.model, Some("claude-haiku".to_string()));
3753        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
3754        assert_eq!(config.max_tool_output, 50000);
3755        assert_eq!(config.bash_timeout, 45);
3756        assert_eq!(config.bash_max_timeout, 600);
3757        assert_eq!(config.subagent_timeout, 120);
3758        assert_eq!(config.api_retries, 5);
3759    }
3760
3761    // ── auth.* config + credential source (#157) ──
3762
3763    // ── events.auto_turn parser ──────────────────────────────────────────────
3764
3765    #[test]
3766    fn events_auto_turn_explicit_true_values() {
3767        for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
3768            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3769            assert!(
3770                cfg.events.auto_turn,
3771                "expected true for events.auto_turn = {val}"
3772            );
3773        }
3774    }
3775
3776    #[test]
3777    fn events_auto_turn_explicit_false_values() {
3778        for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
3779            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
3780            assert!(
3781                !cfg.events.auto_turn,
3782                "expected false for events.auto_turn = {val}"
3783            );
3784        }
3785    }
3786
3787    #[test]
3788    fn events_auto_turn_typo_fails_safe_false() {
3789        // Unrecognised value should fail safe to false (with a warning on stderr).
3790        let cfg = load_config_from_str("events.auto_turn = fales");
3791        assert!(
3792            !cfg.events.auto_turn,
3793            "typo 'fales' must fail safe to false"
3794        );
3795    }
3796
3797    // ── events.auto_turn_cap parser ──────────────────────────────────────────
3798
3799    #[test]
3800    fn events_auto_turn_cap_default_is_five() {
3801        let cfg = load_config_from_str("");
3802        assert_eq!(cfg.events.auto_turn_cap, 5);
3803        assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
3804    }
3805
3806    #[test]
3807    fn events_auto_turn_cap_zero_is_unlimited() {
3808        let cfg = load_config_from_str("events.auto_turn_cap = 0");
3809        assert_eq!(cfg.events.auto_turn_cap, 0);
3810    }
3811
3812    #[test]
3813    fn events_auto_turn_cap_unlimited_aliases() {
3814        for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
3815            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3816            assert_eq!(
3817                cfg.events.auto_turn_cap, 0,
3818                "expected 0 for events.auto_turn_cap = {val:?}"
3819            );
3820        }
3821    }
3822
3823    #[test]
3824    fn events_auto_turn_cap_explicit_number() {
3825        let cfg = load_config_from_str("events.auto_turn_cap = 12");
3826        assert_eq!(cfg.events.auto_turn_cap, 12);
3827    }
3828
3829    #[test]
3830    fn events_auto_turn_cap_garbage_keeps_default() {
3831        for val in ["banana", "-3", "5.5", "", "true"] {
3832            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
3833            assert_eq!(
3834                cfg.events.auto_turn_cap, 5,
3835                "garbage {val:?} must keep default 5"
3836            );
3837        }
3838    }
3839
3840    #[test]
3841    fn events_auto_turn_cap_independent_of_auto_turn() {
3842        let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
3843        assert!(!cfg.events.auto_turn);
3844        assert_eq!(cfg.events.auto_turn_cap, 0);
3845    }
3846
3847    #[test]
3848    fn events_auto_turn_default_is_true() {
3849        let cfg = load_config_from_str("model = claude-haiku");
3850        assert!(cfg.events.auto_turn, "default must be true when key absent");
3851    }
3852
3853    #[test]
3854    #[serial]
3855    fn test_auth_config_parses_endpoint_and_token() {
3856        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
3857        let dir = home.join(".synaps-cli");
3858        std::fs::create_dir_all(&dir).unwrap();
3859        std::fs::write(
3860            dir.join("config"),
3861            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
3862        )
3863        .unwrap();
3864        with_home(&home, || {
3865            let config = load_config();
3866            assert_eq!(
3867                config.auth.remote_endpoint.as_deref(),
3868                Some("https://jade.jade:8181")
3869            );
3870            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
3871            // auth.* are namespaced -> must NOT warn as unknown keys
3872            assert!(
3873                !config.warnings.iter().any(|w| w.contains("auth.")),
3874                "{:?}",
3875                config.warnings
3876            );
3877        });
3878        let _ = std::fs::remove_dir_all(&home);
3879    }
3880
3881    #[test]
3882    #[serial]
3883    fn test_credential_source_local_by_default() {
3884        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3885        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3886        assert!(!AuthConfig::default().credential_source().is_remote());
3887    }
3888
3889    #[test]
3890    #[serial]
3891    fn test_credential_source_remote_from_config() {
3892        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3893        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3894        let auth = AuthConfig {
3895            remote_endpoint: Some("https://b".into()),
3896            machine_token: Some("m".into()),
3897        };
3898        assert_eq!(
3899            auth.credential_source(),
3900            crate::core::auth::CredentialSource::Remote {
3901                endpoint: "https://b".into(),
3902                machine_token: "m".into()
3903            }
3904        );
3905    }
3906
3907    #[test]
3908    #[serial]
3909    fn test_credential_source_env_overrides_config() {
3910        let auth = AuthConfig {
3911            remote_endpoint: Some("https://config-host".into()),
3912            machine_token: Some("config-tok".into()),
3913        };
3914        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
3915        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
3916        let src = auth.credential_source();
3917        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
3918        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
3919        assert_eq!(
3920            src,
3921            crate::core::auth::CredentialSource::Remote {
3922                endpoint: "https://env-host".into(),
3923                machine_token: "env-tok".into()
3924            }
3925        );
3926    }
3927    #[test]
3928    fn config_parses_ultracode_as_distinct_canonical_level() {
3929        let cfg = load_config_from_str("thinking = ultracode\n");
3930        assert_eq!(
3931            cfg.thinking_level,
3932            Some(crate::reasoning::ReasoningLevel::UltraCode)
3933        );
3934        assert_eq!(cfg.thinking_budget, None);
3935        for other in [
3936            crate::reasoning::ReasoningLevel::Ultra,
3937            crate::reasoning::ReasoningLevel::Max,
3938            crate::reasoning::ReasoningLevel::XHigh,
3939        ] {
3940            assert_ne!(cfg.thinking_level, Some(other));
3941        }
3942    }
3943
3944    // ── /settings coverage support ────────────────────────────────────────
3945
3946    /// `context_window = auto` is what the /settings cycler and `/context
3947    /// auto` write: it means "model default" (no override) and must not warn.
3948    #[test]
3949    fn context_window_auto_is_model_default_without_warning() {
3950        for spelling in ["auto", "AUTO", "Auto"] {
3951            let cfg = load_config_from_str(&format!("context_window = {spelling}\n"));
3952            assert_eq!(cfg.context_window, None, "{spelling}");
3953            assert!(cfg.warnings.is_empty(), "{spelling}: {:?}", cfg.warnings);
3954        }
3955        // `auto` after an explicit window clears it.
3956        let cfg = load_config_from_str("context_window = 1m\ncontext_window = auto\n");
3957        assert_eq!(cfg.context_window, None);
3958        assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
3959        // Existing spellings are unchanged; garbage still warns.
3960        assert_eq!(
3961            load_config_from_str("context_window = 200k\n").context_window,
3962            Some(200_000)
3963        );
3964        let bad = load_config_from_str("context_window = huge\n");
3965        assert_eq!(bad.context_window, None);
3966        assert!(bad.warnings.iter().any(|w| w.contains("context_window")));
3967    }
3968
3969    /// The /settings sidecar row writes `sidecar_toggle_key`; it is read by
3970    /// the keybind registry and must not trip the unknown-key warning.
3971    #[test]
3972    fn sidecar_toggle_key_is_a_known_key() {
3973        let cfg = load_config_from_str("sidecar_toggle_key = F2\n");
3974        assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
3975        assert!(KNOWN_CONFIG_KEYS.contains(&"sidecar_toggle_key"));
3976        assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_mode"));
3977        assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_exclude"));
3978    }
3979
3980    /// `config_content_problems` sees the stderr-only family diagnostics
3981    /// (shell/events/turn_budget/memory) as well as `warnings`, and nothing
3982    /// for valid values.
3983    #[test]
3984    fn config_content_problems_covers_family_diagnostics() {
3985        for bad in [
3986            "shell.max_sessions = lots",
3987            "shell.readiness_strategy = eventually",
3988            "events.auto_turn = maybe",
3989            "events.auto_turn_cap = many",
3990            "turn_budget.worker.max_cost_usd = cheap",
3991            "turn_budget.nobody.max_tool_calls = 3",
3992            "memory.capture_tools = everything",
3993            "memory.recall_max_records = -1",
3994            "memory.user_scope = yes",
3995            "cache_ttl = forever",
3996        ] {
3997            let problems = config_content_problems(bad);
3998            assert!(!problems.is_empty(), "{bad} should be reported");
3999        }
4000        for good in [
4001            "shell.max_sessions = 8",
4002            "shell.readiness_strategy = prompt",
4003            "events.auto_turn = off",
4004            "events.auto_turn_cap = unlimited",
4005            "turn_budget.worker.max_cost_usd = 2.5",
4006            "memory.capture_tools = off",
4007            "memory.recall_max_records = 4",
4008            "memory.user_scope = true",
4009            "cache_ttl = hybrid",
4010        ] {
4011            let problems = config_content_problems(good);
4012            assert!(problems.is_empty(), "{good}: {problems:?}");
4013        }
4014        // The family values still land exactly as before.
4015        let cfg = load_config_from_str("shell.max_sessions = 8\nevents.auto_turn = maybe\n");
4016        assert_eq!(cfg.shell.max_sessions, 8);
4017        assert!(!cfg.events.auto_turn, "unrecognised auto_turn still fails safe to false");
4018    }
4019
4020    /// Every listed family key is accepted by its parser for some value
4021    /// (catches a typo in `FAMILY_CONFIG_KEYS`).
4022    #[test]
4023    fn family_config_keys_are_real_parser_keys() {
4024        let sample = |key: &str| -> &'static str {
4025            match key {
4026                "shell.readiness_strategy" => "hybrid",
4027                "memory.backend" => "legacy",
4028                "memory.user_scope" | "memory.default_mode_confirmed" => "true",
4029                "memory.default_mode" => "off",
4030                "memory.capture_tools" => "summary_only",
4031                "memory.capture_assistant" | "memory.capture_user" => "on",
4032                "memory.auto_consolidate" | "memory.local_embeddings" | "memory.gliner" => "off",
4033                "memory.axel.executable" | "memory.axel.brain" => "/opt/axel",
4034                "events.auto_turn" => "on",
4035                "server.allowed_origins" | "server.token" => "x",
4036                "server.auto_approve_confirms" | "bridge.heartbeat_mirror" => "false",
4037                "bridge.uds_path" => "/tmp/x.sock",
4038                "auth.remote_endpoint" | "auth.machine_token" => "x",
4039                _ => "7",
4040            }
4041        };
4042        for key in FAMILY_CONFIG_KEYS.iter().filter(|k| !k.ends_with(".*")) {
4043            assert!(!KNOWN_CONFIG_KEYS.contains(key), "{key} is listed twice");
4044            let content = format!("{key} = {}\n", sample(key));
4045            let problems = config_content_problems(&content);
4046            assert!(problems.is_empty(), "{key}: {problems:?}");
4047        }
4048    }
4049
4050    #[test]
4051    #[serial]
4052    fn validate_config_edit_uses_the_real_parser_and_whole_file() {
4053        let home = make_test_home("validate-edit");
4054        let cfg = home.join(".synaps-cli/config");
4055        with_home(&home, || {
4056            // Single-key value checks.
4057            assert!(validate_config_edit("cache_ttl", "1h").is_ok());
4058            assert!(validate_config_edit("cache_ttl", "2h").is_err());
4059            assert!(validate_config_edit("shell.max_output", "big").is_err());
4060            assert!(validate_config_edit("memory.axel.brain", "relative/brain").is_err());
4061            assert!(validate_config_edit("memory.axel.brain", "/abs/brain.r8").is_ok());
4062            assert!(validate_config_edit("model", "a\nb").is_err());
4063
4064            // Cross-key rules see the rest of the file.
4065            std::fs::write(&cfg, "context_management.rollover_tokens = 100000\n").unwrap();
4066            assert!(validate_config_edit("context_management.pressure_tokens", "90000").is_ok());
4067            assert!(validate_config_edit("context_management.pressure_tokens", "150000").is_err());
4068            std::fs::write(&cfg, "subagent.max_total = 8\n").unwrap();
4069            assert!(validate_config_edit("subagent.max_concurrent", "8").is_ok());
4070            assert!(validate_config_edit("subagent.max_concurrent", "9").is_err());
4071            std::fs::write(&cfg, "").unwrap();
4072            assert!(validate_config_edit("memory.default_mode", "recall_once").is_err());
4073            assert!(validate_config_edit("memory.default_mode", "off").is_ok());
4074            std::fs::write(&cfg, "memory.default_mode_confirmed = true\n").unwrap();
4075            assert!(validate_config_edit("memory.default_mode", "recall_once").is_ok());
4076
4077            // A problem already in the file is not blamed on an unrelated edit.
4078            std::fs::write(&cfg, "cache_ttl = forever\n").unwrap();
4079            assert!(validate_config_edit("max_fps", "120").is_ok());
4080            // Validation never writes.
4081            assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "cache_ttl = forever\n");
4082        });
4083        let _ = std::fs::remove_dir_all(&home);
4084    }
4085}