1use super::serde_defaults;
9#[allow(clippy::wildcard_imports)]
10use super::*;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(default)]
16pub struct ShadowConfig {
17 pub enabled: bool,
18 pub report_interval: usize,
19 pub report_dir: String,
20}
21
22impl Default for ShadowConfig {
23 fn default() -> Self {
24 Self {
25 enabled: false,
26 report_interval: 10,
27 report_dir: String::new(),
28 }
29 }
30}
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35#[serde(default)]
36pub struct OclaConfig {
37 pub delivery: DeliveryConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(default)]
42pub struct DeliveryConfig {
43 pub enabled: bool,
44 pub delivery_for_subagents: bool,
47 pub max_entries: usize,
48 pub ttl_minutes: u64,
49}
50
51impl Default for DeliveryConfig {
52 fn default() -> Self {
53 Self {
54 enabled: true,
55 delivery_for_subagents: true,
56 max_entries: 4096,
57 ttl_minutes: 30,
58 }
59 }
60}
61
62impl OclaConfig {
63 pub fn delivery_enabled(&self) -> bool {
64 self.delivery.enabled
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(default)]
74pub struct CacheConfig {
75 pub l1_ttl_secs: u64,
77 pub l2_max_entries: usize,
79 pub l2_ttl_secs: u64,
81 pub l3_max_bytes: u64,
83 pub compose_cache_enabled: bool,
85}
86
87impl Default for CacheConfig {
88 fn default() -> Self {
89 Self {
90 l1_ttl_secs: 300,
91 l2_max_entries: 10_000,
92 l2_ttl_secs: 3_600,
93 l3_max_bytes: 500_000_000,
94 compose_cache_enabled: true,
95 }
96 }
97}
98
99#[cfg(test)]
100mod cache_config_tests {
101 use super::CacheConfig;
102
103 #[test]
104 fn cache_defaults_match_delivery_budget() {
105 assert_eq!(CacheConfig::default().l3_max_bytes, 500_000_000);
106 assert!(CacheConfig::default().compose_cache_enabled);
107 }
108
109 #[test]
110 fn cache_config_deserializes_partial_overrides() {
111 let parsed: CacheConfig = serde_json::from_str(
115 r#"{"l1_max_entries": 12, "l3_gc_threshold": 0.5, "shell_cache_enabled": true, "l2_ttl_secs": 60}"#,
116 )
117 .unwrap();
118 assert_eq!(parsed.l2_ttl_secs, 60);
119 assert_eq!(parsed.l2_max_entries, 10_000);
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(default)]
129pub struct AgentsConfig {
130 pub gc_interval_minutes: u64,
132 pub presence_ttl_hours: u64,
134 pub scratchpad_default_ttl_hours: u64,
136 pub logical_session_ttl_seconds: u64,
138 pub active_worker_lease_seconds: u64,
140 pub serialize_build_commands: bool,
144 pub cargo_build_jobs: usize,
147 pub shared_cargo_target: bool,
152 pub max_scratchpad_entries: usize,
154}
155
156impl Default for AgentsConfig {
157 fn default() -> Self {
158 Self {
159 gc_interval_minutes: 10,
160 presence_ttl_hours: 24,
161 scratchpad_default_ttl_hours: 12,
162 logical_session_ttl_seconds: 180,
163 active_worker_lease_seconds: 120,
164 serialize_build_commands: false,
165 cargo_build_jobs: 0,
166 shared_cargo_target: false,
167 max_scratchpad_entries: 200,
168 }
169 }
170}
171
172#[cfg(test)]
173mod agents_config_tests {
174 use super::AgentsConfig;
175
176 #[test]
177 fn default_values_are_sane() {
178 let cfg = AgentsConfig::default();
179 assert_eq!(cfg.gc_interval_minutes, 10);
180 assert_eq!(cfg.presence_ttl_hours, 24);
181 assert_eq!(cfg.scratchpad_default_ttl_hours, 12);
182 assert_eq!(cfg.logical_session_ttl_seconds, 180);
183 assert_eq!(cfg.active_worker_lease_seconds, 120);
184 assert!(!cfg.serialize_build_commands);
187 assert_eq!(cfg.cargo_build_jobs, 0);
188 assert!(!cfg.shared_cargo_target);
189 assert_eq!(cfg.max_scratchpad_entries, 200);
190 }
191
192 #[test]
193 fn deserializes_with_missing_fields() {
194 let json = r"{}";
195 let cfg: AgentsConfig = serde_json::from_str(json).expect("empty object → defaults");
196 assert_eq!(cfg.gc_interval_minutes, 10);
197 }
198
199 #[test]
200 fn partial_override() {
201 let json = r#"{"gc_interval_minutes": 5, "presence_ttl_hours": 12}"#;
202 let cfg: AgentsConfig = serde_json::from_str(json).expect("partial");
203 assert_eq!(cfg.gc_interval_minutes, 5);
204 assert_eq!(cfg.presence_ttl_hours, 12);
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(default)]
210pub struct SecretDetectionConfig {
211 pub enabled: bool,
212 pub redact: bool,
213 pub custom_patterns: Vec<String>,
214 pub exclude_patterns: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
226#[serde(default)]
227pub struct SetupConfig {
228 pub auto_inject_rules: Option<bool>,
232 pub auto_inject_skills: Option<bool>,
235 #[serde(default = "serde_defaults::default_true")]
237 pub auto_update_mcp: bool,
238 #[serde(default = "serde_defaults::default_true")]
243 pub manage_hooks: bool,
244 #[serde(skip_serializing_if = "Option::is_none")]
249 pub agents: Option<Vec<String>>,
250}
251
252impl Default for SetupConfig {
253 fn default() -> Self {
254 Self {
255 auto_inject_rules: None,
256 auto_inject_skills: None,
257 auto_update_mcp: true,
258 manage_hooks: true,
259 agents: None,
260 }
261 }
262}
263
264impl SetupConfig {
265 pub fn should_inject_rules(&self) -> bool {
269 match self.auto_inject_rules {
270 Some(v) => v,
271 None => Self::rules_already_present(),
272 }
273 }
274
275 pub fn should_inject_skills(&self) -> bool {
277 match self.auto_inject_skills {
278 Some(v) => v,
279 None => Self::rules_already_present(),
280 }
281 }
282
283 pub fn should_update_mcp(&self) -> bool {
288 self.auto_update_mcp
289 }
290
291 fn rules_already_present() -> bool {
299 let Some(home) = dirs::home_dir() else {
300 return false;
301 };
302 if crate::rules_inject::any_rules_marker_present(&home) {
303 return true;
304 }
305 let legacy_paths = [
306 crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
307 crate::core::editor_registry::codebuddy_rules_dir(&home).join("lean-ctx.md"),
308 ];
309 legacy_paths.iter().any(|p| {
310 std::fs::read_to_string(p)
311 .is_ok_and(|c| c.contains(crate::core::rules_canonical::START_MARK))
312 })
313 }
314}
315
316impl Default for SecretDetectionConfig {
317 fn default() -> Self {
318 Self {
319 enabled: true,
320 redact: true,
321 custom_patterns: Vec::new(),
322 exclude_patterns: Vec::new(),
323 }
324 }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(default)]
330pub struct ArchiveConfig {
331 pub enabled: bool,
332 pub threshold_chars: usize,
333 pub max_age_hours: u64,
334 pub max_disk_mb: u64,
335 pub ephemeral: bool,
336 pub ephemeral_min_tokens: usize,
339 pub inline_max_bytes: usize,
342 pub raw_commands: Vec<String>,
347 pub verbatim_max_tokens: usize,
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366#[serde(default)]
367pub struct ConversationConfig {
368 pub compression_enabled: bool,
370 pub preserve_last_n_turns: usize,
372 pub compression_threshold_tokens: usize,
374 pub min_score_to_preserve: f64,
376 pub drop_score_below: f64,
378}
379
380impl Default for ConversationConfig {
381 fn default() -> Self {
382 Self {
383 compression_enabled: false,
384 preserve_last_n_turns: 10,
385 compression_threshold_tokens: 50_000,
386 min_score_to_preserve: 0.5,
387 drop_score_below: 0.2,
388 }
389 }
390}
391
392impl Default for ArchiveConfig {
393 fn default() -> Self {
394 Self {
395 enabled: true,
396 threshold_chars: 800,
397 max_age_hours: 48,
398 max_disk_mb: 500,
399 ephemeral: true,
400 ephemeral_min_tokens: 2000,
401 inline_max_bytes: 32 * 1024,
402 raw_commands: crate::core::firewall::DEFAULT_RAW_COMMANDS
403 .iter()
404 .map(|s| (*s).to_string())
405 .collect(),
406 verbatim_max_tokens: 100_000,
407 }
408 }
409}
410
411impl ArchiveConfig {
412 pub fn ephemeral_effective(&self) -> bool {
413 if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
414 return !matches!(v.trim(), "0" | "false" | "off");
415 }
416 self.ephemeral && self.enabled
417 }
418
419 pub fn ephemeral_min_tokens_effective(&self) -> usize {
420 if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL_MIN_TOKENS")
421 && let Ok(n) = v.trim().parse::<usize>()
422 {
423 return n;
424 }
425 self.ephemeral_min_tokens
426 }
427
428 pub fn verbatim_max_tokens_effective(&self) -> usize {
430 if let Ok(v) = std::env::var("LEAN_CTX_VERBATIM_MAX_TOKENS")
431 && let Ok(n) = v.trim().parse::<usize>()
432 {
433 return n;
434 }
435 self.verbatim_max_tokens
436 }
437
438 pub fn inline_max_bytes_effective(&self) -> usize {
439 if let Ok(v) = std::env::var("LEAN_CTX_INLINE_MAX_BYTES")
440 && let Ok(n) = v.trim().parse::<usize>()
441 {
442 return n;
443 }
444 self.inline_max_bytes
445 }
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(default)]
453pub struct ProvidersConfig {
454 pub enabled: bool,
456 pub github: ProviderEntryConfig,
458 pub gitlab: ProviderEntryConfig,
460 pub auto_index: bool,
462 pub cache_ttl_secs: u64,
464 #[serde(default)]
466 pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
467}
468
469impl Default for ProvidersConfig {
470 fn default() -> Self {
471 Self {
472 enabled: true,
473 github: ProviderEntryConfig::default(),
474 gitlab: ProviderEntryConfig::default(),
475 auto_index: true,
476 cache_ttl_secs: 120,
477 mcp_bridges: std::collections::HashMap::new(),
478 }
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(default)]
485pub struct KnowledgeRoutingConfig {
486 pub enabled: bool,
487 pub max_references: usize,
488 pub hint_prefix: String,
489}
490
491impl Default for KnowledgeRoutingConfig {
492 fn default() -> Self {
493 Self {
494 enabled: true,
495 max_references: 5,
496 hint_prefix: "--- Knowledge Context ---".to_string(),
497 }
498 }
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct McpBridgeEntry {
503 #[serde(default)]
505 pub url: Option<String>,
506 #[serde(default)]
508 pub command: Option<String>,
509 #[serde(default)]
511 pub args: Vec<String>,
512 #[serde(default)]
514 pub description: Option<String>,
515 #[serde(default)]
517 pub auth_env: Option<String>,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
522#[serde(default)]
523pub struct ProviderEntryConfig {
524 pub enabled: bool,
526 pub token: Option<String>,
528 pub api_url: Option<String>,
530 pub project: Option<String>,
532}
533
534impl Default for ProviderEntryConfig {
535 fn default() -> Self {
536 Self {
537 enabled: true,
538 token: None,
539 api_url: None,
540 project: None,
541 }
542 }
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
547#[serde(default)]
548pub struct AutonomyConfig {
549 pub enabled: bool,
550 pub auto_consolidate: bool,
551 pub consolidate_every_calls: u32,
552 pub consolidate_cooldown_secs: u64,
553 pub cognition_loop_enabled: bool,
557 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
558 pub cognition_loop_interval_secs: u64,
559 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
560 pub cognition_loop_max_steps: u8,
561 #[serde(default = "serde_defaults::default_cognition_synthesis_min_cluster")]
562 pub cognition_synthesis_min_cluster: usize,
563}
564
565impl Default for AutonomyConfig {
566 fn default() -> Self {
567 Self {
568 enabled: true,
569 auto_consolidate: true,
570 consolidate_every_calls: 25,
571 consolidate_cooldown_secs: 120,
572 cognition_loop_enabled: false,
573 cognition_loop_interval_secs: 3600,
574 cognition_loop_max_steps: 9,
575 cognition_synthesis_min_cluster: 3,
576 }
577 }
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(default)]
584pub struct UpdatesConfig {
585 pub auto_update: bool,
586 pub check_interval_hours: u64,
587 pub notify_only: bool,
588 #[serde(default)]
591 pub pinned_version: Option<String>,
592}
593
594impl Default for UpdatesConfig {
595 fn default() -> Self {
596 Self {
597 auto_update: false,
598 check_interval_hours: 6,
599 notify_only: false,
600 pinned_version: None,
601 }
602 }
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
611#[serde(default)]
612pub struct ContextConfig {
613 pub budget_tokens: usize,
614 pub proactive_expansion: bool,
616 pub proactive_expansion_budget_tokens: usize,
618 pub proactive_expansion_threshold: f64,
620 pub proactive_expansion_max_age_secs: u64,
622}
623
624impl Default for ContextConfig {
625 fn default() -> Self {
626 Self {
627 budget_tokens: 8000,
628 proactive_expansion: true,
629 proactive_expansion_budget_tokens: 2000,
630 proactive_expansion_threshold: 0.6,
631 proactive_expansion_max_age_secs: 3600,
632 }
633 }
634}
635
636impl UpdatesConfig {
637 pub fn from_env() -> Self {
638 let mut cfg = Self::default();
639 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
640 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
641 }
642 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS")
643 && let Ok(h) = v.parse::<u64>()
644 {
645 cfg.check_interval_hours = h.clamp(1, 168);
646 }
647 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
648 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
649 }
650 cfg
651 }
652}
653
654impl AutonomyConfig {
655 pub fn from_env() -> Self {
657 let mut cfg = Self::default();
658 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
659 && (v == "false" || v == "0")
660 {
661 cfg.enabled = false;
662 }
663 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
664 cfg.auto_consolidate = v != "false" && v != "0";
665 }
666 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS")
667 && let Ok(n) = v.parse()
668 {
669 cfg.consolidate_every_calls = n;
670 }
671 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS")
672 && let Ok(n) = v.parse()
673 {
674 cfg.consolidate_cooldown_secs = n;
675 }
676 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
677 cfg.cognition_loop_enabled = v != "false" && v != "0";
678 }
679 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
680 && let Ok(n) = v.parse()
681 {
682 cfg.cognition_loop_interval_secs = n;
683 }
684 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
685 && let Ok(n) = v.parse()
686 {
687 cfg.cognition_loop_max_steps = n;
688 }
689 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
690 && let Ok(n) = v.parse()
691 {
692 cfg.cognition_synthesis_min_cluster = n;
693 }
694 cfg
695 }
696
697 pub fn load() -> Self {
699 let file_cfg = Config::load().autonomy;
700 let mut cfg = file_cfg;
701 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
702 && (v == "false" || v == "0")
703 {
704 cfg.enabled = false;
705 }
706 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
707 cfg.cognition_loop_enabled = v != "false" && v != "0";
708 }
709 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
710 && let Ok(n) = v.parse()
711 {
712 cfg.cognition_loop_interval_secs = n;
713 }
714 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
715 && let Ok(n) = v.parse()
716 {
717 cfg.cognition_loop_max_steps = n;
718 }
719 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
720 && let Ok(n) = v.parse()
721 {
722 cfg.cognition_synthesis_min_cluster = n;
723 }
724 cfg
725 }
726}
727
728#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
731#[serde(rename_all = "snake_case")]
732pub enum TelemetryPreference {
733 #[default]
734 DefaultOn,
735 ExplicitlyEnabled,
736 ExplicitlyDisabled,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize)]
747#[serde(default)]
748pub struct TelemetryConfig {
749 pub enabled: bool,
751 pub preference: TelemetryPreference,
753 pub last_heartbeat: Option<String>,
755}
756
757impl Default for TelemetryConfig {
758 fn default() -> Self {
759 Self {
760 enabled: true,
761 preference: TelemetryPreference::DefaultOn,
762 last_heartbeat: None,
763 }
764 }
765}
766
767impl TelemetryConfig {
768 #[must_use]
769 pub fn environment_disables(do_not_track: Option<&str>, env_override: Option<&str>) -> bool {
770 do_not_track.is_some_and(|value| value.trim() == "1")
771 || env_override.is_some_and(|value| {
772 matches!(
773 value.trim().to_ascii_lowercase().as_str(),
774 "off" | "false" | "0" | "no"
775 )
776 })
777 }
778
779 #[must_use]
780 pub fn explicitly_disabled(&self) -> bool {
781 !self.enabled || self.preference == TelemetryPreference::ExplicitlyDisabled
782 }
783
784 #[must_use]
785 pub fn send_eligible(&self, do_not_track: Option<&str>, env_override: Option<&str>) -> bool {
786 self.enabled
787 && self.preference != TelemetryPreference::ExplicitlyDisabled
788 && !Self::environment_disables(do_not_track, env_override)
789 }
790}
791
792#[derive(Debug, Clone, Default, Deserialize)]
795#[serde(default)]
796pub struct LegacyCloudConfig {
797 pub contribute_enabled: bool,
798}
799
800#[derive(Debug, Clone, Default, Serialize, Deserialize)]
809#[serde(default)]
810pub struct CostConfig {
811 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub default_model: Option<String>,
815 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
820 pub models: HashMap<String, String>,
821 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
829 pub prices: HashMap<String, PriceOverride>,
830}
831
832#[derive(Debug, Clone, Serialize, Deserialize)]
834#[serde(default)]
835pub struct ProtectionConfig {
836 pub file_patterns: Vec<String>,
842 pub tags: bool,
846}
847
848impl Default for ProtectionConfig {
849 fn default() -> Self {
850 Self {
851 file_patterns: Vec::new(),
852 tags: true,
853 }
854 }
855}
856
857impl ProtectionConfig {
858 pub fn path_is_protected(&self, path: &str) -> bool {
860 !path.is_empty()
861 && self
862 .file_patterns
863 .iter()
864 .filter_map(|p| glob::Pattern::new(p).ok())
865 .any(|pattern| pattern.matches(path))
866 }
867}
868
869#[cfg(test)]
870mod protection_tests {
871 use super::ProtectionConfig;
872
873 #[test]
874 fn protected_globs_match_paths_and_ignore_invalid_patterns() {
875 let cfg = ProtectionConfig {
876 file_patterns: vec![
877 "docs/audits/**".into(),
878 "*.secret.md".into(),
879 "[invalid".into(),
880 ],
881 tags: true,
882 };
883 assert!(cfg.path_is_protected("docs/audits/q3/blockers.md"));
884 assert!(cfg.path_is_protected("notes.secret.md"));
885 assert!(!cfg.path_is_protected("src/main.rs"));
886 assert!(!cfg.path_is_protected(""));
887
888 let empty = ProtectionConfig::default();
889 assert!(!empty.path_is_protected("docs/audits/q3/blockers.md"));
890 assert!(empty.tags, "protect tags are honored by default");
891 }
892}
893#[derive(Debug, Clone, Serialize, Deserialize)]
895#[serde(default)]
896pub struct DecisionLoopConfig {
897 pub enabled: bool,
898 #[serde(default)]
901 pub max_filter_level: u8,
902}
903
904impl Default for DecisionLoopConfig {
905 fn default() -> Self {
906 Self {
907 enabled: true,
908 max_filter_level: 0,
909 }
910 }
911}
912
913#[derive(Debug, Clone, Default, Serialize, Deserialize)]
916#[serde(default)]
917pub struct PriceOverride {
918 pub input_per_m: Option<f64>,
919 pub output_per_m: Option<f64>,
920 pub cache_write_per_m: Option<f64>,
921 pub cache_read_per_m: Option<f64>,
922}
923
924impl CostConfig {
925 pub fn model_for_client(&self, client: &str) -> Option<String> {
929 self.models
930 .get(client)
931 .or(self.default_model.as_ref())
932 .map(|s| s.trim().to_string())
933 .filter(|s| !s.is_empty())
934 }
935}
936
937#[derive(Debug, Clone, Serialize, Deserialize)]
943#[serde(default)]
944pub struct CodeHealthConfig {
945 pub cognitive_threshold: u32,
948 pub gate: String,
951 pub annotate_reads: bool,
953 pub naming: bool,
955 #[serde(default)]
961 pub inject_context: bool,
962}
963
964impl Default for CodeHealthConfig {
965 fn default() -> Self {
966 Self {
967 cognitive_threshold: 15,
968 gate: "warn".to_string(),
969 annotate_reads: true,
970 naming: true,
971 inject_context: false,
972 }
973 }
974}
975
976#[derive(Debug, Clone, Serialize, Deserialize)]
986#[serde(default)]
987pub struct IndexConfig {
988 pub respect_gitignore: bool,
992 pub exclude: Vec<String>,
995 pub include: Vec<String>,
998}
999
1000impl Default for IndexConfig {
1001 fn default() -> Self {
1002 Self {
1003 respect_gitignore: true,
1004 exclude: Vec::new(),
1005 include: Vec::new(),
1006 }
1007 }
1008}
1009
1010#[derive(Debug, Clone, Serialize, Deserialize)]
1018#[serde(default)]
1019pub struct SkillifyConfig {
1020 pub enabled: bool,
1023 pub scope: String,
1026 pub min_confidence: f32,
1029 pub min_recurrence: u32,
1032}
1033
1034impl Default for SkillifyConfig {
1035 fn default() -> Self {
1036 Self {
1037 enabled: true,
1038 scope: "project".to_string(),
1039 min_confidence: 0.7,
1040 min_recurrence: 2,
1041 }
1042 }
1043}
1044
1045#[derive(Debug, Clone, Serialize, Deserialize)]
1050#[serde(default)]
1051pub struct SummariesConfig {
1052 pub enabled: bool,
1055 pub every_n_turns: u32,
1058 pub max_kept: u32,
1060}
1061
1062impl Default for SummariesConfig {
1063 fn default() -> Self {
1064 Self {
1065 enabled: true,
1066 every_n_turns: 25,
1067 max_kept: 100,
1068 }
1069 }
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1074pub struct AliasEntry {
1075 pub command: String,
1076 pub alias: String,
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize)]
1081#[serde(default)]
1082pub struct LoopDetectionConfig {
1083 pub normal_threshold: u32,
1084 pub reduced_threshold: u32,
1085 pub blocked_threshold: u32,
1086 pub window_secs: u64,
1087 pub search_group_limit: u32,
1088 pub tool_total_limits: HashMap<String, u32>,
1089}
1090
1091impl Default for LoopDetectionConfig {
1092 fn default() -> Self {
1093 let mut tool_total_limits = HashMap::new();
1094 tool_total_limits.insert("ctx_read".to_string(), 100);
1095 tool_total_limits.insert("ctx_search".to_string(), 80);
1096 tool_total_limits.insert("ctx_shell".to_string(), 50);
1097 Self {
1098 normal_threshold: 2,
1099 reduced_threshold: 4,
1100 blocked_threshold: 0,
1101 window_secs: 300,
1102 search_group_limit: 10,
1103 tool_total_limits,
1104 }
1105 }
1106}
1107
1108#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1129#[serde(default)]
1130pub struct GatewayServerConfig {
1131 #[serde(default, skip_serializing_if = "Option::is_none")]
1134 pub seats: Option<u32>,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub org_label: Option<String>,
1138 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub admin_url: Option<String>,
1144 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub pseudonymize_persons: Option<bool>,
1151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1159 pub mcp_servers: Vec<McpServerEntry>,
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1166pub struct McpServerEntry {
1167 pub id: String,
1170 pub url: String,
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1181 pub auth_env: Option<String>,
1182 #[serde(default, skip_serializing_if = "Option::is_none")]
1184 pub enabled: Option<bool>,
1185}
1186
1187#[derive(Debug, Clone, PartialEq, Eq)]
1190pub struct ResolvedMcpServer {
1191 pub id: String,
1192 pub url: String,
1193 pub auth_env: Option<String>,
1194}
1195
1196impl GatewayServerConfig {
1197 #[must_use]
1203 pub fn resolve_mcp_servers(&self, allow_insecure_http: bool) -> Vec<ResolvedMcpServer> {
1204 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1205 let mut out = Vec::new();
1206 for entry in &self.mcp_servers {
1207 if !entry.enabled.unwrap_or(true) {
1208 continue;
1209 }
1210 let id = entry.id.trim();
1211 if !is_valid_mcp_server_id(id) {
1212 tracing::warn!(
1213 "[gateway_server.mcp_servers] invalid id '{id}' \
1214 (lowercase alnum/-/_ only) — entry skipped"
1215 );
1216 continue;
1217 }
1218 if !seen.insert(id) {
1219 tracing::warn!(
1220 "[gateway_server.mcp_servers] duplicate id '{id}' — keeping first entry"
1221 );
1222 continue;
1223 }
1224 match validate_mcp_upstream_url(&entry.url, allow_insecure_http) {
1225 Ok(url) => out.push(ResolvedMcpServer {
1226 id: id.to_string(),
1227 url,
1228 auth_env: entry
1229 .auth_env
1230 .as_deref()
1231 .map(str::trim)
1232 .filter(|v| !v.is_empty())
1233 .map(str::to_string),
1234 }),
1235 Err(e) => {
1236 tracing::warn!(
1237 "[gateway_server.mcp_servers] '{id}' has invalid url — skipped: {e}"
1238 );
1239 }
1240 }
1241 }
1242 out
1243 }
1244}
1245
1246fn is_valid_mcp_server_id(id: &str) -> bool {
1250 !id.is_empty()
1251 && id
1252 .chars()
1253 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1254}
1255
1256fn validate_mcp_upstream_url(url: &str, allow_insecure_http: bool) -> Result<String, String> {
1262 let trimmed = url.trim().trim_end_matches('/');
1263 if trimmed.is_empty() {
1264 return Err("empty url".into());
1265 }
1266 if crate::core::config::is_local_proxy_url(trimmed) {
1267 return Ok(trimmed.to_string());
1268 }
1269 if trimmed.starts_with("http://") {
1270 if allow_insecure_http {
1271 return Ok(trimmed.to_string());
1272 }
1273 return Err(format!(
1274 "MCP upstream must use HTTPS: {trimmed} (for a trusted local-network HTTP \
1275 upstream opt in with `[proxy] allow_insecure_http_upstream = true`)"
1276 ));
1277 }
1278 if trimmed.starts_with("https://") {
1279 return Ok(trimmed.to_string());
1280 }
1281 Err(format!(
1282 "MCP upstream must start with http:// or https://: {trimmed}"
1283 ))
1284}
1285
1286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1287#[serde(default)]
1288pub struct EmbeddingConfig {
1289 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub model: Option<String>,
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1292 pub dimensions: Option<usize>,
1293 #[serde(default, skip_serializing_if = "Option::is_none")]
1299 pub auto_download: Option<bool>,
1300 #[serde(default, skip_serializing_if = "Option::is_none")]
1307 pub deterministic: Option<bool>,
1308}
1309
1310#[cfg(test)]
1311mod gateway_server_tests {
1312 use super::*;
1313
1314 fn mcp_entry(id: &str, url: &str) -> McpServerEntry {
1315 McpServerEntry {
1316 id: id.into(),
1317 url: url.into(),
1318 auth_env: None,
1319 enabled: None,
1320 }
1321 }
1322
1323 #[test]
1324 fn mcp_registry_validates_ids_urls_and_duplicates() {
1325 let cfg = GatewayServerConfig {
1326 mcp_servers: vec![
1327 mcp_entry("github", "https://mcp.example.com/mcp/"),
1328 mcp_entry("GitHub", "https://mcp.example.com/mcp"),
1330 mcp_entry("github", "https://other.example.com/mcp"),
1332 mcp_entry("plain", "http://mcp.example.com/mcp"),
1334 mcp_entry("local", "http://127.0.0.1:9200/mcp"),
1336 McpServerEntry {
1337 enabled: Some(false),
1338 ..mcp_entry("disabled", "https://mcp.example.com/mcp")
1339 },
1340 McpServerEntry {
1341 auth_env: Some(" GITHUB_MCP_PAT ".into()),
1342 ..mcp_entry("authed", "https://api.githubcopilot.com/mcp")
1343 },
1344 ],
1345 ..Default::default()
1346 };
1347 let resolved = cfg.resolve_mcp_servers(false);
1348 let ids: Vec<&str> = resolved.iter().map(|s| s.id.as_str()).collect();
1349 assert_eq!(ids, ["github", "local", "authed"]);
1350 assert_eq!(resolved[0].url, "https://mcp.example.com/mcp");
1352 assert_eq!(resolved[2].auth_env.as_deref(), Some("GITHUB_MCP_PAT"));
1353
1354 let with_optin = cfg.resolve_mcp_servers(true);
1356 assert!(with_optin.iter().any(|s| s.id == "plain"));
1357 }
1358
1359 #[test]
1360 fn mcp_upstream_url_rules_match_the_proxy_posture() {
1361 assert!(validate_mcp_upstream_url("https://mcp.example.com/mcp", false).is_ok());
1362 assert!(validate_mcp_upstream_url("http://localhost:9200/mcp", false).is_ok());
1363 assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", false).is_err());
1364 assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", true).is_ok());
1365 assert!(validate_mcp_upstream_url("ftp://mcp.example.com", false).is_err());
1366 assert!(validate_mcp_upstream_url(" ", false).is_err());
1367 }
1368}
1369
1370#[cfg(test)]
1371mod ocla_tests {
1372 use super::OclaConfig;
1373 use serde::Deserialize;
1374
1375 #[derive(Deserialize)]
1376 struct ConfigFile {
1377 ocla: OclaConfig,
1378 }
1379
1380 #[test]
1382 fn removed_listener_sections_are_ignored() {
1383 let config: ConfigFile = toml::from_str(
1384 r#"
1385 [ocla.sidecar]
1386 bind_addr = "127.0.0.1:9000"
1387 enabled = true
1388 [ocla.grpc]
1389 enabled = true
1390 listen = "127.0.0.1:60051"
1391 [ocla.delivery]
1392 max_entries = 7
1393 "#,
1394 )
1395 .expect("legacy OCLA config");
1396 assert_eq!(config.ocla.delivery.max_entries, 7);
1397 assert!(config.ocla.delivery.enabled);
1398 }
1399}
1400
1401#[cfg(test)]
1402mod telemetry_tests {
1403 use super::*;
1404
1405 #[test]
1406 fn telemetry_config_defaults_on_and_send_eligible() {
1407 let cfg = TelemetryConfig::default();
1408 assert!(cfg.enabled);
1409 assert_eq!(cfg.preference, TelemetryPreference::DefaultOn);
1410 assert!(cfg.send_eligible(None, None));
1411 assert!(cfg.last_heartbeat.is_none());
1412 }
1413
1414 #[test]
1415 fn telemetry_config_serde_roundtrip() {
1416 let toml_str = r#"
1417[telemetry]
1418enabled = true
1419last_heartbeat = "2026-07-30"
1420"#;
1421 #[derive(serde::Deserialize)]
1422 struct Wrap {
1423 telemetry: TelemetryConfig,
1424 }
1425 let wrap: Wrap = toml::from_str(toml_str).expect("parse telemetry config");
1426 assert!(wrap.telemetry.enabled);
1427 assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
1428 assert_eq!(wrap.telemetry.last_heartbeat.as_deref(), Some("2026-07-30"));
1429 }
1430
1431 #[test]
1432 fn a_retired_notice_key_in_an_existing_config_still_parses() {
1433 let cfg: TelemetryConfig =
1434 toml::from_str("enabled = true\nnotice_shown = false").expect("pre-removal config");
1435 assert!(cfg.send_eligible(None, None));
1436 }
1437
1438 #[test]
1439 fn telemetry_config_missing_section_uses_defaults() {
1440 let toml_str = "";
1441 #[derive(serde::Deserialize, Default)]
1442 #[serde(default)]
1443 struct Wrap {
1444 telemetry: TelemetryConfig,
1445 }
1446 let wrap: Wrap = toml::from_str(toml_str).expect("parse empty config");
1447 assert!(wrap.telemetry.enabled);
1448 assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
1449 assert!(wrap.telemetry.last_heartbeat.is_none());
1450 }
1451
1452 #[test]
1453 fn legacy_false_remains_an_explicit_opt_out() {
1454 let cfg: TelemetryConfig = toml::from_str("enabled = false").expect("legacy config");
1455 assert!(cfg.explicitly_disabled());
1456 }
1457
1458 #[test]
1459 fn send_honors_the_explicit_and_both_environment_opt_outs() {
1460 let mut cfg = TelemetryConfig::default();
1461 assert!(cfg.send_eligible(None, None));
1462 assert!(!cfg.send_eligible(Some("1"), None));
1463 assert!(!cfg.send_eligible(None, Some("OFF")));
1464 cfg.preference = TelemetryPreference::ExplicitlyDisabled;
1465 assert!(!cfg.send_eligible(None, None));
1466 }
1467}