Skip to main content

matrixcode_core/memory/
storage.rs

1//! Memory storage with file locking.
2
3use anyhow::Result;
4use chrono::Utc;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::constants::MATRIX_DIR;
9use super::config::MemoryConfig;
10use super::entry::MemoryEntry;
11use super::manager::AutoMemory;
12
13// ============================================================================
14// File Lock
15// ============================================================================
16
17/// File lock for preventing concurrent access to memory storage.
18pub struct MemoryFileLock {
19    /// Path to the lock file.
20    lock_path: PathBuf,
21    /// Whether we currently hold the lock.
22    locked: bool,
23}
24
25impl MemoryFileLock {
26    /// Create a new file lock for the given directory.
27    pub fn new(base_dir: &Path) -> Self {
28        Self {
29            lock_path: base_dir.join("memory.lock"),
30            locked: false,
31        }
32    }
33
34    /// Acquire the lock (blocking with timeout).
35    /// Returns Ok(true) if lock acquired, Err if timeout.
36    pub fn acquire(&mut self, timeout_ms: u64) -> Result<()> {
37        if self.locked {
38            return Ok(());
39        }
40
41        let start = std::time::Instant::now();
42
43        while start.elapsed().as_millis() < timeout_ms as u128 {
44            match fs::File::create_new(&self.lock_path) {
45                Ok(_) => {
46                    let lock_info = format!("{}:{}", std::process::id(), Utc::now().to_rfc3339());
47                    fs::write(&self.lock_path, lock_info)?;
48                    self.locked = true;
49                    return Ok(());
50                }
51                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
52                    if self.is_stale_lock()? {
53                        self.remove_stale_lock()?;
54                    }
55                    std::thread::sleep(std::time::Duration::from_millis(50));
56                }
57                Err(e) => {
58                    return Err(e.into());
59                }
60            }
61        }
62
63        // Timeout - return error instead of Ok(false)
64        anyhow::bail!("Failed to acquire memory lock after {}ms timeout", timeout_ms)
65    }
66
67    /// Check if the existing lock is stale (either old or process is dead).
68    fn is_stale_lock(&self) -> Result<bool> {
69        if !self.lock_path.exists() {
70            return Ok(false);
71        }
72
73        // First check if the lock owner process is still running
74        if let Ok(content) = fs::read_to_string(&self.lock_path)
75            && let Some(pid_str) = content.split(':').next()
76            && let Ok(pid) = pid_str.parse::<u32>()
77            && !self.is_process_running(pid)
78        {
79            // Process is dead, lock is stale
80            return Ok(true);
81        }
82
83        // Then check lock age as fallback
84        let metadata = fs::metadata(&self.lock_path)?;
85        let modified = metadata.modified()?;
86        let age = std::time::SystemTime::now()
87            .duration_since(modified)
88            .unwrap_or(std::time::Duration::ZERO);
89
90        Ok(age > std::time::Duration::from_secs(60))
91    }
92
93    /// Check if a process with the given PID is still running.
94    fn is_process_running(&self, pid: u32) -> bool {
95        #[cfg(unix)]
96        {
97            // On Unix, check if process exists by checking /proc
98            if std::path::Path::new("/proc").exists() {
99                std::path::Path::new(&format!("/proc/{}", pid)).exists()
100            } else {
101                // Fallback: assume process is running if we can't check
102                true
103            }
104        }
105        #[cfg(windows)]
106        {
107            // On Windows, use tasklist command to check if process exists
108            use std::process::Command;
109            let output = Command::new("tasklist")
110                .args(["/FI", &format!("PID eq {}", pid), "/NH"])
111                .output();
112
113            match output {
114                Ok(out) => {
115                    let stdout = String::from_utf8_lossy(&out.stdout);
116                    // tasklist returns "INFO: No tasks are running..." if process not found
117                    // or returns a line with the PID if running
118                    stdout.contains(&pid.to_string()) && !stdout.contains("No tasks")
119                }
120                Err(_) => {
121                    // If tasklist fails, assume process might be running (safer)
122                    true
123                }
124            }
125        }
126        #[cfg(not(any(unix, windows)))]
127        {
128            let _ = pid;
129            true
130        }
131    }
132
133    /// Remove stale lock file with atomic retry.
134    fn remove_stale_lock(&self) -> Result<()> {
135        // Use atomic rename to avoid race condition
136        // Create a temp deletion marker and rename it over the lock
137        let temp_path = self.lock_path.with_extension("lock.del");
138        if self.lock_path.exists() {
139            // Try atomic rename first
140            if fs::rename(&self.lock_path, &temp_path).is_ok() {
141                fs::remove_file(&temp_path)?;
142            } else {
143                // Fallback to direct removal if rename fails
144                fs::remove_file(&self.lock_path)?;
145            }
146        }
147        Ok(())
148    }
149
150    /// Release the lock.
151    pub fn release(&mut self) -> Result<()> {
152        if self.locked {
153            fs::remove_file(&self.lock_path)?;
154            self.locked = false;
155        }
156        Ok(())
157    }
158}
159
160impl Drop for MemoryFileLock {
161    fn drop(&mut self) {
162        let _ = self.release();
163    }
164}
165
166// ============================================================================
167// Memory Storage
168// ============================================================================
169
170/// Storage for memory files (global and project-level) with file locking.
171pub struct MemoryStorage {
172    /// Base directory for global memory (~/.matrix).
173    base_dir: PathBuf,
174    /// Project root directory (optional).
175    project_root: Option<PathBuf>,
176    /// File lock for preventing concurrent writes.
177    lock: MemoryFileLock,
178}
179
180impl MemoryStorage {
181    /// Create a new memory storage.
182    pub fn new(project_root: Option<&Path>) -> Result<Self> {
183        let base_dir = Self::get_base_dir()?;
184        let lock = MemoryFileLock::new(&base_dir);
185        Ok(Self {
186            base_dir,
187            project_root: project_root.map(|p| p.to_path_buf()),
188            lock,
189        })
190    }
191
192    /// Create a new storage with explicit lock timeout.
193    pub fn with_lock_timeout(project_root: Option<&Path>, timeout_ms: u64) -> Result<Self> {
194        let mut storage = Self::new(project_root)?;
195        storage.lock.acquire(timeout_ms)?;
196        Ok(storage)
197    }
198
199    /// Get the base directory for memory storage.
200    fn get_base_dir() -> Result<PathBuf> {
201        let home = std::env::var_os("HOME")
202            .or_else(|| std::env::var_os("USERPROFILE"))
203            .ok_or_else(|| anyhow::anyhow!("HOME or USERPROFILE not set"))?;
204        let mut p = PathBuf::from(home);
205        p.push(MATRIX_DIR);
206        Ok(p)
207    }
208
209    /// Path to global memory file.
210    pub fn global_memory_path(&self) -> PathBuf {
211        self.base_dir.join("memory.json")
212    }
213
214    /// Path to project memory file.
215    pub fn project_memory_path(&self) -> Option<PathBuf> {
216        self.project_root
217            .as_ref()
218            .map(|p| p.join(".matrix/memory.json"))
219    }
220
221    /// Path to config file.
222    pub fn config_path(&self) -> PathBuf {
223        self.base_dir.join("memory_config.json")
224    }
225
226    /// Ensure directories exist.
227    fn ensure_dirs(&self) -> Result<()> {
228        fs::create_dir_all(&self.base_dir)?;
229        if let Some(root) = &self.project_root {
230            let memory_dir = root.join(MATRIX_DIR);
231            fs::create_dir_all(memory_dir)?;
232        }
233        Ok(())
234    }
235
236    /// Acquire lock before write operations.
237    fn acquire_lock(&mut self) -> Result<()> {
238        self.lock.acquire(5000)?;
239        Ok(())
240    }
241
242    /// Release lock after write operations.
243    fn release_lock(&mut self) -> Result<()> {
244        self.lock.release()?;
245        Ok(())
246    }
247
248    /// Load global memory.
249    pub fn load_global(&self) -> Result<AutoMemory> {
250        let path = self.global_memory_path();
251        if !path.exists() {
252            return Ok(AutoMemory::new());
253        }
254        let data = fs::read_to_string(&path)?;
255        let memory: AutoMemory = serde_json::from_str(&data)?;
256        Ok(memory)
257    }
258
259    /// Load project memory.
260    pub fn load_project(&self) -> Result<Option<AutoMemory>> {
261        let path = self.project_memory_path();
262        match path {
263            Some(p) if p.exists() => {
264                let data = fs::read_to_string(&p)?;
265                let memory: AutoMemory = serde_json::from_str(&data)?;
266                Ok(Some(memory))
267            }
268            _ => Ok(None),
269        }
270    }
271
272    /// Load combined memory (global + project).
273    pub fn load_combined(&self) -> Result<AutoMemory> {
274        let mut combined = self.load_global()?;
275
276        if let Some(project) = self.load_project()? {
277            for entry in project.entries {
278                let mut tagged_entry = entry;
279                if !tagged_entry.tags.contains(&"project".to_string()) {
280                    tagged_entry.tags.push("project".to_string());
281                }
282                combined.entries.push(tagged_entry);
283            }
284            combined.prune();
285        }
286
287        Ok(combined)
288    }
289
290    /// Save global memory (with file lock).
291    pub fn save_global(&mut self, memory: &AutoMemory) -> Result<()> {
292        self.acquire_lock()?;
293        self.ensure_dirs()?;
294
295        let path = self.global_memory_path();
296        let json = serde_json::to_string_pretty(memory)?;
297
298        let tmp = path.with_extension("json.tmp");
299        fs::write(&tmp, json)?;
300        fs::rename(&tmp, &path)?;
301
302        self.release_lock()?;
303        Ok(())
304    }
305
306    /// Save project memory (with file lock).
307    pub fn save_project(&mut self, memory: &AutoMemory) -> Result<()> {
308        self.acquire_lock()?;
309        self.ensure_dirs()?;
310
311        let path = self
312            .project_memory_path()
313            .ok_or_else(|| anyhow::anyhow!("no project root"))?;
314        let json = serde_json::to_string_pretty(memory)?;
315
316        let tmp = path.with_extension("json.tmp");
317        fs::write(&tmp, json)?;
318        fs::rename(&tmp, &path)?;
319
320        self.release_lock()?;
321        Ok(())
322    }
323
324    /// Save config to separate file.
325    pub fn save_config(&mut self, config: &MemoryConfig) -> Result<()> {
326        self.ensure_dirs()?;
327        let path = self.config_path();
328        let json = serde_json::to_string_pretty(config)?;
329        fs::write(&path, json)?;
330        Ok(())
331    }
332
333    /// Load config from file.
334    pub fn load_config(&self) -> Result<MemoryConfig> {
335        let path = self.config_path();
336        if !path.exists() {
337            return Ok(MemoryConfig::default());
338        }
339        let data = fs::read_to_string(&path)?;
340        let config: MemoryConfig = serde_json::from_str(&data)?;
341        Ok(config)
342    }
343
344    /// Add entry to appropriate storage.
345    pub fn add_entry(&mut self, entry: MemoryEntry, is_project_specific: bool) -> Result<()> {
346        self.acquire_lock()?;
347
348        if is_project_specific {
349            let mut project = self.load_project()?.unwrap_or_else(AutoMemory::new);
350            project.add(entry);
351            self.save_project_locked(&project)?;
352        } else {
353            let mut global = self.load_global()?;
354            global.add(entry);
355            self.save_global_locked(&global)?;
356        }
357
358        self.release_lock()?;
359        Ok(())
360    }
361
362    /// Remove entry from storage by ID.
363    pub fn remove_entry(&mut self, id: &str, is_project_specific: bool) -> Result<bool> {
364        self.acquire_lock()?;
365
366        let removed = if is_project_specific {
367            if let Some(mut project) = self.load_project()? {
368                let removed = project.remove(id);
369                if removed {
370                    self.save_project_locked(&project)?;
371                }
372                removed
373            } else {
374                false
375            }
376        } else {
377            let mut global = self.load_global()?;
378            let removed = global.remove(id);
379            if removed {
380                self.save_global_locked(&global)?;
381            }
382            removed
383        };
384
385        self.release_lock()?;
386        Ok(removed)
387    }
388
389    /// Internal save methods (assumed already locked).
390    fn save_global_locked(&self, memory: &AutoMemory) -> Result<()> {
391        let path = self.global_memory_path();
392        let json = serde_json::to_string_pretty(memory)?;
393        let tmp = path.with_extension("json.tmp");
394        fs::write(&tmp, json)?;
395        fs::rename(&tmp, &path)?;
396        Ok(())
397    }
398
399    fn save_project_locked(&self, memory: &AutoMemory) -> Result<()> {
400        let path = self
401            .project_memory_path()
402            .ok_or_else(|| anyhow::anyhow!("no project root"))?;
403        let json = serde_json::to_string_pretty(memory)?;
404        let tmp = path.with_extension("json.tmp");
405        fs::write(&tmp, json)?;
406        fs::rename(&tmp, &path)?;
407        Ok(())
408    }
409}