1use super::serde_defaults;
9#[allow(clippy::wildcard_imports)]
10use super::*;
11use serde::{Deserialize, Serialize};
12
13#[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 pub delivery_for_subagents: bool,
32 pub max_entries: usize,
33 pub ttl_minutes: u64,
34 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default)]
112pub struct AgentsConfig {
113 pub gc_interval_minutes: u64,
115 pub identity_ttl_hours: u64,
117 pub presence_ttl_hours: u64,
119 pub scratchpad_default_ttl_hours: u64,
121 pub logical_session_ttl_seconds: u64,
123 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 pub exclude_patterns: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(default)]
191pub struct SetupConfig {
192 pub auto_inject_rules: Option<bool>,
196 pub auto_inject_skills: Option<bool>,
199 #[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 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 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 pub fn should_update_mcp(&self) -> bool {
238 self.auto_update_mcp
239 }
240
241 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#[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 pub ephemeral_min_tokens: usize,
289 pub inline_max_bytes: usize,
292 pub raw_commands: Vec<String>,
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304#[serde(default)]
305pub struct ConversationConfig {
306 pub compression_enabled: bool,
308 pub preserve_last_n_turns: usize,
310 pub compression_threshold_tokens: usize,
312 pub min_score_to_preserve: f64,
314 pub summarize_score_range: [f64; 2],
316 pub drop_score_below: f64,
318 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#[derive(Debug, Clone, Serialize, Deserialize)]
385#[serde(default)]
386pub struct ProvidersConfig {
387 pub enabled: bool,
389 pub github: ProviderEntryConfig,
391 pub gitlab: ProviderEntryConfig,
393 pub auto_index: bool,
395 pub cache_ttl_secs: u64,
397 #[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 #[serde(default)]
419 pub url: Option<String>,
420 #[serde(default)]
422 pub command: Option<String>,
423 #[serde(default)]
425 pub args: Vec<String>,
426 #[serde(default)]
428 pub description: Option<String>,
429 #[serde(default)]
431 pub auth_env: Option<String>,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
436#[serde(default)]
437pub struct ProviderEntryConfig {
438 pub enabled: bool,
440 pub token: Option<String>,
442 pub api_url: Option<String>,
444 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#[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 #[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#[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#[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 pub proactive_expansion: bool,
539 pub proactive_expansion_budget_tokens: usize,
541 pub proactive_expansion_threshold: f64,
543 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
696#[serde(default)]
697pub struct TelemetryConfig {
698 pub enabled: bool,
700 pub last_heartbeat: Option<String>,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, Default)]
706#[serde(default)]
707pub struct CloudConfig {
708 pub contribute_enabled: bool,
709 pub last_contribute: Option<String>,
710 #[serde(default)]
712 pub sync_stats_enabled: bool,
713 pub last_sync: Option<String>,
714 #[serde(default)]
716 pub sync_gain_enabled: bool,
717 pub last_gain_sync: Option<String>,
718 #[serde(default)]
720 pub sync_models_enabled: bool,
721 pub last_model_pull: Option<String>,
722 pub auto_sync: bool,
726 pub last_auto_sync: Option<String>,
727 pub auto_index: bool,
732 pub last_index_push: std::collections::HashMap<String, String>,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
744#[serde(default)]
745pub struct GainConfig {
746 pub auto_publish: bool,
749 pub leaderboard: bool,
751 pub display_name: Option<String>,
753 pub auto_publish_interval_hours: u64,
755 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
781#[serde(default)]
782pub struct CostConfig {
783 #[serde(default)]
788 pub max_session_cost_usd: f64,
789 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub default_model: Option<String>,
793 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
798 pub models: HashMap<String, String>,
799 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
807 pub prices: HashMap<String, PriceOverride>,
808}
809
810#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
840#[serde(default)]
841pub struct CodeHealthConfig {
842 pub cognitive_threshold: u32,
845 pub gate: String,
848 pub annotate_reads: bool,
850 pub naming: bool,
852 pub coupling: bool,
854 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
886#[serde(default)]
887pub struct IndexConfig {
888 pub respect_gitignore: bool,
892 pub exclude: Vec<String>,
895 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#[derive(Debug, Clone, Serialize, Deserialize)]
920#[serde(default)]
921pub struct GraphConfig {
922 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#[derive(Debug, Clone, Serialize, Deserialize)]
944#[serde(default)]
945pub struct SkillifyConfig {
946 pub enabled: bool,
949 pub scope: String,
952 pub min_confidence: f32,
955 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#[derive(Debug, Clone, Serialize, Deserialize)]
976#[serde(default)]
977pub struct SummariesConfig {
978 pub enabled: bool,
981 pub every_n_turns: u32,
984 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#[derive(Debug, Clone, Serialize, Deserialize)]
1000pub struct AliasEntry {
1001 pub command: String,
1002 pub alias: String,
1003}
1004
1005#[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1054#[serde(default)]
1055pub struct GatewayServerConfig {
1056 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub seats: Option<u32>,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub org_label: Option<String>,
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub admin_url: Option<String>,
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub admin_bind_host: Option<String>,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub usage_retention_days: Option<u32>,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub pseudonymize_persons: Option<bool>,
1090 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1097 pub mcp_servers: Vec<McpServerEntry>,
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1104pub struct McpServerEntry {
1105 pub id: String,
1108 pub url: String,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub auth_env: Option<String>,
1120 #[serde(default, skip_serializing_if = "Option::is_none")]
1122 pub enabled: Option<bool>,
1123}
1124
1125#[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 #[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 #[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
1203fn 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
1213fn 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 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub auto_download: Option<bool>,
1257 #[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 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 mcp_entry("GitHub", "https://mcp.example.com/mcp"),
1312 mcp_entry("github", "https://other.example.com/mcp"),
1314 mcp_entry("plain", "http://mcp.example.com/mcp"),
1316 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 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 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}