Skip to main content

enact_memory/
config.rs

1//! Memory configuration with unified config resolution
2//!
3//! Configuration is resolved in order:
4//! 1. `ENACT_MEMORY_CONFIG_PATH` environment variable
5//! 2. `./memory.yaml` in current working directory
6//! 3. `~/.enact/memory.yaml`
7//! 4. Hardcoded defaults
8
9use serde::{Deserialize, Serialize};
10
11/// Episodic memory configuration (subset for YAML)
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct EpisodicConfig {
14    /// Directory for session snapshots (relative to workspace)
15    #[serde(default = "default_snapshot_dir")]
16    pub snapshot_dir: String,
17
18    /// Maximum snapshots to retain before cleanup
19    #[serde(default = "default_max_snapshots")]
20    pub max_snapshots: usize,
21
22    /// Maximum entries per daily log before rolling
23    #[serde(default = "default_max_daily_entries")]
24    pub max_daily_entries: usize,
25
26    /// Whether to automatically consolidate to semantic memory
27    #[serde(default = "default_auto_consolidate")]
28    pub auto_consolidate: bool,
29
30    /// Time of day to run consolidation (HH:MM format)
31    #[serde(default = "default_consolidation_time")]
32    pub consolidation_time: String,
33
34    /// Number of days to retain episodic logs
35    #[serde(default = "default_retention_days")]
36    pub retention_days: u32,
37
38    /// Whether to include timestamps in entries
39    #[serde(default = "default_include_timestamps")]
40    pub include_timestamps: bool,
41}
42
43impl Default for EpisodicConfig {
44    fn default() -> Self {
45        Self {
46            snapshot_dir: default_snapshot_dir(),
47            max_snapshots: default_max_snapshots(),
48            max_daily_entries: default_max_daily_entries(),
49            auto_consolidate: default_auto_consolidate(),
50            consolidation_time: default_consolidation_time(),
51            retention_days: default_retention_days(),
52            include_timestamps: default_include_timestamps(),
53        }
54    }
55}
56
57fn default_snapshot_dir() -> String {
58    "memory/sessions".to_string()
59}
60
61fn default_max_snapshots() -> usize {
62    50
63}
64
65fn default_max_daily_entries() -> usize {
66    100
67}
68
69fn default_auto_consolidate() -> bool {
70    true
71}
72
73fn default_consolidation_time() -> String {
74    "03:00".to_string()
75}
76
77fn default_retention_days() -> u32 {
78    30
79}
80
81fn default_include_timestamps() -> bool {
82    true
83}
84
85/// Main memory configuration
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct MemoryConfig {
88    /// Memory backend type: "sqlite", "markdown", or "none"
89    #[serde(default = "default_backend")]
90    pub backend: String,
91
92    /// Path to SQLite database file (relative to workspace)
93    #[serde(default = "default_db_path")]
94    pub db_path: String,
95
96    /// Directory for markdown memory files (relative to workspace)
97    #[serde(default = "default_markdown_dir")]
98    pub markdown_dir: String,
99
100    /// Episodic memory configuration
101    #[serde(default)]
102    pub episodic: EpisodicConfig,
103}
104
105impl Default for MemoryConfig {
106    fn default() -> Self {
107        Self {
108            backend: default_backend(),
109            db_path: default_db_path(),
110            markdown_dir: default_markdown_dir(),
111            episodic: EpisodicConfig::default(),
112        }
113    }
114}
115
116fn default_backend() -> String {
117    "sqlite".to_string()
118}
119
120fn default_db_path() -> String {
121    "memory/brain.db".to_string()
122}
123
124fn default_markdown_dir() -> String {
125    "memory".to_string()
126}
127
128impl MemoryConfig {
129    /// Load configuration from file
130    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
131        let content = std::fs::read_to_string(path)?;
132        let config: MemoryConfig = serde_yaml::from_str(&content)?;
133        Ok(config)
134    }
135}
136
137/// Load the default memory configuration using unified config resolution.
138///
139/// Resolution order:
140/// 1. `ENACT_MEMORY_CONFIG_PATH` environment variable
141/// 2. `./memory.yaml` in current working directory
142/// 3. `~/.enact/memory.yaml`
143/// 4. Hardcoded defaults
144///
145/// # Example
146///
147/// ```rust,no_run
148/// use enact_memory::config::load_default_memory_config;
149///
150/// let config = load_default_memory_config();
151/// println!("Backend: {}", config.backend);
152/// ```
153pub fn load_default_memory_config() -> MemoryConfig {
154    // Use unified config resolution from enact-config
155    if let Some(path) = enact_config::resolve_config_file("memory.yaml", "ENACT_MEMORY_CONFIG_PATH")
156    {
157        match MemoryConfig::from_file(&path) {
158            Ok(config) => {
159                tracing::debug!("Loaded memory config from {:?}", path);
160                return config;
161            }
162            Err(e) => {
163                tracing::warn!("Failed to load memory config from {:?}: {}", path, e);
164            }
165        }
166    }
167
168    // Fall back to hardcoded defaults
169    tracing::debug!("Using default memory configuration");
170    MemoryConfig::default()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_default_memory_config() {
179        let config = MemoryConfig::default();
180        assert_eq!(config.backend, "sqlite");
181        assert_eq!(config.db_path, "memory/brain.db");
182        assert_eq!(config.markdown_dir, "memory");
183    }
184
185    #[test]
186    fn test_default_episodic_config() {
187        let config = EpisodicConfig::default();
188        assert_eq!(config.snapshot_dir, "memory/sessions");
189        assert_eq!(config.max_snapshots, 50);
190        assert_eq!(config.max_daily_entries, 100);
191        assert!(config.auto_consolidate);
192        assert_eq!(config.consolidation_time, "03:00");
193        assert_eq!(config.retention_days, 30);
194        assert!(config.include_timestamps);
195    }
196
197    #[test]
198    fn test_config_serialization() {
199        let config = MemoryConfig::default();
200        let yaml = serde_yaml::to_string(&config).unwrap();
201        let deserialized: MemoryConfig = serde_yaml::from_str(&yaml).unwrap();
202        assert_eq!(deserialized.backend, config.backend);
203        assert_eq!(deserialized.db_path, config.db_path);
204    }
205
206    #[test]
207    fn test_load_default_memory_config_fallback() {
208        // When no config file exists, should return defaults
209        let config = load_default_memory_config();
210        assert_eq!(config.backend, "sqlite");
211    }
212
213    #[test]
214    fn test_from_file() {
215        let temp_dir = tempfile::tempdir().unwrap();
216        let config_path = temp_dir.path().join("memory.yaml");
217
218        std::fs::write(
219            &config_path,
220            r#"
221backend: markdown
222db_path: "custom/db.sqlite"
223markdown_dir: "custom/memory"
224episodic:
225  snapshot_dir: "custom/sessions"
226  max_snapshots: 100
227  max_daily_entries: 200
228  auto_consolidate: false
229  consolidation_time: "02:00"
230  retention_days: 60
231  include_timestamps: false
232"#,
233        )
234        .unwrap();
235
236        let config = MemoryConfig::from_file(&config_path).unwrap();
237        assert_eq!(config.backend, "markdown");
238        assert_eq!(config.db_path, "custom/db.sqlite");
239        assert_eq!(config.markdown_dir, "custom/memory");
240        assert_eq!(config.episodic.snapshot_dir, "custom/sessions");
241        assert_eq!(config.episodic.max_snapshots, 100);
242        assert_eq!(config.episodic.max_daily_entries, 200);
243        assert!(!config.episodic.auto_consolidate);
244        assert_eq!(config.episodic.consolidation_time, "02:00");
245        assert_eq!(config.episodic.retention_days, 60);
246        assert!(!config.episodic.include_timestamps);
247    }
248}