Skip to main content

subcog/config/
mod.rs

1//! Configuration management.
2
3mod 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/// Warns if a config file has world-readable permissions (SEC-M4).
14///
15/// Config files may contain API keys or other sensitive data. World-readable
16/// permissions (mode 0o004 on Unix) pose a security risk in multi-user systems.
17///
18/// This function logs a warning but does not prevent loading - the user may
19/// have intentionally set these permissions.
20#[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        // Check if "others" have read permission (0o004)
27        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/// No-op on non-Unix platforms.
39#[cfg(not(unix))]
40fn warn_if_world_readable(_path: &Path) {
41    // Windows has a different permission model; skip this check
42}
43
44/// Expands environment variable references in a string.
45///
46/// Supports `${VAR_NAME}` syntax. If the variable is not set, the original
47/// reference is preserved (e.g., `${MISSING_VAR}` stays as-is).
48///
49/// # Performance
50///
51/// Uses `Cow<str>` to avoid allocation when no expansion is needed.
52/// Only allocates when at least one environment variable is found and expanded.
53///
54/// # Examples
55///
56/// ```ignore
57/// // If OPENAI_API_KEY=sk-xxx in environment
58/// expand_env_vars("${OPENAI_API_KEY}") // Returns "sk-xxx"
59/// expand_env_vars("prefix-${VAR}-suffix") // Expands VAR in the middle
60/// expand_env_vars("no vars here") // Returns unchanged (no allocation)
61/// ```
62/// Maximum number of environment variable expansions per string (SEC-M5).
63/// Prevents `DoS` attacks from strings with many `${VAR}` patterns.
64const MAX_ENV_VAR_EXPANSIONS: usize = 100;
65
66fn expand_env_vars(input: &str) -> Cow<'_, str> {
67    // Fast path: no ${} pattern at all
68    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        // SEC-M5: Limit expansions to prevent DoS from many ${} patterns
78        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                // Continue from where we inserted the value
94                // Note: We intentionally skip past the inserted value to prevent
95                // recursive expansion if the value contains ${} patterns.
96                // This is a security feature, not a limitation.
97                start = var_start + value.len();
98            } else {
99                // Skip past this ${...} if var not found
100                start = var_end + 1;
101            }
102        } else {
103            // No closing brace, stop processing
104            break;
105        }
106    }
107
108    // We always return owned in the slow path since we've allocated.
109    // This is acceptable since we only enter this path if the input
110    // contained "${" pattern.
111    Cow::Owned(result)
112}
113
114/// Expands a configuration path, handling `~` for home directory and environment variables.
115///
116/// # Examples
117///
118/// ```ignore
119/// use subcog::config::expand_config_path;
120///
121/// let path = expand_config_path("~/.config/subcog");
122/// // Returns "/home/user/.config/subcog" on Linux
123/// ```
124#[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/// Main configuration for subcog.
156#[derive(Debug, Clone)]
157pub struct SubcogConfig {
158    /// Path to the git repository.
159    pub repo_path: PathBuf,
160    /// Path to the data directory.
161    pub data_dir: PathBuf,
162    /// Feature flags.
163    pub features: FeatureFlags,
164    /// Maximum number of search results.
165    pub max_results: usize,
166    /// Default search mode.
167    pub default_search_mode: crate::models::SearchMode,
168    /// LLM provider configuration.
169    pub llm: LlmConfig,
170    /// Search intent configuration.
171    pub search_intent: SearchIntentConfig,
172    /// Observability settings.
173    pub observability: ObservabilitySettings,
174    /// Prompt customization settings.
175    pub prompt: PromptConfig,
176    /// Storage configuration.
177    pub storage: StorageConfig,
178    /// Consolidation configuration.
179    pub consolidation: ConsolidationConfig,
180    /// TTL (Time-To-Live) configuration for memory expiration.
181    pub ttl: TtlConfig,
182    /// Operation timeout configuration (CHAOS-HIGH-005).
183    pub timeouts: OperationTimeoutConfig,
184    /// Context template configuration.
185    pub context_templates: ContextTemplatesConfig,
186    /// Organization configuration for shared memory graphs.
187    pub org: OrgConfig,
188    /// Webhook configuration.
189    pub webhooks: WebhooksConfig,
190    /// Config files that were loaded (for debugging).
191    pub config_sources: Vec<PathBuf>,
192}
193
194/// LLM provider configuration.
195#[derive(Debug, Clone, Default)]
196pub struct LlmConfig {
197    /// Provider name: "anthropic", "openai", "ollama", "lmstudio".
198    pub provider: LlmProvider,
199    /// Model name.
200    pub model: Option<String>,
201    /// API key (can be environment variable reference like `${OPENAI_API_KEY}`).
202    pub api_key: Option<String>,
203    /// Base URL for the provider (for self-hosted).
204    pub base_url: Option<String>,
205    /// Maximum completion tokens for LLM responses.
206    pub max_tokens: Option<u32>,
207    /// Request timeout in milliseconds.
208    pub timeout_ms: Option<u64>,
209    /// Connect timeout in milliseconds.
210    pub connect_timeout_ms: Option<u64>,
211    /// Maximum retries for LLM calls.
212    pub max_retries: Option<u32>,
213    /// Retry backoff in milliseconds.
214    pub retry_backoff_ms: Option<u64>,
215    /// Consecutive failures before opening circuit breaker.
216    pub breaker_failure_threshold: Option<u32>,
217    /// Circuit breaker reset timeout in milliseconds.
218    pub breaker_reset_ms: Option<u64>,
219    /// Half-open trial requests.
220    pub breaker_half_open_max_calls: Option<u32>,
221    /// Latency budget in milliseconds.
222    pub latency_slo_ms: Option<u64>,
223    /// Error budget ratio threshold.
224    pub error_budget_ratio: Option<f64>,
225    /// Error budget window in seconds.
226    pub error_budget_window_secs: Option<u64>,
227}
228
229impl LlmConfig {
230    /// Creates LLM config from config file settings.
231    ///
232    /// ARCH-HIGH-002: Delegated from `SubcogConfig::apply_config_file`.
233    #[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            // Expand environment variable references like ${OPENAI_API_KEY}
249            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    /// Merges another config into this one.
272    ///
273    /// Only overrides fields that are set in the source.
274    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        // Use file value if present, otherwise keep existing value
294        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/// Observability configuration settings.
315#[derive(Debug, Clone, Default, Deserialize)]
316pub struct ObservabilitySettings {
317    /// Logging settings.
318    pub logging: Option<LoggingSettings>,
319    /// Tracing settings.
320    pub tracing: Option<TracingSettings>,
321    /// Metrics settings.
322    pub metrics: Option<MetricsSettings>,
323}
324
325/// Logging configuration settings.
326#[derive(Debug, Clone, Default, Deserialize)]
327pub struct LoggingSettings {
328    /// Log format ("json" or "pretty").
329    pub format: Option<String>,
330    /// Log level (e.g. "info").
331    pub level: Option<String>,
332    /// Full filter override (e.g. "subcog=debug,hyper=info").
333    pub filter: Option<String>,
334    /// Path to log file (logs to stderr if not set).
335    pub file: Option<String>,
336}
337
338/// Tracing configuration settings.
339#[derive(Debug, Clone, Default, Deserialize)]
340pub struct TracingSettings {
341    /// Whether tracing is enabled.
342    pub enabled: Option<bool>,
343    /// OTLP exporter settings.
344    pub otlp: Option<OtlpSettings>,
345    /// Sample ratio for traces.
346    pub sample_ratio: Option<f64>,
347    /// Service name override.
348    pub service_name: Option<String>,
349    /// Resource attributes (key=value entries).
350    pub resource_attributes: Option<Vec<String>>,
351}
352
353/// OTLP exporter settings.
354#[derive(Debug, Clone, Default, Deserialize)]
355pub struct OtlpSettings {
356    /// Collector endpoint URL.
357    pub endpoint: Option<String>,
358    /// Transport protocol ("grpc" or "http").
359    pub protocol: Option<String>,
360}
361
362/// Metrics configuration settings.
363#[derive(Debug, Clone, Default, Deserialize)]
364pub struct MetricsSettings {
365    /// Whether metrics are enabled.
366    pub enabled: Option<bool>,
367    /// Prometheus exporter port.
368    pub port: Option<u16>,
369    /// Push gateway settings for short-lived processes.
370    pub push_gateway: Option<MetricsPushGatewaySettings>,
371}
372
373/// Prometheus push gateway configuration.
374#[derive(Debug, Clone, Default, Deserialize)]
375pub struct MetricsPushGatewaySettings {
376    /// Push gateway endpoint URI.
377    pub endpoint: Option<String>,
378    /// Optional username for basic auth.
379    pub username: Option<String>,
380    /// Optional password for basic auth.
381    pub password: Option<String>,
382    /// Use HTTP POST instead of PUT.
383    pub use_http_post: Option<bool>,
384}
385
386/// Configuration for search intent detection.
387#[derive(Debug, Clone)]
388pub struct SearchIntentConfig {
389    /// Whether search intent detection is enabled.
390    pub enabled: bool,
391    /// Whether to use LLM for intent classification.
392    pub use_llm: bool,
393    /// Timeout for LLM classification in milliseconds.
394    pub llm_timeout_ms: u64,
395    /// Minimum confidence threshold for memory injection.
396    pub min_confidence: f32,
397    /// Base memory count for adaptive injection.
398    pub base_count: usize,
399    /// Maximum memory count for adaptive injection.
400    pub max_count: usize,
401    /// Maximum tokens for injected memories.
402    pub max_tokens: usize,
403    /// Namespace weights configuration.
404    pub weights: NamespaceWeightsConfig,
405}
406
407/// Runtime namespace weights configuration.
408///
409/// Contains weight multipliers for each intent type. Values are
410/// stored as `HashMap<String, f32>` where keys are namespace names
411/// (lowercase) and values are boost multipliers.
412#[derive(Debug, Clone, Default)]
413pub struct NamespaceWeightsConfig {
414    /// Weights for `HowTo` intent.
415    pub howto: std::collections::HashMap<String, f32>,
416    /// Weights for `Troubleshoot` intent.
417    pub troubleshoot: std::collections::HashMap<String, f32>,
418    /// Weights for `Location` intent.
419    pub location: std::collections::HashMap<String, f32>,
420    /// Weights for `Explanation` intent.
421    pub explanation: std::collections::HashMap<String, f32>,
422    /// Weights for `Comparison` intent.
423    pub comparison: std::collections::HashMap<String, f32>,
424    /// Weights for `General` intent.
425    pub general: std::collections::HashMap<String, f32>,
426}
427
428impl NamespaceWeightsConfig {
429    /// Creates a new config with default weights (matches hard-coded behavior).
430    #[must_use]
431    pub fn with_defaults() -> Self {
432        use std::collections::HashMap;
433
434        // Location/Explanation share the same weights
435        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: patterns 1.5, learnings 1.3, decisions 1.0
443            howto: HashMap::from([
444                ("patterns".to_string(), 1.5),
445                ("learnings".to_string(), 1.3),
446                ("decisions".to_string(), 1.0),
447            ]),
448            // Troubleshoot: blockers 1.5, learnings 1.3, decisions 1.0
449            troubleshoot: HashMap::from([
450                ("blockers".to_string(), 1.5),
451                ("learnings".to_string(), 1.3),
452                ("decisions".to_string(), 1.0),
453            ]),
454            // Location: decisions 1.5, context 1.3, patterns 1.0
455            location: location_weights.clone(),
456            // Explanation: decisions 1.5, context 1.3, patterns 1.0
457            explanation: location_weights,
458            // Comparison: decisions 1.5, patterns 1.3, learnings 1.0
459            comparison: HashMap::from([
460                ("decisions".to_string(), 1.5),
461                ("patterns".to_string(), 1.3),
462                ("learnings".to_string(), 1.0),
463            ]),
464            // General: decisions 1.2, patterns 1.2, learnings 1.0
465            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    /// Gets the weight for a namespace and intent type.
474    ///
475    /// Returns 1.0 if no weight is configured.
476    #[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    /// Gets all namespace weights for a given intent type.
490    ///
491    /// Returns a vector of (`namespace_name`, weight) pairs.
492    #[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    /// Merges config file weights into this config.
506    ///
507    /// Only overrides values that are explicitly set in the file config.
508    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    /// Creates a new configuration.
586    #[must_use]
587    pub fn new() -> Self {
588        Self::default()
589    }
590
591    /// Loads configuration from environment variables.
592    #[must_use]
593    pub fn from_env() -> Self {
594        Self::default().with_env_overrides()
595    }
596
597    /// Applies environment overrides.
598    #[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    /// Builds configuration from config file settings.
623    #[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    /// Sets whether search intent detection is enabled.
656    #[must_use]
657    pub const fn with_enabled(mut self, enabled: bool) -> Self {
658        self.enabled = enabled;
659        self
660    }
661
662    /// Sets whether LLM is enabled.
663    #[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    /// Sets the LLM timeout in milliseconds.
670    #[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    /// Sets the minimum confidence threshold.
677    ///
678    /// Value is clamped to the range [0.0, 1.0].
679    #[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    /// Sets the base memory count for adaptive injection.
686    #[must_use]
687    pub const fn with_base_count(mut self, count: usize) -> Self {
688        self.base_count = count;
689        self
690    }
691
692    /// Sets the maximum memory count for adaptive injection.
693    #[must_use]
694    pub const fn with_max_count(mut self, count: usize) -> Self {
695        self.max_count = count;
696        self
697    }
698
699    /// Sets the maximum tokens for injected memories.
700    #[must_use]
701    pub const fn with_max_tokens(mut self, tokens: usize) -> Self {
702        self.max_tokens = tokens;
703        self
704    }
705
706    /// Sets the namespace weights configuration.
707    #[must_use]
708    pub fn with_weights(mut self, weights: NamespaceWeightsConfig) -> Self {
709        self.weights = weights;
710        self
711    }
712
713    /// Validates and builds the configuration.
714    ///
715    /// # Errors
716    ///
717    /// Returns an error if:
718    /// - `base_count` is greater than `max_count`
719    /// - `max_tokens` is zero
720    /// - `llm_timeout_ms` is zero when LLM is enabled
721    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/// Errors that can occur during configuration validation.
751#[derive(Debug, Clone, thiserror::Error)]
752pub enum ConfigValidationError {
753    /// Invalid range between two related fields.
754    #[error("Invalid range for {field}: {message}")]
755    InvalidRange {
756        /// The field name(s) with invalid range.
757        field: String,
758        /// Description of the issue.
759        message: String,
760    },
761    /// Invalid value for a field.
762    #[error("Invalid value for {field}: {message}")]
763    InvalidValue {
764        /// The field name with invalid value.
765        field: String,
766        /// Description of the issue.
767        message: String,
768    },
769}
770
771/// Available LLM providers.
772#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
773pub enum LlmProvider {
774    /// Anthropic Claude.
775    #[default]
776    Anthropic,
777    /// `OpenAI` GPT.
778    OpenAi,
779    /// Ollama (local).
780    Ollama,
781    /// LM Studio (local).
782    LmStudio,
783    /// No LLM provider configured (skips LLM-powered features).
784    None,
785}
786
787impl LlmProvider {
788    /// Parses a provider string.
789    #[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    /// Returns `true` if this provider is configured (not `None`).
801    #[must_use]
802    pub const fn is_configured(&self) -> bool {
803        !matches!(self, Self::None)
804    }
805}
806
807/// Configuration file structure (for TOML parsing).
808#[derive(Debug, Deserialize, Default)]
809pub struct ConfigFile {
810    /// Repository path.
811    pub repo_path: Option<String>,
812    /// Data directory.
813    pub data_dir: Option<String>,
814    /// Max results.
815    pub max_results: Option<usize>,
816    /// Default search mode.
817    pub default_search_mode: Option<String>,
818    /// Feature flags.
819    pub features: Option<ConfigFileFeatures>,
820    /// LLM configuration.
821    pub llm: Option<ConfigFileLlm>,
822    /// Search intent configuration.
823    pub search_intent: Option<ConfigFileSearchIntent>,
824    /// Observability configuration.
825    pub observability: Option<ObservabilitySettings>,
826    /// Prompt customization.
827    pub prompt: Option<ConfigFilePrompt>,
828    /// Storage configuration.
829    pub storage: Option<ConfigFileStorage>,
830    /// Consolidation configuration.
831    pub consolidation: Option<ConfigFileConsolidation>,
832    /// TTL (Time-To-Live) configuration for memory expiration.
833    pub ttl: Option<ConfigFileTtl>,
834    /// Context template configuration.
835    pub context_templates: Option<ConfigFileContextTemplates>,
836    /// Organization configuration for shared memory graphs.
837    pub org: Option<ConfigFileOrg>,
838    /// Webhook configurations.
839    #[serde(default)]
840    pub webhooks: Vec<ConfigFileWebhook>,
841}
842
843/// Features section in config file.
844#[derive(Debug, Deserialize, Default)]
845pub struct ConfigFileFeatures {
846    /// Secrets filter.
847    pub secrets_filter: Option<bool>,
848    /// PII filter.
849    pub pii_filter: Option<bool>,
850    /// Multi-domain support.
851    pub multi_domain: Option<bool>,
852    /// Audit log.
853    pub audit_log: Option<bool>,
854    /// LLM-powered features.
855    pub llm_features: Option<bool>,
856    /// Auto-capture feature.
857    pub auto_capture: Option<bool>,
858    /// Consolidation feature.
859    pub consolidation: Option<bool>,
860    /// Enable org-scope storage.
861    pub org_scope_enabled: Option<bool>,
862    /// Enable automatic entity extraction during memory capture.
863    pub auto_extract_entities: Option<bool>,
864}
865
866/// LLM section in config file.
867#[derive(Debug, Deserialize, Default)]
868pub struct ConfigFileLlm {
869    /// Provider name.
870    pub provider: Option<String>,
871    /// Model name.
872    pub model: Option<String>,
873    /// API key.
874    pub api_key: Option<String>,
875    /// Base URL.
876    pub base_url: Option<String>,
877    /// Maximum completion tokens for LLM responses (default: 8192).
878    pub max_tokens: Option<u32>,
879    /// Request timeout in milliseconds.
880    pub timeout_ms: Option<u64>,
881    /// Connect timeout in milliseconds.
882    pub connect_timeout_ms: Option<u64>,
883    /// Maximum retries for LLM calls.
884    pub max_retries: Option<u32>,
885    /// Retry backoff in milliseconds.
886    pub retry_backoff_ms: Option<u64>,
887    /// Circuit breaker failure threshold.
888    pub breaker_failure_threshold: Option<u32>,
889    /// Circuit breaker reset timeout in milliseconds.
890    pub breaker_reset_ms: Option<u64>,
891    /// Circuit breaker half-open max calls.
892    pub breaker_half_open_max_calls: Option<u32>,
893    /// Latency budget in milliseconds.
894    pub latency_slo_ms: Option<u64>,
895    /// Error budget ratio threshold.
896    pub error_budget_ratio: Option<f64>,
897    /// Error budget window in seconds.
898    pub error_budget_window_secs: Option<u64>,
899}
900
901/// Search intent section in config file.
902#[derive(Debug, Deserialize, Default)]
903pub struct ConfigFileSearchIntent {
904    /// Whether search intent detection is enabled.
905    pub enabled: Option<bool>,
906    /// Whether to use LLM for intent classification.
907    pub use_llm: Option<bool>,
908    /// Timeout for LLM classification in milliseconds.
909    pub llm_timeout_ms: Option<u64>,
910    /// Minimum confidence threshold.
911    pub min_confidence: Option<f32>,
912    /// Base memory count for adaptive injection.
913    pub base_count: Option<usize>,
914    /// Maximum memory count for adaptive injection.
915    pub max_count: Option<usize>,
916    /// Maximum tokens for injected memories.
917    pub max_tokens: Option<usize>,
918    /// Namespace weights configuration.
919    pub weights: Option<ConfigFileNamespaceWeights>,
920}
921
922/// Namespace weights configuration in config file.
923///
924/// Allows customizing the boost multipliers applied to search results
925/// based on intent type and namespace. Higher values prioritize that
926/// namespace for the given intent type.
927#[derive(Debug, Deserialize, Default, Clone)]
928pub struct ConfigFileNamespaceWeights {
929    /// Weights for `HowTo` intent (e.g., "how do I implement X?").
930    pub howto: Option<ConfigFileIntentWeights>,
931    /// Weights for `Troubleshoot` intent (e.g., "why is X failing?").
932    pub troubleshoot: Option<ConfigFileIntentWeights>,
933    /// Weights for `Location` intent (e.g., "where is X defined?").
934    pub location: Option<ConfigFileIntentWeights>,
935    /// Weights for `Explanation` intent (e.g., "what is X?").
936    pub explanation: Option<ConfigFileIntentWeights>,
937    /// Weights for `Comparison` intent (e.g., "X vs Y?").
938    pub comparison: Option<ConfigFileIntentWeights>,
939    /// Weights for `General` intent (fallback for other queries).
940    pub general: Option<ConfigFileIntentWeights>,
941}
942
943/// Per-intent namespace weight multipliers.
944///
945/// Each field is a boost multiplier (default 1.0). Values > 1.0 boost
946/// that namespace, values < 1.0 reduce priority.
947#[derive(Debug, Deserialize, Default, Clone)]
948pub struct ConfigFileIntentWeights {
949    /// Weight for decisions namespace.
950    pub decisions: Option<f32>,
951    /// Weight for patterns namespace.
952    pub patterns: Option<f32>,
953    /// Weight for learnings namespace.
954    pub learnings: Option<f32>,
955    /// Weight for context namespace.
956    pub context: Option<f32>,
957    /// Weight for tech-debt namespace.
958    pub tech_debt: Option<f32>,
959    /// Weight for blockers namespace.
960    pub blockers: Option<f32>,
961    /// Weight for apis namespace.
962    pub apis: Option<f32>,
963    /// Weight for config namespace.
964    pub config: Option<f32>,
965    /// Weight for security namespace.
966    pub security: Option<f32>,
967    /// Weight for performance namespace.
968    pub performance: Option<f32>,
969    /// Weight for testing namespace.
970    pub testing: Option<f32>,
971}
972
973/// TTL (Time-To-Live) configuration section in config file.
974///
975/// Supports duration strings: "7d" (days), "30d", "0" (no expiration).
976///
977/// # Example TOML
978///
979/// ```toml
980/// [memory.ttl]
981/// default = "30d"
982///
983/// [memory.ttl.namespace]
984/// decisions = "90d"
985/// context = "7d"
986/// tech-debt = "0"  # Never expires
987///
988/// [memory.ttl.scope]
989/// project = "30d"
990/// user = "90d"
991/// org = "365d"
992/// ```
993#[derive(Debug, Clone, Deserialize, Default)]
994pub struct ConfigFileTtl {
995    /// Default TTL for all memories (e.g., "30d").
996    /// "0" means no expiration.
997    pub default: Option<String>,
998    /// Per-namespace TTL overrides.
999    pub namespace: Option<ConfigFileTtlNamespace>,
1000    /// Per-scope TTL overrides.
1001    pub scope: Option<ConfigFileTtlScope>,
1002}
1003
1004/// Per-namespace TTL configuration in config file.
1005#[derive(Debug, Clone, Deserialize, Default)]
1006pub struct ConfigFileTtlNamespace {
1007    /// TTL for decisions namespace.
1008    pub decisions: Option<String>,
1009    /// TTL for patterns namespace.
1010    pub patterns: Option<String>,
1011    /// TTL for learnings namespace.
1012    pub learnings: Option<String>,
1013    /// TTL for context namespace.
1014    pub context: Option<String>,
1015    /// TTL for tech-debt namespace.
1016    #[serde(alias = "tech-debt")]
1017    pub tech_debt: Option<String>,
1018    /// TTL for apis namespace.
1019    pub apis: Option<String>,
1020    /// TTL for config namespace.
1021    pub config: Option<String>,
1022    /// TTL for security namespace.
1023    pub security: Option<String>,
1024    /// TTL for performance namespace.
1025    pub performance: Option<String>,
1026    /// TTL for testing namespace.
1027    pub testing: Option<String>,
1028}
1029
1030/// Per-scope TTL configuration in config file.
1031#[derive(Debug, Clone, Deserialize, Default)]
1032pub struct ConfigFileTtlScope {
1033    /// TTL for project-scoped memories.
1034    pub project: Option<String>,
1035    /// TTL for user-scoped memories.
1036    pub user: Option<String>,
1037    /// TTL for org-scoped memories.
1038    pub org: Option<String>,
1039}
1040
1041/// Context template configuration section in config file.
1042///
1043/// # Example TOML
1044///
1045/// ```toml
1046/// [context_templates]
1047/// enabled = true
1048/// default_format = "markdown"  # markdown, json, xml
1049///
1050/// [context_templates.hooks.session_start]
1051/// template = "session-context"
1052/// version = 1
1053/// format = "markdown"
1054/// ```
1055#[derive(Debug, Clone, Deserialize, Default)]
1056pub struct ConfigFileContextTemplates {
1057    /// Whether context templates feature is enabled.
1058    pub enabled: Option<bool>,
1059    /// Default output format: "markdown", "json", or "xml".
1060    pub default_format: Option<String>,
1061    /// Per-hook template configuration.
1062    pub hooks: Option<ConfigFileHookTemplates>,
1063}
1064
1065/// Per-hook template configuration.
1066#[derive(Debug, Clone, Deserialize, Default)]
1067pub struct ConfigFileHookTemplates {
1068    /// Template for `session_start` hook.
1069    pub session_start: Option<ConfigFileHookTemplate>,
1070    /// Template for `user_prompt_submit` hook.
1071    pub user_prompt_submit: Option<ConfigFileHookTemplate>,
1072    /// Template for `post_tool_use` hook.
1073    pub post_tool_use: Option<ConfigFileHookTemplate>,
1074    /// Template for `pre_compact` hook.
1075    pub pre_compact: Option<ConfigFileHookTemplate>,
1076}
1077
1078/// Configuration for a specific hook's template.
1079#[derive(Debug, Clone, Deserialize, Default)]
1080pub struct ConfigFileHookTemplate {
1081    /// Name of the template to use.
1082    pub template: Option<String>,
1083    /// Specific version to use (None = latest).
1084    pub version: Option<u32>,
1085    /// Output format override: "markdown", "json", or "xml".
1086    pub format: Option<String>,
1087}
1088
1089/// Runtime context template configuration.
1090///
1091/// Controls the context templates feature for formatting memories and statistics
1092/// in hooks and MCP tool responses.
1093#[derive(Debug, Clone)]
1094pub struct ContextTemplatesConfig {
1095    /// Whether context templates feature is enabled.
1096    pub enabled: bool,
1097    /// Default output format for templates.
1098    pub default_format: crate::models::OutputFormat,
1099    /// Per-hook template configuration.
1100    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    /// Creates config from a config file section.
1115    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/// Runtime per-hook template configuration.
1137#[derive(Debug, Clone, Default)]
1138pub struct HookTemplatesConfig {
1139    /// Template for `session_start` hook.
1140    pub session_start: Option<HookTemplateConfig>,
1141    /// Template for `user_prompt_submit` hook.
1142    pub user_prompt_submit: Option<HookTemplateConfig>,
1143    /// Template for `post_tool_use` hook.
1144    pub post_tool_use: Option<HookTemplateConfig>,
1145    /// Template for `pre_compact` hook.
1146    pub pre_compact: Option<HookTemplateConfig>,
1147}
1148
1149impl HookTemplatesConfig {
1150    /// Creates config from a config file section.
1151    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/// Runtime configuration for a specific hook's template.
1174#[derive(Debug, Clone)]
1175pub struct HookTemplateConfig {
1176    /// Name of the template to use.
1177    pub template: String,
1178    /// Specific version to use (None = latest).
1179    pub version: Option<u32>,
1180    /// Output format override.
1181    pub format: Option<crate::models::OutputFormat>,
1182}
1183
1184impl HookTemplateConfig {
1185    /// Creates config from a config file section.
1186    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
1195/// Parses output format from string.
1196fn 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/// Runtime TTL (Time-To-Live) configuration.
1206///
1207/// Controls memory expiration with domain-scoped and per-namespace defaults.
1208/// TTL values are stored in seconds (0 means no expiration).
1209///
1210/// # Defaults
1211///
1212/// - `default_seconds`: None (no expiration)
1213/// - All namespace/scope overrides: None (inherit from default)
1214///
1215/// # Environment Variables
1216///
1217/// | Variable | Description | Example |
1218/// |----------|-------------|---------|
1219/// | `SUBCOG_TTL_DEFAULT` | Default TTL | "30d", "0" |
1220///
1221/// # Priority Order (highest to lowest)
1222///
1223/// 1. Explicit `--ttl` flag on capture command
1224/// 2. Per-namespace TTL (e.g., `ttl.namespace.context = "7d"`)
1225/// 3. Per-scope TTL (e.g., `ttl.scope.project = "30d"`)
1226/// 4. Global default TTL (e.g., `ttl.default = "30d"`)
1227/// 5. No expiration (if nothing configured)
1228#[derive(Debug, Clone, Default)]
1229pub struct TtlConfig {
1230    /// Default TTL in seconds for all memories (None = no expiration, 0 = no expiration).
1231    pub default_seconds: Option<u64>,
1232    /// Per-namespace TTL overrides in seconds.
1233    pub namespace: TtlNamespaceConfig,
1234    /// Per-scope TTL overrides in seconds.
1235    pub scope: TtlScopeConfig,
1236}
1237
1238/// Per-namespace TTL configuration (runtime).
1239#[derive(Debug, Clone, Default)]
1240pub struct TtlNamespaceConfig {
1241    /// TTL for decisions namespace in seconds.
1242    pub decisions: Option<u64>,
1243    /// TTL for patterns namespace in seconds.
1244    pub patterns: Option<u64>,
1245    /// TTL for learnings namespace in seconds.
1246    pub learnings: Option<u64>,
1247    /// TTL for context namespace in seconds.
1248    pub context: Option<u64>,
1249    /// TTL for tech-debt namespace in seconds.
1250    pub tech_debt: Option<u64>,
1251    /// TTL for apis namespace in seconds.
1252    pub apis: Option<u64>,
1253    /// TTL for config namespace in seconds.
1254    pub config: Option<u64>,
1255    /// TTL for security namespace in seconds.
1256    pub security: Option<u64>,
1257    /// TTL for performance namespace in seconds.
1258    pub performance: Option<u64>,
1259    /// TTL for testing namespace in seconds.
1260    pub testing: Option<u64>,
1261}
1262
1263/// Per-scope TTL configuration (runtime).
1264#[derive(Debug, Clone, Default)]
1265pub struct TtlScopeConfig {
1266    /// TTL for project-scoped memories in seconds.
1267    pub project: Option<u64>,
1268    /// TTL for user-scoped memories in seconds.
1269    pub user: Option<u64>,
1270    /// TTL for org-scoped memories in seconds.
1271    pub org: Option<u64>,
1272}
1273
1274impl TtlConfig {
1275    /// Creates a new TTL configuration with defaults.
1276    #[must_use]
1277    pub fn new() -> Self {
1278        Self::default()
1279    }
1280
1281    /// Creates configuration from config file settings.
1282    #[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    /// Loads configuration from environment variables.
1302    #[must_use]
1303    pub fn from_env() -> Self {
1304        Self::default().with_env_overrides()
1305    }
1306
1307    /// Applies environment variable overrides.
1308    #[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    /// Gets the effective TTL in seconds for a given namespace and scope.
1317    ///
1318    /// Returns `None` if no TTL is configured (memory never expires).
1319    /// Returns `Some(0)` if explicitly set to never expire.
1320    ///
1321    /// Priority order:
1322    /// 1. Per-namespace TTL
1323    /// 2. Per-scope TTL
1324    /// 3. Global default TTL
1325    #[must_use]
1326    pub fn get_ttl_seconds(&self, namespace: &str, scope: &str) -> Option<u64> {
1327        // Check namespace-specific TTL first
1328        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        // Check scope-specific TTL
1347        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        // Fall back to global default
1359        self.default_seconds
1360    }
1361
1362    /// Sets the default TTL in seconds.
1363    #[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    /// Creates configuration from config file settings.
1372    #[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    /// Creates configuration from config file settings.
1421    #[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/// Parses a duration string to seconds.
1438///
1439/// Supported formats:
1440/// - "0" or "" - No expiration (returns `Some(0)`)
1441/// - "30d" - 30 days
1442/// - "7d" - 7 days
1443/// - "24h" - 24 hours
1444/// - "60m" - 60 minutes
1445/// - "3600s" or "3600" - 3600 seconds
1446///
1447/// # Returns
1448///
1449/// - `Some(0)` for "0" or empty string (explicitly no expiration)
1450/// - `Some(seconds)` for valid duration strings
1451/// - `None` for invalid formats (caller should use default)
1452#[must_use]
1453pub fn parse_duration_to_seconds(s: &str) -> Option<u64> {
1454    let s = s.trim();
1455
1456    // Empty or "0" means no expiration
1457    if s.is_empty() || s == "0" {
1458        return Some(0);
1459    }
1460
1461    // Try to parse as pure number (seconds)
1462    if let Ok(secs) = s.parse::<u64>() {
1463        return Some(secs);
1464    }
1465
1466    // Parse duration with suffix
1467    let (num_str, multiplier) = if let Some(num) = s.strip_suffix('d') {
1468        (num, 86400u64) // days -> seconds
1469    } else if let Some(num) = s.strip_suffix('h') {
1470        (num, 3600u64) // hours -> seconds
1471    } else if let Some(num) = s.strip_suffix('m') {
1472        (num, 60u64) // minutes -> seconds
1473    } else if let Some(num) = s.strip_suffix('s') {
1474        (num, 1u64) // seconds
1475    } else {
1476        // Unknown format
1477        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/// Consolidation configuration section in config file.
1491#[derive(Debug, Clone, Deserialize, Default)]
1492pub struct ConfigFileConsolidation {
1493    /// Whether consolidation is enabled.
1494    pub enabled: Option<bool>,
1495    /// Filter to specific namespaces (None = all namespaces).
1496    pub namespace_filter: Option<Vec<String>>,
1497    /// Time window in days for consolidation (None = no time limit).
1498    pub time_window_days: Option<u32>,
1499    /// Minimum number of memories required to trigger consolidation.
1500    pub min_memories_to_consolidate: Option<usize>,
1501    /// Similarity threshold for grouping related memories (0.0-1.0).
1502    pub similarity_threshold: Option<f32>,
1503}
1504
1505/// Prompt customization section in config file.
1506///
1507/// Allows users to add custom guidance to the LLM system prompts.
1508#[derive(Debug, Clone, Deserialize, Default)]
1509pub struct ConfigFilePrompt {
1510    /// Additional identity context (who subcog is in your environment).
1511    /// Appended to the identity section of the base prompt.
1512    pub identity_addendum: Option<String>,
1513
1514    /// Additional global guidance (applies to all operations).
1515    /// Appended after the base prompt.
1516    pub additional_guidance: Option<String>,
1517
1518    /// Per-operation customizations.
1519    pub capture: Option<ConfigFilePromptOperation>,
1520    /// Search operation customizations.
1521    pub search: Option<ConfigFilePromptOperation>,
1522    /// Enrichment operation customizations.
1523    pub enrichment: Option<ConfigFilePromptOperation>,
1524    /// Consolidation operation customizations.
1525    pub consolidation: Option<ConfigFilePromptOperation>,
1526}
1527
1528/// Per-operation prompt customization.
1529#[derive(Debug, Clone, Deserialize, Default)]
1530pub struct ConfigFilePromptOperation {
1531    /// Additional guidance for this specific operation.
1532    pub additional_guidance: Option<String>,
1533}
1534
1535/// Storage configuration section in config file.
1536#[derive(Debug, Clone, Deserialize, Default)]
1537pub struct ConfigFileStorage {
1538    /// Project-scoped storage configuration.
1539    pub project: Option<ConfigFileStorageBackend>,
1540    /// User-scoped storage configuration.
1541    pub user: Option<ConfigFileStorageBackend>,
1542    /// Organization-scoped storage configuration.
1543    pub org: Option<ConfigFileStorageBackend>,
1544}
1545
1546/// Storage backend configuration.
1547#[derive(Debug, Clone, Deserialize, Default)]
1548pub struct ConfigFileStorageBackend {
1549    /// Backend type: sqlite, filesystem, postgresql, redis.
1550    pub backend: Option<String>,
1551    /// Path for file-based backends (sqlite, filesystem).
1552    pub path: Option<String>,
1553    /// Connection string for database backends (postgresql).
1554    pub connection_string: Option<String>,
1555    /// Redis URL for redis backend.
1556    pub redis_url: Option<String>,
1557    /// Enable encryption at rest (COMP-CRIT-002).
1558    /// Defaults to true when not specified.
1559    pub encryption_enabled: Option<bool>,
1560}
1561
1562/// Runtime storage configuration.
1563#[derive(Debug, Clone, Default)]
1564pub struct StorageConfig {
1565    /// Project storage settings.
1566    pub project: StorageBackendConfig,
1567    /// User storage settings.
1568    pub user: StorageBackendConfig,
1569    /// Org storage settings.
1570    pub org: StorageBackendConfig,
1571}
1572
1573/// Runtime storage backend configuration.
1574#[derive(Debug, Clone)]
1575pub struct StorageBackendConfig {
1576    /// Backend type.
1577    pub backend: StorageBackendType,
1578    /// Path for file-based backends.
1579    pub path: Option<String>,
1580    /// Connection string for database backends.
1581    pub connection_string: Option<String>,
1582    /// Maximum connection pool size for database backends (PostgreSQL).
1583    /// Defaults to 20 if not specified.
1584    pub pool_max_size: Option<usize>,
1585    /// Enable encryption at rest (COMP-CRIT-002).
1586    /// Defaults to true for security-by-default.
1587    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            // COMP-CRIT-002: Enable encryption by default for security
1598            encryption_enabled: true,
1599        }
1600    }
1601}
1602
1603/// Storage backend types.
1604#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1605pub enum StorageBackendType {
1606    /// `SQLite` database (default, authoritative storage).
1607    #[default]
1608    Sqlite,
1609    /// Filesystem (fallback).
1610    Filesystem,
1611    /// PostgreSQL.
1612    PostgreSQL,
1613    /// Redis.
1614    Redis,
1615}
1616
1617impl StorageBackendType {
1618    /// Parses a backend type from string.
1619    ///
1620    /// Defaults to `Sqlite` for unknown values.
1621    #[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            // sqlite is the default for any unrecognized value
1628            _ => Self::Sqlite,
1629        }
1630    }
1631}
1632
1633/// Operation-level timeout configuration (CHAOS-HIGH-005).
1634///
1635/// Provides configurable timeouts for different operation types to prevent
1636/// indefinite blocking and ensure predictable latency behavior.
1637///
1638/// # Environment Variables
1639///
1640/// | Variable | Description | Default |
1641/// |----------|-------------|---------|
1642/// | `SUBCOG_TIMEOUT_DEFAULT_MS` | Default timeout for all operations | 30000 |
1643/// | `SUBCOG_TIMEOUT_CAPTURE_MS` | Capture operation timeout | 30000 |
1644/// | `SUBCOG_TIMEOUT_RECALL_MS` | Recall/search operation timeout | 30000 |
1645/// | `SUBCOG_TIMEOUT_SYNC_MS` | Git sync operation timeout | 60000 |
1646/// | `SUBCOG_TIMEOUT_EMBED_MS` | Embedding generation timeout | 30000 |
1647/// | `SUBCOG_TIMEOUT_REDIS_MS` | Redis operation timeout | 5000 |
1648/// | `SUBCOG_TIMEOUT_SQLITE_MS` | `SQLite` operation timeout | 5000 |
1649/// | `SUBCOG_TIMEOUT_POSTGRES_MS` | PostgreSQL operation timeout | 10000 |
1650#[derive(Debug, Clone)]
1651pub struct OperationTimeoutConfig {
1652    /// Default timeout in milliseconds for all operations.
1653    pub default_ms: u64,
1654    /// Timeout for capture operations in milliseconds.
1655    pub capture_ms: u64,
1656    /// Timeout for recall/search operations in milliseconds.
1657    pub recall_ms: u64,
1658    /// Timeout for sync operations in milliseconds.
1659    pub sync_ms: u64,
1660    /// Timeout for embedding operations in milliseconds.
1661    pub embed_ms: u64,
1662    /// Timeout for Redis operations in milliseconds.
1663    pub redis_ms: u64,
1664    /// Timeout for `SQLite` operations in milliseconds.
1665    pub sqlite_ms: u64,
1666    /// Timeout for PostgreSQL operations in milliseconds.
1667    pub postgres_ms: u64,
1668    /// Timeout for entity extraction LLM operations in milliseconds.
1669    /// Default is 120 seconds (longer than general LLM timeout for complex content).
1670    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, // Sync can be slower
1680            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, // 120s for complex LLM extraction
1685        }
1686    }
1687}
1688
1689impl OperationTimeoutConfig {
1690    /// Creates a new configuration with default values.
1691    #[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    /// Loads configuration from environment variables.
1707    #[must_use]
1708    pub fn from_env() -> Self {
1709        Self::new().with_env_overrides()
1710    }
1711
1712    /// Applies environment variable overrides.
1713    #[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); // Minimum 100ms
1719        }
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); // Minimum 1s for LLM calls
1759        }
1760        self
1761    }
1762
1763    /// Gets the timeout for a specific operation type.
1764    #[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    /// Builder method to set default timeout.
1781    #[must_use]
1782    pub const fn with_default_ms(mut self, ms: u64) -> Self {
1783        self.default_ms = ms;
1784        self
1785    }
1786
1787    /// Builder method to set capture timeout.
1788    #[must_use]
1789    pub const fn with_capture_ms(mut self, ms: u64) -> Self {
1790        self.capture_ms = ms;
1791        self
1792    }
1793
1794    /// Builder method to set recall timeout.
1795    #[must_use]
1796    pub const fn with_recall_ms(mut self, ms: u64) -> Self {
1797        self.recall_ms = ms;
1798        self
1799    }
1800
1801    /// Builder method to set sync timeout.
1802    #[must_use]
1803    pub const fn with_sync_ms(mut self, ms: u64) -> Self {
1804        self.sync_ms = ms;
1805        self
1806    }
1807
1808    /// Builder method to set embed timeout.
1809    #[must_use]
1810    pub const fn with_embed_ms(mut self, ms: u64) -> Self {
1811        self.embed_ms = ms;
1812        self
1813    }
1814
1815    /// Builder method to set Redis timeout.
1816    #[must_use]
1817    pub const fn with_redis_ms(mut self, ms: u64) -> Self {
1818        self.redis_ms = ms;
1819        self
1820    }
1821
1822    /// Builder method to set `SQLite` timeout.
1823    #[must_use]
1824    pub const fn with_sqlite_ms(mut self, ms: u64) -> Self {
1825        self.sqlite_ms = ms;
1826        self
1827    }
1828
1829    /// Builder method to set PostgreSQL timeout.
1830    #[must_use]
1831    pub const fn with_postgres_ms(mut self, ms: u64) -> Self {
1832        self.postgres_ms = ms;
1833        self
1834    }
1835
1836    /// Builder method to set entity extraction timeout.
1837    #[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/// Operation types for timeout configuration.
1845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1846pub enum OperationType {
1847    /// Memory capture operations.
1848    Capture,
1849    /// Memory recall/search operations.
1850    Recall,
1851    /// Git sync operations.
1852    Sync,
1853    /// Embedding generation.
1854    Embed,
1855    /// Redis operations.
1856    Redis,
1857    /// `SQLite` operations.
1858    Sqlite,
1859    /// PostgreSQL operations.
1860    Postgres,
1861    /// Entity extraction LLM operations.
1862    EntityExtraction,
1863    /// Default/fallback timeout.
1864    Default,
1865}
1866
1867impl StorageConfig {
1868    /// Creates storage config from config file settings.
1869    #[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            // COMP-CRIT-002: Allow explicit override, default is true
1883            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            // COMP-CRIT-002: Allow explicit override, default is true
1898            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            // COMP-CRIT-002: Allow explicit override, default is true
1913            if let Some(encryption) = org.encryption_enabled {
1914                config.org.encryption_enabled = encryption;
1915            }
1916        }
1917
1918        config
1919    }
1920}
1921
1922/// Runtime prompt configuration.
1923#[derive(Debug, Clone, Default)]
1924pub struct PromptConfig {
1925    /// Additional identity context (who subcog is in your environment).
1926    pub identity_addendum: Option<String>,
1927    /// Additional global guidance (applies to all operations).
1928    pub additional_guidance: Option<String>,
1929    /// Per-operation guidance.
1930    pub operation_guidance: PromptOperationConfig,
1931}
1932
1933/// Per-operation prompt guidance.
1934#[derive(Debug, Clone, Default)]
1935pub struct PromptOperationConfig {
1936    /// Additional guidance for capture analysis.
1937    pub capture: Option<String>,
1938    /// Additional guidance for search intent.
1939    pub search: Option<String>,
1940    /// Additional guidance for enrichment.
1941    pub enrichment: Option<String>,
1942    /// Additional guidance for consolidation.
1943    pub consolidation: Option<String>,
1944}
1945
1946/// Runtime consolidation configuration.
1947///
1948/// Controls LLM-powered memory consolidation that summarizes related memories
1949/// while preserving original details.
1950///
1951/// # Defaults
1952///
1953/// - `enabled`: false (requires LLM provider to be useful)
1954/// - `namespace_filter`: None (all namespaces)
1955/// - `time_window_days`: Some(30) (last 30 days)
1956/// - `min_memories_to_consolidate`: 3 (need at least 3 related memories)
1957/// - `similarity_threshold`: 0.7 (70% semantic similarity)
1958///
1959/// # Environment Variables
1960///
1961/// | Variable | Description | Default |
1962/// |----------|-------------|---------|
1963/// | `SUBCOG_CONSOLIDATION_ENABLED` | Enable consolidation | false |
1964/// | `SUBCOG_CONSOLIDATION_TIME_WINDOW_DAYS` | Time window in days | 30 |
1965/// | `SUBCOG_CONSOLIDATION_MIN_MEMORIES` | Minimum memories to consolidate | 3 |
1966/// | `SUBCOG_CONSOLIDATION_SIMILARITY_THRESHOLD` | Similarity threshold (0.0-1.0) | 0.7 |
1967#[derive(Debug, Clone)]
1968pub struct ConsolidationConfig {
1969    /// Whether consolidation is enabled.
1970    pub enabled: bool,
1971    /// Filter to specific namespaces (None = all namespaces).
1972    pub namespace_filter: Option<Vec<crate::models::Namespace>>,
1973    /// Time window in days for consolidation (None = no time limit).
1974    pub time_window_days: Option<u32>,
1975    /// Minimum number of memories required to trigger consolidation.
1976    pub min_memories_to_consolidate: usize,
1977    /// Similarity threshold for grouping related memories (0.0-1.0).
1978    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    /// Creates a new consolidation configuration with defaults.
1995    #[must_use]
1996    pub fn new() -> Self {
1997        Self::default()
1998    }
1999
2000    /// Creates configuration from config file settings.
2001    #[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); // At least 2 memories
2025        }
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    /// Loads configuration from environment variables.
2035    #[must_use]
2036    pub fn from_env() -> Self {
2037        Self::default().with_env_overrides()
2038    }
2039
2040    /// Applies environment variable overrides.
2041    #[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    /// Sets whether consolidation is enabled.
2069    #[must_use]
2070    pub const fn with_enabled(mut self, enabled: bool) -> Self {
2071        self.enabled = enabled;
2072        self
2073    }
2074
2075    /// Sets the namespace filter.
2076    #[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    /// Sets the time window in days.
2083    #[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    /// Sets the minimum number of memories to consolidate.
2090    #[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    /// Sets the similarity threshold.
2097    ///
2098    /// Value is clamped to the range [0.0, 1.0].
2099    #[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    /// Validates the configuration.
2106    ///
2107    /// # Errors
2108    ///
2109    /// Returns an error if:
2110    /// - `min_memories_to_consolidate` is less than 2
2111    /// - `similarity_threshold` is not in range [0.0, 1.0]
2112    /// - `time_window_days` is 0 (if set)
2113    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    /// Creates a new prompt configuration from config file settings.
2146    #[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    /// Gets the operation-specific guidance for a given operation mode.
2173    #[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    /// Applies environment variable overrides.
2185    #[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    /// Creates a new configuration with default values.
2224    #[must_use]
2225    pub fn new() -> Self {
2226        Self::default()
2227    }
2228
2229    /// Loads configuration from a file path.
2230    ///
2231    /// # Errors
2232    ///
2233    /// Returns an error if the file cannot be read or parsed.
2234    pub fn load_from_file(path: &std::path::Path) -> crate::Result<Self> {
2235        // SEC-M4: Warn if config file is world-readable (may contain API keys)
2236        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    /// Loads configuration from the default location.
2258    ///
2259    /// Config location: `~/.config/subcog/config.toml`
2260    /// Data location (platform-specific):
2261    /// - macOS: `~/Library/Application Support/subcog/`
2262    /// - Linux: `~/.local/share/subcog/`
2263    /// - Windows: `C:\Users\<User>\AppData\Local\subcog\`
2264    ///
2265    /// Returns default configuration if no config file is found.
2266    #[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        // Single config location: ~/.config/subcog/
2275        let config_dir = base_dirs.home_dir().join(".config").join("subcog");
2276
2277        // Use platform-appropriate data directory (not config directory)
2278        // - macOS: ~/Library/Application Support/subcog/
2279        // - Linux: ~/.local/share/subcog/
2280        // - Windows: C:\Users\<User>\AppData\Local\subcog\
2281        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        // COMP-CRIT-002: Allow env var override for encryption (applies to all scopes)
2314        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        // Auto-extract entities during capture (for graph-augmented retrieval)
2332        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    /// Applies a `ConfigFile` to the current configuration.
2354    ///
2355    /// ARCH-HIGH-002: Delegates to sub-config `merge_from`/`from_config_file` methods.
2356    fn apply_config_file(&mut self, file: ConfigFile) {
2357        // Core settings
2358        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        // Delegate to sub-config types (ARCH-HIGH-002)
2376        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        // Webhooks from [[webhooks]] array
2408        if !file.webhooks.is_empty() {
2409            self.webhooks = WebhooksConfig::from_config_file(file.webhooks);
2410        }
2411    }
2412
2413    /// Sets the repository path.
2414    #[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    /// Sets the data directory.
2421    #[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            // Log error so config parsing issues are visible
2437            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    // SEC-M4: Warn if config file is world-readable (may contain API keys)
2445    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/// Service configuration (for backwards compatibility).
2459///
2460/// Used by services for runtime configuration.
2461#[derive(Debug, Clone, Default)]
2462pub struct Config {
2463    /// Path to the git repository.
2464    pub repo_path: Option<PathBuf>,
2465    /// Path to the data directory.
2466    pub data_dir: Option<PathBuf>,
2467    /// Feature configuration.
2468    pub features: ServiceFeatures,
2469}
2470
2471/// Feature configuration for services.
2472#[derive(Debug, Clone)]
2473#[allow(clippy::struct_excessive_bools)]
2474pub struct ServiceFeatures {
2475    /// Whether to block content with secrets.
2476    pub block_secrets: bool,
2477    /// Whether to redact secrets.
2478    pub redact_secrets: bool,
2479    /// Whether to enable auto-sync.
2480    pub auto_sync: bool,
2481    /// Whether to auto-extract entities during capture.
2482    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    /// Creates a new config with default values.
2498    #[must_use]
2499    pub fn new() -> Self {
2500        Self::default()
2501    }
2502
2503    /// Creates config with a repository path.
2504    #[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    /// Creates config with a data directory.
2511    #[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// =============================================================================
2534// WEBHOOK CONFIGURATION
2535// =============================================================================
2536
2537/// Webhook configuration from config.toml.
2538#[derive(Debug, Clone, Deserialize, Default)]
2539pub struct ConfigFileWebhook {
2540    /// Unique name for this webhook.
2541    pub name: String,
2542    /// Target URL for webhook delivery.
2543    pub url: String,
2544    /// Authentication configuration.
2545    pub auth: Option<ConfigFileWebhookAuth>,
2546    /// Event types to subscribe to (empty = all events).
2547    #[serde(default)]
2548    pub events: Vec<String>,
2549    /// Domain scopes to filter (empty = all scopes).
2550    #[serde(default)]
2551    pub scopes: Vec<String>,
2552    /// Whether this webhook is enabled.
2553    #[serde(default = "default_true")]
2554    pub enabled: bool,
2555    /// Retry configuration.
2556    #[serde(default)]
2557    pub retry: ConfigFileWebhookRetry,
2558    /// Payload format (default, slack, discord).
2559    pub format: Option<String>,
2560}
2561
2562/// Webhook authentication from config.toml.
2563#[derive(Debug, Clone, Deserialize)]
2564#[serde(tag = "type", rename_all = "snake_case")]
2565pub enum ConfigFileWebhookAuth {
2566    /// Bearer token authentication.
2567    Bearer {
2568        /// The bearer token (supports `${ENV_VAR}` expansion).
2569        token: String,
2570    },
2571    /// HMAC-SHA256 signature authentication.
2572    Hmac {
2573        /// The shared secret (supports `${ENV_VAR}` expansion).
2574        secret: String,
2575    },
2576    /// Both Bearer token and HMAC signature.
2577    Both {
2578        /// The bearer token.
2579        token: String,
2580        /// The HMAC secret.
2581        secret: String,
2582    },
2583}
2584
2585/// Webhook retry configuration from config.toml.
2586#[derive(Debug, Clone, Deserialize)]
2587pub struct ConfigFileWebhookRetry {
2588    /// Maximum number of retry attempts (default: 3).
2589    #[serde(default = "default_webhook_max_retries")]
2590    pub max_retries: u32,
2591    /// Base delay in milliseconds for exponential backoff (default: 1000).
2592    #[serde(default = "default_webhook_base_delay_ms")]
2593    pub base_delay_ms: u64,
2594    /// Request timeout in seconds (default: 30).
2595    #[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/// Runtime webhook configuration.
2626#[derive(Debug, Clone, Default)]
2627pub struct WebhooksConfig {
2628    /// List of configured webhook endpoints.
2629    pub webhooks: Vec<ConfigFileWebhook>,
2630}
2631
2632impl WebhooksConfig {
2633    /// Creates webhooks config from parsed config file entries.
2634    #[must_use]
2635    pub const fn from_config_file(webhooks: Vec<ConfigFileWebhook>) -> Self {
2636        Self { webhooks }
2637    }
2638
2639    /// Returns the number of configured webhooks.
2640    #[must_use]
2641    pub const fn len(&self) -> usize {
2642        self.webhooks.len()
2643    }
2644
2645    /// Returns true if no webhooks are configured.
2646    #[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        // Use HOME which is always set on Unix/macOS
2660        // On Windows, use USERPROFILE instead
2661        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        // Use PATH which is always set
2672        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        // Use HOME and PATH which are always set
2693        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        // Empty var name - should preserve since no var named ""
2710        let result = expand_env_vars("${}");
2711        assert_eq!(result, "${}");
2712    }
2713
2714    #[test]
2715    fn test_expand_env_vars_nested_braces() {
2716        // Nested braces - only outer should be processed
2717        let result = expand_env_vars("${${INNER}}");
2718        // First finds ${${INNER} - var name is "${INNER", which won't exist
2719        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        // Create a temp file with world-readable permissions
2757        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            // Set world-readable permission (0o644)
2766            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            // Function should not panic
2773            warn_if_world_readable(&path);
2774
2775            // Also test with restrictive permissions (0o600)
2776            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        // Should not panic on non-existent file
2788        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); // Above max
2819        assert_eq!(config.similarity_threshold, 1.0);
2820
2821        let config = ConsolidationConfig::new().with_similarity_threshold(-0.5); // Below min
2822        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; // Bypass builder clamping
2840
2841        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 that `ConsolidationConfig::from_env()` correctly reads environment variables.
2871    ///
2872    /// This test is ignored because Rust 2024 edition requires `unsafe` blocks for
2873    /// `std::env::set_var`/`remove_var`, and this crate forbids unsafe code.
2874    /// The functionality is still tested via integration tests that can set env vars
2875    /// before process startup.
2876    #[test]
2877    #[ignore = "Rust 2024: set_var/remove_var require unsafe, crate forbids unsafe_code"]
2878    fn test_consolidation_config_from_env() {
2879        // This test would verify:
2880        // - SUBCOG_CONSOLIDATION_ENABLED=true -> config.enabled = true
2881        // - SUBCOG_CONSOLIDATION_TIME_WINDOW_DAYS=45 -> config.time_window_days = Some(45)
2882        // - SUBCOG_CONSOLIDATION_MIN_MEMORIES=5 -> config.min_memories_to_consolidate = 5
2883        // - SUBCOG_CONSOLIDATION_SIMILARITY_THRESHOLD=0.85 -> config.similarity_threshold = 0.85
2884    }
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        // Check namespace filter was parsed
2904        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), // Too low
2916            ..Default::default()
2917        };
2918
2919        let config = ConsolidationConfig::from_config_file(&file);
2920        // Should be clamped to minimum of 2
2921        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), // Above max
2928            ..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), // Below min
2936            ..Default::default()
2937        };
2938
2939        let config = ConsolidationConfig::from_config_file(&file);
2940        assert_eq!(config.similarity_threshold, 0.0);
2941    }
2942
2943    // TTL Configuration Tests
2944
2945    #[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), // 30 days
3008            namespace: TtlNamespaceConfig {
3009                context: Some(7 * 86400), // 7 days for context
3010                ..Default::default()
3011            },
3012            scope: TtlScopeConfig {
3013                project: Some(14 * 86400), // 14 days for project
3014                ..Default::default()
3015            },
3016        };
3017
3018        // Namespace-specific TTL takes priority
3019        assert_eq!(
3020            config.get_ttl_seconds("context", "project"),
3021            Some(7 * 86400)
3022        );
3023
3024        // Scope-specific TTL used when no namespace TTL
3025        assert_eq!(
3026            config.get_ttl_seconds("decisions", "project"),
3027            Some(14 * 86400)
3028        );
3029
3030        // Default TTL used when no namespace or scope TTL
3031        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()), // Never expires
3045                ..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)); // 0 = no expiration
3060        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        // No TTL configured means None (never expires)
3070        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), // Explicitly set to never expire
3078                ..Default::default()
3079            },
3080            ..Default::default()
3081        };
3082
3083        // Some(0) means explicitly set to never expire
3084        assert_eq!(config.get_ttl_seconds("tech-debt", "project"), Some(0));
3085    }
3086}