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)]
944#[serde(default)]
945pub struct DecisionsConfig {
946 pub enforce: String,
947}
948
949impl Default for DecisionsConfig {
950 fn default() -> Self {
951 Self {
952 enforce: "hint".to_string(),
953 }
954 }
955}
956
957impl DecisionsConfig {
958 pub fn enforce_mode(&self) -> &'static str {
960 match self.enforce.trim().to_ascii_lowercase().as_str() {
961 "refuse" | "strict" | "block" => "refuse",
962 "off" | "false" | "none" => "off",
963 _ => "hint",
964 }
965 }
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize)]
974#[serde(default)]
975pub struct CodeHealthConfig {
976 pub cognitive_threshold: u32,
979 pub gate: String,
982 pub annotate_reads: bool,
984 pub naming: bool,
986 #[serde(default)]
992 pub inject_context: bool,
993}
994
995impl Default for CodeHealthConfig {
996 fn default() -> Self {
997 Self {
998 cognitive_threshold: 15,
999 gate: "warn".to_string(),
1000 annotate_reads: true,
1001 naming: true,
1002 inject_context: false,
1003 }
1004 }
1005}
1006
1007#[derive(Debug, Clone, Serialize, Deserialize)]
1017#[serde(default)]
1018pub struct IndexConfig {
1019 pub respect_gitignore: bool,
1023 pub exclude: Vec<String>,
1026 pub include: Vec<String>,
1029}
1030
1031impl Default for IndexConfig {
1032 fn default() -> Self {
1033 Self {
1034 respect_gitignore: true,
1035 exclude: Vec::new(),
1036 include: Vec::new(),
1037 }
1038 }
1039}
1040
1041#[derive(Debug, Clone, Serialize, Deserialize)]
1049#[serde(default)]
1050pub struct SkillifyConfig {
1051 pub enabled: bool,
1054 pub scope: String,
1057 pub min_confidence: f32,
1060 pub min_recurrence: u32,
1063}
1064
1065impl Default for SkillifyConfig {
1066 fn default() -> Self {
1067 Self {
1068 enabled: true,
1069 scope: "project".to_string(),
1070 min_confidence: 0.7,
1071 min_recurrence: 2,
1072 }
1073 }
1074}
1075
1076#[derive(Debug, Clone, Serialize, Deserialize)]
1081#[serde(default)]
1082pub struct SummariesConfig {
1083 pub enabled: bool,
1086 pub every_n_turns: u32,
1089 pub max_kept: u32,
1091}
1092
1093impl Default for SummariesConfig {
1094 fn default() -> Self {
1095 Self {
1096 enabled: true,
1097 every_n_turns: 25,
1098 max_kept: 100,
1099 }
1100 }
1101}
1102
1103#[derive(Debug, Clone, Serialize, Deserialize)]
1105pub struct AliasEntry {
1106 pub command: String,
1107 pub alias: String,
1108}
1109
1110#[derive(Debug, Clone, Serialize, Deserialize)]
1112#[serde(default)]
1113pub struct LoopDetectionConfig {
1114 pub normal_threshold: u32,
1115 pub reduced_threshold: u32,
1116 pub blocked_threshold: u32,
1117 pub window_secs: u64,
1118 pub search_group_limit: u32,
1119 pub tool_total_limits: HashMap<String, u32>,
1120}
1121
1122impl Default for LoopDetectionConfig {
1123 fn default() -> Self {
1124 let mut tool_total_limits = HashMap::new();
1125 tool_total_limits.insert("ctx_read".to_string(), 100);
1126 tool_total_limits.insert("ctx_search".to_string(), 80);
1127 tool_total_limits.insert("ctx_shell".to_string(), 50);
1128 Self {
1129 normal_threshold: 2,
1130 reduced_threshold: 4,
1131 blocked_threshold: 0,
1132 window_secs: 300,
1133 search_group_limit: 10,
1134 tool_total_limits,
1135 }
1136 }
1137}
1138
1139#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1160#[serde(default)]
1161pub struct GatewayServerConfig {
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1165 pub seats: Option<u32>,
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1168 pub org_label: Option<String>,
1169 #[serde(default, skip_serializing_if = "Option::is_none")]
1174 pub admin_url: Option<String>,
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1181 pub pseudonymize_persons: Option<bool>,
1182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1190 pub mcp_servers: Vec<McpServerEntry>,
1191}
1192
1193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1197pub struct McpServerEntry {
1198 pub id: String,
1201 pub url: String,
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub auth_env: Option<String>,
1213 #[serde(default, skip_serializing_if = "Option::is_none")]
1215 pub enabled: Option<bool>,
1216}
1217
1218#[derive(Debug, Clone, PartialEq, Eq)]
1221pub struct ResolvedMcpServer {
1222 pub id: String,
1223 pub url: String,
1224 pub auth_env: Option<String>,
1225}
1226
1227impl GatewayServerConfig {
1228 #[must_use]
1234 pub fn resolve_mcp_servers(&self, allow_insecure_http: bool) -> Vec<ResolvedMcpServer> {
1235 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1236 let mut out = Vec::new();
1237 for entry in &self.mcp_servers {
1238 if !entry.enabled.unwrap_or(true) {
1239 continue;
1240 }
1241 let id = entry.id.trim();
1242 if !is_valid_mcp_server_id(id) {
1243 tracing::warn!(
1244 "[gateway_server.mcp_servers] invalid id '{id}' \
1245 (lowercase alnum/-/_ only) — entry skipped"
1246 );
1247 continue;
1248 }
1249 if !seen.insert(id) {
1250 tracing::warn!(
1251 "[gateway_server.mcp_servers] duplicate id '{id}' — keeping first entry"
1252 );
1253 continue;
1254 }
1255 match validate_mcp_upstream_url(&entry.url, allow_insecure_http) {
1256 Ok(url) => out.push(ResolvedMcpServer {
1257 id: id.to_string(),
1258 url,
1259 auth_env: entry
1260 .auth_env
1261 .as_deref()
1262 .map(str::trim)
1263 .filter(|v| !v.is_empty())
1264 .map(str::to_string),
1265 }),
1266 Err(e) => {
1267 tracing::warn!(
1268 "[gateway_server.mcp_servers] '{id}' has invalid url — skipped: {e}"
1269 );
1270 }
1271 }
1272 }
1273 out
1274 }
1275}
1276
1277fn is_valid_mcp_server_id(id: &str) -> bool {
1281 !id.is_empty()
1282 && id
1283 .chars()
1284 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
1285}
1286
1287fn validate_mcp_upstream_url(url: &str, allow_insecure_http: bool) -> Result<String, String> {
1293 let trimmed = url.trim().trim_end_matches('/');
1294 if trimmed.is_empty() {
1295 return Err("empty url".into());
1296 }
1297 if crate::core::config::is_local_proxy_url(trimmed) {
1298 return Ok(trimmed.to_string());
1299 }
1300 if trimmed.starts_with("http://") {
1301 if allow_insecure_http {
1302 return Ok(trimmed.to_string());
1303 }
1304 return Err(format!(
1305 "MCP upstream must use HTTPS: {trimmed} (for a trusted local-network HTTP \
1306 upstream opt in with `[proxy] allow_insecure_http_upstream = true`)"
1307 ));
1308 }
1309 if trimmed.starts_with("https://") {
1310 return Ok(trimmed.to_string());
1311 }
1312 Err(format!(
1313 "MCP upstream must start with http:// or https://: {trimmed}"
1314 ))
1315}
1316
1317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1318#[serde(default)]
1319pub struct EmbeddingConfig {
1320 #[serde(default, skip_serializing_if = "Option::is_none")]
1321 pub model: Option<String>,
1322 #[serde(default, skip_serializing_if = "Option::is_none")]
1323 pub dimensions: Option<usize>,
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1330 pub auto_download: Option<bool>,
1331 #[serde(default, skip_serializing_if = "Option::is_none")]
1338 pub deterministic: Option<bool>,
1339}
1340
1341#[cfg(test)]
1342mod gateway_server_tests {
1343 use super::*;
1344
1345 fn mcp_entry(id: &str, url: &str) -> McpServerEntry {
1346 McpServerEntry {
1347 id: id.into(),
1348 url: url.into(),
1349 auth_env: None,
1350 enabled: None,
1351 }
1352 }
1353
1354 #[test]
1355 fn mcp_registry_validates_ids_urls_and_duplicates() {
1356 let cfg = GatewayServerConfig {
1357 mcp_servers: vec![
1358 mcp_entry("github", "https://mcp.example.com/mcp/"),
1359 mcp_entry("GitHub", "https://mcp.example.com/mcp"),
1361 mcp_entry("github", "https://other.example.com/mcp"),
1363 mcp_entry("plain", "http://mcp.example.com/mcp"),
1365 mcp_entry("local", "http://127.0.0.1:9200/mcp"),
1367 McpServerEntry {
1368 enabled: Some(false),
1369 ..mcp_entry("disabled", "https://mcp.example.com/mcp")
1370 },
1371 McpServerEntry {
1372 auth_env: Some(" GITHUB_MCP_PAT ".into()),
1373 ..mcp_entry("authed", "https://api.githubcopilot.com/mcp")
1374 },
1375 ],
1376 ..Default::default()
1377 };
1378 let resolved = cfg.resolve_mcp_servers(false);
1379 let ids: Vec<&str> = resolved.iter().map(|s| s.id.as_str()).collect();
1380 assert_eq!(ids, ["github", "local", "authed"]);
1381 assert_eq!(resolved[0].url, "https://mcp.example.com/mcp");
1383 assert_eq!(resolved[2].auth_env.as_deref(), Some("GITHUB_MCP_PAT"));
1384
1385 let with_optin = cfg.resolve_mcp_servers(true);
1387 assert!(with_optin.iter().any(|s| s.id == "plain"));
1388 }
1389
1390 #[test]
1391 fn mcp_upstream_url_rules_match_the_proxy_posture() {
1392 assert!(validate_mcp_upstream_url("https://mcp.example.com/mcp", false).is_ok());
1393 assert!(validate_mcp_upstream_url("http://localhost:9200/mcp", false).is_ok());
1394 assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", false).is_err());
1395 assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", true).is_ok());
1396 assert!(validate_mcp_upstream_url("ftp://mcp.example.com", false).is_err());
1397 assert!(validate_mcp_upstream_url(" ", false).is_err());
1398 }
1399}
1400
1401#[cfg(test)]
1402mod ocla_tests {
1403 use super::OclaConfig;
1404 use serde::Deserialize;
1405
1406 #[derive(Deserialize)]
1407 struct ConfigFile {
1408 ocla: OclaConfig,
1409 }
1410
1411 #[test]
1413 fn removed_listener_sections_are_ignored() {
1414 let config: ConfigFile = toml::from_str(
1415 r#"
1416 [ocla.sidecar]
1417 bind_addr = "127.0.0.1:9000"
1418 enabled = true
1419 [ocla.grpc]
1420 enabled = true
1421 listen = "127.0.0.1:60051"
1422 [ocla.delivery]
1423 max_entries = 7
1424 "#,
1425 )
1426 .expect("legacy OCLA config");
1427 assert_eq!(config.ocla.delivery.max_entries, 7);
1428 assert!(config.ocla.delivery.enabled);
1429 }
1430}
1431
1432#[cfg(test)]
1433mod telemetry_tests {
1434 use super::*;
1435
1436 #[test]
1437 fn telemetry_config_defaults_on_and_send_eligible() {
1438 let cfg = TelemetryConfig::default();
1439 assert!(cfg.enabled);
1440 assert_eq!(cfg.preference, TelemetryPreference::DefaultOn);
1441 assert!(cfg.send_eligible(None, None));
1442 assert!(cfg.last_heartbeat.is_none());
1443 }
1444
1445 #[test]
1446 fn telemetry_config_serde_roundtrip() {
1447 let toml_str = r#"
1448[telemetry]
1449enabled = true
1450last_heartbeat = "2026-07-30"
1451"#;
1452 #[derive(serde::Deserialize)]
1453 struct Wrap {
1454 telemetry: TelemetryConfig,
1455 }
1456 let wrap: Wrap = toml::from_str(toml_str).expect("parse telemetry config");
1457 assert!(wrap.telemetry.enabled);
1458 assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
1459 assert_eq!(wrap.telemetry.last_heartbeat.as_deref(), Some("2026-07-30"));
1460 }
1461
1462 #[test]
1463 fn a_retired_notice_key_in_an_existing_config_still_parses() {
1464 let cfg: TelemetryConfig =
1465 toml::from_str("enabled = true\nnotice_shown = false").expect("pre-removal config");
1466 assert!(cfg.send_eligible(None, None));
1467 }
1468
1469 #[test]
1470 fn telemetry_config_missing_section_uses_defaults() {
1471 let toml_str = "";
1472 #[derive(serde::Deserialize, Default)]
1473 #[serde(default)]
1474 struct Wrap {
1475 telemetry: TelemetryConfig,
1476 }
1477 let wrap: Wrap = toml::from_str(toml_str).expect("parse empty config");
1478 assert!(wrap.telemetry.enabled);
1479 assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
1480 assert!(wrap.telemetry.last_heartbeat.is_none());
1481 }
1482
1483 #[test]
1484 fn legacy_false_remains_an_explicit_opt_out() {
1485 let cfg: TelemetryConfig = toml::from_str("enabled = false").expect("legacy config");
1486 assert!(cfg.explicitly_disabled());
1487 }
1488
1489 #[test]
1490 fn send_honors_the_explicit_and_both_environment_opt_outs() {
1491 let mut cfg = TelemetryConfig::default();
1492 assert!(cfg.send_eligible(None, None));
1493 assert!(!cfg.send_eligible(Some("1"), None));
1494 assert!(!cfg.send_eligible(None, Some("OFF")));
1495 cfg.preference = TelemetryPreference::ExplicitlyDisabled;
1496 assert!(!cfg.send_eligible(None, None));
1497 }
1498}