Skip to main content

lean_ctx/core/config/
sections.rs

1//! Auxiliary configuration section structs.
2//!
3//! Nested config structs (secret-detection, setup, archive, providers,
4//! autonomy, updates, cloud, gain, loop-detection, embedding, …) split out of
5//! `config/mod.rs` to keep the top-level module focused on `Config` itself.
6//! Re-exported via `pub use sections::*`, so external paths stay stable.
7
8use super::serde_defaults;
9#[allow(clippy::wildcard_imports)]
10use super::*;
11use serde::{Deserialize, Serialize};
12
13/// Opt-in configuration for counterfactual Shadow Mode reports.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(default)]
16pub struct ShadowConfig {
17    pub enabled: bool,
18    pub report_interval: usize,
19    pub report_dir: String,
20}
21
22impl Default for ShadowConfig {
23    fn default() -> Self {
24        Self {
25            enabled: false,
26            report_interval: 10,
27            report_dir: String::new(),
28        }
29    }
30}
31
32/// OCLA settings (`[ocla.delivery]`). The former `[ocla.sidecar]` and
33/// `[ocla.grpc]` sections started no server and are ignored when present.
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35#[serde(default)]
36pub struct OclaConfig {
37    pub delivery: DeliveryConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(default)]
42pub struct DeliveryConfig {
43    pub enabled: bool,
44    /// Allow subagents to receive a cross-agent delivery stub instead of
45    /// forcing a fresh disk read.
46    pub delivery_for_subagents: bool,
47    pub max_entries: usize,
48    pub ttl_minutes: u64,
49}
50
51impl Default for DeliveryConfig {
52    fn default() -> Self {
53        Self {
54            enabled: true,
55            delivery_for_subagents: true,
56            max_entries: 4096,
57            ttl_minutes: 30,
58        }
59    }
60}
61
62impl OclaConfig {
63    pub fn delivery_enabled(&self) -> bool {
64        self.delivery.enabled
65    }
66}
67
68/// Bounds and switches for the cross-agent delivery cache (`[cache]`).
69///
70/// The one source for these values: the former copy under
71/// `[ocla.delivery.cache]` is moved here by the automatic config migration.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(default)]
74pub struct CacheConfig {
75    /// Process-local tier lifetime (seconds).
76    pub l1_ttl_secs: u64,
77    /// Daemon tier size (entries).
78    pub l2_max_entries: usize,
79    /// Daemon tier lifetime (seconds).
80    pub l2_ttl_secs: u64,
81    /// Disk tier budget (bytes), enforced when the cache opens.
82    pub l3_max_bytes: u64,
83    /// Reuse a `ctx_compose` result for the same task and path.
84    pub compose_cache_enabled: bool,
85}
86
87impl Default for CacheConfig {
88    fn default() -> Self {
89        Self {
90            l1_ttl_secs: 300,
91            l2_max_entries: 10_000,
92            l2_ttl_secs: 3_600,
93            l3_max_bytes: 500_000_000,
94            compose_cache_enabled: true,
95        }
96    }
97}
98
99#[cfg(test)]
100mod cache_config_tests {
101    use super::CacheConfig;
102
103    #[test]
104    fn cache_defaults_match_delivery_budget() {
105        assert_eq!(CacheConfig::default().l3_max_bytes, 500_000_000);
106        assert!(CacheConfig::default().compose_cache_enabled);
107    }
108
109    #[test]
110    fn cache_config_deserializes_partial_overrides() {
111        // `shell_cache_enabled` was removed (#1980); configs that still set it
112        // must keep loading.
113        // `l1_max_entries` and `l3_gc_threshold` were retired (never applied).
114        let parsed: CacheConfig = serde_json::from_str(
115            r#"{"l1_max_entries": 12, "l3_gc_threshold": 0.5, "shell_cache_enabled": true, "l2_ttl_secs": 60}"#,
116        )
117        .unwrap();
118        assert_eq!(parsed.l2_ttl_secs, 60);
119        assert_eq!(parsed.l2_max_entries, 10_000);
120    }
121}
122
123/// Agent lifecycle configuration: TTLs, GC intervals, scratchpad limits.
124///
125/// Maps to `[agents]` in config.toml. All fields have sane defaults so existing
126/// configs without this section continue to work.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(default)]
129pub struct AgentsConfig {
130    /// How often the background reaper runs (minutes). 0 = disabled.
131    pub gc_interval_minutes: u64,
132    /// Presence registry: remove finished agents older than this (hours).
133    pub presence_ttl_hours: u64,
134    /// Default TTL for scratchpad messages without explicit expiry (hours).
135    pub scratchpad_default_ttl_hours: u64,
136    /// Logical session timeout (seconds).
137    pub logical_session_ttl_seconds: u64,
138    /// Active worker lease. Missing heartbeats release capacity automatically.
139    pub active_worker_lease_seconds: u64,
140    /// Opt-in: let only one cargo/npm build or test run at a time across all
141    /// lean-ctx sessions on this machine (ctx_shell waits for the slot).
142    /// Off by default — it queued every user's builds behind other sessions.
143    pub serialize_build_commands: bool,
144    /// Opt-in: `CARGO_BUILD_JOBS` for builds run through ctx_shell. `0`
145    /// (default) leaves cargo's own setting alone.
146    pub cargo_build_jobs: usize,
147    /// Opt-in: point builds run through ctx_shell at one machine-wide
148    /// `CARGO_TARGET_DIR` (`<data dir>/build-cache/cargo-target`). Off by
149    /// default: when on, `./target` stops receiving the output, so a binary
150    /// run from there is stale.
151    pub shared_cargo_target: bool,
152    /// Max scratchpad entries before oldest are evicted.
153    pub max_scratchpad_entries: usize,
154}
155
156impl Default for AgentsConfig {
157    fn default() -> Self {
158        Self {
159            gc_interval_minutes: 10,
160            presence_ttl_hours: 24,
161            scratchpad_default_ttl_hours: 12,
162            logical_session_ttl_seconds: 180,
163            active_worker_lease_seconds: 120,
164            serialize_build_commands: false,
165            cargo_build_jobs: 0,
166            shared_cargo_target: false,
167            max_scratchpad_entries: 200,
168        }
169    }
170}
171
172#[cfg(test)]
173mod agents_config_tests {
174    use super::AgentsConfig;
175
176    #[test]
177    fn default_values_are_sane() {
178        let cfg = AgentsConfig::default();
179        assert_eq!(cfg.gc_interval_minutes, 10);
180        assert_eq!(cfg.presence_ttl_hours, 24);
181        assert_eq!(cfg.scratchpad_default_ttl_hours, 12);
182        assert_eq!(cfg.logical_session_ttl_seconds, 180);
183        assert_eq!(cfg.active_worker_lease_seconds, 120);
184        // Builds are the user's own: lean-ctx neither queues, throttles nor
185        // relocates them unless asked (a shared target made `./target` stale).
186        assert!(!cfg.serialize_build_commands);
187        assert_eq!(cfg.cargo_build_jobs, 0);
188        assert!(!cfg.shared_cargo_target);
189        assert_eq!(cfg.max_scratchpad_entries, 200);
190    }
191
192    #[test]
193    fn deserializes_with_missing_fields() {
194        let json = r"{}";
195        let cfg: AgentsConfig = serde_json::from_str(json).expect("empty object → defaults");
196        assert_eq!(cfg.gc_interval_minutes, 10);
197    }
198
199    #[test]
200    fn partial_override() {
201        let json = r#"{"gc_interval_minutes": 5, "presence_ttl_hours": 12}"#;
202        let cfg: AgentsConfig = serde_json::from_str(json).expect("partial");
203        assert_eq!(cfg.gc_interval_minutes, 5);
204        assert_eq!(cfg.presence_ttl_hours, 12);
205    }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(default)]
210pub struct SecretDetectionConfig {
211    pub enabled: bool,
212    pub redact: bool,
213    pub custom_patterns: Vec<String>,
214    /// #718: subtractive counterpart to `custom_patterns` — a detected secret
215    /// whose matched text is covered by any of these regexes is neither
216    /// reported nor redacted. Lets users carve out known-safe identifiers or
217    /// repo naming conventions without disabling secret detection wholesale.
218    pub exclude_patterns: Vec<String>,
219}
220
221/// Controls what lean-ctx injects during `setup` and `update --rewire`.
222/// Fresh installs default to non-invasive (rules/skills off, MCP on).
223/// Users who ran setup interactively get explicit true/false.
224/// `None` = undecided (legacy: check if rules already exist and preserve behavior).
225#[derive(Debug, Clone, Serialize, Deserialize)]
226#[serde(default)]
227pub struct SetupConfig {
228    /// Inject agent rule files (CLAUDE.md, .cursor/rules/, etc.).
229    /// None = undecided (legacy compat: inject if rules already present).
230    /// Some(true) = always inject. Some(false) = never inject.
231    pub auto_inject_rules: Option<bool>,
232    /// Install SKILL.md files for supported agents.
233    /// None = undecided. Some(true) = install. Some(false) = skip.
234    pub auto_inject_skills: Option<bool>,
235    /// Register lean-ctx as an MCP server in editor configs.
236    #[serde(default = "serde_defaults::default_true")]
237    pub auto_update_mcp: bool,
238    /// Keep already-installed agent hooks current on MCP server start (#1879).
239    /// `false` leaves hook entries, `permissions.deny` and the status line in
240    /// agent settings exactly as the user wrote them; only an explicit
241    /// `lean-ctx setup` / `init --agent` / `doctor --fix` writes them.
242    #[serde(default = "serde_defaults::default_true")]
243    pub manage_hooks: bool,
244    /// AI tools (agent keys, e.g. `claude`, `cursor`) that `setup`, `onboard`
245    /// and the post-update refresh may configure. Written by the setup
246    /// selection or `--agents`. None = no choice recorded yet: setup offers
247    /// every detected tool, an update refreshes only already-connected ones.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub agents: Option<Vec<String>>,
250}
251
252impl Default for SetupConfig {
253    fn default() -> Self {
254        Self {
255            auto_inject_rules: None,
256            auto_inject_skills: None,
257            auto_update_mcp: true,
258            manage_hooks: true,
259            agents: None,
260        }
261    }
262}
263
264impl SetupConfig {
265    /// Returns whether rules should be injected, considering legacy installs.
266    /// If undecided (None), checks if lean-ctx rules markers already exist
267    /// in any agent config — if so, keeps injecting for backward compat.
268    pub fn should_inject_rules(&self) -> bool {
269        match self.auto_inject_rules {
270            Some(v) => v,
271            None => Self::rules_already_present(),
272        }
273    }
274
275    /// Returns whether skills should be installed.
276    pub fn should_inject_skills(&self) -> bool {
277        match self.auto_inject_skills {
278            Some(v) => v,
279            None => Self::rules_already_present(),
280        }
281    }
282
283    /// Returns whether `setup`/`onboard`/`init` may (re)register the lean-ctx
284    /// MCP server in editor configs. Honors `auto_update_mcp` (#281) so locked-
285    /// down environments can keep MCP out of agent settings while still getting
286    /// hooks, rules and skills.
287    pub fn should_update_mcp(&self) -> bool {
288        self.auto_update_mcp
289    }
290
291    /// Check if lean-ctx rules markers exist in any known agent config location.
292    ///
293    /// Delegates the per-agent path catalog to `rules_inject::any_rules_marker_present`
294    /// (derived from the injector's own target list) so this never drifts behind
295    /// newly supported agents again (#442). Claude Code and CodeBuddy have no
296    /// rules *target* (they auto-load an inline block instead), so their legacy
297    /// rule files are checked separately to keep honoring older installs.
298    fn rules_already_present() -> bool {
299        let Some(home) = dirs::home_dir() else {
300            return false;
301        };
302        if crate::rules_inject::any_rules_marker_present(&home) {
303            return true;
304        }
305        let legacy_paths = [
306            crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
307            crate::core::editor_registry::codebuddy_rules_dir(&home).join("lean-ctx.md"),
308        ];
309        legacy_paths.iter().any(|p| {
310            std::fs::read_to_string(p)
311                .is_ok_and(|c| c.contains(crate::core::rules_canonical::START_MARK))
312        })
313    }
314}
315
316impl Default for SecretDetectionConfig {
317    fn default() -> Self {
318        Self {
319            enabled: true,
320            redact: true,
321            custom_patterns: Vec::new(),
322            exclude_patterns: Vec::new(),
323        }
324    }
325}
326
327/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(default)]
330pub struct ArchiveConfig {
331    pub enabled: bool,
332    pub threshold_chars: usize,
333    pub max_age_hours: u64,
334    pub max_disk_mb: u64,
335    pub ephemeral: bool,
336    /// Minimum output tokens before the ephemeral firewall replaces an inline tool
337    /// result with a summary + retrieval ref. Outputs below this stay fully inline.
338    pub ephemeral_min_tokens: usize,
339    /// Maximum output size that `ctx_shell(inline=true)` returns verbatim before
340    /// the archive/firewall path takes over.
341    pub inline_max_bytes: usize,
342    /// Programs whose stdout *is* a dataset (#1260). Head+tail elision does not
343    /// compress those — it drops the interior rows that hold the answer — so a
344    /// `ctx_shell` command running one of these passes through verbatim at any
345    /// size. Set to `[]` to disable the passthrough.
346    pub raw_commands: Vec<String>,
347    /// Context-window guard for *implicitly* verbatim `ctx_shell` deliveries
348    /// (dataset passthrough #1260, `inline=true`): above this many output
349    /// tokens the content is archived losslessly and delivered as a head+tail
350    /// digest with `ctx_expand` drilldown instead of flooding the calling
351    /// agent's context window. Explicit `raw=true`/`bypass=true` and the
352    /// `LEAN_CTX_MINIMAL` escape hatch are never capped. `0` disables the
353    /// cap. Env: LEAN_CTX_VERBATIM_MAX_TOKENS (#1541).
354    pub verbatim_max_tokens: usize,
355}
356
357/// Opt-in conversation-history compression settings (#1123).
358///
359/// The proxy leaves conversation history byte-for-byte unchanged unless
360/// `compression_enabled` is true and the configured token threshold is met.
361/// Each older message is scored; it is kept verbatim at or above
362/// `min_score_to_preserve`, replaced by a stub below `drop_score_below`, and
363/// summarized to one line in between. Neither a summary nor a stub keeps a
364/// recoverable copy, so the proxy leaves history untouched under protection.
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366#[serde(default)]
367pub struct ConversationConfig {
368    /// Enable message-level compression in the proxy. Default: false.
369    pub compression_enabled: bool,
370    /// Number of most recent messages always kept verbatim.
371    pub preserve_last_n_turns: usize,
372    /// Minimum estimated message-array size before compression starts.
373    pub compression_threshold_tokens: usize,
374    /// Minimum score for verbatim preservation.
375    pub min_score_to_preserve: f64,
376    /// Messages scoring below this value are replaced by a one-line stub.
377    pub drop_score_below: f64,
378}
379
380impl Default for ConversationConfig {
381    fn default() -> Self {
382        Self {
383            compression_enabled: false,
384            preserve_last_n_turns: 10,
385            compression_threshold_tokens: 50_000,
386            min_score_to_preserve: 0.5,
387            drop_score_below: 0.2,
388        }
389    }
390}
391
392impl Default for ArchiveConfig {
393    fn default() -> Self {
394        Self {
395            enabled: true,
396            threshold_chars: 800,
397            max_age_hours: 48,
398            max_disk_mb: 500,
399            ephemeral: true,
400            ephemeral_min_tokens: 2000,
401            inline_max_bytes: 32 * 1024,
402            raw_commands: crate::core::firewall::DEFAULT_RAW_COMMANDS
403                .iter()
404                .map(|s| (*s).to_string())
405                .collect(),
406            verbatim_max_tokens: 100_000,
407        }
408    }
409}
410
411impl ArchiveConfig {
412    pub fn ephemeral_effective(&self) -> bool {
413        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
414            return !matches!(v.trim(), "0" | "false" | "off");
415        }
416        self.ephemeral && self.enabled
417    }
418
419    pub fn ephemeral_min_tokens_effective(&self) -> usize {
420        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL_MIN_TOKENS")
421            && let Ok(n) = v.trim().parse::<usize>()
422        {
423            return n;
424        }
425        self.ephemeral_min_tokens
426    }
427
428    /// Effective context-window cap for implicitly verbatim deliveries (#1541).
429    pub fn verbatim_max_tokens_effective(&self) -> usize {
430        if let Ok(v) = std::env::var("LEAN_CTX_VERBATIM_MAX_TOKENS")
431            && let Ok(n) = v.trim().parse::<usize>()
432        {
433            return n;
434        }
435        self.verbatim_max_tokens
436    }
437
438    pub fn inline_max_bytes_effective(&self) -> usize {
439        if let Ok(v) = std::env::var("LEAN_CTX_INLINE_MAX_BYTES")
440            && let Ok(n) = v.trim().parse::<usize>()
441        {
442            return n;
443        }
444        self.inline_max_bytes
445    }
446}
447
448/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
449/// Each provider can be enabled/disabled and configured with auth tokens.
450/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
451#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(default)]
453pub struct ProvidersConfig {
454    /// Master switch for the provider subsystem.
455    pub enabled: bool,
456    /// GitHub provider configuration.
457    pub github: ProviderEntryConfig,
458    /// GitLab provider configuration.
459    pub gitlab: ProviderEntryConfig,
460    /// Auto-ingest provider results into BM25/embedding indexes.
461    pub auto_index: bool,
462    /// Default cache TTL for provider results (seconds).
463    pub cache_ttl_secs: u64,
464    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
465    #[serde(default)]
466    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
467}
468
469impl Default for ProvidersConfig {
470    fn default() -> Self {
471        Self {
472            enabled: true,
473            github: ProviderEntryConfig::default(),
474            gitlab: ProviderEntryConfig::default(),
475            auto_index: true,
476            cache_ttl_secs: 120,
477            mcp_bridges: std::collections::HashMap::new(),
478        }
479    }
480}
481
482/// Controls additive KnowledgeRouter hints in Context Gate output.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(default)]
485pub struct KnowledgeRoutingConfig {
486    pub enabled: bool,
487    pub max_references: usize,
488    pub hint_prefix: String,
489}
490
491impl Default for KnowledgeRoutingConfig {
492    fn default() -> Self {
493        Self {
494            enabled: true,
495            max_references: 5,
496            hint_prefix: "--- Knowledge Context ---".to_string(),
497        }
498    }
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct McpBridgeEntry {
503    /// HTTP/SSE URL for remote MCP servers.
504    #[serde(default)]
505    pub url: Option<String>,
506    /// Command to spawn a local MCP server (stdio transport).
507    #[serde(default)]
508    pub command: Option<String>,
509    /// Arguments for the command.
510    #[serde(default)]
511    pub args: Vec<String>,
512    /// Human-readable description.
513    #[serde(default)]
514    pub description: Option<String>,
515    /// Environment variable name containing an auth token.
516    #[serde(default)]
517    pub auth_env: Option<String>,
518}
519
520/// Per-provider configuration entry.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522#[serde(default)]
523pub struct ProviderEntryConfig {
524    /// Whether this specific provider is enabled.
525    pub enabled: bool,
526    /// Auth token (prefer env var; only use this for project-local overrides).
527    pub token: Option<String>,
528    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
529    pub api_url: Option<String>,
530    /// Default project/repo for this provider (auto-detected from git remote if empty).
531    pub project: Option<String>,
532}
533
534impl Default for ProviderEntryConfig {
535    fn default() -> Self {
536        Self {
537            enabled: true,
538            token: None,
539            api_url: None,
540            project: None,
541        }
542    }
543}
544
545/// Controls autonomous background behaviors (preload, dedup, consolidation).
546#[derive(Debug, Clone, Serialize, Deserialize)]
547#[serde(default)]
548pub struct AutonomyConfig {
549    pub enabled: bool,
550    pub auto_consolidate: bool,
551    pub consolidate_every_calls: u32,
552    pub consolidate_cooldown_secs: u64,
553    /// No effect: the background cognition loop is retired (DEPRECATIONS.toml
554    /// `config-autonomy-cognition-loop`). The four `cognition_*` keys stay
555    /// accepted so existing configs keep loading.
556    pub cognition_loop_enabled: bool,
557    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
558    pub cognition_loop_interval_secs: u64,
559    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
560    pub cognition_loop_max_steps: u8,
561    #[serde(default = "serde_defaults::default_cognition_synthesis_min_cluster")]
562    pub cognition_synthesis_min_cluster: usize,
563}
564
565impl Default for AutonomyConfig {
566    fn default() -> Self {
567        Self {
568            enabled: true,
569            auto_consolidate: true,
570            consolidate_every_calls: 25,
571            consolidate_cooldown_secs: 120,
572            cognition_loop_enabled: false,
573            cognition_loop_interval_secs: 3600,
574            cognition_loop_max_steps: 9,
575            cognition_synthesis_min_cluster: 3,
576        }
577    }
578}
579
580/// Controls automatic update behavior. All defaults are OFF — auto-updates
581/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(default)]
584pub struct UpdatesConfig {
585    pub auto_update: bool,
586    pub check_interval_hours: u64,
587    pub notify_only: bool,
588    /// Persistent release pin used by manual and scheduled update checks.
589    /// `None` follows the latest release; values are normalized without `v`.
590    #[serde(default)]
591    pub pinned_version: Option<String>,
592}
593
594impl Default for UpdatesConfig {
595    fn default() -> Self {
596        Self {
597            auto_update: false,
598            check_interval_hours: 6,
599            notify_only: false,
600            pinned_version: None,
601        }
602    }
603}
604
605/// Fixed-context budget accounting (#964). The per-session footprint lean-ctx
606/// adds — tool schemas + MCP instructions + auto-loaded rules files + the wakeup
607/// briefing — is warned about once it crosses `budget_tokens`. The
608/// `LEAN_CTX_CONTEXT_BUDGET_TOKENS` env var overrides it; `lean-ctx doctor
609/// overhead --gate` turns a breach into a non-zero exit for CI.
610#[derive(Debug, Clone, Serialize, Deserialize)]
611#[serde(default)]
612pub struct ContextConfig {
613    pub budget_tokens: usize,
614    /// Inject matching CCR archives into later tool responses.
615    pub proactive_expansion: bool,
616    /// Maximum proactive archive content per tool response.
617    pub proactive_expansion_budget_tokens: usize,
618    /// Minimum normalized BM25 score required for an injection.
619    pub proactive_expansion_threshold: f64,
620    /// Ignore archived content older than this many seconds; 0 disables age expiry.
621    pub proactive_expansion_max_age_secs: u64,
622}
623
624impl Default for ContextConfig {
625    fn default() -> Self {
626        Self {
627            budget_tokens: 8000,
628            proactive_expansion: true,
629            proactive_expansion_budget_tokens: 2000,
630            proactive_expansion_threshold: 0.6,
631            proactive_expansion_max_age_secs: 3600,
632        }
633    }
634}
635
636impl UpdatesConfig {
637    pub fn from_env() -> Self {
638        let mut cfg = Self::default();
639        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
640            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
641        }
642        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS")
643            && let Ok(h) = v.parse::<u64>()
644        {
645            cfg.check_interval_hours = h.clamp(1, 168);
646        }
647        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
648            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
649        }
650        cfg
651    }
652}
653
654impl AutonomyConfig {
655    /// Creates an autonomy config from env vars, falling back to defaults.
656    pub fn from_env() -> Self {
657        let mut cfg = Self::default();
658        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
659            && (v == "false" || v == "0")
660        {
661            cfg.enabled = false;
662        }
663        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
664            cfg.auto_consolidate = v != "false" && v != "0";
665        }
666        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS")
667            && let Ok(n) = v.parse()
668        {
669            cfg.consolidate_every_calls = n;
670        }
671        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS")
672            && let Ok(n) = v.parse()
673        {
674            cfg.consolidate_cooldown_secs = n;
675        }
676        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
677            cfg.cognition_loop_enabled = v != "false" && v != "0";
678        }
679        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
680            && let Ok(n) = v.parse()
681        {
682            cfg.cognition_loop_interval_secs = n;
683        }
684        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
685            && let Ok(n) = v.parse()
686        {
687            cfg.cognition_loop_max_steps = n;
688        }
689        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
690            && let Ok(n) = v.parse()
691        {
692            cfg.cognition_synthesis_min_cluster = n;
693        }
694        cfg
695    }
696
697    /// Loads autonomy config from disk, with env var overrides applied.
698    pub fn load() -> Self {
699        let file_cfg = Config::load().autonomy;
700        let mut cfg = file_cfg;
701        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
702            && (v == "false" || v == "0")
703        {
704            cfg.enabled = false;
705        }
706        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
707            cfg.cognition_loop_enabled = v != "false" && v != "0";
708        }
709        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
710            && let Ok(n) = v.parse()
711        {
712            cfg.cognition_loop_interval_secs = n;
713        }
714        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
715            && let Ok(n) = v.parse()
716        {
717            cfg.cognition_loop_max_steps = n;
718        }
719        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
720            && let Ok(n) = v.parse()
721        {
722            cfg.cognition_synthesis_min_cluster = n;
723        }
724        cfg
725    }
726}
727
728/// Persisted telemetry choice. This distinguishes the v4 default from a user
729/// choice so upgrades never erase an earlier opt-out.
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
731#[serde(rename_all = "snake_case")]
732pub enum TelemetryPreference {
733    #[default]
734    DefaultOn,
735    ExplicitlyEnabled,
736    ExplicitlyDisabled,
737}
738
739/// Privacy-safe product telemetry settings.
740///
741/// When enabled, lean-ctx sends cumulative daily aggregates to `api.leanctx.com`
742/// several times per day: a random installation ID (UUID v4), version, OS,
743/// setup profile and counters. No code, filenames, prompts, or personal data.
744/// Enabled by default (accepted at install); `lean-ctx telemetry off`,
745/// `DO_NOT_TRACK=1` and `LEAN_CTX_TELEMETRY=off` opt out.
746#[derive(Debug, Clone, Serialize, Deserialize)]
747#[serde(default)]
748pub struct TelemetryConfig {
749    /// Master switch for privacy-safe product telemetry.
750    pub enabled: bool,
751    /// Whether the state came from the v4 default or an explicit user choice.
752    pub preference: TelemetryPreference,
753    /// YYYY-MM-DD of the last successful telemetry send.
754    pub last_heartbeat: Option<String>,
755}
756
757impl Default for TelemetryConfig {
758    fn default() -> Self {
759        Self {
760            enabled: true,
761            preference: TelemetryPreference::DefaultOn,
762            last_heartbeat: None,
763        }
764    }
765}
766
767impl TelemetryConfig {
768    #[must_use]
769    pub fn environment_disables(do_not_track: Option<&str>, env_override: Option<&str>) -> bool {
770        do_not_track.is_some_and(|value| value.trim() == "1")
771            || env_override.is_some_and(|value| {
772                matches!(
773                    value.trim().to_ascii_lowercase().as_str(),
774                    "off" | "false" | "0" | "no"
775                )
776            })
777    }
778
779    #[must_use]
780    pub fn explicitly_disabled(&self) -> bool {
781        !self.enabled || self.preference == TelemetryPreference::ExplicitlyDisabled
782    }
783
784    #[must_use]
785    pub fn send_eligible(&self, do_not_track: Option<&str>, env_override: Option<&str>) -> bool {
786        self.enabled
787            && self.preference != TelemetryPreference::ExplicitlyDisabled
788            && !Self::environment_disables(do_not_track, env_override)
789    }
790}
791
792/// What remains of the retired `[cloud]` section: the legacy opt-in the
793/// loader turns into `telemetry.enabled`. Read, never written.
794#[derive(Debug, Clone, Default, Deserialize)]
795#[serde(default)]
796pub struct LegacyCloudConfig {
797    pub contribute_enabled: bool,
798}
799
800/// Model declaration for **measured-vs-estimated** cost reporting.
801///
802/// Proxy-routed clients (Claude Code, Codex, Pi, Gemini CLI, OpenCode) report
803/// their real model and billed tokens, so lean-ctx prices them *measured* with
804/// no configuration. MCP-only IDEs (Cursor, Copilot, Windsurf, VS Code, Zed)
805/// send their LLM traffic straight to the provider, bypassing lean-ctx — their
806/// real model is invisible. Declaring it here lets those *estimated* turns be
807/// priced with the correct model instead of a blended fallback.
808#[derive(Debug, Clone, Default, Serialize, Deserialize)]
809#[serde(default)]
810pub struct CostConfig {
811    /// Fallback pricing model for any client without a per-client entry.
812    /// Unset/empty → lean-ctx keeps its blended heuristic.
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub default_model: Option<String>,
815    /// Per-client pricing model, keyed by client id (`cursor`, `copilot`,
816    /// `windsurf`, `claude`, `codex`, …). Used for MCP-only IDEs whose real
817    /// model lean-ctx cannot observe. Example:
818    /// `[cost.models]` then `cursor = "claude-opus-4.5"`.
819    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
820    pub models: HashMap<String, String>,
821    /// Operator price overrides (#1189), keyed by model name — for negotiated
822    /// enterprise rates (committed-use discounts, Azure PTU, zero-rated
823    /// internal models) that no public catalog can know. Merged into the
824    /// pricing table as **exact** entries, overriding embedded and live rows;
825    /// only a provider-measured bill beats them. Example:
826    /// `[cost.prices."internal-llm"]` then `input_per_m = 0.10`,
827    /// `output_per_m = 0.40`.
828    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
829    pub prices: HashMap<String, PriceOverride>,
830}
831
832/// User-controlled never-lossy zones (`[protection]`, #1570 P4).
833#[derive(Debug, Clone, Serialize, Deserialize)]
834#[serde(default)]
835pub struct ProtectionConfig {
836    /// Glob patterns matched against a call's path-like arguments (`path`,
837    /// `file_path`, `filePath`). A hit exempts the call's output from every
838    /// lossy filter (triage), same standard as `raw=true`. Lossless paths
839    /// (archive + ctx_expand) are unaffected — protection means "never
840    /// lossy", not "never compressed".
841    pub file_patterns: Vec<String>,
842    /// Honor inline `<protect>` spans: any tool output containing the tag
843    /// bypasses lossy line filtering, and history pruning leaves the
844    /// matching tool result verbatim.
845    pub tags: bool,
846}
847
848impl Default for ProtectionConfig {
849    fn default() -> Self {
850        Self {
851            file_patterns: Vec::new(),
852            tags: true,
853        }
854    }
855}
856
857impl ProtectionConfig {
858    /// Whether `path` matches a protected glob. Invalid patterns are ignored.
859    pub fn path_is_protected(&self, path: &str) -> bool {
860        !path.is_empty()
861            && self
862                .file_patterns
863                .iter()
864                .filter_map(|p| glob::Pattern::new(p).ok())
865                .any(|pattern| pattern.matches(path))
866    }
867}
868
869#[cfg(test)]
870mod protection_tests {
871    use super::ProtectionConfig;
872
873    #[test]
874    fn protected_globs_match_paths_and_ignore_invalid_patterns() {
875        let cfg = ProtectionConfig {
876            file_patterns: vec![
877                "docs/audits/**".into(),
878                "*.secret.md".into(),
879                "[invalid".into(),
880            ],
881            tags: true,
882        };
883        assert!(cfg.path_is_protected("docs/audits/q3/blockers.md"));
884        assert!(cfg.path_is_protected("notes.secret.md"));
885        assert!(!cfg.path_is_protected("src/main.rs"));
886        assert!(!cfg.path_is_protected(""));
887
888        let empty = ProtectionConfig::default();
889        assert!(!empty.path_is_protected("docs/audits/q3/blockers.md"));
890        assert!(empty.tags, "protect tags are honored by default");
891    }
892}
893/// MCP decision-loop runtime settings (`[decision_loop]`).
894#[derive(Debug, Clone, Serialize, Deserialize)]
895#[serde(default)]
896pub struct DecisionLoopConfig {
897    pub enabled: bool,
898    /// Maximum lossy post-dispatch triage level (`0` disables output filtering).
899    /// Classification and decision-loop accounting remain active at level 0.
900    #[serde(default)]
901    pub max_filter_level: u8,
902}
903
904impl Default for DecisionLoopConfig {
905    fn default() -> Self {
906        Self {
907            enabled: true,
908            max_filter_level: 0,
909        }
910    }
911}
912
913/// One `[cost.prices.<model>]` row: USD per million tokens. Omitted cache
914/// rates default to the input rate (the same convention the catalogs use).
915#[derive(Debug, Clone, Default, Serialize, Deserialize)]
916#[serde(default)]
917pub struct PriceOverride {
918    pub input_per_m: Option<f64>,
919    pub output_per_m: Option<f64>,
920    pub cache_write_per_m: Option<f64>,
921    pub cache_read_per_m: Option<f64>,
922}
923
924impl CostConfig {
925    /// Configured pricing model for a client id: the per-client entry first, then
926    /// the global default. `None` when neither is set (the caller then falls back
927    /// to the env override / heuristic). Blank entries are ignored.
928    pub fn model_for_client(&self, client: &str) -> Option<String> {
929        self.models
930            .get(client)
931            .or(self.default_model.as_ref())
932            .map(|s| s.trim().to_string())
933            .filter(|s| !s.is_empty())
934    }
935}
936
937/// Code-health engine (`[code_health]`): clean code as a token-cost lever.
938///
939/// Cognitive complexity, naming quality, and coupling are computed once during
940/// indexing and surfaced at read- and edit-time. These switches tune the
941/// thresholds and how assertively findings are surfaced.
942#[derive(Debug, Clone, Serialize, Deserialize)]
943#[serde(default)]
944pub struct CodeHealthConfig {
945    /// Cognitive-complexity threshold above which a function is a hotspot.
946    /// Mirrors `core::code_health::DEFAULT_COGNITIVE_THRESHOLD` (15).
947    pub cognitive_threshold: u32,
948    /// Edit-gate behavior on complexity drift: `"warn"` (annotate, default),
949    /// `"block"` (refuse clean→over-threshold edits), or `"off"`.
950    pub gate: String,
951    /// Annotate over-threshold functions inline in `ctx_read` output.
952    pub annotate_reads: bool,
953    /// Run the naming-quality heuristic.
954    pub naming: bool,
955    /// Inject `[CODE HEALTH]` notices as `additionalContext` in PostToolUse stdout.
956    /// Default: **false** — prevents prompt-cache invalidation on Anthropic models
957    /// (#778: each injection causes 440-520k tokens of cache re-bills when Claude
958    /// Code strips stale system-reminders retroactively).
959    /// When false, notices route to `ctx_knowledge` + dashboard instead.
960    #[serde(default)]
961    pub inject_context: bool,
962}
963
964impl Default for CodeHealthConfig {
965    fn default() -> Self {
966        Self {
967            cognitive_threshold: 15,
968            gate: "warn".to_string(),
969            annotate_reads: true,
970            naming: true,
971            inject_context: false,
972        }
973    }
974}
975
976/// Index-time file filters (#735): declare the retrieval corpus explicitly
977/// instead of abusing `.gitignore` for retrieval policy.
978///
979/// Applies to every index builder through one shared filter layer
980/// (`core::index_filter`): BM25, graph, and the watch/incremental path; the
981/// semantic index chunks the BM25 corpus and inherits the same universe.
982/// Excluded files never produce chunks, graph nodes, or embeddings. Globs are
983/// matched against the root-relative path (forward slashes); exclude wins
984/// over include. The empty default preserves today's behavior byte-for-byte.
985#[derive(Debug, Clone, Serialize, Deserialize)]
986#[serde(default)]
987pub struct IndexConfig {
988    /// Honor `.gitignore` / global gitignore / `.git/info/exclude` during
989    /// index walks. `false` indexes ignored files too (rarely wanted; the
990    /// vendor-directory guard still applies).
991    pub respect_gitignore: bool,
992    /// Files to drop from the index corpus, e.g. `["**/*.csv", "fixtures/**"]`.
993    /// Evaluated after `include`; a file matching both is excluded.
994    pub exclude: Vec<String>,
995    /// When non-empty, ONLY matching files enter the index corpus, e.g.
996    /// `["**/*.rs", "**/*.ts"]`. Empty = no restriction.
997    pub include: Vec<String>,
998}
999
1000impl Default for IndexConfig {
1001    fn default() -> Self {
1002        Self {
1003            respect_gitignore: true,
1004            exclude: Vec::new(),
1005            include: Vec::new(),
1006        }
1007    }
1008}
1009
1010/// Skillify (#290): mine the project's session diary + knowledge facts into
1011/// versioned, git-committable `.cursor/rules/skillify-*.mdc` rule files.
1012///
1013/// The miner is precision-biased — it only codifies recurring or high-confidence
1014/// patterns and never invents content. Runs on demand (`ctx_skillify` /
1015/// `lean-ctx skillify`); re-running merges (bumps version) only when the distilled
1016/// content actually changes.
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1018#[serde(default)]
1019pub struct SkillifyConfig {
1020    /// Master switch for the skillify miner. On by default; the miner only ever
1021    /// acts when explicitly invoked, so this never writes files unprompted.
1022    pub enabled: bool,
1023    /// Where generated rules are written: `project` (`<repo>/.cursor/rules`,
1024    /// git-committable, default) or `global` (`~/.cursor/rules`).
1025    pub scope: String,
1026    /// Minimum confidence for a single curated knowledge fact to be codified even
1027    /// without repetition. 0.0..=1.0.
1028    pub min_confidence: f32,
1029    /// Minimum number of reinforcements (confirmations / repeated mentions) before
1030    /// a pattern is codified when its confidence is below `min_confidence`.
1031    pub min_recurrence: u32,
1032}
1033
1034impl Default for SkillifyConfig {
1035    fn default() -> Self {
1036        Self {
1037            enabled: true,
1038            scope: "project".to_string(),
1039            min_confidence: 0.7,
1040            min_recurrence: 2,
1041        }
1042    }
1043}
1044
1045/// AI session summaries (#292): periodically distil the working session into a
1046/// compact, *semantically recallable* summary so a future session can answer
1047/// "what did I do last time on X?". Deterministic and local-first — recall uses
1048/// embeddings when the `embeddings` feature is on, else a lexical fallback.
1049#[derive(Debug, Clone, Serialize, Deserialize)]
1050#[serde(default)]
1051pub struct SummariesConfig {
1052    /// Record periodic session summaries. On by default; recording is cheap and
1053    /// happens at most once per `every_n_turns` tool calls.
1054    pub enabled: bool,
1055    /// Tool calls between automatic summaries. The auto-checkpoint cadence still
1056    /// gates the check, so the effective minimum is the checkpoint interval.
1057    pub every_n_turns: u32,
1058    /// Maximum summaries kept per project (oldest pruned first).
1059    pub max_kept: u32,
1060}
1061
1062impl Default for SummariesConfig {
1063    fn default() -> Self {
1064        Self {
1065            enabled: true,
1066            every_n_turns: 25,
1067            max_kept: 100,
1068        }
1069    }
1070}
1071
1072/// A user-defined command alias mapping for shell compression patterns.
1073#[derive(Debug, Clone, Serialize, Deserialize)]
1074pub struct AliasEntry {
1075    pub command: String,
1076    pub alias: String,
1077}
1078
1079/// Thresholds for detecting and throttling repetitive agent tool call loops.
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1081#[serde(default)]
1082pub struct LoopDetectionConfig {
1083    pub normal_threshold: u32,
1084    pub reduced_threshold: u32,
1085    pub blocked_threshold: u32,
1086    pub window_secs: u64,
1087    pub search_group_limit: u32,
1088    pub tool_total_limits: HashMap<String, u32>,
1089}
1090
1091impl Default for LoopDetectionConfig {
1092    fn default() -> Self {
1093        let mut tool_total_limits = HashMap::new();
1094        tool_total_limits.insert("ctx_read".to_string(), 100);
1095        tool_total_limits.insert("ctx_search".to_string(), 80);
1096        tool_total_limits.insert("ctx_shell".to_string(), 50);
1097        Self {
1098            normal_threshold: 2,
1099            reduced_threshold: 4,
1100            blocked_threshold: 0,
1101            window_secs: 300,
1102            search_group_limit: 10,
1103            tool_total_limits,
1104        }
1105    }
1106}
1107
1108/// Semantic-embedding engine settings.
1109///
1110/// `model` selects which local embedding model lean-ctx downloads and uses for
1111/// `ctx_semantic_search`. Unset, it is `minilm` where ONNX Runtime is installed and
1112/// `potion-code` otherwise (see `model_registry::default_model`). Accepts the same
1113/// aliases as the `LEAN_CTX_EMBEDDING_MODEL` env var: `minilm` (all-MiniLM-L6-v2,
1114/// 384d), `potion-code` (potion-code-16M, 256d static, pure Rust), `nomic` (768d) — or any
1115/// HuggingFace repo with an ONNX export via `hf:org/repo[@revision]` (GL #397), e.g.
1116/// `hf:jinaai/jina-embeddings-v2-base-code` for code-specialized embeddings. When the
1117/// env var is set it takes precedence; an
1118/// unset/`None` value uses the default model. Switching models triggers a one-time
1119/// re-index on the next semantic search (vector dimensions follow from the model).
1120///
1121/// `dimensions` is only consulted for `hf:` custom models as the declared fallback
1122/// width; the real width is probed from the ONNX graph at load time. Built-ins ignore it.
1123/// `[gateway_server]` — deployment parameters of the self-hosted org gateway
1124/// (enterprise#20). Distinct from `[gateway]` (the MCP tool-catalog gateway):
1125/// this section describes the LLM-proxy *server* deployment and its cockpit.
1126///
1127/// All fields optional; an empty section keeps every local behavior unchanged.
1128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1129#[serde(default)]
1130pub struct GatewayServerConfig {
1131    /// Seats the org-wide projection extrapolates to (e.g. `800`). `None`
1132    /// disables the projection — the cockpit never invents a seat count.
1133    #[serde(default, skip_serializing_if = "Option::is_none")]
1134    pub seats: Option<u32>,
1135    /// Display label for the cockpit header (e.g. `"Zühlke AI Gateway"`).
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub org_label: Option<String>,
1138    /// Central admin API base URL (e.g. `https://ai-gateway.example.com`).
1139    /// When set, the local cockpit's usage breakdown reads the org-wide
1140    /// `GET /api/admin/usage` instead of the machine-local snapshot. The
1141    /// bearer token comes from `LEAN_CTX_GATEWAY_ADMIN_TOKEN` (never config).
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub admin_url: Option<String>,
1144    /// Replace `person` with a stable keyed pseudonym (`p:<hash>`) before it
1145    /// reaches metering, budgets, dashboards and logs (enterprise#39, GDPR).
1146    /// The salt lives in `<data_dir>/gateway_pii_salt`; `gateway gdpr`
1147    /// re-derives pseudonyms from e-mail input, so DSGVO delete/export keep
1148    /// working. Default `false` (cleartext person tags).
1149    #[serde(default, skip_serializing_if = "Option::is_none")]
1150    pub pseudonymize_persons: Option<bool>,
1151    /// MCP upstream registry (GL#91/#99, Doc 15 §7 — the observe stage of MCP
1152    /// context governance). Each entry publishes a governed reverse-proxy
1153    /// route `/mcp/{id}` on the enterprise gateway server's port (the local
1154    /// proxy mounts no `/mcp/{id}` routes): same per-person key auth as the
1155    /// LLM channel, tool calls metered into `mcp_events`, tool definitions
1156    /// inventoried + hash-tracked (rug-pull detection). Observe-only: the
1157    /// gateway never blocks or rewrites MCP traffic in this stage.
1158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1159    pub mcp_servers: Vec<McpServerEntry>,
1160}
1161
1162/// One `[[gateway_server.mcp_servers]]` registry entry — an MCP server the org
1163/// gateway fronts. Distinct from `[[gateway.servers]]` (the *local* tool-
1164/// catalog aggregator, #210): this registry is the org-facing reverse proxy.
1165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1166pub struct McpServerEntry {
1167    /// Registry id, used in the `/mcp/{id}` route. Lowercase alphanumeric
1168    /// plus `-`/`_` (it becomes a URL path segment).
1169    pub id: String,
1170    /// Upstream Streamable-HTTP endpoint (the server's single MCP endpoint,
1171    /// e.g. `https://mcp.example.com/mcp`). HTTPS for any non-loopback host;
1172    /// plaintext HTTP needs the same explicit opt-in as LLM upstreams
1173    /// (`[proxy] allow_insecure_http_upstream`).
1174    pub url: String,
1175    /// Name of the environment variable holding the upstream credential. When
1176    /// set, the gateway sends `Authorization: Bearer <value>` upstream — the
1177    /// credential lives in the gateway's environment, never on laptops. The
1178    /// caller's own `Authorization` header (their gateway key) is **always**
1179    /// stripped before forwarding, with or without this field.
1180    #[serde(default, skip_serializing_if = "Option::is_none")]
1181    pub auth_env: Option<String>,
1182    /// Set `false` to keep the entry in config but take it out of service.
1183    #[serde(default, skip_serializing_if = "Option::is_none")]
1184    pub enabled: Option<bool>,
1185}
1186
1187/// A validated, ready-to-serve MCP registry entry (runtime view of
1188/// [`McpServerEntry`]).
1189#[derive(Debug, Clone, PartialEq, Eq)]
1190pub struct ResolvedMcpServer {
1191    pub id: String,
1192    pub url: String,
1193    pub auth_env: Option<String>,
1194}
1195
1196impl GatewayServerConfig {
1197    /// Validate + resolve the `[[gateway_server.mcp_servers]]` registry.
1198    /// Same resilience contract as `[[proxy.providers]]`: invalid entries are
1199    /// logged and skipped (one typo never takes the gateway down), duplicates
1200    /// keep the first occurrence. `allow_insecure_http` mirrors the proxy's
1201    /// plaintext-HTTP opt-in so the two registries share one security posture.
1202    #[must_use]
1203    pub fn resolve_mcp_servers(&self, allow_insecure_http: bool) -> Vec<ResolvedMcpServer> {
1204        let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1205        let mut out = Vec::new();
1206        for entry in &self.mcp_servers {
1207            if !entry.enabled.unwrap_or(true) {
1208                continue;
1209            }
1210            let id = entry.id.trim();
1211            if !is_valid_mcp_server_id(id) {
1212                tracing::warn!(
1213                    "[gateway_server.mcp_servers] invalid id '{id}' \
1214                     (lowercase alnum/-/_ only) — entry skipped"
1215                );
1216                continue;
1217            }
1218            if !seen.insert(id) {
1219                tracing::warn!(
1220                    "[gateway_server.mcp_servers] duplicate id '{id}' — keeping first entry"
1221                );
1222                continue;
1223            }
1224            match validate_mcp_upstream_url(&entry.url, allow_insecure_http) {
1225                Ok(url) => out.push(ResolvedMcpServer {
1226                    id: id.to_string(),
1227                    url,
1228                    auth_env: entry
1229                        .auth_env
1230                        .as_deref()
1231                        .map(str::trim)
1232                        .filter(|v| !v.is_empty())
1233                        .map(str::to_string),
1234                }),
1235                Err(e) => {
1236                    tracing::warn!(
1237                        "[gateway_server.mcp_servers] '{id}' has invalid url — skipped: {e}"
1238                    );
1239                }
1240            }
1241        }
1242        out
1243    }
1244}
1245
1246/// True when `id` is usable as an MCP registry id: non-empty, lowercase alnum
1247/// plus `-`/`_` (it becomes a URL path segment). Same shape rule as
1248/// `[[proxy.providers]]` ids; no built-in namespace exists to shadow here.
1249fn is_valid_mcp_server_id(id: &str) -> bool {
1250    !id.is_empty()
1251        && id
1252            .chars()
1253            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1254}
1255
1256/// Validates an MCP upstream URL. A declared registry entry is itself the
1257/// deliberate custom-host opt-in (same rationale as `[[proxy.providers]]`):
1258/// any HTTPS host is accepted; loopback HTTP is always fine; non-loopback
1259/// plaintext HTTP requires the explicit insecure-HTTP opt-in. This is the
1260/// SSRF boundary — the proxy only ever connects to URLs that passed here.
1261fn validate_mcp_upstream_url(url: &str, allow_insecure_http: bool) -> Result<String, String> {
1262    let trimmed = url.trim().trim_end_matches('/');
1263    if trimmed.is_empty() {
1264        return Err("empty url".into());
1265    }
1266    if crate::core::config::is_local_proxy_url(trimmed) {
1267        return Ok(trimmed.to_string());
1268    }
1269    if trimmed.starts_with("http://") {
1270        if allow_insecure_http {
1271            return Ok(trimmed.to_string());
1272        }
1273        return Err(format!(
1274            "MCP upstream must use HTTPS: {trimmed} (for a trusted local-network HTTP \
1275             upstream opt in with `[proxy] allow_insecure_http_upstream = true`)"
1276        ));
1277    }
1278    if trimmed.starts_with("https://") {
1279        return Ok(trimmed.to_string());
1280    }
1281    Err(format!(
1282        "MCP upstream must start with http:// or https://: {trimmed}"
1283    ))
1284}
1285
1286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1287#[serde(default)]
1288pub struct EmbeddingConfig {
1289    #[serde(default, skip_serializing_if = "Option::is_none")]
1290    pub model: Option<String>,
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub dimensions: Option<usize>,
1293    /// Allow downloading the embedding model on first semantic need (#551).
1294    /// `None` (unset) means **allowed** — the soft default that activates the
1295    /// semantic features without manual setup. Set `false` for air-gapped
1296    /// machines. The `LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD` env var, when set,
1297    /// overrides this in either direction.
1298    #[serde(default, skip_serializing_if = "Option::is_none")]
1299    pub auto_download: Option<bool>,
1300    /// Pin embedding inference to a single CPU thread (no GPU EP) so vectors are
1301    /// bit-identical across machines, not just run-to-run on one host (#895).
1302    /// `None`/`false` keeps the multi-threaded GPU-capable path. Extractive prose
1303    /// ranking is already deterministic via score quantization + stable tiebreak;
1304    /// this flag is the extra hardening for cross-machine reproducibility. The
1305    /// `LEAN_CTX_EMBEDDING_DETERMINISTIC` env var overrides this either way.
1306    #[serde(default, skip_serializing_if = "Option::is_none")]
1307    pub deterministic: Option<bool>,
1308}
1309
1310#[cfg(test)]
1311mod gateway_server_tests {
1312    use super::*;
1313
1314    fn mcp_entry(id: &str, url: &str) -> McpServerEntry {
1315        McpServerEntry {
1316            id: id.into(),
1317            url: url.into(),
1318            auth_env: None,
1319            enabled: None,
1320        }
1321    }
1322
1323    #[test]
1324    fn mcp_registry_validates_ids_urls_and_duplicates() {
1325        let cfg = GatewayServerConfig {
1326            mcp_servers: vec![
1327                mcp_entry("github", "https://mcp.example.com/mcp/"),
1328                // invalid id (uppercase) — skipped, never panics
1329                mcp_entry("GitHub", "https://mcp.example.com/mcp"),
1330                // duplicate — first occurrence wins
1331                mcp_entry("github", "https://other.example.com/mcp"),
1332                // plaintext HTTP on a non-loopback host without the opt-in — skipped
1333                mcp_entry("plain", "http://mcp.example.com/mcp"),
1334                // loopback HTTP is always fine (local/dev)
1335                mcp_entry("local", "http://127.0.0.1:9200/mcp"),
1336                McpServerEntry {
1337                    enabled: Some(false),
1338                    ..mcp_entry("disabled", "https://mcp.example.com/mcp")
1339                },
1340                McpServerEntry {
1341                    auth_env: Some("  GITHUB_MCP_PAT  ".into()),
1342                    ..mcp_entry("authed", "https://api.githubcopilot.com/mcp")
1343                },
1344            ],
1345            ..Default::default()
1346        };
1347        let resolved = cfg.resolve_mcp_servers(false);
1348        let ids: Vec<&str> = resolved.iter().map(|s| s.id.as_str()).collect();
1349        assert_eq!(ids, ["github", "local", "authed"]);
1350        // Trailing slash normalized; the duplicate kept the first URL.
1351        assert_eq!(resolved[0].url, "https://mcp.example.com/mcp");
1352        assert_eq!(resolved[2].auth_env.as_deref(), Some("GITHUB_MCP_PAT"));
1353
1354        // The insecure-HTTP opt-in admits the plaintext entry (trusted LAN).
1355        let with_optin = cfg.resolve_mcp_servers(true);
1356        assert!(with_optin.iter().any(|s| s.id == "plain"));
1357    }
1358
1359    #[test]
1360    fn mcp_upstream_url_rules_match_the_proxy_posture() {
1361        assert!(validate_mcp_upstream_url("https://mcp.example.com/mcp", false).is_ok());
1362        assert!(validate_mcp_upstream_url("http://localhost:9200/mcp", false).is_ok());
1363        assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", false).is_err());
1364        assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", true).is_ok());
1365        assert!(validate_mcp_upstream_url("ftp://mcp.example.com", false).is_err());
1366        assert!(validate_mcp_upstream_url("   ", false).is_err());
1367    }
1368}
1369
1370#[cfg(test)]
1371mod ocla_tests {
1372    use super::OclaConfig;
1373    use serde::Deserialize;
1374
1375    #[derive(Deserialize)]
1376    struct ConfigFile {
1377        ocla: OclaConfig,
1378    }
1379
1380    /// Configs written for the removed sidecar/gRPC listeners keep loading.
1381    #[test]
1382    fn removed_listener_sections_are_ignored() {
1383        let config: ConfigFile = toml::from_str(
1384            r#"
1385                [ocla.sidecar]
1386                bind_addr = "127.0.0.1:9000"
1387                enabled = true
1388                [ocla.grpc]
1389                enabled = true
1390                listen = "127.0.0.1:60051"
1391                [ocla.delivery]
1392                max_entries = 7
1393            "#,
1394        )
1395        .expect("legacy OCLA config");
1396        assert_eq!(config.ocla.delivery.max_entries, 7);
1397        assert!(config.ocla.delivery.enabled);
1398    }
1399}
1400
1401#[cfg(test)]
1402mod telemetry_tests {
1403    use super::*;
1404
1405    #[test]
1406    fn telemetry_config_defaults_on_and_send_eligible() {
1407        let cfg = TelemetryConfig::default();
1408        assert!(cfg.enabled);
1409        assert_eq!(cfg.preference, TelemetryPreference::DefaultOn);
1410        assert!(cfg.send_eligible(None, None));
1411        assert!(cfg.last_heartbeat.is_none());
1412    }
1413
1414    #[test]
1415    fn telemetry_config_serde_roundtrip() {
1416        let toml_str = r#"
1417[telemetry]
1418enabled = true
1419last_heartbeat = "2026-07-30"
1420"#;
1421        #[derive(serde::Deserialize)]
1422        struct Wrap {
1423            telemetry: TelemetryConfig,
1424        }
1425        let wrap: Wrap = toml::from_str(toml_str).expect("parse telemetry config");
1426        assert!(wrap.telemetry.enabled);
1427        assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
1428        assert_eq!(wrap.telemetry.last_heartbeat.as_deref(), Some("2026-07-30"));
1429    }
1430
1431    #[test]
1432    fn a_retired_notice_key_in_an_existing_config_still_parses() {
1433        let cfg: TelemetryConfig =
1434            toml::from_str("enabled = true\nnotice_shown = false").expect("pre-removal config");
1435        assert!(cfg.send_eligible(None, None));
1436    }
1437
1438    #[test]
1439    fn telemetry_config_missing_section_uses_defaults() {
1440        let toml_str = "";
1441        #[derive(serde::Deserialize, Default)]
1442        #[serde(default)]
1443        struct Wrap {
1444            telemetry: TelemetryConfig,
1445        }
1446        let wrap: Wrap = toml::from_str(toml_str).expect("parse empty config");
1447        assert!(wrap.telemetry.enabled);
1448        assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
1449        assert!(wrap.telemetry.last_heartbeat.is_none());
1450    }
1451
1452    #[test]
1453    fn legacy_false_remains_an_explicit_opt_out() {
1454        let cfg: TelemetryConfig = toml::from_str("enabled = false").expect("legacy config");
1455        assert!(cfg.explicitly_disabled());
1456    }
1457
1458    #[test]
1459    fn send_honors_the_explicit_and_both_environment_opt_outs() {
1460        let mut cfg = TelemetryConfig::default();
1461        assert!(cfg.send_eligible(None, None));
1462        assert!(!cfg.send_eligible(Some("1"), None));
1463        assert!(!cfg.send_eligible(None, Some("OFF")));
1464        cfg.preference = TelemetryPreference::ExplicitlyDisabled;
1465        assert!(!cfg.send_eligible(None, None));
1466    }
1467}