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