Skip to main content

agent_core/core/
config.rs

1use crate::core::shell_config::ShellConfig;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::sync::OnceLock;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8static IDENTITY: OnceLock<String> = OnceLock::new();
9
10pub const DEFAULT_IDENTITY: &str =
11    "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
12
13/// Returns the configured identity string for the system prompt preamble.
14/// Falls back to `DEFAULT_IDENTITY` (the SynapsCLI identity above) if not set
15/// in config. Initialized by `load_config()` — safe to call anytime after boot.
16pub fn get_identity() -> String {
17    IDENTITY
18        .get()
19        .cloned()
20        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
21}
22
23/// Provider API keys parsed from `provider.<name> = ...` lines in config.
24/// Empty if `load_config()` hasn't been called. The registry falls back to
25/// env vars, so e.g. `GROQ_API_KEY` works even with an empty map.
26pub fn get_provider_keys() -> BTreeMap<String, String> {
27    PROVIDER_KEYS.get().cloned().unwrap_or_default()
28}
29
30/// Returns the active profile name, if any.
31/// Reads from `SYNAPS_PROFILE` environment variable if not already set programmatically.
32pub fn get_profile() -> Option<String> {
33    PROFILE_NAME
34        .get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
35        .clone()
36}
37
38/// Sets the active profile name. Must be called before any `get_profile()` call
39/// (i.e., before config resolution begins). Uses OnceLock — first write wins,
40/// subsequent calls are no-ops. No env var mutation (unsafe under tokio).
41pub fn set_profile(name: Option<String>) {
42    let _ = PROFILE_NAME.set(name);
43}
44
45pub fn base_dir() -> PathBuf {
46    if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
47        return PathBuf::from(path);
48    }
49    let home = std::env::var("HOME")
50        .or_else(|_| std::env::var("USERPROFILE"))
51        .unwrap_or_else(|_| ".".to_string());
52    PathBuf::from(home).join(".synaps-cli")
53}
54
55/// Overrides the Synaps base directory. Intended for tests and embedded harnesses.
56#[doc(hidden)]
57pub fn set_base_dir_for_tests(path: PathBuf) {
58    std::env::set_var("SYNAPS_BASE_DIR", path);
59}
60
61/// Resolves a path for reading. Checks the profile folder first, then falls back to the default folder.
62pub fn resolve_read_path(filename: &str) -> PathBuf {
63    let base = base_dir();
64
65    if let Some(profile) = get_profile() {
66        let profile_path = base.join(&profile).join(filename);
67        if profile_path.exists() {
68            return profile_path;
69        }
70    }
71
72    base.join(filename)
73}
74
75/// Resolves a path for reading with an extended arbitrary path tree.
76pub fn resolve_read_path_extended(path: &str) -> PathBuf {
77    let base = base_dir();
78
79    if let Some(profile) = get_profile() {
80        let profile_path = base.join(&profile).join(path);
81        if profile_path.exists() {
82            return profile_path;
83        }
84    }
85
86    base.join(path)
87}
88
89/// Resolves a path for writing. Unconditionally writes to the profile folder if a profile is active.
90pub fn resolve_write_path(filename: &str) -> PathBuf {
91    let mut base = base_dir();
92
93    if let Some(profile) = get_profile() {
94        base.push(profile);
95    }
96
97    let _ = std::fs::create_dir_all(&base);
98    base.join(filename)
99}
100
101/// Gets the absolute directory for the current profile (or root if default).
102pub fn get_active_config_dir() -> PathBuf {
103    let mut base = base_dir();
104    if let Some(profile) = get_profile() {
105        base.push(profile);
106    }
107    base
108}
109
110/// Server security configuration parsed from `server.*` keys.
111#[derive(Debug, Clone, Default)]
112pub struct ServerConfig {
113    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
114    pub allowed_origins: Vec<String>,
115    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
116    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
117    pub token: Option<String>,
118    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
119    pub auto_approve_confirms: bool,
120    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
121    /// None means no artificial cap.
122    pub max_message_size: Option<usize>,
123}
124
125/// Bridge daemon configuration parsed from `bridge.*` keys.
126///
127/// Controls best-effort mirroring of watcher heartbeats over the bridge
128/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
129/// and the feature is OFF by default.
130#[derive(Debug, Clone)]
131pub struct BridgeConfig {
132    /// Path to the bridge daemon's UDS control socket.
133    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
134    pub uds_path: Option<PathBuf>,
135    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
136    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
137    pub heartbeat_mirror: bool,
138    /// Per-call timeout in milliseconds (covers connect + write + read).
139    pub heartbeat_timeout_ms: u64,
140}
141
142impl Default for BridgeConfig {
143    fn default() -> Self {
144        Self {
145            uds_path: None,
146            heartbeat_mirror: false,
147            heartbeat_timeout_ms: 250,
148        }
149    }
150}
151
152impl BridgeConfig {
153    /// Resolve the UDS path, falling back to the default under `base_dir()`.
154    pub fn resolved_uds_path(&self) -> PathBuf {
155        self.uds_path
156            .clone()
157            .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
158    }
159}
160
161/// Auth configuration parsed from `auth.*` keys. Controls whether this client
162/// resolves provider tokens from the local `auth.json` (default) or from a
163/// remote credential broker. See task #157 / `auth::credential_source`.
164#[derive(Debug, Clone, Default)]
165pub struct AuthConfig {
166    /// Broker base URL. When set (here, or via `SYNAPS_AUTH_ENDPOINT`), the
167    /// client fetches short-lived access tokens from the broker instead of
168    /// reading/refreshing the local `auth.json`.
169    pub remote_endpoint: Option<String>,
170    /// Per-machine bearer presented to the broker (or `SYNAPS_MACHINE_TOKEN`).
171    /// This is the machine's own identity, never the provider credential.
172    pub machine_token: Option<String>,
173}
174
175impl AuthConfig {
176    /// Resolve the credential source. Environment variables take precedence over
177    /// config-file values: `SYNAPS_AUTH_ENDPOINT` / `SYNAPS_MACHINE_TOKEN`.
178    /// Returns `Remote` iff an endpoint is set (env or config), else `Local`.
179    pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
180        let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
181            .ok()
182            .filter(|s| !s.trim().is_empty())
183            .or_else(|| self.remote_endpoint.clone());
184        let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
185            .ok()
186            .filter(|s| !s.trim().is_empty())
187            .or_else(|| self.machine_token.clone());
188        crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
189    }
190}
191
192/// Prompt-cache TTL strategy for Anthropic requests.
193///
194/// Controls the `cache_control` value emitted at every cache marker site:
195/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
196///   to historical payloads; the default path can never invalidate existing
197///   cached prefixes.
198/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
199/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
200///   bare 5m on the message-tail marker (written every turn).
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum CacheTtl {
203    #[default]
204    FiveMinutes,
205    OneHour,
206    Hybrid,
207}
208
209impl CacheTtl {
210    /// Parse a config value (case-insensitive). Returns `None` for unknown
211    /// values so the caller can warn and fall back to the default.
212    pub fn parse(val: &str) -> Option<CacheTtl> {
213        match val.to_ascii_lowercase().as_str() {
214            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
215            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
216            "hybrid" => Some(CacheTtl::Hybrid),
217            _ => None,
218        }
219    }
220}
221
222/// Animated theme-transition mode, parsed from `theme_transition`:
223/// - `"on"` (default): cross-fade theme changes over the default 350 ms.
224/// - `"off"`: instant snap — accessibility, and mercy on tmux-over-ssh.
225/// - integer milliseconds: cross-fade over that duration (clamped to
226///   0–2000 ms; `0` behaves like `off`).
227///
228/// Purely a TUI presentation knob: it changes *how* a new palette is
229/// applied, never *which* palette wins.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
231pub enum ThemeTransitionMode {
232    #[default]
233    On,
234    Off,
235    Ms(u64),
236}
237
238impl ThemeTransitionMode {
239    /// Default cross-fade duration for `On` (and the fallback when a live
240    /// palette update carries no advisory duration of its own).
241    pub const DEFAULT_MS: u64 = 350;
242    /// Duration ceiling — both this knob and MXC wire `fade_ms` clamp here.
243    pub const MAX_MS: u64 = 2000;
244
245    /// Parse a config value (case-insensitive). Returns `None` for unknown
246    /// values so the caller can warn and fall back to the default.
247    pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
248        match val.to_ascii_lowercase().as_str() {
249            "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
250            "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
251            other => other
252                .parse::<u64>()
253                .ok()
254                .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
255        }
256    }
257
258    /// Effective default duration in milliseconds; `0` means "snap".
259    pub fn duration_ms(self) -> u64 {
260        match self {
261            ThemeTransitionMode::On => Self::DEFAULT_MS,
262            ThemeTransitionMode::Off => 0,
263            ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
264        }
265    }
266}
267
268/// Runtime event routing configuration parsed from `events.*` keys.
269///
270/// Controls how runtime events (from the `EventQueue`) are delivered in
271/// server and RPC modes.
272#[derive(Debug, Clone)]
273pub struct EventsConfig {
274    /// When `true` (default), the server/RPC session automatically triggers a
275    /// model turn when runtime events arrive while idle.  Set
276    /// `events.auto_turn = false` (or `0` / `no` / `off`) to opt out.
277    /// Unrecognised values fail safe to `false` with a warning.
278    /// The built-in cap (`AUTO_TURN_CAP = 5`) still applies regardless.
279    pub auto_turn: bool,
280}
281
282impl Default for EventsConfig {
283    fn default() -> Self {
284        Self { auto_turn: true }
285    }
286}
287
288/// Parse `events.*` configuration keys.
289fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
290    if key == "events.auto_turn" {
291        let normalised = val.trim().to_lowercase();
292        cfg.auto_turn = match normalised.as_str() {
293            // Explicit true values.
294            "true" | "1" | "yes" | "on" => true,
295            // Explicit false values.
296            "false" | "0" | "no" | "off" => false,
297            // Unrecognised: fail safe to false and warn so the user knows.
298            other => {
299                eprintln!(
300                    "warning: config: unrecognised value for events.auto_turn = {:?}; \
301                     expected true/false/yes/no/on/off/1/0 — defaulting to false",
302                    other
303                );
304                false
305            }
306        };
307    } // unknown events.* keys ignored
308}
309
310/// Typed per-role turn-budget overrides (Task 23, spec §8.1). Every field
311/// is optional: unset fields keep the role's compiled default. Values are
312/// parsed from `turn_budget.<role>.<field>` config keys.
313#[derive(Debug, Clone, Default, PartialEq)]
314pub struct TurnBudgetOverrides {
315    pub max_provider_rounds: Option<u32>,
316    pub max_tool_calls: Option<u32>,
317    pub max_elapsed_secs: Option<u64>,
318    pub max_accumulated_tool_result_bytes: Option<usize>,
319    pub max_context_tokens: Option<u64>,
320    pub max_cost_usd: Option<f64>,
321    /// Bounded auto-renewals of the provider-round allowance after it is
322    /// exhausted mid-turn (spec §8.1). Each renewal resets the round counter;
323    /// wall-clock is never renewed, so total turn time stays bounded. `0`
324    /// disables graceful continuation (hard stop at the first exhaustion).
325    pub max_round_renewals: Option<u32>,
326}
327
328/// Per-role turn budgets (foreground, autonomous/watcher, worker).
329#[derive(Debug, Clone, Default, PartialEq)]
330pub struct TurnBudgetsConfig {
331    pub foreground: TurnBudgetOverrides,
332    pub autonomous: TurnBudgetOverrides,
333    pub worker: TurnBudgetOverrides,
334}
335
336/// Parse `turn_budget.<role>.<field>` keys. Invalid values warn and keep
337/// the default (parity with shell.* parsing).
338fn parse_turn_budget_config_key(budgets: &mut TurnBudgetsConfig, key: &str, val: &str) {
339    let Some(rest) = key.strip_prefix("turn_budget.") else {
340        return;
341    };
342    let Some((role, field)) = rest.split_once('.') else {
343        eprintln!("Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)");
344        return;
345    };
346    let overrides = match role {
347        "foreground" => &mut budgets.foreground,
348        "autonomous" => &mut budgets.autonomous,
349        "worker" => &mut budgets.worker,
350        other => {
351            eprintln!("Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)");
352            return;
353        }
354    };
355    macro_rules! set {
356        ($slot:expr, $ty:ty) => {
357            match val.parse::<$ty>() {
358                Ok(parsed) => $slot = Some(parsed),
359                Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
360            }
361        };
362    }
363    match field {
364        "max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
365        "max_tool_calls" => set!(overrides.max_tool_calls, u32),
366        "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
367        "max_accumulated_tool_result_bytes" => {
368            set!(overrides.max_accumulated_tool_result_bytes, usize)
369        }
370        "max_context_tokens" => set!(overrides.max_context_tokens, u64),
371        "max_cost_usd" => set!(overrides.max_cost_usd, f64),
372        "max_round_renewals" => set!(overrides.max_round_renewals, u32),
373        other => eprintln!("Warning: unknown turn_budget field '{other}'"),
374    }
375}
376
377/// Continuous-memory settings surface (Task A2, spec §12). Every field is
378/// fail-closed: unknown or invalid values warn and keep the compiled default.
379///
380/// By construction this struct is the complete `memory.*` config surface.
381/// Secret handling, retention, and project-scope rules are deliberately NOT
382/// fields here — they are not configurable at all (spec §12: "Secret,
383/// retention, and project rules are not configurable to fail open").
384#[derive(Debug, Clone, PartialEq)]
385pub struct MemoryConfig {
386    /// Durable default memory mode. "off" unless the operator explicitly
387    /// consents via `memory.default_mode_confirmed = true` in the same
388    /// config parse pass (spec §12 operator-consent rule). Known values:
389    /// off | recall_once | recall_each_prompt | capture_only |
390    /// capture_and_recall (spec §6.1).
391    pub default_mode: String,
392    /// Operator-consent latch for `default_mode` (spec §12). Must be set to
393    /// `true` in the same parse pass for a non-"off" `default_mode` to take
394    /// effect; otherwise `default_mode` reverts to "off" with a warning.
395    pub default_mode_confirmed: bool,
396    /// Max records surfaced per recall (spec §10.3 budget).
397    pub recall_max_records: u32,
398    /// Max tokens surfaced per recall (spec §10.3 budget).
399    pub recall_max_tokens: u32,
400    /// Recall time budget in milliseconds (spec §16).
401    pub recall_timeout_ms: u64,
402    /// Tool-result capture policy: off | summary_only (spec §11).
403    pub capture_tools: String,
404    /// Capture assistant turns (spec §8.2).
405    pub capture_assistant: bool,
406    /// Capture user turns (spec §8.2).
407    pub capture_user: bool,
408    /// Background consolidation: off | on (spec §11.3, explicit opt-in).
409    pub auto_consolidate: String,
410    /// Local embedding models: off | on (spec §11.2, never implicit).
411    pub local_embeddings: String,
412    /// GLiNER entity extraction: off | on (spec §11.2, never implicit).
413    pub gliner: String,
414}
415
416impl Default for MemoryConfig {
417    fn default() -> Self {
418        Self {
419            default_mode: "off".to_string(),
420            default_mode_confirmed: false,
421            recall_max_records: 8,
422            recall_max_tokens: 4096,
423            recall_timeout_ms: 150,
424            capture_tools: "summary_only".to_string(),
425            capture_assistant: true,
426            capture_user: true,
427            auto_consolidate: "off".to_string(),
428            local_embeddings: "off".to_string(),
429            gliner: "off".to_string(),
430        }
431    }
432}
433
434/// Memory mode strings accepted by `memory.default_mode` (spec §6.1,
435/// snake_case of `MemoryContextMode`). Anything else fails closed to the
436/// current value — `default_mode` can never silently become a non-off mode
437/// through an unrecognised string.
438const KNOWN_MEMORY_MODES: &[&str] = &[
439    "off",
440    "recall_once",
441    "recall_each_prompt",
442    "capture_only",
443    "capture_and_recall",
444];
445
446/// Parse `memory.<field>` keys (Task A2, spec §12). Invalid values warn and
447/// keep the default (parity with turn_budget.* parsing) — never fail open.
448fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) {
449    let Some(field) = key.strip_prefix("memory.") else {
450        return;
451    };
452    /// Strict boolean: recognised forms parse; anything else warns and
453    /// keeps the current (default) value.
454    fn parse_bool(slot: &mut bool, key: &str, val: &str) {
455        match val {
456            "true" | "1" | "yes" | "on" => *slot = true,
457            "false" | "0" | "no" | "off" => *slot = false,
458            other => eprintln!(
459                "Warning: invalid value for {key}: '{other}', expected true/false — using default"
460            ),
461        }
462    }
463    /// Closed string-enum: value must be in `allowed`, else warn and keep
464    /// the current (default) value.
465    fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) {
466        if allowed.contains(&val) {
467            *slot = val.to_string();
468        } else {
469            eprintln!(
470                "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
471                allowed.join("|")
472            );
473        }
474    }
475    macro_rules! set_num {
476        ($slot:expr, $ty:ty) => {
477            match val.parse::<$ty>() {
478                Ok(parsed) => $slot = parsed,
479                Err(_) => eprintln!("Warning: invalid value for {key}: '{val}', using default"),
480            }
481        };
482    }
483    match field {
484        "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
485        "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
486        "recall_max_records" => set_num!(memory.recall_max_records, u32),
487        "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
488        "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
489        "capture_tools" => parse_enum(
490            &mut memory.capture_tools,
491            key,
492            val,
493            &["off", "summary_only"],
494        ),
495        "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
496        "capture_user" => parse_bool(&mut memory.capture_user, key, val),
497        "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
498        "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
499        "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
500        other => eprintln!("Warning: unknown memory field '{other}'"),
501    }
502}
503
504/// Parsed configuration from the config file.
505#[derive(Debug, Clone)]
506pub struct SynapsConfig {
507    pub model: Option<String>,
508    pub thinking_budget: Option<u32>,
509    /// Named reasoning level, parsed from the same `thinking = …` key.
510    /// When `Some`, this is the authoritative level. When `None`, fall back
511    /// to `thinking_budget` for legacy numeric-only values.
512    pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
513    pub context_window: Option<u64>, // override auto-detected context window (tokens)
514    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
515    /// Where compaction summarization runs (spec §9.4): remote provider or
516    /// local-only (zero network construction).
517    pub compaction_mode: crate::core::compaction::CompactionMode,
518    /// Content classes excluded from remote compaction disclosure.
519    pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
520    pub max_tool_output: usize,  // default 30000
521    pub bash_timeout: u64,       // default 30
522    pub bash_max_timeout: u64,   // default 300
523    pub subagent_timeout: u64,   // default 300
524    pub api_retries: u32,        // default 3
525    pub refusal_retries: u32,    // default 2 — retries on stop_reason=refusal
526    pub telemetry: String,       // off | basic | full (default off)
527    pub cache_diagnostics: bool, // opt into cache-diagnosis beta (default false)
528    /// Prompt-cache TTL strategy: "5m" (default) | "1h" | "hybrid".
529    pub cache_ttl: CacheTtl,
530    /// Max TUI redraw rate in frames/sec — caps streaming redraws (e.g. 60,
531    /// 144, 240). User input always redraws immediately regardless. Default
532    /// 60. Range 1–1000. The frame budget is `1000 / max_fps` ms.
533    pub max_fps: u32,
534    /// Lines to scroll per mouse-wheel event. Different terminal emulators
535    /// emit 1–9+ scroll events per physical notch; set this to compensate.
536    /// Default: 3. Range 1–20.
537    pub scroll_lines: Option<u16>,
538    pub theme: Option<String>,
539    /// Animated theme transitions: `"on"` (350 ms cross-fade, default),
540    /// `"off"` (instant snap), or integer milliseconds (0–2000).
541    pub theme_transition: ThemeTransitionMode,
542    /// Whether the TUI paints its own opaque background. `false` preserves the terminal background.
543    pub tui_background_opaque: bool,
544    pub agent_name: Option<String>,
545    pub identity: Option<String>,
546    pub disabled_plugins: Vec<String>,
547    pub favorite_models: Vec<String>,
548    pub disabled_skills: Vec<String>,
549    /// Opt-in progressive tool disclosure (Task 18). When false, providers
550    /// receive the existing full tool schema byte-for-byte. When true, each
551    /// stream starts with the small essential local core plus discovery and
552    /// authorization gateways; exact activations are added per session.
553    pub progressive_tool_disclosure: bool,
554    /// Opt-in session persistence strategy (Task 35, spec §9.8). `Json`
555    /// (default) is the unchanged legacy full-rewrite path; `Journal` adds
556    /// an append-only delta journal with periodic atomic snapshots. See
557    /// docs/decisions/T35-session-journal-opt-in.md.
558    pub session_persistence: crate::core::session_journal::SessionPersistence,
559    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
560    /// from the registry at boot so they're never offered to the model.
561    pub disabled_tools: Vec<String>,
562    pub shell: ShellConfig,
563    pub server: ServerConfig,
564    pub bridge: BridgeConfig,
565    pub auth: AuthConfig,
566    pub events: EventsConfig,
567    pub provider_keys: BTreeMap<String, String>,
568    pub keybinds: std::collections::HashMap<String, String>,
569    /// Typed per-role turn budgets (Task 23, spec §8.1).
570    pub turn_budgets: TurnBudgetsConfig,
571    /// Continuous-memory settings surface (Task A2, spec §12).
572    pub memory: MemoryConfig,
573    /// Non-fatal problems found while parsing the config file (unknown keys,
574    /// unparseable values). Surfaced once at startup — never block boot.
575    pub warnings: Vec<String>,
576}
577
578impl Default for SynapsConfig {
579    fn default() -> Self {
580        Self {
581            model: None,
582            thinking_budget: None,
583            thinking_level: None,
584            context_window: None,
585            compaction_model: None,
586            compaction_mode: crate::core::compaction::CompactionMode::default(),
587            compaction_exclude: Vec::new(),
588            max_tool_output: 30000,
589            bash_timeout: 30,
590            bash_max_timeout: 300,
591            subagent_timeout: 300,
592            api_retries: 3,
593            refusal_retries: 2,
594            telemetry: "off".to_string(),
595            cache_diagnostics: false,
596            cache_ttl: CacheTtl::default(),
597            max_fps: 60,
598            scroll_lines: None,
599            theme: None,
600            theme_transition: ThemeTransitionMode::default(),
601            tui_background_opaque: true,
602            agent_name: None,
603            identity: None,
604            disabled_plugins: Vec::new(),
605            favorite_models: Vec::new(),
606            disabled_skills: Vec::new(),
607            progressive_tool_disclosure: false,
608            session_persistence: crate::core::session_journal::SessionPersistence::default(),
609            disabled_tools: Vec::new(),
610            shell: ShellConfig::default(),
611            server: ServerConfig::default(),
612            bridge: BridgeConfig::default(),
613            auth: AuthConfig::default(),
614            events: EventsConfig::default(),
615            provider_keys: BTreeMap::new(),
616            keybinds: std::collections::HashMap::new(),
617            turn_budgets: TurnBudgetsConfig::default(),
618            memory: MemoryConfig::default(),
619            warnings: Vec::new(),
620        }
621    }
622}
623
624/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
625const KNOWN_CONFIG_KEYS: &[&str] = &[
626    "model",
627    "thinking",
628    "compaction_model",
629    "context_window",
630    "max_tool_output",
631    "bash_timeout",
632    "bash_max_timeout",
633    "subagent_timeout",
634    "api_retries",
635    "refusal_retries",
636    "telemetry",
637    "cache_diagnostics",
638    "cache_ttl",
639    "max_fps",
640    "scroll_lines",
641    "theme",
642    "theme_transition",
643    "tui_background_opaque",
644    "agent_name",
645    "identity",
646    "disabled_plugins",
647    "favorite_models",
648    "disabled_skills",
649    "disabled_tools",
650    "progressive_tool_disclosure",
651    "session_persistence",
652];
653
654/// Simple Levenshtein distance for did-you-mean suggestions.
655fn levenshtein(a: &str, b: &str) -> usize {
656    let a: Vec<char> = a.chars().collect();
657    let b: Vec<char> = b.chars().collect();
658    let mut prev: Vec<usize> = (0..=b.len()).collect();
659    let mut cur = vec![0; b.len() + 1];
660    for (i, ca) in a.iter().enumerate() {
661        cur[0] = i + 1;
662        for (j, cb) in b.iter().enumerate() {
663            let cost = if ca == cb { 0 } else { 1 };
664            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
665        }
666        std::mem::swap(&mut prev, &mut cur);
667    }
668    prev[b.len()]
669}
670
671/// Closest known key within edit distance 2, for typo suggestions.
672fn did_you_mean(key: &str) -> Option<&'static str> {
673    KNOWN_CONFIG_KEYS
674        .iter()
675        .map(|k| (*k, levenshtein(key, k)))
676        .filter(|(_, d)| *d <= 2)
677        .min_by_key(|(_, d)| *d)
678        .map(|(k, _)| k)
679}
680
681// parse_thinking_budget replaced by ThinkingSpec::parse in apply_config_content.
682
683fn parse_comma_list(val: &str) -> Vec<String> {
684    val.split(',')
685        .map(|s| s.trim().to_string())
686        .filter(|s| !s.is_empty())
687        .collect()
688}
689
690fn write_comma_list(key: &str, values: &[String]) -> std::io::Result<()> {
691    write_config_value(key, &values.join(", "))
692}
693
694/// Parse shell.* configuration keys and update the ShellConfig.
695fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
696    match key {
697        "shell.max_sessions" => {
698            if let Ok(sessions) = val.parse::<usize>() {
699                shell_config.max_sessions = sessions;
700            } else {
701                eprintln!(
702                    "Warning: invalid value for shell.max_sessions: '{}', using default",
703                    val
704                );
705            }
706        }
707        "shell.idle_timeout" => {
708            if let Ok(timeout) = val.parse::<u64>() {
709                shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
710            } else {
711                eprintln!(
712                    "Warning: invalid value for shell.idle_timeout: '{}', using default",
713                    val
714                );
715            }
716        }
717        "shell.readiness_timeout_ms" => {
718            if let Ok(timeout) = val.parse::<u64>() {
719                shell_config.readiness_timeout_ms = timeout;
720            } else {
721                eprintln!(
722                    "Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
723                    val
724                );
725            }
726        }
727        "shell.max_readiness_timeout_ms" => {
728            if let Ok(timeout) = val.parse::<u64>() {
729                shell_config.max_readiness_timeout_ms = timeout;
730            } else {
731                eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
732            }
733        }
734        "shell.default_rows" => {
735            if let Ok(rows) = val.parse::<u16>() {
736                shell_config.default_rows = rows;
737            } else {
738                eprintln!(
739                    "Warning: invalid value for shell.default_rows: '{}', using default",
740                    val
741                );
742            }
743        }
744        "shell.default_cols" => {
745            if let Ok(cols) = val.parse::<u16>() {
746                shell_config.default_cols = cols;
747            } else {
748                eprintln!(
749                    "Warning: invalid value for shell.default_cols: '{}', using default",
750                    val
751                );
752            }
753        }
754        "shell.readiness_strategy" => {
755            let val_lower = val.to_lowercase();
756            match val_lower.as_str() {
757                "timeout" | "prompt" | "hybrid" => {
758                    shell_config.readiness_strategy = val.to_string();
759                }
760                _ => {
761                    eprintln!(
762                        "Warning: invalid value for shell.readiness_strategy: '{}', using default",
763                        val
764                    );
765                }
766            }
767        }
768        "shell.max_output" => {
769            if let Ok(max_output) = val.parse::<usize>() {
770                shell_config.max_output = max_output;
771            } else {
772                eprintln!(
773                    "Warning: invalid value for shell.max_output: '{}', using default",
774                    val
775                );
776            }
777        }
778        _ => {
779            // Unknown shell.* keys are preserved (not rejected)
780        }
781    }
782}
783
784/// Parse server.* configuration keys and update the ServerConfig.
785#[allow(clippy::collapsible_match)]
786fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
787    match key {
788        "server.allowed_origins" => {
789            server_config.allowed_origins = parse_comma_list(val);
790        }
791        "server.token" => {
792            if !val.is_empty() {
793                server_config.token = Some(val.to_string());
794            }
795        }
796        "server.auto_approve_confirms" => {
797            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
798        }
799        "server.max_message_size" => {
800            if let Ok(size) = val.parse::<usize>() {
801                server_config.max_message_size = Some(size);
802            } else {
803                eprintln!(
804                    "Warning: invalid value for server.max_message_size: '{}', ignored",
805                    val
806                );
807            }
808        }
809        _ => {
810            // Unknown server.* keys preserved (not rejected)
811        }
812    }
813}
814
815/// Parse bridge.* configuration keys and update the BridgeConfig.
816fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
817    match key {
818        "bridge.uds_path" => {
819            if val.is_empty() {
820                bridge_config.uds_path = None;
821            } else {
822                bridge_config.uds_path = Some(PathBuf::from(val));
823            }
824        }
825        "bridge.heartbeat_mirror" => {
826            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
827        }
828        "bridge.heartbeat_timeout_ms" => {
829            if let Ok(ms) = val.parse::<u64>() {
830                bridge_config.heartbeat_timeout_ms = ms;
831            } else {
832                eprintln!(
833                    "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
834                    val
835                );
836            }
837        }
838        _ => {
839            // Unknown bridge.* keys preserved (not rejected)
840        }
841    }
842}
843
844/// Parse auth.* configuration keys and update the AuthConfig.
845fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
846    let v = val.trim();
847    match key {
848        "auth.remote_endpoint" => {
849            auth_config.remote_endpoint = if v.is_empty() {
850                None
851            } else {
852                Some(v.to_string())
853            };
854        }
855        "auth.machine_token" => {
856            auth_config.machine_token = if v.is_empty() {
857                None
858            } else {
859                Some(v.to_string())
860            };
861        }
862        _ => {
863            // Unknown auth.* keys preserved (not rejected)
864        }
865    }
866}
867
868/// Parse config from a raw string — useful for tests and embedded harnesses.
869/// Does NOT write to `PROVIDER_KEYS` or `IDENTITY` OnceLocks.
870pub fn load_config_from_str(content: &str) -> SynapsConfig {
871    let mut config = SynapsConfig::default();
872    apply_config_content(&mut config, content);
873    config
874}
875
876/// Parse the config file at ~/.synaps-cli/config (or profile variant).
877/// Returns default config if file doesn't exist or can't be read.
878pub fn load_config() -> SynapsConfig {
879    let path = resolve_read_path("config");
880    let mut config = SynapsConfig::default();
881
882    let Ok(content) = std::fs::read_to_string(&path) else {
883        return config;
884    };
885
886    apply_config_content(&mut config, &content);
887
888    // Publish provider keys to the process-wide cache for the API router.
889    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
890    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
891
892    // Publish identity to the process-wide cache for API system prompt preamble.
893    let identity_val = config
894        .identity
895        .clone()
896        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
897    let _ = IDENTITY.set(identity_val);
898
899    config
900}
901
902/// Apply key=value config lines from `content` into `config`.
903/// Shared by `load_config` (file path) and `load_config_from_str` (test helper).
904fn apply_config_content(config: &mut SynapsConfig, content: &str) {
905    for line in content.lines() {
906        let line = line.trim();
907        if line.is_empty() || line.starts_with('#') {
908            continue;
909        }
910        let Some((key, val)) = line.split_once('=') else {
911            continue;
912        };
913        let key = key.trim();
914        let val = val.trim();
915        match key {
916            "model" => config.model = Some(val.to_string()),
917            "thinking" => {
918                use crate::core::reasoning::ThinkingSpec;
919                match ThinkingSpec::parse(val) {
920                    Some(ThinkingSpec::Named(level)) => {
921                        config.thinking_level = Some(level);
922                        config.thinking_budget = level.to_legacy_budget();
923                    }
924                    Some(ThinkingSpec::Budget(budget)) => {
925                        // Preserve exact legacy budgets; do not make the derived
926                        // bucket authoritative over the user's token count.
927                        config.thinking_level = None;
928                        config.thinking_budget = Some(budget);
929                    }
930                    None => {
931                        config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
932                    }
933                }
934            }
935            "compaction_model" => config.compaction_model = Some(val.to_string()),
936            "compaction_mode" => match val {
937                "remote" => {
938                    config.compaction_mode = crate::core::compaction::CompactionMode::Remote
939                }
940                "local" | "local_only" | "local-only" => {
941                    config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
942                }
943                _ => {
944                    config.warnings.push(format!(
945                        "compaction_mode = {val} — expected remote or local; ignored"
946                    ));
947                }
948            },
949            "compaction_exclude" => {
950                let mut classes = Vec::new();
951                for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
952                    match crate::core::compaction::ContentClass::parse(part) {
953                        Some(class) => {
954                            if !classes.contains(&class) {
955                                classes.push(class);
956                            }
957                        }
958                        None => config.warnings.push(format!(
959                            "compaction_exclude — unknown content class '{part}'; \
960                             expected one of: user_text, assistant_text, thinking, \
961                             tool_calls, tool_results, file_paths, event_data"
962                        )),
963                    }
964                }
965                config.compaction_exclude = classes;
966            }
967            "context_window" => {
968                let parsed = match val {
969                    "200k" | "200K" => Some(200_000),
970                    "1m" | "1M" => Some(1_000_000),
971                    _ => val.parse::<u64>().ok(),
972                };
973                if parsed.is_none() {
974                    config.warnings.push(format!(
975                        "context_window = {val} — expected 200k, 1m, or a token count; ignored"
976                    ));
977                }
978                config.context_window = parsed;
979            }
980            "max_tool_output" => match val.parse::<usize>() {
981                Ok(size) => config.max_tool_output = size,
982                Err(_) => config.warnings.push(format!(
983                    "max_tool_output = {val} — not a number; using {}",
984                    config.max_tool_output
985                )),
986            },
987            "bash_timeout" => match val.parse::<u64>() {
988                Ok(t) if t >= 1 => config.bash_timeout = t,
989                Ok(_) => config.warnings.push(format!(
990                    "bash_timeout = {val} — below minimum (1s); using {}",
991                    config.bash_timeout
992                )),
993                Err(_) => config.warnings.push(format!(
994                    "bash_timeout = {val} — not a number; using {}",
995                    config.bash_timeout
996                )),
997            },
998            "bash_max_timeout" => {
999                if let Ok(timeout) = val.parse::<u64>() {
1000                    config.bash_max_timeout = timeout;
1001                }
1002            }
1003            "subagent_timeout" => {
1004                if let Ok(timeout) = val.parse::<u64>() {
1005                    config.subagent_timeout = timeout;
1006                }
1007            }
1008            "api_retries" => {
1009                if let Ok(retries) = val.parse::<u32>() {
1010                    config.api_retries = retries;
1011                }
1012            }
1013            "refusal_retries" => {
1014                if let Ok(retries) = val.parse::<u32>() {
1015                    config.refusal_retries = retries;
1016                }
1017            }
1018            "telemetry" => config.telemetry = val.to_string(),
1019            "cache_diagnostics" => {
1020                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1021            }
1022            "cache_ttl" => match CacheTtl::parse(val) {
1023                Some(ttl) => config.cache_ttl = ttl,
1024                None => config.warnings.push(format!(
1025                    "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1026                )),
1027            },
1028            "max_fps" => match val.parse::<u32>() {
1029                Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1030                Ok(_) => config.warnings.push(format!(
1031                    "max_fps = {val} — expected 1–1000; using {}",
1032                    config.max_fps
1033                )),
1034                Err(_) => config.warnings.push(format!(
1035                    "max_fps = {val} — not a number; using {}",
1036                    config.max_fps
1037                )),
1038            },
1039            "scroll_lines" => match val.parse::<u16>() {
1040                Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1041                Ok(_) => config
1042                    .warnings
1043                    .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1044                Err(_) => config
1045                    .warnings
1046                    .push(format!("scroll_lines = {val} — not a number; ignoring")),
1047            },
1048            "theme" => config.theme = Some(val.to_string()),
1049            "theme_transition" => match ThemeTransitionMode::parse(val) {
1050                Some(mode) => config.theme_transition = mode,
1051                None => config.warnings.push(format!(
1052                    "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1053                )),
1054            },
1055            "tui_background_opaque" => match val {
1056                "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1057                "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1058                _ => config.warnings.push(format!(
1059                    "tui_background_opaque = {val} — expected opaque or invisible; using {}",
1060                    if config.tui_background_opaque {
1061                        "opaque"
1062                    } else {
1063                        "invisible"
1064                    }
1065                )),
1066            },
1067            "agent_name" => config.agent_name = Some(val.to_string()),
1068            "identity" => config.identity = Some(val.to_string()),
1069            "disabled_plugins" => {
1070                config.disabled_plugins = parse_comma_list(val);
1071            }
1072            "favorite_models" => {
1073                config.favorite_models = parse_comma_list(val);
1074            }
1075            "disabled_skills" => {
1076                config.disabled_skills = parse_comma_list(val);
1077            }
1078            "progressive_tool_disclosure" => {
1079                config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
1080            }
1081            "session_persistence" => {
1082                match crate::core::session_journal::SessionPersistence::parse(val) {
1083                    Some(mode) => config.session_persistence = mode,
1084                    None => config.warnings.push(format!(
1085                        "session_persistence = {val} — expected json or journal; \
1086                         keeping the default (json)"
1087                    )),
1088                }
1089            }
1090            "disabled_tools" => {
1091                config.disabled_tools = parse_comma_list(val);
1092            }
1093            _ => {
1094                // Handle namespaced keys
1095                if key.starts_with("shell.") {
1096                    parse_shell_config_key(&mut config.shell, key, val);
1097                } else if key.starts_with("server.") {
1098                    parse_server_config_key(&mut config.server, key, val);
1099                } else if key.starts_with("bridge.") {
1100                    parse_bridge_config_key(&mut config.bridge, key, val);
1101                } else if key.starts_with("auth.") {
1102                    parse_auth_config_key(&mut config.auth, key, val);
1103                } else if key.starts_with("events.") {
1104                    parse_events_config_key(&mut config.events, key, val);
1105                } else if key.starts_with("turn_budget.") {
1106                    parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
1107                } else if key.starts_with("memory.") {
1108                    parse_memory_config_key(&mut config.memory, key, val);
1109                } else if let Some(provider_key) = key.strip_prefix("provider.") {
1110                    config
1111                        .provider_keys
1112                        .insert(provider_key.to_string(), val.to_string());
1113                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
1114                    config
1115                        .keybinds
1116                        .insert(keybind_key.to_string(), val.to_string());
1117                } else if key.contains('.') {
1118                    // Dotted keys are namespaced (plugin/extension config, e.g.
1119                    // `knowledge.jawz_notes`). Plugins define their own keys —
1120                    // not ours to police. Silently preserved.
1121                } else {
1122                    // Unknown top-level key — warn with a did-you-mean if close.
1123                    match did_you_mean(key) {
1124                        Some(suggestion) => config.warnings.push(format!(
1125                            "unknown key '{key}' (did you mean '{suggestion}'?)"
1126                        )),
1127                        None => config
1128                            .warnings
1129                            .push(format!("unknown key '{key}' — ignored")),
1130                    }
1131                }
1132            }
1133        }
1134    }
1135
1136    // Fail-closed operator-consent rule (Task A2, spec §12): a non-"off"
1137    // `memory.default_mode` takes effect only when the operator also set
1138    // `memory.default_mode_confirmed = true` in this same parse pass.
1139    // Checked after the loop so key order never matters.
1140    if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
1141        config.warnings.push(format!(
1142            "memory.default_mode = {} requires memory.default_mode_confirmed = true \
1143             in the same config; reverting to off",
1144            config.memory.default_mode
1145        ));
1146        config.memory.default_mode = "off".to_string();
1147    }
1148
1149    // Derive max_message_size from context_window if not explicitly set.
1150    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
1151    if config.server.max_message_size.is_none() {
1152        if let Some(ctx_tokens) = config.context_window {
1153            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
1154        }
1155    }
1156}
1157
1158/// Read a single config value by exact key from the active config file.
1159pub fn read_config_value(key: &str) -> Option<String> {
1160    let path = resolve_read_path("config");
1161    let content = std::fs::read_to_string(&path).ok()?;
1162    for line in content.lines() {
1163        let line = line.trim();
1164        if line.is_empty() || line.starts_with('#') {
1165            continue;
1166        }
1167        let Some((k, v)) = line.split_once('=') else {
1168            continue;
1169        };
1170        if k.trim() == key.trim() {
1171            return Some(v.trim().to_string());
1172        }
1173    }
1174    None
1175}
1176
1177/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
1178/// Replaces the first existing line that matches the key, or appends if absent.
1179/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
1180pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
1181    let path = resolve_write_path("config");
1182    let existing = std::fs::read_to_string(&path).unwrap_or_default();
1183
1184    let key_trimmed = key.trim();
1185    let replacement = format!("{} = {}", key_trimmed, value);
1186
1187    let mut found = false;
1188    let mut new_lines: Vec<String> = existing
1189        .lines()
1190        .map(|line| {
1191            if found {
1192                return line.to_string();
1193            }
1194            let t = line.trim_start();
1195            if t.starts_with('#') || t.is_empty() {
1196                return line.to_string();
1197            }
1198            if let Some((k, _)) = t.split_once('=') {
1199                if k.trim() == key_trimmed {
1200                    found = true;
1201                    return replacement.clone();
1202                }
1203            }
1204            line.to_string()
1205        })
1206        .collect();
1207
1208    if !found {
1209        new_lines.push(replacement);
1210    }
1211
1212    let mut out = new_lines.join("\n");
1213    if !out.ends_with('\n') {
1214        out.push('\n');
1215    }
1216
1217    let tmp = path.with_extension("tmp");
1218    std::fs::write(&tmp, out)?;
1219    // Config may contain API keys — restrict to owner-only
1220    #[cfg(unix)]
1221    {
1222        use std::os::unix::fs::PermissionsExt;
1223        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
1224    }
1225    std::fs::rename(&tmp, &path)?;
1226    Ok(())
1227}
1228
1229/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
1230pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
1231    let trimmed = id.trim();
1232    if trimmed.is_empty() {
1233        return Ok(());
1234    }
1235    let mut values = load_config().favorite_models;
1236    if !values.iter().any(|v| v == trimmed) {
1237        values.push(trimmed.to_string());
1238        values.sort();
1239    }
1240    write_comma_list("favorite_models", &values)
1241}
1242
1243/// Remove a favorite model id (`provider/model`) from config.
1244pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
1245    let mut values = load_config().favorite_models;
1246    values.retain(|v| v != id.trim());
1247    write_comma_list("favorite_models", &values)
1248}
1249
1250/// Return whether a model id is marked as favorite.
1251pub fn is_favorite_model(id: &str) -> bool {
1252    load_config().favorite_models.iter().any(|v| v == id.trim())
1253}
1254
1255/// Resolve the system prompt from CLI flag, config file, or default.
1256/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
1257pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
1258    #[cfg(windows)]
1259    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
1260        You have access to powershell, bash, read, and write tools. \
1261        Prefer the powershell tool for system commands and filesystem operations — it is native \
1262        to Windows and avoids quoting/path translation issues. \
1263        Use bash only when the task specifically requires WSL or a Unix environment. \
1264        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1265    #[cfg(not(windows))]
1266    const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
1267        You have access to bash, read, and write tools. \
1268        Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
1269
1270    if let Some(val) = explicit {
1271        let path = std::path::Path::new(val);
1272        if path.exists() && path.is_file() {
1273            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
1274        }
1275        return val.to_string();
1276    }
1277
1278    let system_path = resolve_read_path("system.md");
1279    if system_path.exists() {
1280        return std::fs::read_to_string(&system_path).unwrap_or_default();
1281    }
1282
1283    DEFAULT_PROMPT.to_string()
1284}
1285
1286#[cfg(test)]
1287mod tests {
1288    #[test]
1289    fn tui_background_opaque_defaults_and_parses_supported_values() {
1290        assert!(super::load_config_from_str("").tui_background_opaque);
1291        assert!(
1292            super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
1293        );
1294        assert!(
1295            !super::load_config_from_str("tui_background_opaque = invisible\n")
1296                .tui_background_opaque
1297        );
1298        let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
1299        assert!(invalid.tui_background_opaque);
1300        assert!(invalid
1301            .warnings
1302            .iter()
1303            .any(|w| w.contains("tui_background_opaque")));
1304    }
1305
1306    #[test]
1307    fn turn_budget_keys_parse_typed_per_role() {
1308        let config = super::load_config_from_str(
1309            "turn_budget.worker.max_provider_rounds = 3\n\
1310             turn_budget.worker.max_cost_usd = 0.25\n\
1311             turn_budget.autonomous.max_elapsed_secs = 90\n\
1312             turn_budget.foreground.max_tool_calls = 7\n\
1313             turn_budget.foreground.max_round_renewals = 12\n\
1314             turn_budget.worker.max_provider_rounds_bogus = 1\n\
1315             turn_budget.nosuchrole.max_tool_calls = 1\n",
1316        );
1317        assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
1318        assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
1319        assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
1320        assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
1321        assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
1322        // Unknown fields/roles warn and change nothing.
1323        assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
1324        // Invalid values keep the default (None).
1325        let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
1326        assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
1327    }
1328
1329    #[test]
1330    fn memory_keys_all_parse_with_consent() {
1331        let config = super::load_config_from_str(
1332            "memory.default_mode = capture_and_recall\n\
1333             memory.default_mode_confirmed = true\n\
1334             memory.recall_max_records = 12\n\
1335             memory.recall_max_tokens = 2048\n\
1336             memory.recall_timeout_ms = 250\n\
1337             memory.capture_tools = off\n\
1338             memory.capture_assistant = false\n\
1339             memory.capture_user = false\n\
1340             memory.auto_consolidate = on\n\
1341             memory.local_embeddings = on\n\
1342             memory.gliner = on\n",
1343        );
1344        assert_eq!(config.memory.default_mode, "capture_and_recall");
1345        assert!(config.memory.default_mode_confirmed);
1346        assert_eq!(config.memory.recall_max_records, 12);
1347        assert_eq!(config.memory.recall_max_tokens, 2048);
1348        assert_eq!(config.memory.recall_timeout_ms, 250);
1349        assert_eq!(config.memory.capture_tools, "off");
1350        assert!(!config.memory.capture_assistant);
1351        assert!(!config.memory.capture_user);
1352        assert_eq!(config.memory.auto_consolidate, "on");
1353        assert_eq!(config.memory.local_embeddings, "on");
1354        assert_eq!(config.memory.gliner, "on");
1355    }
1356
1357    #[test]
1358    fn memory_defaults_match_spec_12() {
1359        let config = super::load_config_from_str("");
1360        assert_eq!(config.memory, super::MemoryConfig::default());
1361        assert_eq!(config.memory.default_mode, "off");
1362        assert!(!config.memory.default_mode_confirmed);
1363        assert_eq!(config.memory.recall_max_records, 8);
1364        assert_eq!(config.memory.recall_max_tokens, 4096);
1365        assert_eq!(config.memory.recall_timeout_ms, 150);
1366        assert_eq!(config.memory.capture_tools, "summary_only");
1367        assert!(config.memory.capture_assistant);
1368        assert!(config.memory.capture_user);
1369        assert_eq!(config.memory.auto_consolidate, "off");
1370        assert_eq!(config.memory.local_embeddings, "off");
1371        assert_eq!(config.memory.gliner, "off");
1372    }
1373
1374    #[test]
1375    fn memory_unknown_values_fail_closed_to_defaults() {
1376        let config = super::load_config_from_str(
1377            "memory.default_mode = recall_everything_forever\n\
1378             memory.default_mode_confirmed = true\n\
1379             memory.recall_max_records = lots\n\
1380             memory.recall_max_tokens = -5\n\
1381             memory.recall_timeout_ms = fast\n\
1382             memory.capture_tools = full\n\
1383             memory.capture_assistant = maybe\n\
1384             memory.capture_user = sometimes\n\
1385             memory.auto_consolidate = aggressive\n\
1386             memory.local_embeddings = auto\n\
1387             memory.gliner = download\n\
1388             memory.nosuchfield = 1\n",
1389        );
1390        // Unrecognised mode string must stay "off" — never a non-off mode.
1391        assert_eq!(config.memory.default_mode, "off");
1392        assert_eq!(config.memory.recall_max_records, 8);
1393        assert_eq!(config.memory.recall_max_tokens, 4096);
1394        assert_eq!(config.memory.recall_timeout_ms, 150);
1395        assert_eq!(config.memory.capture_tools, "summary_only");
1396        assert!(config.memory.capture_assistant);
1397        assert!(config.memory.capture_user);
1398        assert_eq!(config.memory.auto_consolidate, "off");
1399        assert_eq!(config.memory.local_embeddings, "off");
1400        assert_eq!(config.memory.gliner, "off");
1401    }
1402
1403    #[test]
1404    fn memory_default_mode_without_consent_reverts_to_off() {
1405        let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
1406        assert_eq!(config.memory.default_mode, "off");
1407        assert!(
1408            config
1409                .warnings
1410                .iter()
1411                .any(|w| w.contains("memory.default_mode_confirmed")),
1412            "reversion must surface a warning; got: {:?}",
1413            config.warnings
1414        );
1415
1416        // Explicit false consent is not consent.
1417        let denied = super::load_config_from_str(
1418            "memory.default_mode = capture_only\n\
1419             memory.default_mode_confirmed = false\n",
1420        );
1421        assert_eq!(denied.memory.default_mode, "off");
1422
1423        // Garbage consent value fails closed — not consent.
1424        let garbage = super::load_config_from_str(
1425            "memory.default_mode = capture_only\n\
1426             memory.default_mode_confirmed = definitely\n",
1427        );
1428        assert_eq!(garbage.memory.default_mode, "off");
1429    }
1430
1431    #[test]
1432    fn memory_consent_is_order_independent() {
1433        // Consent key before the mode key.
1434        let before = super::load_config_from_str(
1435            "memory.default_mode_confirmed = true\n\
1436             memory.default_mode = recall_once\n",
1437        );
1438        assert_eq!(before.memory.default_mode, "recall_once");
1439        // Consent key after the mode key.
1440        let after = super::load_config_from_str(
1441            "memory.default_mode = recall_once\n\
1442             memory.default_mode_confirmed = true\n",
1443        );
1444        assert_eq!(after.memory.default_mode, "recall_once");
1445    }
1446
1447    #[test]
1448    fn memory_config_surface_is_closed_by_construction() {
1449        // Exhaustive struct literal — no `..Default::default()` spread. This
1450        // fails to COMPILE if any field is added to or removed from
1451        // MemoryConfig, pinning the exact memory.* config surface. Secret,
1452        // retention, and project-scope semantics have no field here, so no
1453        // `memory.secret*` / `memory.retention*` / `memory.project*` config
1454        // key can exist (spec §12: those rules are not configurable to fail
1455        // open). Asserted by construction, not by test disproof.
1456        let _closed_surface = super::MemoryConfig {
1457            default_mode: "off".to_string(),
1458            default_mode_confirmed: false,
1459            recall_max_records: 8,
1460            recall_max_tokens: 4096,
1461            recall_timeout_ms: 150,
1462            capture_tools: "summary_only".to_string(),
1463            capture_assistant: true,
1464            capture_user: true,
1465            auto_consolidate: "off".to_string(),
1466            local_embeddings: "off".to_string(),
1467            gliner: "off".to_string(),
1468        };
1469    }
1470
1471    use super::*;
1472    use serial_test::serial;
1473
1474    #[test]
1475    fn test_levenshtein_basics() {
1476        assert_eq!(levenshtein("model", "model"), 0);
1477        assert_eq!(levenshtein("modle", "model"), 2);
1478        assert_eq!(levenshtein("them", "theme"), 1);
1479    }
1480
1481    #[test]
1482    fn test_did_you_mean_close_typos() {
1483        assert_eq!(did_you_mean("modle"), Some("model"));
1484        assert_eq!(did_you_mean("them"), Some("theme"));
1485        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
1486        assert_eq!(did_you_mean("completely_unrelated_key"), None);
1487    }
1488
1489    #[test]
1490    #[serial]
1491    fn test_config_warnings_unknown_key_and_bad_values() {
1492        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
1493        let dir = home.join(".synaps-cli");
1494        std::fs::create_dir_all(&dir).unwrap();
1495        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();
1496
1497        with_home(&home, || {
1498            let config = load_config();
1499            // Dotted (namespaced) keys must NOT warn — plugins own those.
1500            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
1501            assert!(
1502                !config.warnings.iter().any(|w| w.contains("knowledge")),
1503                "{:?}",
1504                config.warnings
1505            );
1506            assert!(
1507                config
1508                    .warnings
1509                    .iter()
1510                    .any(|w| w.contains("did you mean 'model'")),
1511                "{:?}",
1512                config.warnings
1513            );
1514            assert!(
1515                config.warnings.iter().any(|w| w.contains("thinking")),
1516                "{:?}",
1517                config.warnings
1518            );
1519            assert!(
1520                config.warnings.iter().any(|w| w.contains("below minimum")),
1521                "{:?}",
1522                config.warnings
1523            );
1524            // Bad values fall back to defaults
1525            assert_eq!(config.bash_timeout, 30);
1526            assert_eq!(config.thinking_budget, None);
1527        });
1528        let _ = std::fs::remove_dir_all(&home);
1529    }
1530
1531    // ── cache_ttl parse table (spec §3.1) ──
1532
1533    #[test]
1534    fn test_cache_ttl_parse_table() {
1535        // 5m aliases
1536        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
1537        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
1538        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
1539        // 1h aliases
1540        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
1541        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
1542        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
1543        // hybrid
1544        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
1545        // case-insensitive
1546        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
1547        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
1548        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
1549        // garbage → None (caller warns + defaults)
1550        assert_eq!(CacheTtl::parse("2h"), None);
1551        assert_eq!(CacheTtl::parse(""), None);
1552        assert_eq!(CacheTtl::parse("forever"), None);
1553    }
1554
1555    #[test]
1556    fn test_cache_ttl_default_is_five_minutes() {
1557        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
1558        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
1559    }
1560
1561    #[test]
1562    fn test_max_fps_default_is_60() {
1563        assert_eq!(SynapsConfig::default().max_fps, 60);
1564    }
1565
1566    // ── theme_transition parse table (animated theme transitions) ──
1567
1568    #[test]
1569    fn test_theme_transition_parse_table() {
1570        use ThemeTransitionMode as M;
1571        assert_eq!(M::parse("on"), Some(M::On));
1572        assert_eq!(M::parse("ON"), Some(M::On));
1573        assert_eq!(M::parse("off"), Some(M::Off));
1574        assert_eq!(M::parse("Off"), Some(M::Off));
1575        // integer ms, clamped to 0..=2000
1576        assert_eq!(M::parse("500"), Some(M::Ms(500)));
1577        assert_eq!(M::parse("0"), Some(M::Ms(0)));
1578        assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
1579        assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
1580        // garbage → None (caller warns + keeps default)
1581        assert_eq!(M::parse("fast"), None);
1582        assert_eq!(M::parse(""), None);
1583        assert_eq!(M::parse("-5"), None);
1584        assert_eq!(M::parse("1.5"), None);
1585    }
1586
1587    #[test]
1588    fn test_theme_transition_duration_ms() {
1589        use ThemeTransitionMode as M;
1590        assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
1591        assert_eq!(M::DEFAULT_MS, 350);
1592        assert_eq!(M::Off.duration_ms(), 0);
1593        assert_eq!(M::Ms(120).duration_ms(), 120);
1594        assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
1595        assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
1596    }
1597
1598    #[test]
1599    fn test_theme_transition_config_key_parses_and_defaults() {
1600        use ThemeTransitionMode as M;
1601        assert_eq!(SynapsConfig::default().theme_transition, M::On);
1602        assert_eq!(
1603            super::load_config_from_str("theme_transition = off\n").theme_transition,
1604            M::Off
1605        );
1606        assert_eq!(
1607            super::load_config_from_str("theme_transition = 750\n").theme_transition,
1608            M::Ms(750)
1609        );
1610        let bad = super::load_config_from_str("theme_transition = warp\n");
1611        assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
1612        assert!(
1613            bad.warnings.iter().any(|w| w.contains("theme_transition")),
1614            "{:?}",
1615            bad.warnings
1616        );
1617    }
1618
1619    #[test]
1620    #[serial]
1621    fn test_max_fps_config_parse_and_validation() {
1622        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
1623        let dir = home.join(".synaps-cli");
1624        std::fs::create_dir_all(&dir).unwrap();
1625
1626        // Valid high-refresh value parses, no warning.
1627        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
1628        with_home(&home, || {
1629            let config = load_config();
1630            assert_eq!(config.max_fps, 144);
1631            assert!(
1632                config.warnings.is_empty(),
1633                "warnings: {:?}",
1634                config.warnings
1635            );
1636        });
1637
1638        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
1639        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
1640        with_home(&home, || {
1641            let config = load_config();
1642            assert_eq!(config.max_fps, 60);
1643            assert!(
1644                config.warnings.iter().any(|w| w.contains("max_fps")),
1645                "warnings: {:?}",
1646                config.warnings
1647            );
1648        });
1649
1650        // Non-numeric → default 60 + warning.
1651        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
1652        with_home(&home, || {
1653            let config = load_config();
1654            assert_eq!(config.max_fps, 60);
1655            assert!(
1656                config.warnings.iter().any(|w| w.contains("max_fps")),
1657                "warnings: {:?}",
1658                config.warnings
1659            );
1660        });
1661
1662        let _ = std::fs::remove_dir_all(&home);
1663    }
1664
1665    #[test]
1666    #[serial]
1667    fn test_cache_ttl_config_parse_and_garbage_warning() {
1668        let home =
1669            std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
1670        let dir = home.join(".synaps-cli");
1671        std::fs::create_dir_all(&dir).unwrap();
1672
1673        // Valid value parses, no warning.
1674        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
1675        with_home(&home, || {
1676            let config = load_config();
1677            assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
1678            assert!(
1679                config.warnings.is_empty(),
1680                "warnings: {:?}",
1681                config.warnings
1682            );
1683        });
1684
1685        // Garbage value → 5m default + boot warning (never blocks boot).
1686        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
1687        with_home(&home, || {
1688            let config = load_config();
1689            assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
1690            assert!(
1691                config.warnings.iter().any(|w| w.contains("cache_ttl")),
1692                "warnings: {:?}",
1693                config.warnings
1694            );
1695        });
1696
1697        let _ = std::fs::remove_dir_all(&home);
1698    }
1699
1700    #[test]
1701    fn test_parse_thinking_budget() {
1702        use crate::core::reasoning::ThinkingSpec;
1703        // ThinkingSpec::parse replaced parse_thinking_budget; verify budget values.
1704        assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
1705        assert_eq!(
1706            ThinkingSpec::parse("medium").unwrap().to_budget(),
1707            Some(4096)
1708        );
1709        assert_eq!(
1710            ThinkingSpec::parse("high").unwrap().to_budget(),
1711            Some(16384)
1712        );
1713        assert_eq!(
1714            ThinkingSpec::parse("xhigh").unwrap().to_budget(),
1715            Some(32768)
1716        );
1717        assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
1718        let config = load_config_from_str("thinking = 8192\n");
1719        assert_eq!(config.thinking_level, None);
1720        assert_eq!(config.thinking_budget, Some(8192));
1721        assert_eq!(ThinkingSpec::parse("invalid"), None);
1722    }
1723
1724    #[test]
1725    fn test_base_dir() {
1726        let path = base_dir();
1727        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
1728    }
1729
1730    #[test]
1731    fn test_resolve_system_prompt_explicit() {
1732        let result = resolve_system_prompt(Some("test prompt"));
1733        assert_eq!(result, "test prompt");
1734    }
1735
1736    #[test]
1737    fn test_resolve_system_prompt_none() {
1738        let result = resolve_system_prompt(None);
1739        assert!(result.contains("helpful AI agent"));
1740    }
1741
1742    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
1743    // is not thread-safe and races with shell config tests. Coverage provided
1744    // by shell::config::tests::test_shell_config_from_file.
1745
1746    #[test]
1747    fn test_synaps_config_default() {
1748        let config = SynapsConfig::default();
1749        assert_eq!(config.model, None);
1750        assert_eq!(config.thinking_budget, None);
1751        assert_eq!(config.max_tool_output, 30000);
1752        assert_eq!(config.bash_timeout, 30);
1753        assert_eq!(config.bash_max_timeout, 300);
1754        assert_eq!(config.subagent_timeout, 300);
1755        assert_eq!(config.api_retries, 3);
1756        assert_eq!(config.theme, None);
1757        assert!(config.disabled_plugins.is_empty());
1758        assert!(config.favorite_models.is_empty());
1759        assert!(config.disabled_skills.is_empty());
1760        assert!(!config.progressive_tool_disclosure);
1761        assert_eq!(config.shell.max_sessions, 5);
1762        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
1763        // Server config defaults
1764        assert!(config.server.allowed_origins.is_empty());
1765        assert_eq!(config.server.token, None);
1766        assert!(!config.server.auto_approve_confirms);
1767        assert_eq!(config.server.max_message_size, None);
1768        // Bridge config defaults
1769        assert!(config.bridge.uds_path.is_none());
1770        assert!(!config.bridge.heartbeat_mirror);
1771        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1772    }
1773
1774    #[test]
1775    #[serial]
1776    fn test_load_config_progressive_tool_disclosure_is_opt_in() {
1777        let home = make_test_home("progressive-tool-disclosure");
1778        let cfg = home.join(".synaps-cli/config");
1779        std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
1780
1781        with_home(&home, || {
1782            let config = load_config();
1783            assert!(config.progressive_tool_disclosure);
1784            assert!(config.warnings.is_empty());
1785        });
1786
1787        let _ = std::fs::remove_dir_all(&home);
1788    }
1789
1790    #[test]
1791    #[serial]
1792    fn test_load_config_bridge_keys() {
1793        let home = make_test_home("bridge-keys");
1794        let cfg = home.join(".synaps-cli/config");
1795        std::fs::write(
1796            &cfg,
1797            "\
1798bridge.uds_path = /tmp/some/control.sock\n\
1799bridge.heartbeat_mirror = true\n\
1800bridge.heartbeat_timeout_ms = 750\n\
1801",
1802        )
1803        .unwrap();
1804
1805        with_home(&home, || {
1806            let config = load_config();
1807            assert_eq!(
1808                config.bridge.uds_path,
1809                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
1810            );
1811            assert!(config.bridge.heartbeat_mirror);
1812            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
1813            assert_eq!(
1814                config.bridge.resolved_uds_path(),
1815                std::path::PathBuf::from("/tmp/some/control.sock"),
1816            );
1817        });
1818
1819        let _ = std::fs::remove_dir_all(&home);
1820    }
1821
1822    #[test]
1823    fn test_bridge_config_defaults() {
1824        let cfg = BridgeConfig::default();
1825        assert!(cfg.uds_path.is_none());
1826        assert!(!cfg.heartbeat_mirror);
1827        assert_eq!(cfg.heartbeat_timeout_ms, 250);
1828        // resolved path falls under base_dir()/bridge/control.sock
1829        let resolved = cfg.resolved_uds_path();
1830        assert!(resolved.ends_with("bridge/control.sock"));
1831    }
1832
1833    #[test]
1834    #[serial]
1835    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
1836        let home = make_test_home("bridge-default-off");
1837        let cfg = home.join(".synaps-cli/config");
1838        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
1839
1840        with_home(&home, || {
1841            let config = load_config();
1842            assert!(!config.bridge.heartbeat_mirror);
1843            assert!(config.bridge.uds_path.is_none());
1844            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
1845        });
1846
1847        let _ = std::fs::remove_dir_all(&home);
1848    }
1849
1850    #[test]
1851    fn compaction_disclosure_keys_parse_with_typed_warnings() {
1852        let config = load_config_from_str(
1853            "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
1854        );
1855        assert_eq!(
1856            config.compaction_mode,
1857            crate::core::compaction::CompactionMode::LocalOnly
1858        );
1859        assert_eq!(
1860            config.compaction_exclude,
1861            vec![
1862                crate::core::compaction::ContentClass::Thinking,
1863                crate::core::compaction::ContentClass::ToolResults,
1864            ]
1865        );
1866        assert!(
1867            config.warnings.iter().any(|w| w.contains("bogus")),
1868            "unknown class must warn: {:?}",
1869            config.warnings
1870        );
1871
1872        let defaults = load_config_from_str("");
1873        assert_eq!(
1874            defaults.compaction_mode,
1875            crate::core::compaction::CompactionMode::Remote
1876        );
1877        assert!(defaults.compaction_exclude.is_empty());
1878
1879        let bad_mode = load_config_from_str("compaction_mode = cloud\n");
1880        assert_eq!(
1881            bad_mode.compaction_mode,
1882            crate::core::compaction::CompactionMode::Remote
1883        );
1884        assert!(bad_mode
1885            .warnings
1886            .iter()
1887            .any(|w| w.contains("compaction_mode")));
1888    }
1889
1890    #[test]
1891    #[serial]
1892    fn test_load_config_server_keys() {
1893        let home = make_test_home("server-keys");
1894        let cfg = home.join(".synaps-cli/config");
1895        std::fs::write(
1896            &cfg,
1897            "\
1898server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
1899server.token = my-secret-token\n\
1900server.auto_approve_confirms = true\n\
1901server.max_message_size = 65536\n\
1902context_window = 200k\n\
1903",
1904        )
1905        .unwrap();
1906
1907        with_home(&home, || {
1908            let config = load_config();
1909            assert_eq!(
1910                config.server.allowed_origins,
1911                vec![
1912                    "http://localhost:3000".to_string(),
1913                    "http://localhost:5193".to_string(),
1914                ]
1915            );
1916            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
1917            assert!(config.server.auto_approve_confirms);
1918            // Explicit max_message_size takes precedence over context_window derivation
1919            assert_eq!(config.server.max_message_size, Some(65536));
1920        });
1921
1922        let _ = std::fs::remove_dir_all(&home);
1923    }
1924
1925    #[test]
1926    #[serial]
1927    fn test_server_max_message_size_derived_from_context_window() {
1928        let home = make_test_home("server-derive");
1929        let cfg = home.join(".synaps-cli/config");
1930        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
1931
1932        with_home(&home, || {
1933            let config = load_config();
1934            // 200_000 tokens * 4 bytes/token = 800_000 bytes
1935            assert_eq!(config.server.max_message_size, Some(800_000));
1936        });
1937
1938        let _ = std::fs::remove_dir_all(&home);
1939    }
1940
1941    fn make_test_home(subdir: &str) -> std::path::PathBuf {
1942        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
1943        let _ = std::fs::remove_dir_all(&dir);
1944        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
1945        dir
1946    }
1947
1948    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
1949        let original = std::env::var("HOME").ok();
1950        std::env::set_var("HOME", home);
1951        f();
1952        if let Some(h) = original {
1953            std::env::set_var("HOME", h);
1954        } else {
1955            std::env::remove_var("HOME");
1956        }
1957    }
1958
1959    #[test]
1960    #[serial]
1961    fn write_config_value_replaces_existing_key() {
1962        let home = make_test_home("replace");
1963        let cfg = home.join(".synaps-cli/config");
1964        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
1965
1966        with_home(&home, || {
1967            write_config_value("model", "claude-sonnet-4-6").unwrap();
1968        });
1969
1970        let contents = std::fs::read_to_string(&cfg).unwrap();
1971        assert!(contents.contains("model = claude-sonnet-4-6"));
1972        assert!(contents.contains("thinking = low"));
1973        let _ = std::fs::remove_dir_all(&home);
1974    }
1975
1976    #[test]
1977    #[serial]
1978    fn write_config_value_appends_when_missing() {
1979        let home = make_test_home("append");
1980        let cfg = home.join(".synaps-cli/config");
1981        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
1982
1983        with_home(&home, || {
1984            write_config_value("theme", "dracula").unwrap();
1985        });
1986
1987        let contents = std::fs::read_to_string(&cfg).unwrap();
1988        assert!(contents.contains("model = claude-opus-4-6"));
1989        assert!(contents.contains("theme = dracula"));
1990        let _ = std::fs::remove_dir_all(&home);
1991    }
1992
1993    #[test]
1994    #[serial]
1995    fn write_config_value_preserves_comments() {
1996        let home = make_test_home("comments");
1997        let cfg = home.join(".synaps-cli/config");
1998        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
1999
2000        with_home(&home, || {
2001            write_config_value("model", "claude-sonnet-4-6").unwrap();
2002        });
2003
2004        let contents = std::fs::read_to_string(&cfg).unwrap();
2005        assert!(contents.contains("# user comment"));
2006        assert!(contents.contains("# another"));
2007        assert!(contents.contains("model = claude-sonnet-4-6"));
2008        let _ = std::fs::remove_dir_all(&home);
2009    }
2010
2011    #[test]
2012    #[serial]
2013    fn write_config_value_preserves_unknown_keys() {
2014        let home = make_test_home("unknown");
2015        let cfg = home.join(".synaps-cli/config");
2016        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
2017
2018        with_home(&home, || {
2019            write_config_value("model", "claude-sonnet-4-6").unwrap();
2020        });
2021
2022        let contents = std::fs::read_to_string(&cfg).unwrap();
2023        assert!(contents.contains("custom_thing = 42"));
2024        let _ = std::fs::remove_dir_all(&home);
2025    }
2026
2027    #[test]
2028    #[serial]
2029    fn write_config_value_creates_file_if_absent() {
2030        let home = make_test_home("create");
2031        let cfg = home.join(".synaps-cli/config");
2032        assert!(!cfg.exists());
2033
2034        with_home(&home, || {
2035            write_config_value("model", "claude-sonnet-4-6").unwrap();
2036        });
2037
2038        let contents = std::fs::read_to_string(&cfg).unwrap();
2039        assert!(contents.contains("model = claude-sonnet-4-6"));
2040        let _ = std::fs::remove_dir_all(&home);
2041    }
2042
2043    #[test]
2044    #[serial]
2045    fn load_config_parses_theme_key() {
2046        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
2047        let _ = std::fs::create_dir_all(&dir);
2048        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
2049
2050        let original_home = std::env::var("HOME").ok();
2051        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
2052
2053        let config = load_config();
2054
2055        if let Some(home) = original_home {
2056            std::env::set_var("HOME", home);
2057        } else {
2058            std::env::remove_var("HOME");
2059        }
2060        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
2061
2062        assert_eq!(config.theme.as_deref(), Some("dracula"));
2063    }
2064
2065    #[test]
2066    #[serial]
2067    fn test_load_config_disable_lists() {
2068        let test_dir =
2069            std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
2070        let _ = std::fs::create_dir_all(&test_dir);
2071        let config_path = test_dir.join("config");
2072
2073        let config_content = r#"
2074# Test config with disable lists
2075favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
2076
2077disabled_plugins = foo, bar
2078disabled_skills = baz, plug:qual
2079"#;
2080        std::fs::write(&config_path, config_content).unwrap();
2081
2082        let original_home = std::env::var("HOME").ok();
2083        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
2084
2085        let config = load_config();
2086
2087        if let Some(home) = original_home {
2088            std::env::set_var("HOME", home);
2089        } else {
2090            std::env::remove_var("HOME");
2091        }
2092
2093        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
2094
2095        assert_eq!(
2096            config.disabled_plugins,
2097            vec!["foo".to_string(), "bar".to_string()]
2098        );
2099        assert_eq!(
2100            config.favorite_models,
2101            vec![
2102                "claude/claude-opus-4-7".to_string(),
2103                "groq/llama-3.3-70b-versatile".to_string(),
2104            ]
2105        );
2106        assert_eq!(
2107            config.disabled_skills,
2108            vec!["baz".to_string(), "plug:qual".to_string()]
2109        );
2110    }
2111
2112    #[test]
2113    #[serial]
2114    fn favorite_model_helpers_round_trip_through_config_file() {
2115        let home = make_test_home("favorite-models");
2116        let cfg = home.join(".synaps-cli/config");
2117        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
2118
2119        with_home(&home, || {
2120            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2121            add_favorite_model("claude/claude-opus-4-7").unwrap();
2122            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2123            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
2124            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
2125            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
2126            assert!(is_favorite_model("claude/claude-opus-4-7"));
2127        });
2128
2129        let contents = std::fs::read_to_string(&cfg).unwrap();
2130        assert!(contents.contains("model = claude-opus-4-7"));
2131        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
2132        let _ = std::fs::remove_dir_all(&home);
2133    }
2134
2135    #[test]
2136    #[serial]
2137    fn test_load_config_new_keys() {
2138        // Create a temporary config directory with the new keys
2139        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
2140        let _ = std::fs::create_dir_all(&test_dir);
2141        let config_path = test_dir.join("config");
2142
2143        let config_content = r#"
2144# Test config with new keys
2145model = claude-haiku
2146thinking = medium
2147max_tool_output = 50000
2148bash_timeout = 45
2149bash_max_timeout = 600
2150subagent_timeout = 120
2151api_retries = 5
2152"#;
2153        std::fs::write(&config_path, config_content).unwrap();
2154
2155        // Temporarily override the config path for this test
2156        let original_home = std::env::var("HOME").ok();
2157        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
2158
2159        let config = load_config();
2160
2161        // Restore original HOME
2162        if let Some(home) = original_home {
2163            std::env::set_var("HOME", home);
2164        } else {
2165            std::env::remove_var("HOME");
2166        }
2167
2168        // Cleanup
2169        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
2170
2171        assert_eq!(config.model, Some("claude-haiku".to_string()));
2172        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
2173        assert_eq!(config.max_tool_output, 50000);
2174        assert_eq!(config.bash_timeout, 45);
2175        assert_eq!(config.bash_max_timeout, 600);
2176        assert_eq!(config.subagent_timeout, 120);
2177        assert_eq!(config.api_retries, 5);
2178    }
2179
2180    // ── auth.* config + credential source (#157) ──
2181
2182    // ── events.auto_turn parser ──────────────────────────────────────────────
2183
2184    #[test]
2185    fn events_auto_turn_explicit_true_values() {
2186        for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
2187            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2188            assert!(
2189                cfg.events.auto_turn,
2190                "expected true for events.auto_turn = {val}"
2191            );
2192        }
2193    }
2194
2195    #[test]
2196    fn events_auto_turn_explicit_false_values() {
2197        for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
2198            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
2199            assert!(
2200                !cfg.events.auto_turn,
2201                "expected false for events.auto_turn = {val}"
2202            );
2203        }
2204    }
2205
2206    #[test]
2207    fn events_auto_turn_typo_fails_safe_false() {
2208        // Unrecognised value should fail safe to false (with a warning on stderr).
2209        let cfg = load_config_from_str("events.auto_turn = fales");
2210        assert!(
2211            !cfg.events.auto_turn,
2212            "typo 'fales' must fail safe to false"
2213        );
2214    }
2215
2216    #[test]
2217    fn events_auto_turn_default_is_true() {
2218        let cfg = load_config_from_str("model = claude-haiku");
2219        assert!(cfg.events.auto_turn, "default must be true when key absent");
2220    }
2221
2222    #[test]
2223    #[serial]
2224    fn test_auth_config_parses_endpoint_and_token() {
2225        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
2226        let dir = home.join(".synaps-cli");
2227        std::fs::create_dir_all(&dir).unwrap();
2228        std::fs::write(
2229            dir.join("config"),
2230            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
2231        )
2232        .unwrap();
2233        with_home(&home, || {
2234            let config = load_config();
2235            assert_eq!(
2236                config.auth.remote_endpoint.as_deref(),
2237                Some("https://jade.jade:8181")
2238            );
2239            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
2240            // auth.* are namespaced -> must NOT warn as unknown keys
2241            assert!(
2242                !config.warnings.iter().any(|w| w.contains("auth.")),
2243                "{:?}",
2244                config.warnings
2245            );
2246        });
2247        let _ = std::fs::remove_dir_all(&home);
2248    }
2249
2250    #[test]
2251    #[serial]
2252    fn test_credential_source_local_by_default() {
2253        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2254        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2255        assert!(!AuthConfig::default().credential_source().is_remote());
2256    }
2257
2258    #[test]
2259    #[serial]
2260    fn test_credential_source_remote_from_config() {
2261        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2262        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2263        let auth = AuthConfig {
2264            remote_endpoint: Some("https://b".into()),
2265            machine_token: Some("m".into()),
2266        };
2267        assert_eq!(
2268            auth.credential_source(),
2269            crate::core::auth::CredentialSource::Remote {
2270                endpoint: "https://b".into(),
2271                machine_token: "m".into()
2272            }
2273        );
2274    }
2275
2276    #[test]
2277    #[serial]
2278    fn test_credential_source_env_overrides_config() {
2279        let auth = AuthConfig {
2280            remote_endpoint: Some("https://config-host".into()),
2281            machine_token: Some("config-tok".into()),
2282        };
2283        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
2284        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
2285        let src = auth.credential_source();
2286        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
2287        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
2288        assert_eq!(
2289            src,
2290            crate::core::auth::CredentialSource::Remote {
2291                endpoint: "https://env-host".into(),
2292                machine_token: "env-tok".into()
2293            }
2294        );
2295    }
2296    #[test]
2297    fn config_parses_ultracode_as_distinct_canonical_level() {
2298        let cfg = load_config_from_str("thinking = ultracode\n");
2299        assert_eq!(
2300            cfg.thinking_level,
2301            Some(crate::reasoning::ReasoningLevel::UltraCode)
2302        );
2303        assert_eq!(cfg.thinking_budget, None);
2304        for other in [
2305            crate::reasoning::ReasoningLevel::Ultra,
2306            crate::reasoning::ReasoningLevel::Max,
2307            crate::reasoning::ReasoningLevel::XHigh,
2308        ] {
2309            assert_ne!(cfg.thinking_level, Some(other));
2310        }
2311    }
2312}