use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::models::Tier;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EmbeddingModel {
MiniLmL6V2,
NomicEmbedV15,
}
impl EmbeddingModel {
pub fn dim(self) -> usize {
match self {
Self::MiniLmL6V2 => 384,
Self::NomicEmbedV15 => 768,
}
}
pub fn hf_model_id(&self) -> &str {
match self {
Self::MiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
Self::NomicEmbedV15 => "nomic-ai/nomic-embed-text-v1.5",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmModel {
Gemma4E2B,
Gemma4E4B,
}
impl LlmModel {
pub fn ollama_model_id(&self) -> &str {
match self {
Self::Gemma4E2B => "gemma4:e2b",
Self::Gemma4E4B => "gemma4:e4b",
}
}
pub fn display_name(&self) -> &str {
match self {
Self::Gemma4E2B => "Gemma 4 Effective 2B (Q4)",
Self::Gemma4E4B => "Gemma 4 Effective 4B (Q4)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FeatureTier {
Keyword,
Semantic,
Smart,
Autonomous,
}
impl FeatureTier {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"keyword" => Some(Self::Keyword),
"semantic" => Some(Self::Semantic),
"smart" => Some(Self::Smart),
"autonomous" => Some(Self::Autonomous),
_ => None,
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Keyword => "keyword",
Self::Semantic => "semantic",
Self::Smart => "smart",
Self::Autonomous => "autonomous",
}
}
pub fn config(self) -> TierConfig {
match self {
Self::Keyword => TierConfig {
tier: self,
embedding_model: None,
llm_model: None,
cross_encoder: false,
max_memory_mb: 0,
},
Self::Semantic => TierConfig {
tier: self,
embedding_model: Some(EmbeddingModel::MiniLmL6V2),
llm_model: None,
cross_encoder: false,
max_memory_mb: 256,
},
Self::Smart => TierConfig {
tier: self,
embedding_model: Some(EmbeddingModel::NomicEmbedV15),
llm_model: Some(LlmModel::Gemma4E2B),
cross_encoder: false,
max_memory_mb: 1024,
},
Self::Autonomous => TierConfig {
tier: self,
embedding_model: Some(EmbeddingModel::NomicEmbedV15),
llm_model: Some(LlmModel::Gemma4E4B),
cross_encoder: true,
max_memory_mb: 4096,
},
}
}
#[allow(dead_code)]
pub fn from_memory_budget(mb: usize) -> Self {
if mb >= 4096 {
Self::Autonomous
} else if mb >= 1024 {
Self::Smart
} else if mb >= 256 {
Self::Semantic
} else {
Self::Keyword
}
}
}
impl std::fmt::Display for FeatureTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TierConfig {
pub tier: FeatureTier,
pub embedding_model: Option<EmbeddingModel>,
pub llm_model: Option<LlmModel>,
pub cross_encoder: bool,
pub max_memory_mb: usize,
}
impl TierConfig {
pub fn capabilities(&self) -> Capabilities {
let has_embeddings = self.embedding_model.is_some();
let has_llm = self.llm_model.is_some();
Capabilities {
schema_version: "2".to_string(),
tier: self.tier.as_str().to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
features: CapabilityFeatures {
keyword_search: true,
semantic_search: has_embeddings,
hybrid_recall: has_embeddings,
query_expansion: has_llm,
auto_consolidation: has_llm,
auto_tagging: has_llm,
contradiction_analysis: has_llm,
cross_encoder_reranking: self.cross_encoder,
memory_reflection: PlannedFeature::planned("v0.7+"),
embedder_loaded: false,
recall_mode_active: RecallMode::Disabled,
reranker_active: RerankerMode::Off,
},
models: CapabilityModels {
embedding: self
.embedding_model
.map_or_else(|| "none".to_string(), |m| m.hf_model_id().to_string()),
embedding_dim: self.embedding_model.map_or(0, EmbeddingModel::dim),
llm: self
.llm_model
.map_or_else(|| "none".to_string(), |m| m.ollama_model_id().to_string()),
cross_encoder: if self.cross_encoder {
"cross-encoder/ms-marco-MiniLM-L-6-v2".to_string()
} else {
"none".to_string()
},
},
permissions: CapabilityPermissions {
mode: "advisory".to_string(),
active_rules: 0,
inheritance: Some("enforced".to_string()),
},
hooks: CapabilityHooks::default(),
compaction: CapabilityCompaction::planned(),
approval: CapabilityApproval {
pending_requests: 0,
},
transcripts: CapabilityTranscripts::planned(),
hnsw: CapabilityHnsw::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capabilities {
pub schema_version: String,
pub tier: String,
pub version: String,
pub features: CapabilityFeatures,
pub models: CapabilityModels,
pub permissions: CapabilityPermissions,
pub hooks: CapabilityHooks,
pub compaction: CapabilityCompaction,
pub approval: CapabilityApproval,
pub transcripts: CapabilityTranscripts,
#[serde(default)]
pub hnsw: CapabilityHnsw,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecallMode {
Hybrid,
KeywordOnly,
Degraded,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RerankerMode {
Neural,
LexicalFallback,
Off,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlannedFeature {
pub planned: bool,
pub version: String,
pub enabled: bool,
}
impl PlannedFeature {
#[must_use]
pub fn planned(version: &str) -> Self {
Self {
planned: true,
version: version.to_string(),
enabled: false,
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityFeatures {
pub keyword_search: bool,
pub semantic_search: bool,
pub hybrid_recall: bool,
pub query_expansion: bool,
pub auto_consolidation: bool,
pub auto_tagging: bool,
pub contradiction_analysis: bool,
pub cross_encoder_reranking: bool,
pub memory_reflection: PlannedFeature,
#[serde(default)]
pub embedder_loaded: bool,
#[serde(default = "default_recall_mode")]
pub recall_mode_active: RecallMode,
#[serde(default = "default_reranker_mode")]
pub reranker_active: RerankerMode,
}
fn default_recall_mode() -> RecallMode {
RecallMode::Disabled
}
fn default_reranker_mode() -> RerankerMode {
RerankerMode::Off
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityModels {
pub embedding: String,
pub embedding_dim: usize,
pub llm: String,
pub cross_encoder: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilityPermissions {
pub mode: String,
pub active_rules: usize,
#[serde(default)]
pub inheritance: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityHooks {
pub registered_count: usize,
#[serde(default = "default_webhook_events")]
pub webhook_events: Vec<String>,
}
impl Default for CapabilityHooks {
fn default() -> Self {
Self {
registered_count: 0,
webhook_events: default_webhook_events(),
}
}
}
fn default_webhook_events() -> Vec<String> {
vec![
"memory_store".to_string(),
"memory_promote".to_string(),
"memory_delete".to_string(),
"memory_link_created".to_string(),
"memory_consolidated".to_string(),
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityCompaction {
#[serde(flatten)]
pub status: PlannedFeature,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_minutes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run_stats: Option<serde_json::Value>,
}
impl CapabilityCompaction {
#[must_use]
pub fn planned() -> Self {
Self {
status: PlannedFeature::planned("v0.8+"),
interval_minutes: None,
last_run_at: None,
last_run_stats: None,
}
}
}
impl Default for CapabilityCompaction {
fn default() -> Self {
Self::planned()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilityApproval {
pub pending_requests: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityTranscripts {
#[serde(flatten)]
pub status: PlannedFeature,
#[serde(default, skip_serializing_if = "is_zero_usize")]
pub total_count: usize,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub total_size_mb: u64,
}
impl CapabilityTranscripts {
#[must_use]
pub fn planned() -> Self {
Self {
status: PlannedFeature::planned("v0.7+"),
total_count: 0,
total_size_mb: 0,
}
}
}
impl Default for CapabilityTranscripts {
fn default() -> Self {
Self::planned()
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_usize(n: &usize) -> bool {
*n == 0
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_u64(n: &u64) -> bool {
*n == 0
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CapabilityHnsw {
pub evictions_total: u64,
pub evicted_recently: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilitiesV1 {
pub tier: String,
pub version: String,
pub features: CapabilityFeaturesV1,
pub models: CapabilityModels,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityFeaturesV1 {
pub keyword_search: bool,
pub semantic_search: bool,
pub hybrid_recall: bool,
pub query_expansion: bool,
pub auto_consolidation: bool,
pub auto_tagging: bool,
pub contradiction_analysis: bool,
pub cross_encoder_reranking: bool,
pub memory_reflection: bool,
#[serde(default)]
pub embedder_loaded: bool,
}
impl Capabilities {
#[must_use]
pub fn to_v1(&self) -> CapabilitiesV1 {
CapabilitiesV1 {
tier: self.tier.clone(),
version: self.version.clone(),
features: CapabilityFeaturesV1 {
keyword_search: self.features.keyword_search,
semantic_search: self.features.semantic_search,
hybrid_recall: self.features.hybrid_recall,
query_expansion: self.features.query_expansion,
auto_consolidation: self.features.auto_consolidation,
auto_tagging: self.features.auto_tagging,
contradiction_analysis: self.features.contradiction_analysis,
cross_encoder_reranking: self.features.cross_encoder_reranking,
memory_reflection: self.features.memory_reflection.enabled,
embedder_loaded: self.features.embedder_loaded,
},
models: self.models.clone(),
}
}
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TtlConfig {
pub short_ttl_secs: Option<i64>,
pub mid_ttl_secs: Option<i64>,
pub long_ttl_secs: Option<i64>,
pub short_extend_secs: Option<i64>,
pub mid_extend_secs: Option<i64>,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)]
pub struct ResolvedTtl {
pub short_ttl_secs: Option<i64>,
pub mid_ttl_secs: Option<i64>,
pub long_ttl_secs: Option<i64>,
pub short_extend_secs: i64,
pub mid_extend_secs: i64,
}
impl Default for ResolvedTtl {
fn default() -> Self {
Self {
short_ttl_secs: Tier::Short.default_ttl_secs(),
mid_ttl_secs: Tier::Mid.default_ttl_secs(),
long_ttl_secs: Tier::Long.default_ttl_secs(),
short_extend_secs: crate::models::SHORT_TTL_EXTEND_SECS,
mid_extend_secs: crate::models::MID_TTL_EXTEND_SECS,
}
}
}
const MAX_TTL_SECS: i64 = 315_360_000;
#[allow(dead_code)]
impl ResolvedTtl {
pub fn from_config(cfg: Option<&TtlConfig>) -> Self {
let defaults = Self::default();
let Some(c) = cfg else {
return defaults;
};
let clamp_ttl = |v: i64| -> Option<i64> {
if v <= 0 {
None
} else {
Some(v.min(MAX_TTL_SECS))
}
};
Self {
short_ttl_secs: c.short_ttl_secs.map_or(defaults.short_ttl_secs, clamp_ttl),
mid_ttl_secs: c.mid_ttl_secs.map_or(defaults.mid_ttl_secs, clamp_ttl),
long_ttl_secs: c.long_ttl_secs.map_or(defaults.long_ttl_secs, clamp_ttl),
short_extend_secs: c
.short_extend_secs
.unwrap_or(defaults.short_extend_secs)
.max(0),
mid_extend_secs: c.mid_extend_secs.unwrap_or(defaults.mid_extend_secs).max(0),
}
}
pub fn ttl_for_tier(&self, tier: &Tier) -> Option<i64> {
match tier {
Tier::Short => self.short_ttl_secs,
Tier::Mid => self.mid_ttl_secs,
Tier::Long => self.long_ttl_secs,
}
}
pub fn extend_for_tier(&self, tier: &Tier) -> Option<i64> {
match tier {
Tier::Short => Some(self.short_extend_secs),
Tier::Mid => Some(self.mid_extend_secs),
Tier::Long => None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecallScoringConfig {
pub half_life_days_short: Option<f64>,
pub half_life_days_mid: Option<f64>,
pub half_life_days_long: Option<f64>,
#[serde(default)]
pub legacy_scoring: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct ResolvedScoring {
pub half_life_days_short: f64,
pub half_life_days_mid: f64,
pub half_life_days_long: f64,
pub legacy_scoring: bool,
}
impl Default for ResolvedScoring {
fn default() -> Self {
Self {
half_life_days_short: 7.0,
half_life_days_mid: 30.0,
half_life_days_long: 365.0,
legacy_scoring: false,
}
}
}
impl ResolvedScoring {
const MIN_HALF_LIFE: f64 = 0.1;
const MAX_HALF_LIFE: f64 = 36_500.0;
pub fn from_config(cfg: Option<&RecallScoringConfig>) -> Self {
let defaults = Self::default();
let Some(c) = cfg else {
return defaults;
};
let clamp = |v: f64| -> f64 { v.clamp(Self::MIN_HALF_LIFE, Self::MAX_HALF_LIFE) };
Self {
half_life_days_short: c
.half_life_days_short
.map_or(defaults.half_life_days_short, clamp),
half_life_days_mid: c
.half_life_days_mid
.map_or(defaults.half_life_days_mid, clamp),
half_life_days_long: c
.half_life_days_long
.map_or(defaults.half_life_days_long, clamp),
legacy_scoring: c.legacy_scoring,
}
}
pub fn half_life_for_tier(&self, tier: &Tier) -> f64 {
match tier {
Tier::Short => self.half_life_days_short,
Tier::Mid => self.half_life_days_mid,
Tier::Long => self.half_life_days_long,
}
}
#[must_use]
pub fn decay_multiplier(&self, tier: &Tier, age_days: f64) -> f64 {
if self.legacy_scoring || age_days <= 0.0 {
return 1.0;
}
let half_life = self.half_life_for_tier(tier);
(-std::f64::consts::LN_2 * age_days / half_life).exp()
}
}
const CONFIG_DIR: &str = ".config/ai-memory";
const CONFIG_FILE: &str = "config.toml";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppConfig {
pub tier: Option<String>,
pub db: Option<String>,
pub ollama_url: Option<String>,
pub embed_url: Option<String>,
pub embedding_model: Option<String>,
pub llm_model: Option<String>,
pub cross_encoder: Option<bool>,
pub default_namespace: Option<String>,
pub max_memory_mb: Option<usize>,
pub ttl: Option<TtlConfig>,
pub archive_on_gc: Option<bool>,
pub api_key: Option<String>,
pub archive_max_days: Option<i64>,
pub identity: Option<IdentityConfig>,
pub scoring: Option<RecallScoringConfig>,
pub autonomous_hooks: Option<bool>,
pub logging: Option<LoggingConfig>,
pub audit: Option<AuditConfig>,
pub boot: Option<BootConfig>,
pub mcp: Option<McpConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LoggingConfig {
pub enabled: Option<bool>,
pub path: Option<String>,
pub max_size_mb: Option<u64>,
pub max_files: Option<usize>,
pub retention_days: Option<u32>,
pub structured: Option<bool>,
pub level: Option<String>,
pub rotation: Option<String>,
pub filename_prefix: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditConfig {
pub enabled: Option<bool>,
pub path: Option<String>,
pub schema_version: Option<u32>,
pub redact_content: Option<bool>,
pub hash_chain: Option<bool>,
pub attestation_cadence_minutes: Option<u32>,
pub append_only: Option<bool>,
pub retention_days: Option<u32>,
pub compliance: Option<AuditComplianceConfig>,
}
impl AuditConfig {
#[must_use]
pub fn effective_retention_days(&self) -> u32 {
let mut chosen = self.retention_days.unwrap_or(90);
if let Some(comp) = &self.compliance {
for preset in comp.applied_presets() {
if let Some(d) = preset.retention_days
&& d > chosen
{
chosen = d;
}
}
}
chosen
}
#[must_use]
pub fn effective_attestation_cadence_minutes(&self) -> u32 {
let base = self.attestation_cadence_minutes.unwrap_or(60);
let mut chosen = base;
if let Some(comp) = &self.compliance {
for preset in comp.applied_presets() {
if let Some(m) = preset.attestation_cadence_minutes
&& m > 0
&& (chosen == 0 || m < chosen)
{
chosen = m;
}
}
}
chosen
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BootConfig {
pub enabled: Option<bool>,
pub redact_titles: Option<bool>,
}
impl BootConfig {
#[must_use]
pub fn effective_enabled(&self) -> bool {
if let Ok(v) = std::env::var("AI_MEMORY_BOOT_ENABLED") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "0" | "false" | "no" | "off") {
return false;
}
if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
return true;
}
}
self.enabled.unwrap_or(true)
}
#[must_use]
pub fn effective_redact_titles(&self) -> bool {
self.redact_titles.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpConfig {
pub profile: Option<String>,
pub allowlist: Option<std::collections::HashMap<String, Vec<String>>>,
}
impl McpConfig {
#[must_use]
pub fn allowlist_decision(&self, agent_id: Option<&str>, family: &str) -> AllowlistDecision {
let table = match self.allowlist.as_ref() {
Some(t) if !t.is_empty() => t,
_ => return AllowlistDecision::Disabled,
};
let aid = agent_id.unwrap_or("");
if let Some(families) = table.get(aid) {
return decide(families, family);
}
let mut keys: Vec<&String> = table
.keys()
.filter(|k| k.as_str() != "*" && aid.starts_with(k.as_str()))
.collect();
keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
if let Some(k) = keys.first() {
if let Some(families) = table.get(*k) {
return decide(families, family);
}
}
if let Some(families) = table.get("*") {
return decide(families, family);
}
AllowlistDecision::Deny
}
}
fn decide(families: &[String], requested: &str) -> AllowlistDecision {
if families.iter().any(|f| f == "full" || f == requested) {
AllowlistDecision::Allow
} else {
AllowlistDecision::Deny
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AllowlistDecision {
Disabled,
Allow,
Deny,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditComplianceConfig {
pub soc2: Option<CompliancePreset>,
pub hipaa: Option<CompliancePreset>,
pub gdpr: Option<CompliancePreset>,
pub fedramp: Option<CompliancePreset>,
}
impl AuditComplianceConfig {
pub fn applied_presets(&self) -> impl Iterator<Item = &CompliancePreset> {
[
self.soc2.as_ref(),
self.hipaa.as_ref(),
self.gdpr.as_ref(),
self.fedramp.as_ref(),
]
.into_iter()
.flatten()
.filter(|p| p.applied.unwrap_or(false))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompliancePreset {
pub applied: Option<bool>,
pub retention_days: Option<u32>,
pub redact_content: Option<bool>,
pub attestation_cadence_minutes: Option<u32>,
pub encrypt_at_rest: Option<bool>,
pub pseudonymize_actors: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdentityConfig {
#[serde(default)]
pub anonymize_default: bool,
}
impl AppConfig {
pub fn config_path() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(Path::new(&home).join(CONFIG_DIR).join(CONFIG_FILE))
}
pub fn load() -> Self {
if std::env::var("AI_MEMORY_NO_CONFIG").is_ok() {
return Self::default();
}
let Some(path) = Self::config_path() else {
return Self::default();
};
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(contents) => match toml::from_str(&contents) {
Ok(cfg) => {
eprintln!("ai-memory: loaded config from {}", path.display());
cfg
}
Err(e) => {
eprintln!("ai-memory: config parse error ({}): {}", path.display(), e);
Self::default()
}
},
Err(_) => Self::default(),
}
}
pub fn effective_tier(&self, cli_tier: Option<&str>) -> FeatureTier {
let tier_str = cli_tier.or(self.tier.as_deref()).unwrap_or("semantic");
FeatureTier::from_str(tier_str).unwrap_or(FeatureTier::Semantic)
}
pub fn effective_db(&self, cli_db: &Path) -> PathBuf {
let default_db = PathBuf::from("ai-memory.db");
if cli_db != default_db {
return cli_db.to_path_buf();
}
self.db
.as_ref()
.map_or_else(|| cli_db.to_path_buf(), PathBuf::from)
}
pub fn effective_ollama_url(&self) -> &str {
self.ollama_url
.as_deref()
.unwrap_or("http://localhost:11434")
}
pub fn effective_ttl(&self) -> ResolvedTtl {
ResolvedTtl::from_config(self.ttl.as_ref())
}
pub fn effective_scoring(&self) -> ResolvedScoring {
ResolvedScoring::from_config(self.scoring.as_ref())
}
pub fn effective_archive_on_gc(&self) -> bool {
self.archive_on_gc.unwrap_or(true)
}
pub fn effective_profile(
&self,
cli_or_env: Option<&str>,
) -> Result<crate::profile::Profile, crate::profile::ProfileParseError> {
let raw = cli_or_env
.or_else(|| self.mcp.as_ref().and_then(|m| m.profile.as_deref()))
.unwrap_or("core");
crate::profile::Profile::parse(raw)
}
pub fn effective_autonomous_hooks(&self) -> bool {
if let Ok(v) = std::env::var("AI_MEMORY_AUTONOMOUS_HOOKS") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
return true;
}
if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
return false;
}
}
self.autonomous_hooks.unwrap_or(false)
}
pub fn effective_anonymize_default(&self) -> bool {
if let Ok(v) = std::env::var("AI_MEMORY_ANONYMIZE") {
let v = v.trim().to_ascii_lowercase();
if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
return true;
}
if matches!(v.as_str(), "0" | "false" | "no" | "off" | "") {
return false;
}
}
self.identity.as_ref().is_some_and(|i| i.anonymize_default)
}
pub fn effective_logging(&self) -> LoggingConfig {
self.logging.clone().unwrap_or_default()
}
pub fn effective_audit(&self) -> AuditConfig {
self.audit.clone().unwrap_or_default()
}
pub fn effective_boot(&self) -> BootConfig {
self.boot.clone().unwrap_or_default()
}
pub fn effective_embed_url(&self) -> &str {
self.embed_url
.as_deref()
.or(self.ollama_url.as_deref())
.unwrap_or("http://localhost:11434")
}
pub fn write_default_if_missing() {
let Some(path) = Self::config_path() else {
return;
};
if path.exists() {
return;
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let default_toml = r#"# ai-memory configuration
# See: https://github.com/alphaonedev/ai-memory-mcp
# Feature tier: keyword, semantic, smart, autonomous
# tier = "semantic"
# Path to SQLite database
# db = "~/.claude/ai-memory.db"
# Ollama base URL (for smart/autonomous tiers)
# ollama_url = "http://localhost:11434"
# Embedding model: mini_lm_l6_v2 (384-dim) or nomic_embed_v15 (768-dim)
# embedding_model = "mini_lm_l6_v2"
# LLM model tag for Ollama
# llm_model = "gemma4:e2b"
# Enable neural cross-encoder reranking (autonomous tier)
# cross_encoder = true
# Default namespace for new memories
# default_namespace = "global"
# Memory budget in MB (for auto tier selection)
# max_memory_mb = 4096
# Archive expired memories before GC deletion (default: true)
# archive_on_gc = true
# Per-tier TTL overrides (uncomment to customize)
# [ttl]
# short_ttl_secs = 21600 # 6 hours (default)
# mid_ttl_secs = 604800 # 7 days (default)
# long_ttl_secs = 0 # 0 = never expires (default)
# short_extend_secs = 3600 # +1h on access (default)
# mid_extend_secs = 86400 # +1d on access (default)
# v0.6.3.1 (PR-5 / issue #487) — operational logging facility.
# Default-OFF. Uncomment + set enabled = true to capture every
# `tracing::*` call site to a rotating on-disk log file. See
# `docs/security/audit-trail.md` §SIEM ingestion guide for Splunk /
# Datadog / Elastic / Loki recipes.
# [logging]
# enabled = false
# path = "~/.local/state/ai-memory/logs/"
# max_size_mb = 100
# max_files = 30
# retention_days = 90
# structured = false # true = emit JSON lines for SIEM ingest
# level = "info" # tracing EnvFilter directive
# rotation = "daily" # minutely | hourly | daily | never
# v0.6.3.1 (PR-5 / issue #487) — security audit trail. Default-OFF.
# When enabled, every memory mutation emits one hash-chained JSON
# line per event suitable for SOC2 / HIPAA / GDPR / FedRAMP evidence.
# `ai-memory audit verify` walks the chain; `ai-memory logs tail`
# streams events.
# [audit]
# enabled = false
# path = "~/.local/state/ai-memory/audit/"
# schema_version = 1
# redact_content = true # v1 schema never emits content; reserved
# hash_chain = true
# attestation_cadence_minutes = 60
# append_only = true # best-effort chflags(2) / FS_IOC_SETFLAGS
# Compliance presets. Set `applied = true` and the documented retention
# / cadence values override the defaults above. See
# `docs/security/audit-trail.md` §Compliance.
# [audit.compliance.soc2]
# applied = false
# retention_days = 730
# redact_content = true
# attestation_cadence_minutes = 60
#
# [audit.compliance.hipaa]
# applied = false
# retention_days = 2190
# redact_content = true
# encrypt_at_rest = true # pair with --features sqlcipher
#
# [audit.compliance.gdpr]
# applied = false
# retention_days = 1095
# redact_content = true
# pseudonymize_actors = true # reserved for v0.7+
#
# [audit.compliance.fedramp]
# applied = false
# retention_days = 1095
# redact_content = true
# attestation_cadence_minutes = 30
# v0.6.3.1 (PR-9h / issue #487 PR #497 req #73) — boot privacy controls.
# Default-ON (omit the section entirely for the historical pre-v0.6.3.1
# behavior). Two knobs:
#
# - `enabled = false` silences `ai-memory boot` entirely: empty stdout,
# empty stderr, exit 0. The SessionStart hook injects nothing. Use on
# privacy-sensitive hosts where memory titles must never enter CI
# logs. The env var `AI_MEMORY_BOOT_ENABLED=0` takes precedence over
# this config (same precedence pattern as PR-5's log-dir resolution).
#
# - `redact_titles = true` keeps the manifest header but replaces row
# `title` fields with `<redacted>` — useful for compliance contexts
# that need the audit-trail signal of "boot ran with N memories"
# without exposing memory subjects.
# [boot]
# enabled = true
# redact_titles = false
"#;
let _ = std::fs::write(&path, default_toml);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tier_roundtrip() {
for tier in [
FeatureTier::Keyword,
FeatureTier::Semantic,
FeatureTier::Smart,
FeatureTier::Autonomous,
] {
assert_eq!(FeatureTier::from_str(tier.as_str()), Some(tier));
}
}
#[test]
fn budget_selection() {
assert_eq!(FeatureTier::from_memory_budget(0), FeatureTier::Keyword);
assert_eq!(FeatureTier::from_memory_budget(128), FeatureTier::Keyword);
assert_eq!(FeatureTier::from_memory_budget(256), FeatureTier::Semantic);
assert_eq!(FeatureTier::from_memory_budget(512), FeatureTier::Semantic);
assert_eq!(FeatureTier::from_memory_budget(1024), FeatureTier::Smart);
assert_eq!(FeatureTier::from_memory_budget(2048), FeatureTier::Smart);
assert_eq!(
FeatureTier::from_memory_budget(4096),
FeatureTier::Autonomous
);
assert_eq!(
FeatureTier::from_memory_budget(8192),
FeatureTier::Autonomous
);
}
#[test]
fn embedding_dimensions() {
assert_eq!(EmbeddingModel::MiniLmL6V2.dim(), 384);
assert_eq!(EmbeddingModel::NomicEmbedV15.dim(), 768);
}
#[test]
fn autonomous_has_cross_encoder() {
let cfg = FeatureTier::Autonomous.config();
assert!(cfg.cross_encoder);
let caps = cfg.capabilities();
assert!(caps.features.cross_encoder_reranking);
assert!(caps.features.memory_reflection.planned);
assert!(!caps.features.memory_reflection.enabled);
assert_eq!(caps.features.memory_reflection.version, "v0.7+");
}
#[test]
fn keyword_has_no_models() {
let cfg = FeatureTier::Keyword.config();
assert!(cfg.embedding_model.is_none());
assert!(cfg.llm_model.is_none());
assert!(!cfg.cross_encoder);
assert_eq!(cfg.max_memory_mb, 0);
}
#[test]
fn capabilities_serialize() {
let caps = FeatureTier::Smart.config().capabilities();
let json = serde_json::to_string_pretty(&caps).unwrap();
assert!(json.contains("\"tier\": \"smart\""));
assert!(json.contains("nomic"));
assert!(json.contains("gemma4:e2b"));
}
#[test]
fn capabilities_v2_zero_state_round_trip() {
let caps = FeatureTier::Keyword.config().capabilities();
let val: serde_json::Value = serde_json::to_value(&caps).unwrap();
assert_eq!(val["schema_version"], "2");
assert_eq!(val["permissions"]["mode"], "advisory");
assert_eq!(val["permissions"]["active_rules"], 0);
assert!(
val["permissions"].get("rule_summary").is_none(),
"v2 honesty patch drops `permissions.rule_summary` (no per-rule serializer)"
);
assert_eq!(val["permissions"]["inheritance"], "enforced");
assert_eq!(val["hooks"]["registered_count"], 0);
assert!(
val["hooks"].get("by_event").is_none(),
"v2 honesty patch drops `hooks.by_event` (no event registry)"
);
assert_eq!(val["hooks"]["registered_count"], 0);
assert!(
val["hooks"].get("by_event").is_none(),
"v2 drops hooks.by_event (no event registry)"
);
let events = val["hooks"]["webhook_events"].as_array().unwrap();
assert_eq!(events.len(), 5);
for expected in [
"memory_store",
"memory_promote",
"memory_delete",
"memory_link_created",
"memory_consolidated",
] {
assert!(
events.iter().any(|v| v.as_str() == Some(expected)),
"webhook_events missing {expected}"
);
}
assert_eq!(val["compaction"]["planned"], true);
assert_eq!(val["compaction"]["enabled"], false);
assert_eq!(val["compaction"]["version"], "v0.8+");
assert!(
val["compaction"].get("interval_minutes").is_none(),
"Option::None values must be skipped in serialization"
);
assert!(val["compaction"].get("last_run_at").is_none());
assert!(val["compaction"].get("last_run_stats").is_none());
assert_eq!(val["approval"]["pending_requests"], 0);
assert!(
val["approval"].get("subscribers").is_none(),
"v2 honesty patch drops `approval.subscribers` (no subscription API)"
);
assert!(
val["approval"].get("default_timeout_seconds").is_none(),
"v2 honesty patch drops `approval.default_timeout_seconds` (no sweeper)"
);
assert_eq!(val["transcripts"]["planned"], true);
assert_eq!(val["transcripts"]["enabled"], false);
assert_eq!(val["transcripts"]["version"], "v0.7+");
assert_eq!(val["features"]["memory_reflection"]["planned"], true);
assert_eq!(val["features"]["memory_reflection"]["enabled"], false);
assert_eq!(val["features"]["memory_reflection"]["version"], "v0.7+");
assert_eq!(val["features"]["recall_mode_active"], "disabled");
assert_eq!(val["features"]["reranker_active"], "off");
let restored: Capabilities = serde_json::from_value(val).unwrap();
assert_eq!(restored.schema_version, "2");
assert_eq!(restored.permissions.mode, "advisory");
assert!(restored.compaction.status.planned);
assert!(restored.transcripts.status.planned);
assert_eq!(restored.features.recall_mode_active, RecallMode::Disabled);
assert_eq!(restored.features.reranker_active, RerankerMode::Off);
}
#[test]
fn capabilities_v1_projection_preserves_legacy_shape() {
let caps = FeatureTier::Autonomous.config().capabilities();
let v1 = caps.to_v1();
let val: serde_json::Value = serde_json::to_value(&v1).unwrap();
assert!(
val.get("schema_version").is_none(),
"v1 has no schema_version"
);
assert!(
val.get("permissions").is_none(),
"v1 has no permissions block"
);
assert!(val.get("hooks").is_none());
assert!(val.get("compaction").is_none());
assert!(val.get("approval").is_none());
assert!(val.get("transcripts").is_none());
assert!(val["tier"].is_string());
assert!(val["version"].is_string());
assert!(val["features"].is_object());
assert!(val["models"].is_object());
assert!(val["features"]["memory_reflection"].is_boolean());
assert_eq!(val["features"]["memory_reflection"], false);
assert!(val["features"].get("recall_mode_active").is_none());
assert!(val["features"].get("reranker_active").is_none());
}
#[test]
fn config_default_is_empty() {
let cfg = AppConfig::default();
assert!(cfg.tier.is_none());
assert!(cfg.db.is_none());
assert!(cfg.ollama_url.is_none());
}
#[test]
fn config_parse_toml() {
let toml_str = r#"
tier = "smart"
db = "/tmp/test.db"
ollama_url = "http://localhost:11434"
cross_encoder = true
"#;
let cfg: AppConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.tier.as_deref(), Some("smart"));
assert_eq!(cfg.db.as_deref(), Some("/tmp/test.db"));
assert!(cfg.cross_encoder.unwrap());
}
#[test]
fn resolved_ttl_defaults_match_hardcoded() {
let resolved = ResolvedTtl::default();
assert_eq!(resolved.short_ttl_secs, Some(6 * 3600));
assert_eq!(resolved.mid_ttl_secs, Some(7 * 24 * 3600));
assert_eq!(resolved.long_ttl_secs, None);
assert_eq!(resolved.short_extend_secs, 3600);
assert_eq!(resolved.mid_extend_secs, 86400);
}
#[test]
fn resolved_ttl_from_partial_config() {
let cfg = TtlConfig {
mid_ttl_secs: Some(90 * 24 * 3600), ..Default::default()
};
let resolved = ResolvedTtl::from_config(Some(&cfg));
assert_eq!(resolved.short_ttl_secs, Some(6 * 3600)); assert_eq!(resolved.mid_ttl_secs, Some(90 * 24 * 3600)); assert_eq!(resolved.long_ttl_secs, None); }
#[test]
fn resolved_ttl_zero_means_no_expiry() {
let cfg = TtlConfig {
short_ttl_secs: Some(0),
mid_ttl_secs: Some(0),
..Default::default()
};
let resolved = ResolvedTtl::from_config(Some(&cfg));
assert_eq!(resolved.short_ttl_secs, None); assert_eq!(resolved.mid_ttl_secs, None);
}
#[test]
fn resolved_ttl_clamps_overflow() {
let cfg = TtlConfig {
mid_ttl_secs: Some(i64::MAX),
short_extend_secs: Some(-3600),
..Default::default()
};
let resolved = ResolvedTtl::from_config(Some(&cfg));
assert_eq!(resolved.mid_ttl_secs, Some(super::MAX_TTL_SECS));
assert_eq!(resolved.short_extend_secs, 0);
}
#[test]
fn ttl_config_parse_toml() {
let toml_str = r#"
tier = "semantic"
archive_on_gc = false
[ttl]
mid_ttl_secs = 7776000
short_extend_secs = 7200
"#;
let cfg: AppConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.ttl.as_ref().unwrap().mid_ttl_secs, Some(7776000));
assert_eq!(cfg.ttl.as_ref().unwrap().short_extend_secs, Some(7200));
assert!(!cfg.effective_archive_on_gc());
}
#[test]
fn resolved_ttl_tier_methods() {
let resolved = ResolvedTtl::default();
assert_eq!(resolved.ttl_for_tier(&Tier::Short), Some(6 * 3600));
assert_eq!(resolved.ttl_for_tier(&Tier::Mid), Some(7 * 24 * 3600));
assert_eq!(resolved.ttl_for_tier(&Tier::Long), None);
assert_eq!(resolved.extend_for_tier(&Tier::Short), Some(3600));
assert_eq!(resolved.extend_for_tier(&Tier::Mid), Some(86400));
assert_eq!(resolved.extend_for_tier(&Tier::Long), None);
}
#[test]
fn config_effective_tier() {
let cfg = AppConfig {
tier: Some("smart".to_string()),
..Default::default()
};
assert_eq!(
cfg.effective_tier(Some("autonomous")),
FeatureTier::Autonomous
);
assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
}
#[test]
fn scoring_defaults_match_spec() {
let s = ResolvedScoring::default();
assert!((s.half_life_days_short - 7.0).abs() < f64::EPSILON);
assert!((s.half_life_days_mid - 30.0).abs() < f64::EPSILON);
assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
assert!(!s.legacy_scoring);
}
#[test]
fn scoring_from_config_overrides() {
let cfg = RecallScoringConfig {
half_life_days_short: Some(3.5),
half_life_days_mid: Some(14.0),
half_life_days_long: Some(730.0),
legacy_scoring: false,
};
let s = ResolvedScoring::from_config(Some(&cfg));
assert!((s.half_life_days_short - 3.5).abs() < f64::EPSILON);
assert!((s.half_life_days_mid - 14.0).abs() < f64::EPSILON);
assert!((s.half_life_days_long - 730.0).abs() < f64::EPSILON);
}
#[test]
fn scoring_clamps_out_of_range() {
let cfg = RecallScoringConfig {
half_life_days_short: Some(-10.0),
half_life_days_mid: Some(0.0),
half_life_days_long: Some(1_000_000.0),
legacy_scoring: false,
};
let s = ResolvedScoring::from_config(Some(&cfg));
assert!(s.half_life_days_short >= ResolvedScoring::MIN_HALF_LIFE);
assert!(s.half_life_days_mid >= ResolvedScoring::MIN_HALF_LIFE);
assert!(s.half_life_days_long <= ResolvedScoring::MAX_HALF_LIFE);
}
#[test]
fn scoring_decay_at_half_life_is_half() {
let s = ResolvedScoring::default();
let d = s.decay_multiplier(&Tier::Short, 7.0);
assert!((d - 0.5).abs() < 1e-9);
let d = s.decay_multiplier(&Tier::Mid, 30.0);
assert!((d - 0.5).abs() < 1e-9);
let d = s.decay_multiplier(&Tier::Long, 365.0);
assert!((d - 0.5).abs() < 1e-9);
}
#[test]
fn scoring_decay_monotonic() {
let s = ResolvedScoring::default();
let d_new = s.decay_multiplier(&Tier::Mid, 1.0);
let d_old = s.decay_multiplier(&Tier::Mid, 60.0);
assert!(d_new > d_old);
assert!(d_new < 1.0);
assert!(d_old > 0.0);
}
#[test]
fn scoring_decay_zero_age_is_one() {
let s = ResolvedScoring::default();
assert!((s.decay_multiplier(&Tier::Short, 0.0) - 1.0).abs() < f64::EPSILON);
assert!((s.decay_multiplier(&Tier::Short, -5.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn scoring_legacy_disables_decay() {
let cfg = RecallScoringConfig {
legacy_scoring: true,
..Default::default()
};
let s = ResolvedScoring::from_config(Some(&cfg));
assert!((s.decay_multiplier(&Tier::Short, 100.0) - 1.0).abs() < f64::EPSILON);
assert!((s.decay_multiplier(&Tier::Mid, 1000.0) - 1.0).abs() < f64::EPSILON);
assert!((s.decay_multiplier(&Tier::Long, 10_000.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn effective_scoring_on_empty_config() {
let cfg = AppConfig::default();
let s = cfg.effective_scoring();
assert_eq!(s.half_life_days_short, 7.0);
assert!(!s.legacy_scoring);
}
#[test]
fn scoring_roundtrip_through_toml() {
let toml_src = r"
[scoring]
half_life_days_short = 5.0
half_life_days_mid = 25.0
legacy_scoring = false
";
let cfg: AppConfig = toml::from_str(toml_src).expect("parses");
let s = cfg.effective_scoring();
assert!((s.half_life_days_short - 5.0).abs() < f64::EPSILON);
assert!((s.half_life_days_mid - 25.0).abs() < f64::EPSILON);
assert!((s.half_life_days_long - 365.0).abs() < f64::EPSILON);
}
#[test]
fn effective_tier_cli_overrides_config() {
let cfg = AppConfig {
tier: Some("smart".to_string()),
..AppConfig::default()
};
assert_eq!(
cfg.effective_tier(Some("autonomous")),
FeatureTier::Autonomous
);
assert_eq!(cfg.effective_tier(None), FeatureTier::Smart);
}
#[test]
fn effective_tier_unknown_falls_back_to_semantic() {
let cfg = AppConfig::default();
assert_eq!(
cfg.effective_tier(Some("invalid-tier")),
FeatureTier::Semantic
);
assert_eq!(cfg.effective_tier(None), FeatureTier::Semantic);
}
#[test]
fn effective_profile_cli_or_env_overrides_config() {
let cfg = AppConfig {
mcp: Some(McpConfig {
profile: Some("graph".to_string()),
allowlist: None,
}),
..AppConfig::default()
};
assert_eq!(
cfg.effective_profile(Some("admin")).unwrap(),
crate::profile::Profile::admin()
);
assert_eq!(
cfg.effective_profile(None).unwrap(),
crate::profile::Profile::graph()
);
}
#[test]
fn effective_profile_falls_back_to_core_default() {
let cfg = AppConfig::default();
assert_eq!(
cfg.effective_profile(None).unwrap(),
crate::profile::Profile::core()
);
}
#[test]
fn effective_profile_surfaces_parse_error_for_unknown_family() {
let cfg = AppConfig::default();
assert!(matches!(
cfg.effective_profile(Some("xyz")),
Err(crate::profile::ProfileParseError::UnknownFamily(_))
));
}
#[test]
fn effective_profile_surfaces_parse_error_for_mixed_case() {
let cfg = AppConfig::default();
assert!(matches!(
cfg.effective_profile(Some("Core")),
Err(crate::profile::ProfileParseError::CaseMismatch(_))
));
}
fn allowlist_table(rows: &[(&str, &[&str])]) -> McpConfig {
let mut map = std::collections::HashMap::new();
for (k, v) in rows {
map.insert(
(*k).to_string(),
v.iter().map(|s| (*s).to_string()).collect(),
);
}
McpConfig {
profile: None,
allowlist: Some(map),
}
}
#[test]
fn allowlist_disabled_when_table_absent() {
let cfg = McpConfig::default();
assert_eq!(
cfg.allowlist_decision(Some("alice"), "graph"),
AllowlistDecision::Disabled
);
}
#[test]
fn allowlist_disabled_when_table_empty() {
let cfg = McpConfig {
profile: None,
allowlist: Some(std::collections::HashMap::new()),
};
assert_eq!(
cfg.allowlist_decision(Some("alice"), "graph"),
AllowlistDecision::Disabled
);
}
#[test]
fn allowlist_exact_match_grants_or_denies_per_family_set() {
let cfg = allowlist_table(&[("alice", &["core", "graph"]), ("*", &["core"])]);
assert_eq!(
cfg.allowlist_decision(Some("alice"), "graph"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("alice"), "power"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_full_grants_every_family() {
let cfg = allowlist_table(&[("bob", &["full"])]);
assert_eq!(
cfg.allowlist_decision(Some("bob"), "graph"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("bob"), "archive"),
AllowlistDecision::Allow
);
}
#[test]
fn allowlist_wildcard_default_for_unknown_agents() {
let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
assert_eq!(
cfg.allowlist_decision(Some("eve"), "core"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("eve"), "graph"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_default_deny_when_no_wildcard() {
let cfg = allowlist_table(&[("alice", &["full"])]);
assert_eq!(
cfg.allowlist_decision(Some("eve"), "core"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_longest_prefix_match_wins() {
let cfg = allowlist_table(&[
("ai:", &["core"]),
("ai:claude-code", &["full"]),
("*", &["core"]),
]);
assert_eq!(
cfg.allowlist_decision(Some("ai:claude-code@host"), "graph"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(Some("ai:codex@host"), "graph"),
AllowlistDecision::Deny
);
}
#[test]
fn allowlist_no_agent_id_uses_wildcard() {
let cfg = allowlist_table(&[("alice", &["full"]), ("*", &["core"])]);
assert_eq!(
cfg.allowlist_decision(None, "core"),
AllowlistDecision::Allow
);
assert_eq!(
cfg.allowlist_decision(None, "graph"),
AllowlistDecision::Deny
);
}
#[test]
fn effective_db_cli_path_wins_when_non_default() {
let cfg = AppConfig {
db: Some("/from/config.db".to_string()),
..AppConfig::default()
};
let cli_path = Path::new("/from/cli.db");
assert_eq!(cfg.effective_db(cli_path), PathBuf::from("/from/cli.db"));
}
#[test]
fn effective_db_falls_back_to_config_when_cli_default() {
let cfg = AppConfig {
db: Some("/from/config.db".to_string()),
..AppConfig::default()
};
assert_eq!(
cfg.effective_db(Path::new("ai-memory.db")),
PathBuf::from("/from/config.db")
);
}
#[test]
fn effective_db_falls_back_to_cli_when_no_config() {
let cfg = AppConfig::default();
let cli_path = Path::new("ai-memory.db");
assert_eq!(cfg.effective_db(cli_path), PathBuf::from("ai-memory.db"));
}
#[test]
fn effective_ollama_url_default_when_unset() {
let cfg = AppConfig::default();
assert_eq!(cfg.effective_ollama_url(), "http://localhost:11434");
}
#[test]
fn effective_ollama_url_uses_configured_value() {
let cfg = AppConfig {
ollama_url: Some("http://my-host:9999".to_string()),
..AppConfig::default()
};
assert_eq!(cfg.effective_ollama_url(), "http://my-host:9999");
}
#[test]
fn effective_embed_url_falls_back_to_ollama_url() {
let cfg = AppConfig {
ollama_url: Some("http://ollama:11434".to_string()),
..AppConfig::default()
};
assert_eq!(cfg.effective_embed_url(), "http://ollama:11434");
}
#[test]
fn effective_embed_url_uses_dedicated_value_when_set() {
let cfg = AppConfig {
ollama_url: Some("http://ollama:11434".to_string()),
embed_url: Some("http://embed:8080".to_string()),
..AppConfig::default()
};
assert_eq!(cfg.effective_embed_url(), "http://embed:8080");
}
#[test]
fn effective_embed_url_uses_default_when_neither_set() {
let cfg = AppConfig::default();
assert_eq!(cfg.effective_embed_url(), "http://localhost:11434");
}
#[test]
fn effective_archive_on_gc_default_is_true() {
let cfg = AppConfig::default();
assert!(cfg.effective_archive_on_gc());
}
#[test]
fn effective_archive_on_gc_respects_explicit_false() {
let cfg = AppConfig {
archive_on_gc: Some(false),
..AppConfig::default()
};
assert!(!cfg.effective_archive_on_gc());
}
#[test]
fn effective_autonomous_hooks_default_is_false() {
unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
let cfg = AppConfig::default();
assert!(!cfg.effective_autonomous_hooks());
}
#[test]
fn effective_autonomous_hooks_config_value_used_when_env_unset() {
unsafe { std::env::remove_var("AI_MEMORY_AUTONOMOUS_HOOKS") };
let cfg = AppConfig {
autonomous_hooks: Some(true),
..AppConfig::default()
};
assert!(cfg.effective_autonomous_hooks());
}
#[test]
fn effective_anonymize_default_falls_back_to_config() {
unsafe { std::env::remove_var("AI_MEMORY_ANONYMIZE") };
let cfg = AppConfig::default();
assert!(!cfg.effective_anonymize_default());
}
#[test]
fn write_default_if_missing_creates_file_then_noops() {
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("HOME", tmp.path()) };
AppConfig::write_default_if_missing();
let expected = AppConfig::config_path().unwrap();
assert!(expected.exists(), "config not written at {expected:?}");
let original = std::fs::read_to_string(&expected).unwrap();
assert!(original.contains("ai-memory configuration"));
std::fs::write(&expected, "# user-edited\n").unwrap();
AppConfig::write_default_if_missing();
let after = std::fs::read_to_string(&expected).unwrap();
assert_eq!(after, "# user-edited\n");
}
#[test]
fn config_path_returns_some_when_home_set() {
unsafe { std::env::set_var("HOME", "/some/home") };
let path = AppConfig::config_path().unwrap();
assert!(path.starts_with("/some/home"));
}
#[test]
fn load_from_returns_default_for_missing_file() {
let cfg = AppConfig::load_from(Path::new("/non/existent/path.toml"));
assert!(cfg.tier.is_none());
assert!(cfg.db.is_none());
}
#[test]
fn load_from_returns_default_for_unparseable_toml() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "this is not [valid toml]]]").unwrap();
let cfg = AppConfig::load_from(tmp.path());
assert!(cfg.tier.is_none());
}
#[test]
fn load_from_parses_valid_toml() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"
tier = "smart"
db = "/disk.db"
"#,
)
.unwrap();
let cfg = AppConfig::load_from(tmp.path());
assert_eq!(cfg.tier.as_deref(), Some("smart"));
assert_eq!(cfg.db.as_deref(), Some("/disk.db"));
}
}