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