1mod features;
4mod org;
5
6pub use features::FeatureFlags;
7pub use org::{ConfigFileOrg, OrgBackendConfig, OrgConfig};
8
9use serde::Deserialize;
10use std::borrow::Cow;
11use std::path::{Path, PathBuf};
12
13#[cfg(unix)]
21fn warn_if_world_readable(path: &Path) {
22 use std::os::unix::fs::PermissionsExt;
23
24 if let Ok(metadata) = std::fs::metadata(path) {
25 let mode = metadata.permissions().mode();
26 if mode & 0o004 != 0 {
28 tracing::warn!(
29 path = %path.display(),
30 mode = format!("{mode:04o}"),
31 "Config file is world-readable. Consider restricting permissions with: chmod 600 {}",
32 path.display()
33 );
34 }
35 }
36}
37
38#[cfg(not(unix))]
40fn warn_if_world_readable(_path: &Path) {
41 }
43
44const MAX_ENV_VAR_EXPANSIONS: usize = 100;
65
66fn expand_env_vars(input: &str) -> Cow<'_, str> {
67 if !input.contains("${") {
69 return Cow::Borrowed(input);
70 }
71
72 let mut result = input.to_string();
73 let mut start = 0;
74 let mut expansion_count = 0;
75
76 while let Some(var_start) = result[start..].find("${") {
77 expansion_count += 1;
79 if expansion_count > MAX_ENV_VAR_EXPANSIONS {
80 tracing::warn!(
81 count = expansion_count,
82 "Environment variable expansion limit reached"
83 );
84 break;
85 }
86
87 let var_start = start + var_start;
88 if let Some(var_end) = result[var_start..].find('}') {
89 let var_end = var_start + var_end;
90 let var_name = &result[var_start + 2..var_end];
91 if let Ok(value) = std::env::var(var_name) {
92 result.replace_range(var_start..=var_end, &value);
93 start = var_start + value.len();
98 } else {
99 start = var_end + 1;
101 }
102 } else {
103 break;
105 }
106 }
107
108 Cow::Owned(result)
112}
113
114#[must_use]
125pub fn expand_config_path(input: &str) -> String {
126 let expanded = expand_env_vars(input);
127 let expanded_ref = expanded.as_ref();
128 let is_tilde_home =
129 expanded_ref == "~" || expanded_ref.starts_with("~/") || expanded_ref.starts_with("~\\");
130
131 if is_tilde_home && let Some(base_dirs) = directories::BaseDirs::new() {
132 let mut path = base_dirs.home_dir().to_path_buf();
133 let suffix = expanded_ref
134 .strip_prefix("~/")
135 .or_else(|| expanded_ref.strip_prefix("~\\"));
136 if let Some(suffix) = suffix
137 && !suffix.is_empty()
138 {
139 path.push(suffix);
140 }
141 return path.to_string_lossy().into_owned();
142 }
143
144 expanded.into_owned()
145}
146
147fn parse_bool_env(value: &str) -> Option<bool> {
148 match value.trim().to_lowercase().as_str() {
149 "true" | "1" | "yes" | "on" => Some(true),
150 "false" | "0" | "no" | "off" => Some(false),
151 _ => None,
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct SubcogConfig {
158 pub repo_path: PathBuf,
160 pub data_dir: PathBuf,
162 pub features: FeatureFlags,
164 pub max_results: usize,
166 pub default_search_mode: crate::models::SearchMode,
168 pub llm: LlmConfig,
170 pub search_intent: SearchIntentConfig,
172 pub observability: ObservabilitySettings,
174 pub prompt: PromptConfig,
176 pub storage: StorageConfig,
178 pub consolidation: ConsolidationConfig,
180 pub ttl: TtlConfig,
182 pub timeouts: OperationTimeoutConfig,
184 pub context_templates: ContextTemplatesConfig,
186 pub org: OrgConfig,
188 pub webhooks: WebhooksConfig,
190 pub config_sources: Vec<PathBuf>,
192}
193
194#[derive(Debug, Clone, Default)]
196pub struct LlmConfig {
197 pub provider: LlmProvider,
199 pub model: Option<String>,
201 pub api_key: Option<String>,
203 pub base_url: Option<String>,
205 pub max_tokens: Option<u32>,
207 pub timeout_ms: Option<u64>,
209 pub connect_timeout_ms: Option<u64>,
211 pub max_retries: Option<u32>,
213 pub retry_backoff_ms: Option<u64>,
215 pub breaker_failure_threshold: Option<u32>,
217 pub breaker_reset_ms: Option<u64>,
219 pub breaker_half_open_max_calls: Option<u32>,
221 pub latency_slo_ms: Option<u64>,
223 pub error_budget_ratio: Option<f64>,
225 pub error_budget_window_secs: Option<u64>,
227}
228
229impl LlmConfig {
230 #[must_use]
234 pub fn from_config_file(file: &ConfigFileLlm) -> Self {
235 let mut config = Self::default();
236
237 if let Some(ref provider) = file.provider {
238 config.provider = LlmProvider::parse(provider);
239 }
240 if let Some(ref model) = file.model
241 && !model.trim().is_empty()
242 {
243 config.model = Some(model.clone());
244 }
245 if let Some(ref api_key) = file.api_key
246 && !api_key.trim().is_empty()
247 {
248 config.api_key = Some(expand_env_vars(api_key).into_owned());
250 }
251 if let Some(ref base_url) = file.base_url
252 && !base_url.trim().is_empty()
253 {
254 config.base_url = Some(base_url.clone());
255 }
256 config.max_tokens = file.max_tokens;
257 config.timeout_ms = file.timeout_ms;
258 config.connect_timeout_ms = file.connect_timeout_ms;
259 config.max_retries = file.max_retries;
260 config.retry_backoff_ms = file.retry_backoff_ms;
261 config.breaker_failure_threshold = file.breaker_failure_threshold;
262 config.breaker_reset_ms = file.breaker_reset_ms;
263 config.breaker_half_open_max_calls = file.breaker_half_open_max_calls;
264 config.latency_slo_ms = file.latency_slo_ms;
265 config.error_budget_ratio = file.error_budget_ratio;
266 config.error_budget_window_secs = file.error_budget_window_secs;
267
268 config
269 }
270
271 pub fn merge_from(&mut self, file: &ConfigFileLlm) {
275 if let Some(ref provider) = file.provider {
276 self.provider = LlmProvider::parse(provider);
277 }
278 if let Some(ref model) = file.model
279 && !model.trim().is_empty()
280 {
281 self.model = Some(model.clone());
282 }
283 if let Some(ref api_key) = file.api_key
284 && !api_key.trim().is_empty()
285 {
286 self.api_key = Some(expand_env_vars(api_key).into_owned());
287 }
288 if let Some(ref base_url) = file.base_url
289 && !base_url.trim().is_empty()
290 {
291 self.base_url = Some(base_url.clone());
292 }
293 self.max_tokens = file.max_tokens.or(self.max_tokens);
295 self.timeout_ms = file.timeout_ms.or(self.timeout_ms);
296 self.connect_timeout_ms = file.connect_timeout_ms.or(self.connect_timeout_ms);
297 self.max_retries = file.max_retries.or(self.max_retries);
298 self.retry_backoff_ms = file.retry_backoff_ms.or(self.retry_backoff_ms);
299 self.breaker_failure_threshold = file
300 .breaker_failure_threshold
301 .or(self.breaker_failure_threshold);
302 self.breaker_reset_ms = file.breaker_reset_ms.or(self.breaker_reset_ms);
303 self.breaker_half_open_max_calls = file
304 .breaker_half_open_max_calls
305 .or(self.breaker_half_open_max_calls);
306 self.latency_slo_ms = file.latency_slo_ms.or(self.latency_slo_ms);
307 self.error_budget_ratio = file.error_budget_ratio.or(self.error_budget_ratio);
308 self.error_budget_window_secs = file
309 .error_budget_window_secs
310 .or(self.error_budget_window_secs);
311 }
312}
313
314#[derive(Debug, Clone, Default, Deserialize)]
316pub struct ObservabilitySettings {
317 pub logging: Option<LoggingSettings>,
319 pub tracing: Option<TracingSettings>,
321 pub metrics: Option<MetricsSettings>,
323}
324
325#[derive(Debug, Clone, Default, Deserialize)]
327pub struct LoggingSettings {
328 pub format: Option<String>,
330 pub level: Option<String>,
332 pub filter: Option<String>,
334 pub file: Option<String>,
336}
337
338#[derive(Debug, Clone, Default, Deserialize)]
340pub struct TracingSettings {
341 pub enabled: Option<bool>,
343 pub otlp: Option<OtlpSettings>,
345 pub sample_ratio: Option<f64>,
347 pub service_name: Option<String>,
349 pub resource_attributes: Option<Vec<String>>,
351}
352
353#[derive(Debug, Clone, Default, Deserialize)]
355pub struct OtlpSettings {
356 pub endpoint: Option<String>,
358 pub protocol: Option<String>,
360}
361
362#[derive(Debug, Clone, Default, Deserialize)]
364pub struct MetricsSettings {
365 pub enabled: Option<bool>,
367 pub port: Option<u16>,
369 pub push_gateway: Option<MetricsPushGatewaySettings>,
371}
372
373#[derive(Debug, Clone, Default, Deserialize)]
375pub struct MetricsPushGatewaySettings {
376 pub endpoint: Option<String>,
378 pub username: Option<String>,
380 pub password: Option<String>,
382 pub use_http_post: Option<bool>,
384}
385
386#[derive(Debug, Clone)]
388pub struct SearchIntentConfig {
389 pub enabled: bool,
391 pub use_llm: bool,
393 pub llm_timeout_ms: u64,
395 pub min_confidence: f32,
397 pub base_count: usize,
399 pub max_count: usize,
401 pub max_tokens: usize,
403 pub weights: NamespaceWeightsConfig,
405}
406
407#[derive(Debug, Clone, Default)]
413pub struct NamespaceWeightsConfig {
414 pub howto: std::collections::HashMap<String, f32>,
416 pub troubleshoot: std::collections::HashMap<String, f32>,
418 pub location: std::collections::HashMap<String, f32>,
420 pub explanation: std::collections::HashMap<String, f32>,
422 pub comparison: std::collections::HashMap<String, f32>,
424 pub general: std::collections::HashMap<String, f32>,
426}
427
428impl NamespaceWeightsConfig {
429 #[must_use]
431 pub fn with_defaults() -> Self {
432 use std::collections::HashMap;
433
434 let location_weights = HashMap::from([
436 ("decisions".to_string(), 1.5),
437 ("context".to_string(), 1.3),
438 ("patterns".to_string(), 1.0),
439 ]);
440
441 Self {
442 howto: HashMap::from([
444 ("patterns".to_string(), 1.5),
445 ("learnings".to_string(), 1.3),
446 ("decisions".to_string(), 1.0),
447 ]),
448 troubleshoot: HashMap::from([
450 ("blockers".to_string(), 1.5),
451 ("learnings".to_string(), 1.3),
452 ("decisions".to_string(), 1.0),
453 ]),
454 location: location_weights.clone(),
456 explanation: location_weights,
458 comparison: HashMap::from([
460 ("decisions".to_string(), 1.5),
461 ("patterns".to_string(), 1.3),
462 ("learnings".to_string(), 1.0),
463 ]),
464 general: HashMap::from([
466 ("decisions".to_string(), 1.2),
467 ("patterns".to_string(), 1.2),
468 ("learnings".to_string(), 1.0),
469 ]),
470 }
471 }
472
473 #[must_use]
477 pub fn get_weight(&self, intent_type: &str, namespace: &str) -> f32 {
478 let weights = match intent_type.to_lowercase().as_str() {
479 "howto" => &self.howto,
480 "troubleshoot" => &self.troubleshoot,
481 "location" => &self.location,
482 "explanation" => &self.explanation,
483 "comparison" => &self.comparison,
484 _ => &self.general,
485 };
486 weights.get(namespace).copied().unwrap_or(1.0)
487 }
488
489 #[must_use]
493 pub fn get_intent_weights(&self, intent_type: &str) -> Vec<(String, f32)> {
494 let weights = match intent_type.to_lowercase().as_str() {
495 "howto" => &self.howto,
496 "troubleshoot" => &self.troubleshoot,
497 "location" => &self.location,
498 "explanation" => &self.explanation,
499 "comparison" => &self.comparison,
500 _ => &self.general,
501 };
502 weights.iter().map(|(k, v)| (k.clone(), *v)).collect()
503 }
504
505 pub fn merge_from_file(&mut self, file: &ConfigFileNamespaceWeights) {
509 if let Some(ref howto) = file.howto {
510 Self::merge_intent_weights(&mut self.howto, howto);
511 }
512 if let Some(ref troubleshoot) = file.troubleshoot {
513 Self::merge_intent_weights(&mut self.troubleshoot, troubleshoot);
514 }
515 if let Some(ref location) = file.location {
516 Self::merge_intent_weights(&mut self.location, location);
517 }
518 if let Some(ref explanation) = file.explanation {
519 Self::merge_intent_weights(&mut self.explanation, explanation);
520 }
521 if let Some(ref comparison) = file.comparison {
522 Self::merge_intent_weights(&mut self.comparison, comparison);
523 }
524 if let Some(ref general) = file.general {
525 Self::merge_intent_weights(&mut self.general, general);
526 }
527 }
528
529 fn merge_intent_weights(
530 target: &mut std::collections::HashMap<String, f32>,
531 source: &ConfigFileIntentWeights,
532 ) {
533 if let Some(v) = source.decisions {
534 target.insert("decisions".to_string(), v);
535 }
536 if let Some(v) = source.patterns {
537 target.insert("patterns".to_string(), v);
538 }
539 if let Some(v) = source.learnings {
540 target.insert("learnings".to_string(), v);
541 }
542 if let Some(v) = source.context {
543 target.insert("context".to_string(), v);
544 }
545 if let Some(v) = source.tech_debt {
546 target.insert("tech-debt".to_string(), v);
547 }
548 if let Some(v) = source.blockers {
549 target.insert("blockers".to_string(), v);
550 }
551 if let Some(v) = source.apis {
552 target.insert("apis".to_string(), v);
553 }
554 if let Some(v) = source.config {
555 target.insert("config".to_string(), v);
556 }
557 if let Some(v) = source.security {
558 target.insert("security".to_string(), v);
559 }
560 if let Some(v) = source.performance {
561 target.insert("performance".to_string(), v);
562 }
563 if let Some(v) = source.testing {
564 target.insert("testing".to_string(), v);
565 }
566 }
567}
568
569impl Default for SearchIntentConfig {
570 fn default() -> Self {
571 Self {
572 enabled: true,
573 use_llm: true,
574 llm_timeout_ms: 200,
575 min_confidence: 0.5,
576 base_count: 5,
577 max_count: 15,
578 max_tokens: 4000,
579 weights: NamespaceWeightsConfig::with_defaults(),
580 }
581 }
582}
583
584impl SearchIntentConfig {
585 #[must_use]
587 pub fn new() -> Self {
588 Self::default()
589 }
590
591 #[must_use]
593 pub fn from_env() -> Self {
594 Self::default().with_env_overrides()
595 }
596
597 #[must_use]
599 pub fn with_env_overrides(mut self) -> Self {
600 if let Ok(v) = std::env::var("SUBCOG_SEARCH_INTENT_ENABLED") {
601 self.enabled = v.to_lowercase() == "true" || v == "1";
602 }
603 if let Ok(v) = std::env::var("SUBCOG_SEARCH_INTENT_USE_LLM") {
604 self.use_llm = v.to_lowercase() == "true" || v == "1";
605 }
606 if let Some(ms) = std::env::var("SUBCOG_SEARCH_INTENT_LLM_TIMEOUT_MS")
607 .ok()
608 .and_then(|v| v.parse::<u64>().ok())
609 {
610 self.llm_timeout_ms = ms;
611 }
612 if let Some(conf) = std::env::var("SUBCOG_SEARCH_INTENT_MIN_CONFIDENCE")
613 .ok()
614 .and_then(|v| v.parse::<f32>().ok())
615 {
616 self.min_confidence = conf.clamp(0.0, 1.0);
617 }
618
619 self
620 }
621
622 #[must_use]
624 pub fn from_config_file(config: &ConfigFileSearchIntent) -> Self {
625 let mut settings = Self::default();
626
627 if let Some(enabled) = config.enabled {
628 settings.enabled = enabled;
629 }
630 if let Some(use_llm) = config.use_llm {
631 settings.use_llm = use_llm;
632 }
633 if let Some(llm_timeout_ms) = config.llm_timeout_ms {
634 settings.llm_timeout_ms = llm_timeout_ms;
635 }
636 if let Some(min_confidence) = config.min_confidence {
637 settings.min_confidence = min_confidence.clamp(0.0, 1.0);
638 }
639 if let Some(base_count) = config.base_count {
640 settings.base_count = base_count;
641 }
642 if let Some(max_count) = config.max_count {
643 settings.max_count = max_count;
644 }
645 if let Some(max_tokens) = config.max_tokens {
646 settings.max_tokens = max_tokens;
647 }
648 if let Some(ref weights) = config.weights {
649 settings.weights.merge_from_file(weights);
650 }
651
652 settings
653 }
654
655 #[must_use]
657 pub const fn with_enabled(mut self, enabled: bool) -> Self {
658 self.enabled = enabled;
659 self
660 }
661
662 #[must_use]
664 pub const fn with_use_llm(mut self, use_llm: bool) -> Self {
665 self.use_llm = use_llm;
666 self
667 }
668
669 #[must_use]
671 pub const fn with_llm_timeout_ms(mut self, timeout_ms: u64) -> Self {
672 self.llm_timeout_ms = timeout_ms;
673 self
674 }
675
676 #[must_use]
680 pub const fn with_min_confidence(mut self, confidence: f32) -> Self {
681 self.min_confidence = confidence.clamp(0.0, 1.0);
682 self
683 }
684
685 #[must_use]
687 pub const fn with_base_count(mut self, count: usize) -> Self {
688 self.base_count = count;
689 self
690 }
691
692 #[must_use]
694 pub const fn with_max_count(mut self, count: usize) -> Self {
695 self.max_count = count;
696 self
697 }
698
699 #[must_use]
701 pub const fn with_max_tokens(mut self, tokens: usize) -> Self {
702 self.max_tokens = tokens;
703 self
704 }
705
706 #[must_use]
708 pub fn with_weights(mut self, weights: NamespaceWeightsConfig) -> Self {
709 self.weights = weights;
710 self
711 }
712
713 pub fn build(self) -> Result<Self, ConfigValidationError> {
722 if self.base_count > self.max_count {
723 return Err(ConfigValidationError::InvalidRange {
724 field: "base_count/max_count".to_string(),
725 message: format!(
726 "base_count ({}) cannot be greater than max_count ({})",
727 self.base_count, self.max_count
728 ),
729 });
730 }
731
732 if self.max_tokens == 0 {
733 return Err(ConfigValidationError::InvalidValue {
734 field: "max_tokens".to_string(),
735 message: "max_tokens must be greater than 0".to_string(),
736 });
737 }
738
739 if self.use_llm && self.llm_timeout_ms == 0 {
740 return Err(ConfigValidationError::InvalidValue {
741 field: "llm_timeout_ms".to_string(),
742 message: "llm_timeout_ms must be greater than 0 when LLM is enabled".to_string(),
743 });
744 }
745
746 Ok(self)
747 }
748}
749
750#[derive(Debug, Clone, thiserror::Error)]
752pub enum ConfigValidationError {
753 #[error("Invalid range for {field}: {message}")]
755 InvalidRange {
756 field: String,
758 message: String,
760 },
761 #[error("Invalid value for {field}: {message}")]
763 InvalidValue {
764 field: String,
766 message: String,
768 },
769}
770
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
773pub enum LlmProvider {
774 #[default]
776 Anthropic,
777 OpenAi,
779 Ollama,
781 LmStudio,
783 None,
785}
786
787impl LlmProvider {
788 #[must_use]
790 pub fn parse(s: &str) -> Self {
791 match s.to_lowercase().as_str() {
792 "openai" => Self::OpenAi,
793 "ollama" => Self::Ollama,
794 "lmstudio" | "lm_studio" | "lm-studio" => Self::LmStudio,
795 "none" | "disabled" | "" => Self::None,
796 _ => Self::Anthropic,
797 }
798 }
799
800 #[must_use]
802 pub const fn is_configured(&self) -> bool {
803 !matches!(self, Self::None)
804 }
805}
806
807#[derive(Debug, Deserialize, Default)]
809pub struct ConfigFile {
810 pub repo_path: Option<String>,
812 pub data_dir: Option<String>,
814 pub max_results: Option<usize>,
816 pub default_search_mode: Option<String>,
818 pub features: Option<ConfigFileFeatures>,
820 pub llm: Option<ConfigFileLlm>,
822 pub search_intent: Option<ConfigFileSearchIntent>,
824 pub observability: Option<ObservabilitySettings>,
826 pub prompt: Option<ConfigFilePrompt>,
828 pub storage: Option<ConfigFileStorage>,
830 pub consolidation: Option<ConfigFileConsolidation>,
832 pub ttl: Option<ConfigFileTtl>,
834 pub context_templates: Option<ConfigFileContextTemplates>,
836 pub org: Option<ConfigFileOrg>,
838 #[serde(default)]
840 pub webhooks: Vec<ConfigFileWebhook>,
841}
842
843#[derive(Debug, Deserialize, Default)]
845pub struct ConfigFileFeatures {
846 pub secrets_filter: Option<bool>,
848 pub pii_filter: Option<bool>,
850 pub multi_domain: Option<bool>,
852 pub audit_log: Option<bool>,
854 pub llm_features: Option<bool>,
856 pub auto_capture: Option<bool>,
858 pub consolidation: Option<bool>,
860 pub org_scope_enabled: Option<bool>,
862 pub auto_extract_entities: Option<bool>,
864}
865
866#[derive(Debug, Deserialize, Default)]
868pub struct ConfigFileLlm {
869 pub provider: Option<String>,
871 pub model: Option<String>,
873 pub api_key: Option<String>,
875 pub base_url: Option<String>,
877 pub max_tokens: Option<u32>,
879 pub timeout_ms: Option<u64>,
881 pub connect_timeout_ms: Option<u64>,
883 pub max_retries: Option<u32>,
885 pub retry_backoff_ms: Option<u64>,
887 pub breaker_failure_threshold: Option<u32>,
889 pub breaker_reset_ms: Option<u64>,
891 pub breaker_half_open_max_calls: Option<u32>,
893 pub latency_slo_ms: Option<u64>,
895 pub error_budget_ratio: Option<f64>,
897 pub error_budget_window_secs: Option<u64>,
899}
900
901#[derive(Debug, Deserialize, Default)]
903pub struct ConfigFileSearchIntent {
904 pub enabled: Option<bool>,
906 pub use_llm: Option<bool>,
908 pub llm_timeout_ms: Option<u64>,
910 pub min_confidence: Option<f32>,
912 pub base_count: Option<usize>,
914 pub max_count: Option<usize>,
916 pub max_tokens: Option<usize>,
918 pub weights: Option<ConfigFileNamespaceWeights>,
920}
921
922#[derive(Debug, Deserialize, Default, Clone)]
928pub struct ConfigFileNamespaceWeights {
929 pub howto: Option<ConfigFileIntentWeights>,
931 pub troubleshoot: Option<ConfigFileIntentWeights>,
933 pub location: Option<ConfigFileIntentWeights>,
935 pub explanation: Option<ConfigFileIntentWeights>,
937 pub comparison: Option<ConfigFileIntentWeights>,
939 pub general: Option<ConfigFileIntentWeights>,
941}
942
943#[derive(Debug, Deserialize, Default, Clone)]
948pub struct ConfigFileIntentWeights {
949 pub decisions: Option<f32>,
951 pub patterns: Option<f32>,
953 pub learnings: Option<f32>,
955 pub context: Option<f32>,
957 pub tech_debt: Option<f32>,
959 pub blockers: Option<f32>,
961 pub apis: Option<f32>,
963 pub config: Option<f32>,
965 pub security: Option<f32>,
967 pub performance: Option<f32>,
969 pub testing: Option<f32>,
971}
972
973#[derive(Debug, Clone, Deserialize, Default)]
994pub struct ConfigFileTtl {
995 pub default: Option<String>,
998 pub namespace: Option<ConfigFileTtlNamespace>,
1000 pub scope: Option<ConfigFileTtlScope>,
1002}
1003
1004#[derive(Debug, Clone, Deserialize, Default)]
1006pub struct ConfigFileTtlNamespace {
1007 pub decisions: Option<String>,
1009 pub patterns: Option<String>,
1011 pub learnings: Option<String>,
1013 pub context: Option<String>,
1015 #[serde(alias = "tech-debt")]
1017 pub tech_debt: Option<String>,
1018 pub apis: Option<String>,
1020 pub config: Option<String>,
1022 pub security: Option<String>,
1024 pub performance: Option<String>,
1026 pub testing: Option<String>,
1028}
1029
1030#[derive(Debug, Clone, Deserialize, Default)]
1032pub struct ConfigFileTtlScope {
1033 pub project: Option<String>,
1035 pub user: Option<String>,
1037 pub org: Option<String>,
1039}
1040
1041#[derive(Debug, Clone, Deserialize, Default)]
1056pub struct ConfigFileContextTemplates {
1057 pub enabled: Option<bool>,
1059 pub default_format: Option<String>,
1061 pub hooks: Option<ConfigFileHookTemplates>,
1063}
1064
1065#[derive(Debug, Clone, Deserialize, Default)]
1067pub struct ConfigFileHookTemplates {
1068 pub session_start: Option<ConfigFileHookTemplate>,
1070 pub user_prompt_submit: Option<ConfigFileHookTemplate>,
1072 pub post_tool_use: Option<ConfigFileHookTemplate>,
1074 pub pre_compact: Option<ConfigFileHookTemplate>,
1076}
1077
1078#[derive(Debug, Clone, Deserialize, Default)]
1080pub struct ConfigFileHookTemplate {
1081 pub template: Option<String>,
1083 pub version: Option<u32>,
1085 pub format: Option<String>,
1087}
1088
1089#[derive(Debug, Clone)]
1094pub struct ContextTemplatesConfig {
1095 pub enabled: bool,
1097 pub default_format: crate::models::OutputFormat,
1099 pub hooks: HookTemplatesConfig,
1101}
1102
1103impl Default for ContextTemplatesConfig {
1104 fn default() -> Self {
1105 Self {
1106 enabled: true,
1107 default_format: crate::models::OutputFormat::Markdown,
1108 hooks: HookTemplatesConfig::default(),
1109 }
1110 }
1111}
1112
1113impl ContextTemplatesConfig {
1114 pub fn from_config_file(file: &ConfigFileContextTemplates) -> Self {
1116 let default_format = file
1117 .default_format
1118 .as_deref()
1119 .and_then(parse_output_format)
1120 .unwrap_or(crate::models::OutputFormat::Markdown);
1121
1122 let hooks = file
1123 .hooks
1124 .as_ref()
1125 .map(HookTemplatesConfig::from_config_file)
1126 .unwrap_or_default();
1127
1128 Self {
1129 enabled: file.enabled.unwrap_or(true),
1130 default_format,
1131 hooks,
1132 }
1133 }
1134}
1135
1136#[derive(Debug, Clone, Default)]
1138pub struct HookTemplatesConfig {
1139 pub session_start: Option<HookTemplateConfig>,
1141 pub user_prompt_submit: Option<HookTemplateConfig>,
1143 pub post_tool_use: Option<HookTemplateConfig>,
1145 pub pre_compact: Option<HookTemplateConfig>,
1147}
1148
1149impl HookTemplatesConfig {
1150 pub fn from_config_file(file: &ConfigFileHookTemplates) -> Self {
1152 Self {
1153 session_start: file
1154 .session_start
1155 .as_ref()
1156 .map(HookTemplateConfig::from_config_file),
1157 user_prompt_submit: file
1158 .user_prompt_submit
1159 .as_ref()
1160 .map(HookTemplateConfig::from_config_file),
1161 post_tool_use: file
1162 .post_tool_use
1163 .as_ref()
1164 .map(HookTemplateConfig::from_config_file),
1165 pre_compact: file
1166 .pre_compact
1167 .as_ref()
1168 .map(HookTemplateConfig::from_config_file),
1169 }
1170 }
1171}
1172
1173#[derive(Debug, Clone)]
1175pub struct HookTemplateConfig {
1176 pub template: String,
1178 pub version: Option<u32>,
1180 pub format: Option<crate::models::OutputFormat>,
1182}
1183
1184impl HookTemplateConfig {
1185 pub fn from_config_file(file: &ConfigFileHookTemplate) -> Self {
1187 Self {
1188 template: file.template.clone().unwrap_or_default(),
1189 version: file.version,
1190 format: file.format.as_deref().and_then(parse_output_format),
1191 }
1192 }
1193}
1194
1195fn parse_output_format(s: &str) -> Option<crate::models::OutputFormat> {
1197 match s.to_lowercase().as_str() {
1198 "markdown" | "md" => Some(crate::models::OutputFormat::Markdown),
1199 "json" => Some(crate::models::OutputFormat::Json),
1200 "xml" => Some(crate::models::OutputFormat::Xml),
1201 _ => None,
1202 }
1203}
1204
1205#[derive(Debug, Clone, Default)]
1229pub struct TtlConfig {
1230 pub default_seconds: Option<u64>,
1232 pub namespace: TtlNamespaceConfig,
1234 pub scope: TtlScopeConfig,
1236}
1237
1238#[derive(Debug, Clone, Default)]
1240pub struct TtlNamespaceConfig {
1241 pub decisions: Option<u64>,
1243 pub patterns: Option<u64>,
1245 pub learnings: Option<u64>,
1247 pub context: Option<u64>,
1249 pub tech_debt: Option<u64>,
1251 pub apis: Option<u64>,
1253 pub config: Option<u64>,
1255 pub security: Option<u64>,
1257 pub performance: Option<u64>,
1259 pub testing: Option<u64>,
1261}
1262
1263#[derive(Debug, Clone, Default)]
1265pub struct TtlScopeConfig {
1266 pub project: Option<u64>,
1268 pub user: Option<u64>,
1270 pub org: Option<u64>,
1272}
1273
1274impl TtlConfig {
1275 #[must_use]
1277 pub fn new() -> Self {
1278 Self::default()
1279 }
1280
1281 #[must_use]
1283 pub fn from_config_file(file: &ConfigFileTtl) -> Self {
1284 let mut config = Self::default();
1285
1286 if let Some(ref default) = file.default {
1287 config.default_seconds = parse_duration_to_seconds(default);
1288 }
1289
1290 if let Some(ref ns) = file.namespace {
1291 config.namespace = TtlNamespaceConfig::from_config_file(ns);
1292 }
1293
1294 if let Some(ref scope) = file.scope {
1295 config.scope = TtlScopeConfig::from_config_file(scope);
1296 }
1297
1298 config
1299 }
1300
1301 #[must_use]
1303 pub fn from_env() -> Self {
1304 Self::default().with_env_overrides()
1305 }
1306
1307 #[must_use]
1309 pub fn with_env_overrides(mut self) -> Self {
1310 if let Ok(v) = std::env::var("SUBCOG_TTL_DEFAULT") {
1311 self.default_seconds = parse_duration_to_seconds(&v);
1312 }
1313 self
1314 }
1315
1316 #[must_use]
1326 pub fn get_ttl_seconds(&self, namespace: &str, scope: &str) -> Option<u64> {
1327 let ns_ttl = match namespace.to_lowercase().as_str() {
1329 "decisions" => self.namespace.decisions,
1330 "patterns" => self.namespace.patterns,
1331 "learnings" => self.namespace.learnings,
1332 "context" => self.namespace.context,
1333 "tech-debt" | "tech_debt" => self.namespace.tech_debt,
1334 "apis" => self.namespace.apis,
1335 "config" => self.namespace.config,
1336 "security" => self.namespace.security,
1337 "performance" => self.namespace.performance,
1338 "testing" => self.namespace.testing,
1339 _ => None,
1340 };
1341
1342 if ns_ttl.is_some() {
1343 return ns_ttl;
1344 }
1345
1346 let scope_ttl = match scope.to_lowercase().as_str() {
1348 "project" => self.scope.project,
1349 "user" => self.scope.user,
1350 "org" => self.scope.org,
1351 _ => None,
1352 };
1353
1354 if scope_ttl.is_some() {
1355 return scope_ttl;
1356 }
1357
1358 self.default_seconds
1360 }
1361
1362 #[must_use]
1364 pub const fn with_default_seconds(mut self, seconds: Option<u64>) -> Self {
1365 self.default_seconds = seconds;
1366 self
1367 }
1368}
1369
1370impl TtlNamespaceConfig {
1371 #[must_use]
1373 pub fn from_config_file(file: &ConfigFileTtlNamespace) -> Self {
1374 Self {
1375 decisions: file
1376 .decisions
1377 .as_ref()
1378 .and_then(|s| parse_duration_to_seconds(s)),
1379 patterns: file
1380 .patterns
1381 .as_ref()
1382 .and_then(|s| parse_duration_to_seconds(s)),
1383 learnings: file
1384 .learnings
1385 .as_ref()
1386 .and_then(|s| parse_duration_to_seconds(s)),
1387 context: file
1388 .context
1389 .as_ref()
1390 .and_then(|s| parse_duration_to_seconds(s)),
1391 tech_debt: file
1392 .tech_debt
1393 .as_ref()
1394 .and_then(|s| parse_duration_to_seconds(s)),
1395 apis: file
1396 .apis
1397 .as_ref()
1398 .and_then(|s| parse_duration_to_seconds(s)),
1399 config: file
1400 .config
1401 .as_ref()
1402 .and_then(|s| parse_duration_to_seconds(s)),
1403 security: file
1404 .security
1405 .as_ref()
1406 .and_then(|s| parse_duration_to_seconds(s)),
1407 performance: file
1408 .performance
1409 .as_ref()
1410 .and_then(|s| parse_duration_to_seconds(s)),
1411 testing: file
1412 .testing
1413 .as_ref()
1414 .and_then(|s| parse_duration_to_seconds(s)),
1415 }
1416 }
1417}
1418
1419impl TtlScopeConfig {
1420 #[must_use]
1422 pub fn from_config_file(file: &ConfigFileTtlScope) -> Self {
1423 Self {
1424 project: file
1425 .project
1426 .as_ref()
1427 .and_then(|s| parse_duration_to_seconds(s)),
1428 user: file
1429 .user
1430 .as_ref()
1431 .and_then(|s| parse_duration_to_seconds(s)),
1432 org: file.org.as_ref().and_then(|s| parse_duration_to_seconds(s)),
1433 }
1434 }
1435}
1436
1437#[must_use]
1453pub fn parse_duration_to_seconds(s: &str) -> Option<u64> {
1454 let s = s.trim();
1455
1456 if s.is_empty() || s == "0" {
1458 return Some(0);
1459 }
1460
1461 if let Ok(secs) = s.parse::<u64>() {
1463 return Some(secs);
1464 }
1465
1466 let (num_str, multiplier) = if let Some(num) = s.strip_suffix('d') {
1468 (num, 86400u64) } else if let Some(num) = s.strip_suffix('h') {
1470 (num, 3600u64) } else if let Some(num) = s.strip_suffix('m') {
1472 (num, 60u64) } else if let Some(num) = s.strip_suffix('s') {
1474 (num, 1u64) } else {
1476 tracing::warn!(duration = %s, "Invalid TTL duration format, expected Nd/Nh/Nm/Ns");
1478 return None;
1479 };
1480
1481 num_str.trim().parse::<u64>().map_or_else(
1482 |_| {
1483 tracing::warn!(duration = %s, "Invalid TTL duration number");
1484 None
1485 },
1486 |num| Some(num.saturating_mul(multiplier)),
1487 )
1488}
1489
1490#[derive(Debug, Clone, Deserialize, Default)]
1492pub struct ConfigFileConsolidation {
1493 pub enabled: Option<bool>,
1495 pub namespace_filter: Option<Vec<String>>,
1497 pub time_window_days: Option<u32>,
1499 pub min_memories_to_consolidate: Option<usize>,
1501 pub similarity_threshold: Option<f32>,
1503}
1504
1505#[derive(Debug, Clone, Deserialize, Default)]
1509pub struct ConfigFilePrompt {
1510 pub identity_addendum: Option<String>,
1513
1514 pub additional_guidance: Option<String>,
1517
1518 pub capture: Option<ConfigFilePromptOperation>,
1520 pub search: Option<ConfigFilePromptOperation>,
1522 pub enrichment: Option<ConfigFilePromptOperation>,
1524 pub consolidation: Option<ConfigFilePromptOperation>,
1526}
1527
1528#[derive(Debug, Clone, Deserialize, Default)]
1530pub struct ConfigFilePromptOperation {
1531 pub additional_guidance: Option<String>,
1533}
1534
1535#[derive(Debug, Clone, Deserialize, Default)]
1537pub struct ConfigFileStorage {
1538 pub project: Option<ConfigFileStorageBackend>,
1540 pub user: Option<ConfigFileStorageBackend>,
1542 pub org: Option<ConfigFileStorageBackend>,
1544}
1545
1546#[derive(Debug, Clone, Deserialize, Default)]
1548pub struct ConfigFileStorageBackend {
1549 pub backend: Option<String>,
1551 pub path: Option<String>,
1553 pub connection_string: Option<String>,
1555 pub redis_url: Option<String>,
1557 pub encryption_enabled: Option<bool>,
1560}
1561
1562#[derive(Debug, Clone, Default)]
1564pub struct StorageConfig {
1565 pub project: StorageBackendConfig,
1567 pub user: StorageBackendConfig,
1569 pub org: StorageBackendConfig,
1571}
1572
1573#[derive(Debug, Clone)]
1575pub struct StorageBackendConfig {
1576 pub backend: StorageBackendType,
1578 pub path: Option<String>,
1580 pub connection_string: Option<String>,
1582 pub pool_max_size: Option<usize>,
1585 pub encryption_enabled: bool,
1588}
1589
1590impl Default for StorageBackendConfig {
1591 fn default() -> Self {
1592 Self {
1593 backend: StorageBackendType::default(),
1594 path: None,
1595 connection_string: None,
1596 pool_max_size: None,
1597 encryption_enabled: true,
1599 }
1600 }
1601}
1602
1603#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1605pub enum StorageBackendType {
1606 #[default]
1608 Sqlite,
1609 Filesystem,
1611 PostgreSQL,
1613 Redis,
1615}
1616
1617impl StorageBackendType {
1618 #[must_use]
1622 pub fn parse(s: &str) -> Self {
1623 match s.to_lowercase().as_str() {
1624 "filesystem" | "fs" | "file" => Self::Filesystem,
1625 "postgresql" | "postgres" | "pg" => Self::PostgreSQL,
1626 "redis" => Self::Redis,
1627 _ => Self::Sqlite,
1629 }
1630 }
1631}
1632
1633#[derive(Debug, Clone)]
1651pub struct OperationTimeoutConfig {
1652 pub default_ms: u64,
1654 pub capture_ms: u64,
1656 pub recall_ms: u64,
1658 pub sync_ms: u64,
1660 pub embed_ms: u64,
1662 pub redis_ms: u64,
1664 pub sqlite_ms: u64,
1666 pub postgres_ms: u64,
1668 pub entity_extraction_ms: u64,
1671}
1672
1673impl Default for OperationTimeoutConfig {
1674 fn default() -> Self {
1675 Self {
1676 default_ms: 30_000,
1677 capture_ms: 30_000,
1678 recall_ms: 30_000,
1679 sync_ms: 60_000, embed_ms: 30_000,
1681 redis_ms: 5_000,
1682 sqlite_ms: 5_000,
1683 postgres_ms: 10_000,
1684 entity_extraction_ms: 120_000, }
1686 }
1687}
1688
1689impl OperationTimeoutConfig {
1690 #[must_use]
1692 pub const fn new() -> Self {
1693 Self {
1694 default_ms: 30_000,
1695 capture_ms: 30_000,
1696 recall_ms: 30_000,
1697 sync_ms: 60_000,
1698 embed_ms: 30_000,
1699 redis_ms: 5_000,
1700 sqlite_ms: 5_000,
1701 postgres_ms: 10_000,
1702 entity_extraction_ms: 120_000,
1703 }
1704 }
1705
1706 #[must_use]
1708 pub fn from_env() -> Self {
1709 Self::new().with_env_overrides()
1710 }
1711
1712 #[must_use]
1714 pub fn with_env_overrides(mut self) -> Self {
1715 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_DEFAULT_MS")
1716 && let Ok(parsed) = v.parse::<u64>()
1717 {
1718 self.default_ms = parsed.max(100); }
1720 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_CAPTURE_MS")
1721 && let Ok(parsed) = v.parse::<u64>()
1722 {
1723 self.capture_ms = parsed.max(100);
1724 }
1725 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_RECALL_MS")
1726 && let Ok(parsed) = v.parse::<u64>()
1727 {
1728 self.recall_ms = parsed.max(100);
1729 }
1730 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_SYNC_MS")
1731 && let Ok(parsed) = v.parse::<u64>()
1732 {
1733 self.sync_ms = parsed.max(100);
1734 }
1735 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_EMBED_MS")
1736 && let Ok(parsed) = v.parse::<u64>()
1737 {
1738 self.embed_ms = parsed.max(100);
1739 }
1740 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_REDIS_MS")
1741 && let Ok(parsed) = v.parse::<u64>()
1742 {
1743 self.redis_ms = parsed.max(100);
1744 }
1745 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_SQLITE_MS")
1746 && let Ok(parsed) = v.parse::<u64>()
1747 {
1748 self.sqlite_ms = parsed.max(100);
1749 }
1750 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_POSTGRES_MS")
1751 && let Ok(parsed) = v.parse::<u64>()
1752 {
1753 self.postgres_ms = parsed.max(100);
1754 }
1755 if let Ok(v) = std::env::var("SUBCOG_TIMEOUT_ENTITY_EXTRACTION_MS")
1756 && let Ok(parsed) = v.parse::<u64>()
1757 {
1758 self.entity_extraction_ms = parsed.max(1000); }
1760 self
1761 }
1762
1763 #[must_use]
1765 pub const fn get(&self, operation: OperationType) -> std::time::Duration {
1766 let ms = match operation {
1767 OperationType::Capture => self.capture_ms,
1768 OperationType::Recall => self.recall_ms,
1769 OperationType::Sync => self.sync_ms,
1770 OperationType::Embed => self.embed_ms,
1771 OperationType::Redis => self.redis_ms,
1772 OperationType::Sqlite => self.sqlite_ms,
1773 OperationType::Postgres => self.postgres_ms,
1774 OperationType::EntityExtraction => self.entity_extraction_ms,
1775 OperationType::Default => self.default_ms,
1776 };
1777 std::time::Duration::from_millis(ms)
1778 }
1779
1780 #[must_use]
1782 pub const fn with_default_ms(mut self, ms: u64) -> Self {
1783 self.default_ms = ms;
1784 self
1785 }
1786
1787 #[must_use]
1789 pub const fn with_capture_ms(mut self, ms: u64) -> Self {
1790 self.capture_ms = ms;
1791 self
1792 }
1793
1794 #[must_use]
1796 pub const fn with_recall_ms(mut self, ms: u64) -> Self {
1797 self.recall_ms = ms;
1798 self
1799 }
1800
1801 #[must_use]
1803 pub const fn with_sync_ms(mut self, ms: u64) -> Self {
1804 self.sync_ms = ms;
1805 self
1806 }
1807
1808 #[must_use]
1810 pub const fn with_embed_ms(mut self, ms: u64) -> Self {
1811 self.embed_ms = ms;
1812 self
1813 }
1814
1815 #[must_use]
1817 pub const fn with_redis_ms(mut self, ms: u64) -> Self {
1818 self.redis_ms = ms;
1819 self
1820 }
1821
1822 #[must_use]
1824 pub const fn with_sqlite_ms(mut self, ms: u64) -> Self {
1825 self.sqlite_ms = ms;
1826 self
1827 }
1828
1829 #[must_use]
1831 pub const fn with_postgres_ms(mut self, ms: u64) -> Self {
1832 self.postgres_ms = ms;
1833 self
1834 }
1835
1836 #[must_use]
1838 pub const fn with_entity_extraction_ms(mut self, ms: u64) -> Self {
1839 self.entity_extraction_ms = ms;
1840 self
1841 }
1842}
1843
1844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1846pub enum OperationType {
1847 Capture,
1849 Recall,
1851 Sync,
1853 Embed,
1855 Redis,
1857 Sqlite,
1859 Postgres,
1861 EntityExtraction,
1863 Default,
1865}
1866
1867impl StorageConfig {
1868 #[must_use]
1870 pub fn from_config_file(file: &ConfigFileStorage) -> Self {
1871 let mut config = Self::default();
1872
1873 if let Some(ref project) = file.project {
1874 if let Some(ref backend) = project.backend {
1875 config.project.backend = StorageBackendType::parse(backend);
1876 }
1877 config.project.path = project.path.as_ref().map(|path| expand_config_path(path));
1878 config
1879 .project
1880 .connection_string
1881 .clone_from(&project.connection_string);
1882 if let Some(encryption) = project.encryption_enabled {
1884 config.project.encryption_enabled = encryption;
1885 }
1886 }
1887
1888 if let Some(ref user) = file.user {
1889 if let Some(ref backend) = user.backend {
1890 config.user.backend = StorageBackendType::parse(backend);
1891 }
1892 config.user.path = user.path.as_ref().map(|path| expand_config_path(path));
1893 config
1894 .user
1895 .connection_string
1896 .clone_from(&user.connection_string);
1897 if let Some(encryption) = user.encryption_enabled {
1899 config.user.encryption_enabled = encryption;
1900 }
1901 }
1902
1903 if let Some(ref org) = file.org {
1904 if let Some(ref backend) = org.backend {
1905 config.org.backend = StorageBackendType::parse(backend);
1906 }
1907 config.org.path = org.path.as_ref().map(|path| expand_config_path(path));
1908 config
1909 .org
1910 .connection_string
1911 .clone_from(&org.connection_string);
1912 if let Some(encryption) = org.encryption_enabled {
1914 config.org.encryption_enabled = encryption;
1915 }
1916 }
1917
1918 config
1919 }
1920}
1921
1922#[derive(Debug, Clone, Default)]
1924pub struct PromptConfig {
1925 pub identity_addendum: Option<String>,
1927 pub additional_guidance: Option<String>,
1929 pub operation_guidance: PromptOperationConfig,
1931}
1932
1933#[derive(Debug, Clone, Default)]
1935pub struct PromptOperationConfig {
1936 pub capture: Option<String>,
1938 pub search: Option<String>,
1940 pub enrichment: Option<String>,
1942 pub consolidation: Option<String>,
1944}
1945
1946#[derive(Debug, Clone)]
1968pub struct ConsolidationConfig {
1969 pub enabled: bool,
1971 pub namespace_filter: Option<Vec<crate::models::Namespace>>,
1973 pub time_window_days: Option<u32>,
1975 pub min_memories_to_consolidate: usize,
1977 pub similarity_threshold: f32,
1979}
1980
1981impl Default for ConsolidationConfig {
1982 fn default() -> Self {
1983 Self {
1984 enabled: false,
1985 namespace_filter: None,
1986 time_window_days: Some(30),
1987 min_memories_to_consolidate: 3,
1988 similarity_threshold: 0.7,
1989 }
1990 }
1991}
1992
1993impl ConsolidationConfig {
1994 #[must_use]
1996 pub fn new() -> Self {
1997 Self::default()
1998 }
1999
2000 #[must_use]
2002 pub fn from_config_file(file: &ConfigFileConsolidation) -> Self {
2003 let mut config = Self::default();
2004
2005 if let Some(enabled) = file.enabled {
2006 config.enabled = enabled;
2007 }
2008
2009 if let Some(ref namespace_filter) = file.namespace_filter {
2010 let namespaces: Vec<crate::models::Namespace> = namespace_filter
2011 .iter()
2012 .filter_map(|s| s.parse().ok())
2013 .collect();
2014 if !namespaces.is_empty() {
2015 config.namespace_filter = Some(namespaces);
2016 }
2017 }
2018
2019 if let Some(time_window_days) = file.time_window_days {
2020 config.time_window_days = Some(time_window_days);
2021 }
2022
2023 if let Some(min_memories) = file.min_memories_to_consolidate {
2024 config.min_memories_to_consolidate = min_memories.max(2); }
2026
2027 if let Some(threshold) = file.similarity_threshold {
2028 config.similarity_threshold = threshold.clamp(0.0, 1.0);
2029 }
2030
2031 config
2032 }
2033
2034 #[must_use]
2036 pub fn from_env() -> Self {
2037 Self::default().with_env_overrides()
2038 }
2039
2040 #[must_use]
2042 pub fn with_env_overrides(mut self) -> Self {
2043 if let Ok(v) = std::env::var("SUBCOG_CONSOLIDATION_ENABLED") {
2044 self.enabled = v.to_lowercase() == "true" || v == "1";
2045 }
2046
2047 if let Ok(v) = std::env::var("SUBCOG_CONSOLIDATION_TIME_WINDOW_DAYS")
2048 && let Ok(days) = v.parse::<u32>()
2049 {
2050 self.time_window_days = Some(days);
2051 }
2052
2053 if let Ok(v) = std::env::var("SUBCOG_CONSOLIDATION_MIN_MEMORIES")
2054 && let Ok(min) = v.parse::<usize>()
2055 {
2056 self.min_memories_to_consolidate = min.max(2);
2057 }
2058
2059 if let Ok(v) = std::env::var("SUBCOG_CONSOLIDATION_SIMILARITY_THRESHOLD")
2060 && let Ok(threshold) = v.parse::<f32>()
2061 {
2062 self.similarity_threshold = threshold.clamp(0.0, 1.0);
2063 }
2064
2065 self
2066 }
2067
2068 #[must_use]
2070 pub const fn with_enabled(mut self, enabled: bool) -> Self {
2071 self.enabled = enabled;
2072 self
2073 }
2074
2075 #[must_use]
2077 pub fn with_namespace_filter(mut self, filter: Vec<crate::models::Namespace>) -> Self {
2078 self.namespace_filter = Some(filter);
2079 self
2080 }
2081
2082 #[must_use]
2084 pub const fn with_time_window_days(mut self, days: Option<u32>) -> Self {
2085 self.time_window_days = days;
2086 self
2087 }
2088
2089 #[must_use]
2091 pub const fn with_min_memories(mut self, min: usize) -> Self {
2092 self.min_memories_to_consolidate = min;
2093 self
2094 }
2095
2096 #[must_use]
2100 pub const fn with_similarity_threshold(mut self, threshold: f32) -> Self {
2101 self.similarity_threshold = threshold.clamp(0.0, 1.0);
2102 self
2103 }
2104
2105 pub fn build(self) -> Result<Self, ConfigValidationError> {
2114 if self.min_memories_to_consolidate < 2 {
2115 return Err(ConfigValidationError::InvalidValue {
2116 field: "min_memories_to_consolidate".to_string(),
2117 message: "min_memories_to_consolidate must be at least 2".to_string(),
2118 });
2119 }
2120
2121 if !(0.0..=1.0).contains(&self.similarity_threshold) {
2122 return Err(ConfigValidationError::InvalidValue {
2123 field: "similarity_threshold".to_string(),
2124 message: format!(
2125 "similarity_threshold must be in range [0.0, 1.0], got {}",
2126 self.similarity_threshold
2127 ),
2128 });
2129 }
2130
2131 if let Some(days) = self.time_window_days
2132 && days == 0
2133 {
2134 return Err(ConfigValidationError::InvalidValue {
2135 field: "time_window_days".to_string(),
2136 message: "time_window_days must be greater than 0".to_string(),
2137 });
2138 }
2139
2140 Ok(self)
2141 }
2142}
2143
2144impl PromptConfig {
2145 #[must_use]
2147 pub fn from_config_file(file: &ConfigFilePrompt) -> Self {
2148 Self {
2149 identity_addendum: file.identity_addendum.clone(),
2150 additional_guidance: file.additional_guidance.clone(),
2151 operation_guidance: PromptOperationConfig {
2152 capture: file
2153 .capture
2154 .as_ref()
2155 .and_then(|c| c.additional_guidance.clone()),
2156 search: file
2157 .search
2158 .as_ref()
2159 .and_then(|c| c.additional_guidance.clone()),
2160 enrichment: file
2161 .enrichment
2162 .as_ref()
2163 .and_then(|c| c.additional_guidance.clone()),
2164 consolidation: file
2165 .consolidation
2166 .as_ref()
2167 .and_then(|c| c.additional_guidance.clone()),
2168 },
2169 }
2170 }
2171
2172 #[must_use]
2174 pub fn get_operation_guidance(&self, operation: &str) -> Option<&str> {
2175 match operation {
2176 "capture_analysis" => self.operation_guidance.capture.as_deref(),
2177 "search_intent" => self.operation_guidance.search.as_deref(),
2178 "enrichment" => self.operation_guidance.enrichment.as_deref(),
2179 "consolidation" => self.operation_guidance.consolidation.as_deref(),
2180 _ => None,
2181 }
2182 }
2183
2184 #[must_use]
2186 pub fn with_env_overrides(mut self) -> Self {
2187 if let Ok(v) = std::env::var("SUBCOG_PROMPT_IDENTITY_ADDENDUM") {
2188 self.identity_addendum = Some(v);
2189 }
2190 if let Ok(v) = std::env::var("SUBCOG_PROMPT_ADDITIONAL_GUIDANCE") {
2191 self.additional_guidance = Some(v);
2192 }
2193 self
2194 }
2195}
2196
2197impl Default for SubcogConfig {
2198 fn default() -> Self {
2199 Self {
2200 repo_path: PathBuf::from("."),
2201 data_dir: directories::BaseDirs::new()
2202 .map_or_else(|| PathBuf::from("."), |b| b.data_local_dir().join("subcog")),
2203 features: FeatureFlags::default(),
2204 max_results: 10,
2205 default_search_mode: crate::models::SearchMode::Hybrid,
2206 llm: LlmConfig::default(),
2207 search_intent: SearchIntentConfig::default(),
2208 observability: ObservabilitySettings::default(),
2209 prompt: PromptConfig::default(),
2210 storage: StorageConfig::default(),
2211 consolidation: ConsolidationConfig::default(),
2212 ttl: TtlConfig::default(),
2213 timeouts: OperationTimeoutConfig::from_env(),
2214 context_templates: ContextTemplatesConfig::default(),
2215 org: OrgConfig::default(),
2216 webhooks: WebhooksConfig::default(),
2217 config_sources: Vec::new(),
2218 }
2219 }
2220}
2221
2222impl SubcogConfig {
2223 #[must_use]
2225 pub fn new() -> Self {
2226 Self::default()
2227 }
2228
2229 pub fn load_from_file(path: &std::path::Path) -> crate::Result<Self> {
2235 warn_if_world_readable(path);
2237
2238 let contents =
2239 std::fs::read_to_string(path).map_err(|e| crate::Error::OperationFailed {
2240 operation: "read_config_file".to_string(),
2241 cause: e.to_string(),
2242 })?;
2243
2244 let file: ConfigFile =
2245 toml::from_str(&contents).map_err(|e| crate::Error::OperationFailed {
2246 operation: "parse_config_file".to_string(),
2247 cause: e.to_string(),
2248 })?;
2249
2250 let mut config = Self::default();
2251 config.apply_config_file(file);
2252 config.config_sources.push(path.to_path_buf());
2253 config.apply_env_overrides();
2254 Ok(config)
2255 }
2256
2257 #[must_use]
2267 pub fn load_default() -> Self {
2268 let Some(base_dirs) = directories::BaseDirs::new() else {
2269 let mut config = Self::default();
2270 config.apply_env_overrides();
2271 return config;
2272 };
2273
2274 let config_dir = base_dirs.home_dir().join(".config").join("subcog");
2276
2277 let data_dir = base_dirs.data_local_dir().join("subcog");
2282
2283 let mut config = Self {
2284 data_dir,
2285 ..Self::default()
2286 };
2287
2288 let config_path = config_dir.join("config.toml");
2289 if apply_config_path(&mut config, &config_path) {
2290 config.config_sources.push(config_path);
2291 }
2292
2293 config.apply_env_overrides();
2294 config
2295 }
2296
2297 fn apply_env_overrides(&mut self) {
2298 if let Ok(value) = std::env::var("SUBCOG_ORG_SCOPE_ENABLED") {
2299 let Some(enabled) = parse_bool_env(&value) else {
2300 self.features.org_scope_enabled = false;
2301 tracing::warn!(
2302 value = %value,
2303 "Invalid SUBCOG_ORG_SCOPE_ENABLED value, defaulting to false"
2304 );
2305 return;
2306 };
2307 self.features.org_scope_enabled = enabled;
2308 if enabled {
2309 tracing::info!("Org-scope enabled via SUBCOG_ORG_SCOPE_ENABLED");
2310 }
2311 }
2312
2313 if let Ok(value) = std::env::var("SUBCOG_STORAGE_ENCRYPTION_ENABLED") {
2315 if let Some(enabled) = parse_bool_env(&value) {
2316 self.storage.project.encryption_enabled = enabled;
2317 self.storage.user.encryption_enabled = enabled;
2318 self.storage.org.encryption_enabled = enabled;
2319 tracing::info!(
2320 enabled = enabled,
2321 "Storage encryption configured via SUBCOG_STORAGE_ENCRYPTION_ENABLED"
2322 );
2323 } else {
2324 tracing::warn!(
2325 value = %value,
2326 "Invalid SUBCOG_STORAGE_ENCRYPTION_ENABLED value, keeping default (true)"
2327 );
2328 }
2329 }
2330
2331 if let Ok(value) = std::env::var("SUBCOG_AUTO_EXTRACT_ENTITIES") {
2333 if let Some(enabled) = parse_bool_env(&value) {
2334 self.features.auto_extract_entities = enabled;
2335 tracing::info!(
2336 enabled = enabled,
2337 "Auto entity extraction configured via SUBCOG_AUTO_EXTRACT_ENTITIES"
2338 );
2339 } else {
2340 tracing::warn!(
2341 value = %value,
2342 "Invalid SUBCOG_AUTO_EXTRACT_ENTITIES value, keeping default (false)"
2343 );
2344 }
2345 }
2346
2347 self.search_intent = self.search_intent.clone().with_env_overrides();
2348 self.prompt = self.prompt.clone().with_env_overrides();
2349 self.consolidation = self.consolidation.clone().with_env_overrides();
2350 self.ttl = self.ttl.clone().with_env_overrides();
2351 }
2352
2353 fn apply_config_file(&mut self, file: ConfigFile) {
2357 if let Some(repo_path) = file.repo_path {
2359 self.repo_path = PathBuf::from(expand_config_path(&repo_path));
2360 }
2361 if let Some(data_dir) = file.data_dir {
2362 self.data_dir = PathBuf::from(expand_config_path(&data_dir));
2363 }
2364 if let Some(max_results) = file.max_results {
2365 self.max_results = max_results;
2366 }
2367 if let Some(mode) = file.default_search_mode {
2368 self.default_search_mode = match mode.to_lowercase().as_str() {
2369 "text" => crate::models::SearchMode::Text,
2370 "vector" => crate::models::SearchMode::Vector,
2371 _ => crate::models::SearchMode::Hybrid,
2372 };
2373 }
2374
2375 if let Some(ref features) = file.features {
2377 self.features.merge_from(features);
2378 }
2379 if let Some(ref llm) = file.llm {
2380 self.llm.merge_from(llm);
2381 }
2382 if let Some(ref search_intent) = file.search_intent {
2383 self.search_intent = SearchIntentConfig::from_config_file(search_intent);
2384 }
2385 if let Some(observability) = file.observability {
2386 self.observability = observability;
2387 }
2388 if let Some(ref prompt) = file.prompt {
2389 self.prompt = PromptConfig::from_config_file(prompt);
2390 }
2391 if let Some(ref storage) = file.storage {
2392 self.storage = StorageConfig::from_config_file(storage);
2393 }
2394 if let Some(ref consolidation) = file.consolidation {
2395 self.consolidation = ConsolidationConfig::from_config_file(consolidation);
2396 }
2397 if let Some(ref ttl) = file.ttl {
2398 self.ttl = TtlConfig::from_config_file(ttl);
2399 }
2400 if let Some(ref context_templates) = file.context_templates {
2401 self.context_templates = ContextTemplatesConfig::from_config_file(context_templates);
2402 }
2403 if let Some(ref org) = file.org {
2404 self.org = OrgConfig::from_config_file(org, self.features.org_scope_enabled);
2405 }
2406
2407 if !file.webhooks.is_empty() {
2409 self.webhooks = WebhooksConfig::from_config_file(file.webhooks);
2410 }
2411 }
2412
2413 #[must_use]
2415 pub fn with_repo_path(mut self, path: impl Into<PathBuf>) -> Self {
2416 self.repo_path = path.into();
2417 self
2418 }
2419
2420 #[must_use]
2422 pub fn with_data_dir(mut self, path: impl Into<PathBuf>) -> Self {
2423 self.data_dir = path.into();
2424 self
2425 }
2426}
2427
2428fn apply_config_path(config: &mut SubcogConfig, path: &std::path::Path) -> bool {
2429 match load_config_file(path) {
2430 Ok(file) => {
2431 tracing::debug!(path = %path.display(), "Loaded config file");
2432 config.apply_config_file(file);
2433 true
2434 },
2435 Err(e) => {
2436 tracing::warn!(path = %path.display(), error = %e, "Failed to load config file");
2438 false
2439 },
2440 }
2441}
2442
2443fn load_config_file(path: &std::path::Path) -> crate::Result<ConfigFile> {
2444 warn_if_world_readable(path);
2446
2447 let contents = std::fs::read_to_string(path).map_err(|e| crate::Error::OperationFailed {
2448 operation: "read_config_file".to_string(),
2449 cause: e.to_string(),
2450 })?;
2451
2452 toml::from_str(&contents).map_err(|e| crate::Error::OperationFailed {
2453 operation: "parse_config_file".to_string(),
2454 cause: e.to_string(),
2455 })
2456}
2457
2458#[derive(Debug, Clone, Default)]
2462pub struct Config {
2463 pub repo_path: Option<PathBuf>,
2465 pub data_dir: Option<PathBuf>,
2467 pub features: ServiceFeatures,
2469}
2470
2471#[derive(Debug, Clone)]
2473#[allow(clippy::struct_excessive_bools)]
2474pub struct ServiceFeatures {
2475 pub block_secrets: bool,
2477 pub redact_secrets: bool,
2479 pub auto_sync: bool,
2481 pub auto_extract_entities: bool,
2483}
2484
2485impl Default for ServiceFeatures {
2486 fn default() -> Self {
2487 Self {
2488 block_secrets: false,
2489 redact_secrets: true,
2490 auto_sync: false,
2491 auto_extract_entities: false,
2492 }
2493 }
2494}
2495
2496impl Config {
2497 #[must_use]
2499 pub fn new() -> Self {
2500 Self::default()
2501 }
2502
2503 #[must_use]
2505 pub fn with_repo_path(mut self, path: impl Into<PathBuf>) -> Self {
2506 self.repo_path = Some(path.into());
2507 self
2508 }
2509
2510 #[must_use]
2512 pub fn with_data_dir(mut self, path: impl Into<PathBuf>) -> Self {
2513 self.data_dir = Some(path.into());
2514 self
2515 }
2516}
2517
2518impl From<SubcogConfig> for Config {
2519 fn from(subcog: SubcogConfig) -> Self {
2520 Self {
2521 repo_path: Some(subcog.repo_path),
2522 data_dir: Some(subcog.data_dir),
2523 features: ServiceFeatures {
2524 block_secrets: false,
2525 redact_secrets: subcog.features.secrets_filter,
2526 auto_sync: false,
2527 auto_extract_entities: subcog.features.auto_extract_entities,
2528 },
2529 }
2530 }
2531}
2532
2533#[derive(Debug, Clone, Deserialize, Default)]
2539pub struct ConfigFileWebhook {
2540 pub name: String,
2542 pub url: String,
2544 pub auth: Option<ConfigFileWebhookAuth>,
2546 #[serde(default)]
2548 pub events: Vec<String>,
2549 #[serde(default)]
2551 pub scopes: Vec<String>,
2552 #[serde(default = "default_true")]
2554 pub enabled: bool,
2555 #[serde(default)]
2557 pub retry: ConfigFileWebhookRetry,
2558 pub format: Option<String>,
2560}
2561
2562#[derive(Debug, Clone, Deserialize)]
2564#[serde(tag = "type", rename_all = "snake_case")]
2565pub enum ConfigFileWebhookAuth {
2566 Bearer {
2568 token: String,
2570 },
2571 Hmac {
2573 secret: String,
2575 },
2576 Both {
2578 token: String,
2580 secret: String,
2582 },
2583}
2584
2585#[derive(Debug, Clone, Deserialize)]
2587pub struct ConfigFileWebhookRetry {
2588 #[serde(default = "default_webhook_max_retries")]
2590 pub max_retries: u32,
2591 #[serde(default = "default_webhook_base_delay_ms")]
2593 pub base_delay_ms: u64,
2594 #[serde(default = "default_webhook_timeout_secs")]
2596 pub timeout_secs: u64,
2597}
2598
2599impl Default for ConfigFileWebhookRetry {
2600 fn default() -> Self {
2601 Self {
2602 max_retries: default_webhook_max_retries(),
2603 base_delay_ms: default_webhook_base_delay_ms(),
2604 timeout_secs: default_webhook_timeout_secs(),
2605 }
2606 }
2607}
2608
2609const fn default_true() -> bool {
2610 true
2611}
2612
2613const fn default_webhook_max_retries() -> u32 {
2614 3
2615}
2616
2617const fn default_webhook_base_delay_ms() -> u64 {
2618 1000
2619}
2620
2621const fn default_webhook_timeout_secs() -> u64 {
2622 30
2623}
2624
2625#[derive(Debug, Clone, Default)]
2627pub struct WebhooksConfig {
2628 pub webhooks: Vec<ConfigFileWebhook>,
2630}
2631
2632impl WebhooksConfig {
2633 #[must_use]
2635 pub const fn from_config_file(webhooks: Vec<ConfigFileWebhook>) -> Self {
2636 Self { webhooks }
2637 }
2638
2639 #[must_use]
2641 pub const fn len(&self) -> usize {
2642 self.webhooks.len()
2643 }
2644
2645 #[must_use]
2647 pub const fn is_empty(&self) -> bool {
2648 self.webhooks.is_empty()
2649 }
2650}
2651
2652#[cfg(test)]
2653#[allow(clippy::float_cmp)]
2654mod tests {
2655 use super::*;
2656
2657 #[test]
2658 fn test_expand_env_vars_with_existing_var() {
2659 let var_name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
2662 if let Ok(expected) = std::env::var(var_name) {
2663 let input = format!("${{{var_name}}}");
2664 let result = expand_env_vars(&input);
2665 assert_eq!(result, expected);
2666 }
2667 }
2668
2669 #[test]
2670 fn test_expand_env_vars_with_prefix_suffix() {
2671 if let Ok(path_value) = std::env::var("PATH") {
2673 let result = expand_env_vars("prefix-${PATH}-suffix");
2674 assert_eq!(result, format!("prefix-{path_value}-suffix"));
2675 }
2676 }
2677
2678 #[test]
2679 fn test_expand_env_vars_no_vars() {
2680 let result = expand_env_vars("no variables here");
2681 assert_eq!(result, "no variables here");
2682 }
2683
2684 #[test]
2685 fn test_expand_env_vars_missing_var_preserved() {
2686 let result = expand_env_vars("${DEFINITELY_NOT_SET_12345_SUBCOG_TEST}");
2687 assert_eq!(result, "${DEFINITELY_NOT_SET_12345_SUBCOG_TEST}");
2688 }
2689
2690 #[test]
2691 fn test_expand_env_vars_multiple_existing() {
2692 let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
2694 if let (Ok(home), Ok(path)) = (std::env::var(home_var), std::env::var("PATH")) {
2695 let input = format!("${{{home_var}}}:${{PATH}}");
2696 let result = expand_env_vars(&input);
2697 assert_eq!(result, format!("{home}:{path}"));
2698 }
2699 }
2700
2701 #[test]
2702 fn test_expand_env_vars_unclosed_brace() {
2703 let result = expand_env_vars("${UNCLOSED");
2704 assert_eq!(result, "${UNCLOSED");
2705 }
2706
2707 #[test]
2708 fn test_expand_env_vars_empty_braces() {
2709 let result = expand_env_vars("${}");
2711 assert_eq!(result, "${}");
2712 }
2713
2714 #[test]
2715 fn test_expand_env_vars_nested_braces() {
2716 let result = expand_env_vars("${${INNER}}");
2718 assert_eq!(result, "${${INNER}}");
2720 }
2721
2722 #[test]
2723 fn test_expand_config_path_tilde_home() {
2724 if let Some(base_dirs) = directories::BaseDirs::new() {
2725 let expected = base_dirs.home_dir().to_path_buf();
2726 let result = expand_config_path("~");
2727 assert_eq!(PathBuf::from(result), expected);
2728 }
2729 }
2730
2731 #[test]
2732 fn test_expand_config_path_tilde_suffix() {
2733 if let Some(base_dirs) = directories::BaseDirs::new() {
2734 let expected = base_dirs.home_dir().join(".config/subcog");
2735 let result = expand_config_path("~/.config/subcog");
2736 assert_eq!(PathBuf::from(result), expected);
2737 }
2738 }
2739
2740 #[test]
2741 fn test_expand_config_path_env_var() {
2742 let var_name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
2743 if let Ok(home) = std::env::var(var_name) {
2744 let input = format!("${{{var_name}}}/data");
2745 let result = expand_config_path(&input);
2746 assert_eq!(PathBuf::from(result), PathBuf::from(home).join("data"));
2747 }
2748 }
2749
2750 #[test]
2751 #[cfg(unix)]
2752 fn test_warn_if_world_readable_does_not_panic() {
2753 use std::io::Write;
2754 use std::os::unix::fs::PermissionsExt;
2755
2756 let dir = tempfile::tempdir().ok();
2758 if let Some(ref dir) = dir {
2759 let path = dir.path().join("test_config.toml");
2760 let mut file = std::fs::File::create(&path).ok();
2761 if let Some(ref mut f) = file {
2762 let _ = f.write_all(b"[llm]\nprovider = \"anthropic\"\n");
2763 }
2764
2765 if let Ok(metadata) = std::fs::metadata(&path) {
2767 let mut perms = metadata.permissions();
2768 perms.set_mode(0o644);
2769 let _ = std::fs::set_permissions(&path, perms);
2770 }
2771
2772 warn_if_world_readable(&path);
2774
2775 if let Ok(metadata) = std::fs::metadata(&path) {
2777 let mut perms = metadata.permissions();
2778 perms.set_mode(0o600);
2779 let _ = std::fs::set_permissions(&path, perms);
2780 }
2781 warn_if_world_readable(&path);
2782 }
2783 }
2784
2785 #[test]
2786 fn test_warn_if_world_readable_nonexistent_file() {
2787 let path = Path::new("/nonexistent/path/to/config.toml");
2789 warn_if_world_readable(path);
2790 }
2791
2792 #[test]
2793 fn test_consolidation_config_defaults() {
2794 let config = ConsolidationConfig::default();
2795 assert!(!config.enabled);
2796 assert_eq!(config.namespace_filter, None);
2797 assert_eq!(config.time_window_days, Some(30));
2798 assert_eq!(config.min_memories_to_consolidate, 3);
2799 assert_eq!(config.similarity_threshold, 0.7);
2800 }
2801
2802 #[test]
2803 fn test_consolidation_config_builder() {
2804 let config = ConsolidationConfig::new()
2805 .with_enabled(true)
2806 .with_time_window_days(Some(60))
2807 .with_min_memories(5)
2808 .with_similarity_threshold(0.8);
2809
2810 assert!(config.enabled);
2811 assert_eq!(config.time_window_days, Some(60));
2812 assert_eq!(config.min_memories_to_consolidate, 5);
2813 assert_eq!(config.similarity_threshold, 0.8);
2814 }
2815
2816 #[test]
2817 fn test_consolidation_config_similarity_threshold_clamping() {
2818 let config = ConsolidationConfig::new().with_similarity_threshold(1.5); assert_eq!(config.similarity_threshold, 1.0);
2820
2821 let config = ConsolidationConfig::new().with_similarity_threshold(-0.5); assert_eq!(config.similarity_threshold, 0.0);
2823 }
2824
2825 #[test]
2826 fn test_consolidation_config_validation_min_memories() {
2827 let config = ConsolidationConfig::new().with_min_memories(1);
2828
2829 let result = config.build();
2830 assert!(result.is_err());
2831 if let Err(ConfigValidationError::InvalidValue { field, .. }) = result {
2832 assert_eq!(field, "min_memories_to_consolidate");
2833 }
2834 }
2835
2836 #[test]
2837 fn test_consolidation_config_validation_similarity_threshold() {
2838 let mut config = ConsolidationConfig::new();
2839 config.similarity_threshold = 1.5; let result = config.build();
2842 assert!(result.is_err());
2843 if let Err(ConfigValidationError::InvalidValue { field, .. }) = result {
2844 assert_eq!(field, "similarity_threshold");
2845 }
2846 }
2847
2848 #[test]
2849 fn test_consolidation_config_validation_time_window() {
2850 let config = ConsolidationConfig::new().with_time_window_days(Some(0));
2851
2852 let result = config.build();
2853 assert!(result.is_err());
2854 if let Err(ConfigValidationError::InvalidValue { field, .. }) = result {
2855 assert_eq!(field, "time_window_days");
2856 }
2857 }
2858
2859 #[test]
2860 fn test_consolidation_config_validation_success() {
2861 let config = ConsolidationConfig::new()
2862 .with_enabled(true)
2863 .with_min_memories(3)
2864 .with_similarity_threshold(0.7);
2865
2866 let result = config.build();
2867 assert!(result.is_ok());
2868 }
2869
2870 #[test]
2877 #[ignore = "Rust 2024: set_var/remove_var require unsafe, crate forbids unsafe_code"]
2878 fn test_consolidation_config_from_env() {
2879 }
2885
2886 #[test]
2887 fn test_consolidation_config_from_config_file() {
2888 let file = ConfigFileConsolidation {
2889 enabled: Some(true),
2890 namespace_filter: Some(vec!["decisions".to_string(), "patterns".to_string()]),
2891 time_window_days: Some(60),
2892 min_memories_to_consolidate: Some(4),
2893 similarity_threshold: Some(0.8),
2894 };
2895
2896 let config = ConsolidationConfig::from_config_file(&file);
2897
2898 assert!(config.enabled);
2899 assert_eq!(config.time_window_days, Some(60));
2900 assert_eq!(config.min_memories_to_consolidate, 4);
2901 assert_eq!(config.similarity_threshold, 0.8);
2902
2903 assert!(
2905 config.namespace_filter.is_some(),
2906 "Expected namespace_filter to be Some"
2907 );
2908 let namespaces = config.namespace_filter.as_ref().unwrap();
2909 assert_eq!(namespaces.len(), 2);
2910 }
2911
2912 #[test]
2913 fn test_consolidation_config_min_memories_enforcement() {
2914 let file = ConfigFileConsolidation {
2915 min_memories_to_consolidate: Some(1), ..Default::default()
2917 };
2918
2919 let config = ConsolidationConfig::from_config_file(&file);
2920 assert_eq!(config.min_memories_to_consolidate, 2);
2922 }
2923
2924 #[test]
2925 fn test_consolidation_config_threshold_clamping_in_from_file() {
2926 let file = ConfigFileConsolidation {
2927 similarity_threshold: Some(1.5), ..Default::default()
2929 };
2930
2931 let config = ConsolidationConfig::from_config_file(&file);
2932 assert_eq!(config.similarity_threshold, 1.0);
2933
2934 let file = ConfigFileConsolidation {
2935 similarity_threshold: Some(-0.5), ..Default::default()
2937 };
2938
2939 let config = ConsolidationConfig::from_config_file(&file);
2940 assert_eq!(config.similarity_threshold, 0.0);
2941 }
2942
2943 #[test]
2946 fn test_parse_duration_to_seconds_days() {
2947 assert_eq!(parse_duration_to_seconds("7d"), Some(7 * 86400));
2948 assert_eq!(parse_duration_to_seconds("30d"), Some(30 * 86400));
2949 assert_eq!(parse_duration_to_seconds("365d"), Some(365 * 86400));
2950 }
2951
2952 #[test]
2953 fn test_parse_duration_to_seconds_hours() {
2954 assert_eq!(parse_duration_to_seconds("24h"), Some(24 * 3600));
2955 assert_eq!(parse_duration_to_seconds("1h"), Some(3600));
2956 }
2957
2958 #[test]
2959 fn test_parse_duration_to_seconds_minutes() {
2960 assert_eq!(parse_duration_to_seconds("60m"), Some(3600));
2961 assert_eq!(parse_duration_to_seconds("5m"), Some(300));
2962 }
2963
2964 #[test]
2965 fn test_parse_duration_to_seconds_seconds() {
2966 assert_eq!(parse_duration_to_seconds("3600s"), Some(3600));
2967 assert_eq!(parse_duration_to_seconds("60s"), Some(60));
2968 }
2969
2970 #[test]
2971 fn test_parse_duration_to_seconds_raw_number() {
2972 assert_eq!(parse_duration_to_seconds("3600"), Some(3600));
2973 assert_eq!(parse_duration_to_seconds("86400"), Some(86400));
2974 }
2975
2976 #[test]
2977 fn test_parse_duration_to_seconds_zero_and_empty() {
2978 assert_eq!(parse_duration_to_seconds("0"), Some(0));
2979 assert_eq!(parse_duration_to_seconds(""), Some(0));
2980 assert_eq!(parse_duration_to_seconds(" "), Some(0));
2981 }
2982
2983 #[test]
2984 fn test_parse_duration_to_seconds_invalid() {
2985 assert_eq!(parse_duration_to_seconds("abc"), None);
2986 assert_eq!(parse_duration_to_seconds("7x"), None);
2987 assert_eq!(parse_duration_to_seconds("d7"), None);
2988 }
2989
2990 #[test]
2991 fn test_parse_duration_to_seconds_whitespace() {
2992 assert_eq!(parse_duration_to_seconds(" 7d "), Some(7 * 86400));
2993 assert_eq!(parse_duration_to_seconds(" 30d"), Some(30 * 86400));
2994 }
2995
2996 #[test]
2997 fn test_ttl_config_defaults() {
2998 let config = TtlConfig::default();
2999 assert_eq!(config.default_seconds, None);
3000 assert_eq!(config.namespace.decisions, None);
3001 assert_eq!(config.scope.project, None);
3002 }
3003
3004 #[test]
3005 fn test_ttl_config_get_ttl_seconds_priority() {
3006 let config = TtlConfig {
3007 default_seconds: Some(30 * 86400), namespace: TtlNamespaceConfig {
3009 context: Some(7 * 86400), ..Default::default()
3011 },
3012 scope: TtlScopeConfig {
3013 project: Some(14 * 86400), ..Default::default()
3015 },
3016 };
3017
3018 assert_eq!(
3020 config.get_ttl_seconds("context", "project"),
3021 Some(7 * 86400)
3022 );
3023
3024 assert_eq!(
3026 config.get_ttl_seconds("decisions", "project"),
3027 Some(14 * 86400)
3028 );
3029
3030 assert_eq!(
3032 config.get_ttl_seconds("decisions", "user"),
3033 Some(30 * 86400)
3034 );
3035 }
3036
3037 #[test]
3038 fn test_ttl_config_from_config_file() {
3039 let file = ConfigFileTtl {
3040 default: Some("30d".to_string()),
3041 namespace: Some(ConfigFileTtlNamespace {
3042 decisions: Some("90d".to_string()),
3043 context: Some("7d".to_string()),
3044 tech_debt: Some("0".to_string()), ..Default::default()
3046 }),
3047 scope: Some(ConfigFileTtlScope {
3048 project: Some("30d".to_string()),
3049 user: Some("90d".to_string()),
3050 org: Some("365d".to_string()),
3051 }),
3052 };
3053
3054 let config = TtlConfig::from_config_file(&file);
3055
3056 assert_eq!(config.default_seconds, Some(30 * 86400));
3057 assert_eq!(config.namespace.decisions, Some(90 * 86400));
3058 assert_eq!(config.namespace.context, Some(7 * 86400));
3059 assert_eq!(config.namespace.tech_debt, Some(0)); assert_eq!(config.scope.project, Some(30 * 86400));
3061 assert_eq!(config.scope.user, Some(90 * 86400));
3062 assert_eq!(config.scope.org, Some(365 * 86400));
3063 }
3064
3065 #[test]
3066 fn test_ttl_config_no_expiration() {
3067 let config = TtlConfig::default();
3068
3069 assert_eq!(config.get_ttl_seconds("decisions", "project"), None);
3071 }
3072
3073 #[test]
3074 fn test_ttl_config_explicit_no_expiration() {
3075 let config = TtlConfig {
3076 namespace: TtlNamespaceConfig {
3077 tech_debt: Some(0), ..Default::default()
3079 },
3080 ..Default::default()
3081 };
3082
3083 assert_eq!(config.get_ttl_seconds("tech-debt", "project"), Some(0));
3085 }
3086}