Skip to main content

agent_core/core/
config.rs

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