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