Skip to main content

ai_memory/
config.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7use crate::models::Tier;
8
9// ---------------------------------------------------------------------------
10// Embedding models
11// ---------------------------------------------------------------------------
12
13/// Supported embedding models for semantic search.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum EmbeddingModel {
17    /// sentence-transformers/all-MiniLM-L6-v2 — 384-dim, ~90 MB
18    MiniLmL6V2,
19    /// nomic-ai/nomic-embed-text-v1.5 — 768-dim, ~270 MB
20    NomicEmbedV15,
21}
22
23impl EmbeddingModel {
24    /// Embedding vector dimensionality.
25    pub fn dim(self) -> usize {
26        match self {
27            Self::MiniLmL6V2 => 384,
28            Self::NomicEmbedV15 => 768,
29        }
30    }
31
32    /// `HuggingFace` model identifier.
33    pub fn hf_model_id(&self) -> &str {
34        match self {
35            Self::MiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
36            Self::NomicEmbedV15 => "nomic-ai/nomic-embed-text-v1.5",
37        }
38    }
39}
40
41// ---------------------------------------------------------------------------
42// LLM models
43// ---------------------------------------------------------------------------
44
45/// Supported LLM models (served via Ollama).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum LlmModel {
49    /// Google Gemma 4 Effective 2B — ~1 GB Q4
50    Gemma4E2B,
51    /// Google Gemma 4 Effective 4B — ~2.3 GB Q4
52    Gemma4E4B,
53}
54
55impl LlmModel {
56    /// Ollama model tag used to pull / run this model.
57    pub fn ollama_model_id(&self) -> &str {
58        match self {
59            Self::Gemma4E2B => "gemma4:e2b",
60            Self::Gemma4E4B => "gemma4:e4b",
61        }
62    }
63
64    /// Human-readable display name.
65    pub fn display_name(&self) -> &str {
66        match self {
67            Self::Gemma4E2B => "Gemma 4 Effective 2B (Q4)",
68            Self::Gemma4E4B => "Gemma 4 Effective 4B (Q4)",
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Feature tiers
75// ---------------------------------------------------------------------------
76
77/// Feature tiers control which AI capabilities are active based on the
78/// available memory budget on the host machine.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum FeatureTier {
82    /// FTS5 keyword search only — 0 MB extra.
83    Keyword,
84    /// `MiniLM` embeddings + HNSW index — ~256 MB.
85    Semantic,
86    /// nomic-embed + Gemma 4 E2B via Ollama — ~1 GB.
87    Smart,
88    /// nomic-embed + Gemma 4 E4B + cross-encoder via Ollama — ~4 GB.
89    Autonomous,
90}
91
92impl FeatureTier {
93    /// Parse a tier name (case-insensitive).
94    pub fn from_str(s: &str) -> Option<Self> {
95        match s.to_ascii_lowercase().as_str() {
96            "keyword" => Some(Self::Keyword),
97            "semantic" => Some(Self::Semantic),
98            "smart" => Some(Self::Smart),
99            "autonomous" => Some(Self::Autonomous),
100            _ => None,
101        }
102    }
103
104    /// Canonical lowercase name.
105    pub fn as_str(&self) -> &str {
106        match self {
107            Self::Keyword => "keyword",
108            Self::Semantic => "semantic",
109            Self::Smart => "smart",
110            Self::Autonomous => "autonomous",
111        }
112    }
113
114    /// Build the full [`TierConfig`] for this tier.
115    pub fn config(self) -> TierConfig {
116        match self {
117            Self::Keyword => TierConfig {
118                tier: self,
119                embedding_model: None,
120                llm_model: None,
121                cross_encoder: false,
122                max_memory_mb: 0,
123            },
124            Self::Semantic => TierConfig {
125                tier: self,
126                embedding_model: Some(EmbeddingModel::MiniLmL6V2),
127                llm_model: None,
128                cross_encoder: false,
129                max_memory_mb: 256,
130            },
131            Self::Smart => TierConfig {
132                tier: self,
133                embedding_model: Some(EmbeddingModel::NomicEmbedV15),
134                llm_model: Some(LlmModel::Gemma4E2B),
135                cross_encoder: false,
136                max_memory_mb: 1024,
137            },
138            Self::Autonomous => TierConfig {
139                tier: self,
140                embedding_model: Some(EmbeddingModel::NomicEmbedV15),
141                llm_model: Some(LlmModel::Gemma4E4B),
142                cross_encoder: true,
143                max_memory_mb: 4096,
144            },
145        }
146    }
147
148    /// Automatically select the best tier that fits within `mb` megabytes.
149    #[allow(dead_code)]
150    pub fn from_memory_budget(mb: usize) -> Self {
151        if mb >= 4096 {
152            Self::Autonomous
153        } else if mb >= 1024 {
154            Self::Smart
155        } else if mb >= 256 {
156            Self::Semantic
157        } else {
158            Self::Keyword
159        }
160    }
161}
162
163impl std::fmt::Display for FeatureTier {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(self.as_str())
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Tier configuration
171// ---------------------------------------------------------------------------
172
173/// Runtime configuration derived from a [`FeatureTier`].
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct TierConfig {
176    pub tier: FeatureTier,
177    pub embedding_model: Option<EmbeddingModel>,
178    pub llm_model: Option<LlmModel>,
179    pub cross_encoder: bool,
180    pub max_memory_mb: usize,
181}
182
183impl TierConfig {
184    /// Produce a [`Capabilities`] report suitable for JSON serialisation.
185    pub fn capabilities(&self) -> Capabilities {
186        let has_embeddings = self.embedding_model.is_some();
187        let has_llm = self.llm_model.is_some();
188
189        Capabilities {
190            // Capabilities schema v2 — see `Capabilities` doc comment.
191            schema_version: "2".to_string(),
192            tier: self.tier.as_str().to_string(),
193            version: env!("CARGO_PKG_VERSION").to_string(),
194            features: CapabilityFeatures {
195                keyword_search: true,
196                semantic_search: has_embeddings,
197                hybrid_recall: has_embeddings,
198                query_expansion: has_llm,
199                auto_consolidation: has_llm,
200                auto_tagging: has_llm,
201                contradiction_analysis: has_llm,
202                cross_encoder_reranking: self.cross_encoder,
203                memory_reflection: self.cross_encoder && has_llm,
204                // Default false — the HTTP/MCP capabilities handler
205                // overwrites this with the live runtime state when it
206                // has access to the embedder handle.
207                embedder_loaded: false,
208            },
209            models: CapabilityModels {
210                embedding: self
211                    .embedding_model
212                    .map_or_else(|| "none".to_string(), |m| m.hf_model_id().to_string()),
213                embedding_dim: self.embedding_model.map_or(0, EmbeddingModel::dim),
214                llm: self
215                    .llm_model
216                    .map_or_else(|| "none".to_string(), |m| m.ollama_model_id().to_string()),
217                cross_encoder: if self.cross_encoder {
218                    "cross-encoder/ms-marco-MiniLM-L-6-v2".to_string()
219                } else {
220                    "none".to_string()
221                },
222            },
223            // v2 dynamic blocks — start at zero-state defaults. The MCP
224            // and HTTP `handle_capabilities` wrappers overwrite these
225            // with live counts when they have a `&Connection` handle.
226            permissions: CapabilityPermissions {
227                mode: "ask".to_string(),
228                active_rules: 0,
229                rule_summary: Vec::new(),
230            },
231            hooks: CapabilityHooks::default(),
232            compaction: CapabilityCompaction::default(),
233            approval: CapabilityApproval {
234                subscribers: 0,
235                pending_requests: 0,
236                default_timeout_seconds: 30,
237            },
238            transcripts: CapabilityTranscripts::default(),
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Capability reporting
245// ---------------------------------------------------------------------------
246
247/// Top-level capabilities report for a running instance.
248///
249/// Schema versions:
250/// - v1 (implicit, pre-v0.6.3.1): `tier`, `version`, `features`, `models`
251/// - v2 (v0.6.3 / arch-enhancement-spec §7): adds `schema_version` plus
252///   `permissions`, `hooks`, `compaction`, `approval`, `transcripts` blocks.
253///   v1 fields preserved at the same paths — old clients reading by name
254///   continue to work.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Capabilities {
257    /// Schema-version discriminator. Always `"2"` since v0.6.3.
258    pub schema_version: String,
259    pub tier: String,
260    pub version: String,
261    pub features: CapabilityFeatures,
262    pub models: CapabilityModels,
263
264    /// Active permission/governance rules. Pre-v0.7 reports the count of
265    /// namespaces that have a `metadata.governance` policy attached to
266    /// their standard memory; the underlying permission system itself
267    /// is v0.7 work.
268    pub permissions: CapabilityPermissions,
269
270    /// Registered hooks. Pre-v0.7 reports webhook subscriptions as a
271    /// proxy (hook system itself is v0.7 Bucket 0).
272    pub hooks: CapabilityHooks,
273
274    /// Compaction state. v0.8 work — pre-v0.8 reports `enabled: false`.
275    pub compaction: CapabilityCompaction,
276
277    /// Approval API state. Reports the live count of pending actions
278    /// from the existing `pending_actions` table; subscriber count is
279    /// v0.7 work.
280    pub approval: CapabilityApproval,
281
282    /// Sidechain-transcript state. v0.7 Bucket 1.7 work — pre-v0.7
283    /// reports `enabled: false`.
284    pub transcripts: CapabilityTranscripts,
285}
286
287/// Boolean feature flags exposed in the capabilities report.
288#[allow(clippy::struct_excessive_bools)]
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct CapabilityFeatures {
291    pub keyword_search: bool,
292    pub semantic_search: bool,
293    pub hybrid_recall: bool,
294    pub query_expansion: bool,
295    pub auto_consolidation: bool,
296    pub auto_tagging: bool,
297    pub contradiction_analysis: bool,
298    pub cross_encoder_reranking: bool,
299    pub memory_reflection: bool,
300    /// v0.6.2 (S18): runtime-observed embedder state. `semantic_search`
301    /// above reflects *configured* capability (derived from the tier's
302    /// `embedding_model` setting). `embedder_loaded` reflects *actual*
303    /// state after `Embedder::load()` attempted to materialize the
304    /// `HuggingFace` model on startup. When an operator configures the
305    /// `semantic` tier but the model download or mmap fails (offline
306    /// runner, read-only fs, missing tokens), `semantic_search=true`
307    /// would mislead. This flag exposes the truth so setup scripts can
308    /// assert the daemon is actually ready for semantic recall before
309    /// dispatching scenarios. Default false; populated by
310    /// `handle_capabilities` when the HTTP/MCP wrapper hands in the
311    /// live embedder handle.
312    #[serde(default)]
313    pub embedder_loaded: bool,
314}
315
316/// Model identifiers exposed in the capabilities report.
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct CapabilityModels {
319    pub embedding: String,
320    pub embedding_dim: usize,
321    pub llm: String,
322    pub cross_encoder: String,
323}
324
325/// Permissions block (capabilities schema v2). Pre-v0.7 reports a live
326/// count of namespace standards carrying a `metadata.governance` policy;
327/// the full permission system lands in v0.7 (arch-enhancement-spec §3).
328#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329pub struct CapabilityPermissions {
330    /// Enforcement mode. `"ask"` = current default (governance gate runs
331    /// on store/delete/promote and may return Pending). `"off"` would
332    /// disable enforcement; not yet wired.
333    pub mode: String,
334    /// Number of namespace standards whose `metadata.governance` is
335    /// non-null. Counts policies, not memories.
336    pub active_rules: usize,
337    /// Per-namespace summary; empty pre-v0.7.
338    #[serde(default)]
339    pub rule_summary: Vec<String>,
340}
341
342/// Hook-pipeline block (capabilities schema v2). Pre-v0.7 reports webhook
343/// subscriptions as the closest analogue. The full hook pipeline lands in
344/// v0.7 Bucket 0 (arch-enhancement-spec §2).
345#[derive(Debug, Clone, Serialize, Deserialize, Default)]
346pub struct CapabilityHooks {
347    /// Number of registered hook subscribers (proxy: webhook subscriptions).
348    pub registered_count: usize,
349    /// Per-event registration map; empty pre-v0.7.
350    #[serde(default)]
351    pub by_event: std::collections::BTreeMap<String, usize>,
352}
353
354/// Compaction block (capabilities schema v2). v0.8 Pillar 2.5 work —
355/// pre-v0.8 reports `enabled: false` and the rest as `None`.
356#[derive(Debug, Clone, Serialize, Deserialize, Default)]
357pub struct CapabilityCompaction {
358    pub enabled: bool,
359    #[serde(default)]
360    pub interval_minutes: Option<u64>,
361    #[serde(default)]
362    pub last_run_at: Option<String>,
363    #[serde(default)]
364    pub last_run_stats: Option<serde_json::Value>,
365}
366
367/// Approval-API block (capabilities schema v2). `pending_requests`
368/// counts the existing `pending_actions` table (live signal). Subscriber
369/// reporting is v0.7 work.
370#[derive(Debug, Clone, Serialize, Deserialize, Default)]
371pub struct CapabilityApproval {
372    /// Number of agents/humans subscribed to approval-decision events.
373    /// 0 pre-v0.7.
374    pub subscribers: usize,
375    /// Live count of `pending_actions` with status='pending'.
376    pub pending_requests: usize,
377    /// Default approval-request timeout. 30s for the v0.6.3 patch.
378    pub default_timeout_seconds: u64,
379}
380
381/// Sidechain-transcript block (capabilities schema v2). v0.7 Bucket 1.7
382/// work — pre-v0.7 reports `enabled: false`.
383#[derive(Debug, Clone, Serialize, Deserialize, Default)]
384pub struct CapabilityTranscripts {
385    pub enabled: bool,
386    pub total_count: usize,
387    pub total_size_mb: u64,
388}
389
390// ---------------------------------------------------------------------------
391// TTL configuration
392// ---------------------------------------------------------------------------
393
394/// Per-tier TTL overrides loaded from `[ttl]` section of config.toml.
395#[allow(clippy::struct_field_names)]
396#[derive(Debug, Clone, Default, Serialize, Deserialize)]
397pub struct TtlConfig {
398    /// Short-tier default TTL in seconds (default: 21600 = 6 hours)
399    pub short_ttl_secs: Option<i64>,
400    /// Mid-tier default TTL in seconds (default: 604800 = 7 days)
401    pub mid_ttl_secs: Option<i64>,
402    /// Long-tier TTL in seconds (default: none = never expires). Set >0 to add expiry.
403    pub long_ttl_secs: Option<i64>,
404    /// Short-tier TTL extension on access in seconds (default: 3600 = 1 hour)
405    pub short_extend_secs: Option<i64>,
406    /// Mid-tier TTL extension on access in seconds (default: 86400 = 1 day)
407    pub mid_extend_secs: Option<i64>,
408}
409
410/// Resolved TTL values after merging config overrides with compiled defaults.
411#[derive(Debug, Clone)]
412#[allow(clippy::struct_field_names)]
413pub struct ResolvedTtl {
414    pub short_ttl_secs: Option<i64>,
415    pub mid_ttl_secs: Option<i64>,
416    pub long_ttl_secs: Option<i64>,
417    pub short_extend_secs: i64,
418    pub mid_extend_secs: i64,
419}
420
421impl Default for ResolvedTtl {
422    fn default() -> Self {
423        Self {
424            short_ttl_secs: Tier::Short.default_ttl_secs(),
425            mid_ttl_secs: Tier::Mid.default_ttl_secs(),
426            long_ttl_secs: Tier::Long.default_ttl_secs(),
427            short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
428            mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
429        }
430    }
431}
432
433/// Maximum configurable TTL: 10 years in seconds. Prevents integer overflow
434/// when adding Duration to `Utc::now()`.
435const MAX_TTL_SECS: i64 = 315_360_000;
436
437#[allow(dead_code)]
438impl ResolvedTtl {
439    /// Build from optional config overrides, falling back to compiled defaults.
440    /// TTL values are clamped to `MAX_TTL_SECS` (10 years) to prevent overflow.
441    /// Extension values are clamped to non-negative.
442    pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
443        let defaults = Self::default();
444        let Some(c) = cfg else {
445            return defaults;
446        };
447        let clamp_ttl = |v: i64| -> Option<i64> {
448            if v <= 0 {
449                None
450            } else {
451                Some(v.min(MAX_TTL_SECS))
452            }
453        };
454        Self {
455            short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
456            mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
457            long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
458            short_extend_secs: c
459                .short_extend_secs
460                .unwrap_or(defaults.short_extend_secs)
461                .max(0),
462            mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
463        }
464    }
465
466    /// Get the default TTL for a given tier.
467    pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
468        match tier {
469            Tier::Short => self.short_ttl_secs,
470            Tier::Mid => self.mid_ttl_secs,
471            Tier::Long => self.long_ttl_secs,
472        }
473    }
474
475    /// Get the TTL extension on access for a given tier.
476    pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
477        match tier {
478            Tier::Short => Some(self.short_extend_secs),
479            Tier::Mid => Some(self.mid_extend_secs),
480            Tier::Long => None,
481        }
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Recall scoring (time-decay half-life) — v0.6.0.0
487// ---------------------------------------------------------------------------
488
489/// Per-tier half-life (days) overrides loaded from `[scoring]` section of
490/// `config.toml`.
491///
492/// The half-life is the number of days it takes for a memory's recall score
493/// to drop to 50% of its undecayed value. Shorter half-lives prioritize fresh
494/// memories; longer half-lives give older memories more weight. Defaults are
495/// chosen so each tier's decay curve matches its retention expectations:
496/// `short` memories decay quickly (7 d), `mid` moderately (30 d), `long`
497/// slowly (365 d).
498///
499/// Setting `legacy_scoring = true` disables the decay multiplier entirely,
500/// restoring the pre-v0.6.0.0 blended-score behavior for A/B comparison or
501/// if a recall-quality regression is reported.
502#[derive(Debug, Clone, Default, Serialize, Deserialize)]
503pub struct RecallScoringConfig {
504    /// Half-life for `short`-tier memories, in days (default 7).
505    pub half_life_days_short: Option<f64>,
506    /// Half-life for `mid`-tier memories, in days (default 30).
507    pub half_life_days_mid: Option<f64>,
508    /// Half-life for `long`-tier memories, in days (default 365).
509    pub half_life_days_long: Option<f64>,
510    /// When true, skip the decay multiplier entirely. Default false.
511    #[serde(default)]
512    pub legacy_scoring: bool,
513}
514
515/// Resolved scoring values after merging config overrides with compiled
516/// defaults. Half-lives are clamped to the range `[0.1, 36_500.0]` days
517/// (≈100 years) to keep the decay math well-behaved.
518#[derive(Debug, Clone, Copy)]
519pub struct ResolvedScoring {
520    pub half_life_days_short: f64,
521    pub half_life_days_mid: f64,
522    pub half_life_days_long: f64,
523    pub legacy_scoring: bool,
524}
525
526impl Default for ResolvedScoring {
527    fn default() -> Self {
528        Self {
529            half_life_days_short: 7.0,
530            half_life_days_mid: 30.0,
531            half_life_days_long: 365.0,
532            legacy_scoring: false,
533        }
534    }
535}
536
537impl ResolvedScoring {
538    const MIN_HALF_LIFE: f64 = 0.1;
539    const MAX_HALF_LIFE: f64 = 36_500.0;
540
541    /// Build from optional config overrides, falling back to compiled
542    /// defaults. Out-of-range values are silently clamped.
543    pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
544        let defaults = Self::default();
545        let Some(c) = cfg else {
546            return defaults;
547        };
548        let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
549        Self {
550            half_life_days_short: c
551                .half_life_days_short
552                .map_or(defaults.half_life_days_short, clamp),
553            half_life_days_mid: c
554                .half_life_days_mid
555                .map_or(defaults.half_life_days_mid, clamp),
556            half_life_days_long: c
557                .half_life_days_long
558                .map_or(defaults.half_life_days_long, clamp),
559            legacy_scoring: c.legacy_scoring,
560        }
561    }
562
563    /// Half-life in days for a given tier.
564    pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
565        match tier {
566            Tier::Short => self.half_life_days_short,
567            Tier::Mid => self.half_life_days_mid,
568            Tier::Long => self.half_life_days_long,
569        }
570    }
571
572    /// Compute the decay multiplier `exp(-ln(2) * age_days / half_life)`
573    /// for a memory of the given tier and age. Returns `1.0` when
574    /// `legacy_scoring` is true (no decay) or when `age_days` is non-positive
575    /// (future timestamps, clock skew, or new memories).
576    #[must_use]
577    pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
578        if self.legacy_scoring || age_days <= 0.0 {
579            return 1.0;
580        }
581        let half_life = self.half_life_for_tier(tier);
582        (-std::f64::consts::LN_2 * age_days / half_life).exp()
583    }
584}
585
586// ---------------------------------------------------------------------------
587// Persistent config file (~/.config/ai-memory/config.toml)
588// ---------------------------------------------------------------------------
589
590const CONFIG_DIR: &str = ".config/ai-memory";
591const CONFIG_FILE: &str = "config.toml";
592
593/// Persistent configuration loaded from `~/.config/ai-memory/config.toml`.
594///
595/// All fields are optional — CLI flags override file values, which override
596/// compiled defaults.
597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
598pub struct AppConfig {
599    /// Feature tier: keyword, semantic, smart, autonomous
600    pub tier: Option<String>,
601    /// Path to the `SQLite` database file
602    pub db: Option<String>,
603    /// Ollama base URL for LLM generation (default: <http://localhost:11434>)
604    pub ollama_url: Option<String>,
605    /// Separate URL for embedding model (defaults to `ollama_url` if unset)
606    pub embed_url: Option<String>,
607    /// Embedding model override: `mini_lm_l6_v2` or `nomic_embed_v15`
608    pub embedding_model: Option<String>,
609    /// LLM model override (Ollama tag, e.g. "gemma4:e2b")
610    pub llm_model: Option<String>,
611    /// Enable cross-encoder reranking (true/false)
612    pub cross_encoder: Option<bool>,
613    /// Default namespace for new memories
614    pub default_namespace: Option<String>,
615    /// Maximum memory budget in MB (used for auto tier selection)
616    pub max_memory_mb: Option<usize>,
617    /// Per-tier TTL overrides
618    pub ttl: Option<TtlConfig>,
619    /// Archive memories before GC deletion (default: true)
620    pub archive_on_gc: Option<bool>,
621    /// Optional API key for HTTP API authentication
622    pub api_key: Option<String>,
623    /// Maximum archive age in days for automatic purge during GC (default: disabled)
624    pub archive_max_days: Option<i64>,
625    /// Identity-resolution overrides (Task 1.2 follow-up #198).
626    pub identity: Option<IdentityConfig>,
627    /// Recall scoring — per-tier half-life for time-decay, and `legacy_scoring`
628    /// kill switch (v0.6.0.0).
629    pub scoring: Option<RecallScoringConfig>,
630    /// v0.6.0.0: when true, fire LLM autonomy hooks (`auto_tag` +
631    /// `detect_contradiction`) synchronously on every successful
632    /// `memory_store`. Off by default — the hook blocks store latency
633    /// behind an Ollama round-trip. `AI_MEMORY_AUTONOMOUS_HOOKS=1`
634    /// env var overrides the config file.
635    pub autonomous_hooks: Option<bool>,
636}
637
638/// Identity-resolution configuration (Task 1.2 follow-up #198).
639///
640/// Lets operators opt out of the default `host:<hostname>:pid-<pid>-<uuid8>`
641/// fallback when no explicit `agent_id` is supplied. `anonymize_default = true`
642/// swaps the hostname-revealing default for `anonymous:pid-<pid>-<uuid8>`,
643/// matching what the `AI_MEMORY_ANONYMIZE=1` env var does ephemerally.
644#[derive(Debug, Clone, Default, Serialize, Deserialize)]
645pub struct IdentityConfig {
646    /// When true, the "no flag, no env, no MCP clientInfo" fallback uses
647    /// `anonymous:pid-<pid>-<uuid8>` instead of the hostname-revealing
648    /// `host:<hostname>:pid-<pid>-<uuid8>`. Default false.
649    #[serde(default)]
650    pub anonymize_default: bool,
651}
652
653impl AppConfig {
654    /// Returns the config file path: `~/.config/ai-memory/config.toml`
655    pub fn config_path() -> Option<PathBuf> {
656        let home = std::env::var("HOME").ok()?;
657        Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
658    }
659
660    /// Load config from disk. Returns `AppConfig::default()` if file is missing.
661    /// Set `AI_MEMORY_NO_CONFIG=1` to skip config loading (used by integration tests).
662    pub fn load() -> Self {
663        if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
664            return Self::default();
665        }
666        let Some(path) = Self::config_path() else {
667            return Self::default();
668        };
669        Self::load_from(&path)
670    }
671
672    /// Load config from a specific path.
673    pub fn load_from(path: &Path) -> Self {
674        match std::fs::read_to_string(path) {
675            Ok(contents) => match toml::from_str(&contents) {
676                Ok(cfg) => {
677                    eprintln!("ai-memory: loaded config from {}", path.display());
678                    cfg
679                }
680                Err(e) => {
681                    eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
682                    Self::default()
683                }
684            },
685            Err(_) => Self::default(),
686        }
687    }
688
689    /// Resolve the effective feature tier from config (CLI flag overrides).
690    pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
691        let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
692        FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
693    }
694
695    /// Resolve the effective database path (CLI flag overrides config).
696    pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
697        // If CLI provided a non-default path, use it
698        let default_db = PathBuf::from("ai-memory.db");
699        if cli_db != default_db {
700            return cli_db.to_path_buf();
701        }
702        // Otherwise check config
703        self.db
704            .as_ref()
705            .map_or_else(|| cli_db.to_path_buf(), PathBuf::from)
706    }
707
708    /// Resolve Ollama URL for LLM generation (config or default).
709    pub fn effective_ollama_url(&self) -> &str {
710        self.ollama_url
711            .as_deref()
712            .unwrap_or("http://localhost:11434")
713    }
714
715    /// Resolve TTL configuration from config file, falling back to compiled defaults.
716    pub fn effective_ttl(&self) -> ResolvedTtl {
717        ResolvedTtl::from_config(self.ttl.as_ref())
718    }
719
720    /// Resolve recall-scoring configuration (time-decay half-life) from the
721    /// config file, falling back to compiled defaults. v0.6.0.0.
722    pub fn effective_scoring(&self) -> ResolvedScoring {
723        ResolvedScoring::from_config(self.scoring.as_ref())
724    }
725
726    /// Whether to archive memories before GC deletion (default: true).
727    pub fn effective_archive_on_gc(&self) -> bool {
728        self.archive_on_gc.unwrap_or(true)
729    }
730
731    /// Whether post-store autonomy hooks (`auto_tag` + `detect_contradiction`)
732    /// fire on every successful `memory_store`. v0.6.0.0.
733    /// Precedence: `AI_MEMORY_AUTONOMOUS_HOOKS=1` env var (truthy) >
734    /// config file > default false. `AI_MEMORY_AUTONOMOUS_HOOKS=0` also
735    /// honored for explicit-off.
736    pub fn effective_autonomous_hooks(&self) -> bool {
737        if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
738            let v = v.trim().to_ascii_lowercase();
739            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
740                return true;
741            }
742            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
743                return false;
744            }
745        }
746        self.autonomous_hooks.unwrap_or(false)
747    }
748
749    /// Whether to anonymize the default `agent_id` fallback (Task 1.2 #198).
750    /// Precedence: `AI_MEMORY_ANONYMIZE=1` env var (truthy) > config file > default false.
751    pub fn effective_anonymize_default(&self) -> bool {
752        if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
753            let v = v.trim().to_ascii_lowercase();
754            if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
755                return true;
756            }
757            if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
758                return false;
759            }
760        }
761        self.identity.as_ref().is_some_and(|i| i.anonymize_default)
762    }
763
764    /// Resolve URL for embedding model (falls back to `ollama_url`).
765    pub fn effective_embed_url(&self) -> &str {
766        self.embed_url
767            .as_deref()
768            .or(self.ollama_url.as_deref())
769            .unwrap_or("http://localhost:11434")
770    }
771
772    /// Write a default config file if one doesn't exist yet.
773    pub fn write_default_if_missing() {
774        let Some(path) = Self::config_path() else {
775            return;
776        };
777        if path.exists() {
778            return;
779        }
780        if let Some(parent) = path.parent() {
781            let _ = std::fs::create_dir_all(parent);
782        }
783        let default_toml = r#"# ai-memory configuration
784# See: https://github.com/alphaonedev/ai-memory-mcp
785
786# Feature tier: keyword, semantic, smart, autonomous
787# tier = "semantic"
788
789# Path to SQLite database
790# db = "~/.claude/ai-memory.db"
791
792# Ollama base URL (for smart/autonomous tiers)
793# ollama_url = "http://localhost:11434"
794
795# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
796# embedding_model = "mini_lm_l6_v2"
797
798# LLM model tag for Ollama
799# llm_model = "gemma4:e2b"
800
801# Enable neural cross-encoder reranking (autonomous tier)
802# cross_encoder = true
803
804# Default namespace for new memories
805# default_namespace = "global"
806
807# Memory budget in MB (for auto tier selection)
808# max_memory_mb = 4096
809
810# Archive expired memories before GC deletion (default: true)
811# archive_on_gc = true
812
813# Per-tier TTL overrides (uncomment to customize)
814# [ttl]
815# short_ttl_secs = 21600        # 6 hours (default)
816# mid_ttl_secs = 604800         # 7 days (default)
817# long_ttl_secs = 0             # 0 = never expires (default)
818# short_extend_secs = 3600      # +1h on access (default)
819# mid_extend_secs = 86400       # +1d on access (default)
820"#;
821        let _ = std::fs::write(&path, default_toml);
822    }
823}
824
825// ---------------------------------------------------------------------------
826// Tests
827// ---------------------------------------------------------------------------
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn tier_roundtrip() {
835        for tier in [
836            FeatureTier::Keyword,
837            FeatureTier::Semantic,
838            FeatureTier::Smart,
839            FeatureTier::Autonomous,
840        ] {
841            assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
842        }
843    }
844
845    #[test]
846    fn budget_selection() {
847        assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
848        assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
849        assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
850        assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
851        assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
852        assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
853        assert_eq!(
854            FeatureTier::from_memory_budget(4096),
855            FeatureTier::Autonomous
856        );
857        assert_eq!(
858            FeatureTier::from_memory_budget(8192),
859            FeatureTier::Autonomous
860        );
861    }
862
863    #[test]
864    fn embedding_dimensions() {
865        assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
866        assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
867    }
868
869    #[test]
870    fn autonomous_has_cross_encoder() {
871        let cfg = FeatureTier::Autonomous.config();
872        assert!(cfg.cross_encoder);
873        assert!(cfg.capabilities().features.cross_encoder_reranking);
874        assert!(cfg.capabilities().features.memory_reflection);
875    }
876
877    #[test]
878    fn keyword_has_no_models() {
879        let cfg = FeatureTier::Keyword.config();
880        assert!(cfg.embedding_model.is_none());
881        assert!(cfg.llm_model.is_none());
882        assert!(!cfg.cross_encoder);
883        assert_eq!(cfg.max_memory_mb, 0);
884    }
885
886    #[test]
887    fn capabilities_serialize() {
888        let caps = FeatureTier::Smart.config().capabilities();
889        let json = serde_json::to_string_pretty(&caps).unwrap();
890        assert!(json.contains("\"tier\": \"smart\""));
891        assert!(json.contains("nomic"));
892        assert!(json.contains("gemma4:e2b"));
893    }
894
895    /// v0.6.3 (capabilities schema v2 — arch-enhancement-spec §7).
896    /// Round-trip the new struct through serde_json and assert every
897    /// new top-level block is present with the documented zero-state.
898    #[test]
899    fn capabilities_v2_zero_state_round_trip() {
900        let caps = FeatureTier::Keyword.config().capabilities();
901        let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
902
903        assert_eq!(val["schema_version"], "2");
904
905        // permissions zero-state: mode="ask", active_rules=0, empty summary
906        assert_eq!(val["permissions"]["mode"], "ask");
907        assert_eq!(val["permissions"]["active_rules"], 0);
908        assert!(
909            val["permissions"]["rule_summary"]
910                .as_array()
911                .unwrap()
912                .is_empty()
913        );
914
915        // hooks zero-state: 0 registered, empty by_event map
916        assert_eq!(val["hooks"]["registered_count"], 0);
917        assert!(val["hooks"]["by_event"].as_object().unwrap().is_empty());
918
919        // compaction zero-state: disabled
920        assert_eq!(val["compaction"]["enabled"], false);
921        assert!(val["compaction"]["interval_minutes"].is_null());
922        assert!(val["compaction"]["last_run_at"].is_null());
923        assert!(val["compaction"]["last_run_stats"].is_null());
924
925        // approval zero-state: 0 subscribers, 0 pending, 30s timeout
926        assert_eq!(val["approval"]["subscribers"], 0);
927        assert_eq!(val["approval"]["pending_requests"], 0);
928        assert_eq!(val["approval"]["default_timeout_seconds"], 30);
929
930        // transcripts zero-state: disabled
931        assert_eq!(val["transcripts"]["enabled"], false);
932        assert_eq!(val["transcripts"]["total_count"], 0);
933        assert_eq!(val["transcripts"]["total_size_mb"], 0);
934
935        // Round-trip back to a typed Capabilities and confirm field
936        // identity (proves Deserialize works for all 5 new structs).
937        let restored: Capabilities = serde_json::from_value(val).unwrap();
938        assert_eq!(restored.schema_version, "2");
939        assert_eq!(restored.permissions.mode, "ask");
940        assert_eq!(restored.approval.default_timeout_seconds, 30);
941    }
942
943    #[test]
944    fn config_default_is_empty() {
945        let cfg = AppConfig::default();
946        assert!(cfg.tier.is_none());
947        assert!(cfg.db.is_none());
948        assert!(cfg.ollama_url.is_none());
949    }
950
951    #[test]
952    fn config_parse_toml() {
953        let toml_str = r#"
954            tier = "smart"
955            db = "/tmp/test.db"
956            ollama_url = "http://localhost:11434"
957            cross_encoder = true
958        "#;
959        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
960        assert_eq!(cfg.tier.as_deref(), Some("smart"));
961        assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
962        assert!(cfg.cross_encoder.unwrap());
963    }
964
965    #[test]
966    fn resolved_ttl_defaults_match_hardcoded() {
967        let resolved = ResolvedTtl::default();
968        assert_eq!(resolved.short_ttl_secs, Some(6 * 3600));
969        assert_eq!(resolved.mid_ttl_secs, Some(7 * 24 * 3600));
970        assert_eq!(resolved.long_ttl_secs, None);
971        assert_eq!(resolved.short_extend_secs, 3600);
972        assert_eq!(resolved.mid_extend_secs, 86400);
973    }
974
975    #[test]
976    fn resolved_ttl_from_partial_config() {
977        let cfg = TtlConfig {
978            mid_ttl_secs: Some(90 * 24 * 3600), // ~3 months
979            ..Default::default()
980        };
981        let resolved = ResolvedTtl::from_config(Some(&cfg));
982        assert_eq!(resolved.short_ttl_secs, Some(6 * 3600)); // unchanged
983        assert_eq!(resolved.mid_ttl_secs, Some(90 * 24 * 3600)); // overridden
984        assert_eq!(resolved.long_ttl_secs, None); // unchanged
985    }
986
987    #[test]
988    fn resolved_ttl_zero_means_no_expiry() {
989        let cfg = TtlConfig {
990            short_ttl_secs: Some(0),
991            mid_ttl_secs: Some(0),
992            ..Default::default()
993        };
994        let resolved = ResolvedTtl::from_config(Some(&cfg));
995        assert_eq!(resolved.short_ttl_secs, None); // 0 → no expiry
996        assert_eq!(resolved.mid_ttl_secs, None);
997    }
998
999    #[test]
1000    fn resolved_ttl_clamps_overflow() {
1001        let cfg = TtlConfig {
1002            mid_ttl_secs: Some(i64::MAX),
1003            short_extend_secs: Some(-3600),
1004            ..Default::default()
1005        };
1006        let resolved = ResolvedTtl::from_config(Some(&cfg));
1007        // i64::MAX should be clamped to MAX_TTL_SECS (10 years)
1008        assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
1009        // negative extend should be clamped to 0
1010        assert_eq!(resolved.short_extend_secs, 0);
1011    }
1012
1013    #[test]
1014    fn ttl_config_parse_toml() {
1015        let toml_str = r#"
1016            tier = "semantic"
1017            archive_on_gc = false
1018            [ttl]
1019            mid_ttl_secs = 7776000
1020            short_extend_secs = 7200
1021        "#;
1022        let cfg: AppConfig = toml::from_str(toml_str).unwrap();
1023        assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
1024        assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
1025        assert!(!cfg.effective_archive_on_gc());
1026    }
1027
1028    #[test]
1029    fn resolved_ttl_tier_methods() {
1030        let resolved = ResolvedTtl::default();
1031        assert_eq!(resolved.ttl_for_tier(&Tier::Short), Some(6 * 3600));
1032        assert_eq!(resolved.ttl_for_tier(&Tier::Mid), Some(7 * 24 * 3600));
1033        assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
1034        assert_eq!(resolved.extend_for_tier(&Tier::Short), Some(3600));
1035        assert_eq!(resolved.extend_for_tier(&Tier::Mid), Some(86400));
1036        assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
1037    }
1038
1039    #[test]
1040    fn config_effective_tier() {
1041        let cfg = AppConfig {
1042            tier: Some("smart".to_string()),
1043            ..Default::default()
1044        };
1045        // CLI override wins
1046        assert_eq!(
1047            cfg.effective_tier(Some("autonomous")),
1048            FeatureTier::Autonomous
1049        );
1050        // Config value used when no CLI
1051        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
1052    }
1053
1054    // --- v0.6.0.0 recall scoring (time-decay half-life) ---
1055
1056    #[test]
1057    fn scoring_defaults_match_spec() {
1058        let s = ResolvedScoring::default();
1059        assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
1060        assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
1061        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
1062        assert!(!s.legacy_scoring);
1063    }
1064
1065    #[test]
1066    fn scoring_from_config_overrides() {
1067        let cfg = RecallScoringConfig {
1068            half_life_days_short: Some(3.5),
1069            half_life_days_mid: Some(14.0),
1070            half_life_days_long: Some(730.0),
1071            legacy_scoring: false,
1072        };
1073        let s = ResolvedScoring::from_config(Some(&cfg));
1074        assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
1075        assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
1076        assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
1077    }
1078
1079    #[test]
1080    fn scoring_clamps_out_of_range() {
1081        let cfg = RecallScoringConfig {
1082            half_life_days_short: Some(-10.0),
1083            half_life_days_mid: Some(0.0),
1084            half_life_days_long: Some(1_000_000.0),
1085            legacy_scoring: false,
1086        };
1087        let s = ResolvedScoring::from_config(Some(&cfg));
1088        assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
1089        assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
1090        assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
1091    }
1092
1093    #[test]
1094    fn scoring_decay_at_half_life_is_half() {
1095        let s = ResolvedScoring::default();
1096        // Short tier half-life is 7 days → at age=7d, decay=0.5
1097        let d = s.decay_multiplier(&Tier::Short, 7.0);
1098        assert!((d - 0.5).abs() < 1e-9);
1099        let d = s.decay_multiplier(&Tier::Mid, 30.0);
1100        assert!((d - 0.5).abs() < 1e-9);
1101        let d = s.decay_multiplier(&Tier::Long, 365.0);
1102        assert!((d - 0.5).abs() < 1e-9);
1103    }
1104
1105    #[test]
1106    fn scoring_decay_monotonic() {
1107        let s = ResolvedScoring::default();
1108        let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
1109        let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
1110        // Older memories decay more (lower multiplier).
1111        assert!(d_new > d_old);
1112        assert!(d_new < 1.0);
1113        assert!(d_old > 0.0);
1114    }
1115
1116    #[test]
1117    fn scoring_decay_zero_age_is_one() {
1118        let s = ResolvedScoring::default();
1119        assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
1120        // Negative ages (clock skew, future timestamps) are also treated as fresh.
1121        assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
1122    }
1123
1124    #[test]
1125    fn scoring_legacy_disables_decay() {
1126        let cfg = RecallScoringConfig {
1127            legacy_scoring: true,
1128            ..Default::default()
1129        };
1130        let s = ResolvedScoring::from_config(Some(&cfg));
1131        // No decay regardless of age.
1132        assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
1133        assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
1134        assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
1135    }
1136
1137    #[test]
1138    fn effective_scoring_on_empty_config() {
1139        let cfg = AppConfig::default();
1140        let s = cfg.effective_scoring();
1141        assert_eq!(s.half_life_days_short, 7.0);
1142        assert!(!s.legacy_scoring);
1143    }
1144
1145    #[test]
1146    fn scoring_roundtrip_through_toml() {
1147        let toml_src = r"
1148[scoring]
1149half_life_days_short = 5.0
1150half_life_days_mid = 25.0
1151legacy_scoring = false
1152";
1153        let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
1154        let s = cfg.effective_scoring();
1155        assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
1156        assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
1157        // Unset long defaults.
1158        assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
1159    }
1160
1161    // ---- Wave 3 (Closer T) tests for uncovered effective_* helpers
1162    // and write_default_if_missing. ----
1163
1164    #[test]
1165    fn effective_tier_cli_overrides_config() {
1166        let cfg = AppConfig {
1167            tier: Some("smart".to_string()),
1168            ..AppConfig::default()
1169        };
1170        // CLI flag wins over config.
1171        assert_eq!(
1172            cfg.effective_tier(Some("autonomous")),
1173            FeatureTier::Autonomous
1174        );
1175        // No CLI flag → config used.
1176        assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
1177    }
1178
1179    #[test]
1180    fn effective_tier_unknown_falls_back_to_semantic() {
1181        let cfg = AppConfig::default();
1182        assert_eq!(
1183            cfg.effective_tier(Some("invalid-tier")),
1184            FeatureTier::Semantic
1185        );
1186        // No CLI, no config → default semantic.
1187        assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
1188    }
1189
1190    #[test]
1191    fn effective_db_cli_path_wins_when_non_default() {
1192        let cfg = AppConfig {
1193            db: Some("/from/config.db".to_string()),
1194            ..AppConfig::default()
1195        };
1196        let cli_path = Path::new("/from/cli.db");
1197        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
1198    }
1199
1200    #[test]
1201    fn effective_db_falls_back_to_config_when_cli_default() {
1202        let cfg = AppConfig {
1203            db: Some("/from/config.db".to_string()),
1204            ..AppConfig::default()
1205        };
1206        // The CLI default is "ai-memory.db" — config wins for that case.
1207        assert_eq!(
1208            cfg.effective_db(Path::new("ai-memory.db")),
1209            PathBuf::from("/from/config.db")
1210        );
1211    }
1212
1213    #[test]
1214    fn effective_db_falls_back_to_cli_when_no_config() {
1215        let cfg = AppConfig::default();
1216        let cli_path = Path::new("ai-memory.db");
1217        assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
1218    }
1219
1220    #[test]
1221    fn effective_ollama_url_default_when_unset() {
1222        let cfg = AppConfig::default();
1223        assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
1224    }
1225
1226    #[test]
1227    fn effective_ollama_url_uses_configured_value() {
1228        let cfg = AppConfig {
1229            ollama_url: Some("http://my-host:9999".to_string()),
1230            ..AppConfig::default()
1231        };
1232        assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
1233    }
1234
1235    #[test]
1236    fn effective_embed_url_falls_back_to_ollama_url() {
1237        let cfg = AppConfig {
1238            ollama_url: Some("http://ollama:11434".to_string()),
1239            ..AppConfig::default()
1240        };
1241        // No embed_url → fall back to ollama_url.
1242        assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
1243    }
1244
1245    #[test]
1246    fn effective_embed_url_uses_dedicated_value_when_set() {
1247        let cfg = AppConfig {
1248            ollama_url: Some("http://ollama:11434".to_string()),
1249            embed_url: Some("http://embed:8080".to_string()),
1250            ..AppConfig::default()
1251        };
1252        // Dedicated embed_url wins.
1253        assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
1254    }
1255
1256    #[test]
1257    fn effective_embed_url_uses_default_when_neither_set() {
1258        let cfg = AppConfig::default();
1259        assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
1260    }
1261
1262    #[test]
1263    fn effective_archive_on_gc_default_is_true() {
1264        let cfg = AppConfig::default();
1265        assert!(cfg.effective_archive_on_gc());
1266    }
1267
1268    #[test]
1269    fn effective_archive_on_gc_respects_explicit_false() {
1270        let cfg = AppConfig {
1271            archive_on_gc: Some(false),
1272            ..AppConfig::default()
1273        };
1274        assert!(!cfg.effective_archive_on_gc());
1275    }
1276
1277    #[test]
1278    fn effective_autonomous_hooks_default_is_false() {
1279        // SAFETY: clear env so this test is deterministic; tests run with
1280        // --test-threads=1 in CI for env-based tests, but we stay
1281        // defensive and set+unset locally.
1282        // SAFETY: env mutation is acceptable here because we set then unset.
1283        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
1284        let cfg = AppConfig::default();
1285        assert!(!cfg.effective_autonomous_hooks());
1286    }
1287
1288    #[test]
1289    fn effective_autonomous_hooks_config_value_used_when_env_unset() {
1290        unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
1291        let cfg = AppConfig {
1292            autonomous_hooks: Some(true),
1293            ..AppConfig::default()
1294        };
1295        assert!(cfg.effective_autonomous_hooks());
1296    }
1297
1298    #[test]
1299    fn effective_anonymize_default_falls_back_to_config() {
1300        unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
1301        let cfg = AppConfig::default();
1302        assert!(!cfg.effective_anonymize_default());
1303    }
1304
1305    #[test]
1306    fn write_default_if_missing_creates_file_then_noops() {
1307        // Use a temp dir as $HOME so we don't clobber a real config.
1308        let tmp = tempfile::tempdir().unwrap();
1309        // SAFETY: env mutation is contained; we restore at end.
1310        unsafe { std::env::set_var("HOME", tmp.path()) };
1311        // First call writes the file.
1312        AppConfig::write_default_if_missing();
1313        let expected = AppConfig::config_path().unwrap();
1314        assert!(expected.exists(), "config not written at {expected:?}");
1315        let original = std::fs::read_to_string(&expected).unwrap();
1316        assert!(original.contains("ai-memory configuration"));
1317        // Second call must NOT overwrite (idempotent).
1318        std::fs::write(&expected, "# user-edited\n").unwrap();
1319        AppConfig::write_default_if_missing();
1320        let after = std::fs::read_to_string(&expected).unwrap();
1321        assert_eq!(after, "# user-edited\n");
1322    }
1323
1324    #[test]
1325    fn config_path_returns_some_when_home_set() {
1326        // SAFETY: env mutation contained to this test.
1327        unsafe { std::env::set_var("HOME", "/some/home") };
1328        let path = AppConfig::config_path().unwrap();
1329        assert!(path.starts_with("/some/home"));
1330    }
1331
1332    #[test]
1333    fn load_from_returns_default_for_missing_file() {
1334        // Non-existent path → default config.
1335        let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
1336        assert!(cfg.tier.is_none());
1337        assert!(cfg.db.is_none());
1338    }
1339
1340    #[test]
1341    fn load_from_returns_default_for_unparseable_toml() {
1342        // Garbage TOML → load_from prints a warning and returns default.
1343        let tmp = tempfile::NamedTempFile::new().unwrap();
1344        std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
1345        let cfg = AppConfig::load_from(tmp.path());
1346        assert!(cfg.tier.is_none());
1347    }
1348
1349    #[test]
1350    fn load_from_parses_valid_toml() {
1351        let tmp = tempfile::NamedTempFile::new().unwrap();
1352        std::fs::write(
1353            tmp.path(),
1354            r#"
1355                tier = "smart"
1356                db = "/disk.db"
1357            "#,
1358        )
1359        .unwrap();
1360        let cfg = AppConfig::load_from(tmp.path());
1361        assert_eq!(cfg.tier.as_deref(), Some("smart"));
1362        assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
1363    }
1364}