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