Skip to main content

aegis_memory/
wal.rs

1use anyhow::Result;
2use std::path::PathBuf;
3use tokio::io::AsyncWriteExt;
4
5/// Write-Ahead Log for audit + crash recovery.
6/// Appends JSONL entries to ~/.aegis/wal/write_log.jsonl
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct WalEntry {
10    pub timestamp: chrono::DateTime<chrono::Utc>,
11    pub operation: String, // "write", "delete", "rename"
12    pub path: String,
13    pub size: u64,
14    pub session_id: Option<String>,
15}
16
17/// Write-ahead log backed by a JSONL file for audit and crash recovery.
18pub struct WriteAheadLog {
19    path: PathBuf,
20}
21
22impl WriteAheadLog {
23    /// Create a new WAL that writes to `write_log.jsonl` inside `base_dir`.
24    pub fn new(base_dir: &std::path::Path) -> Self {
25        Self {
26            path: base_dir.join("write_log.jsonl"),
27        }
28    }
29
30    /// Return the default WAL directory (`~/.aegis/wal`).
31    pub fn default_path() -> PathBuf {
32        dirs_next::home_dir()
33            .unwrap_or_else(|| PathBuf::from("/tmp"))
34            .join(".aegis")
35            .join("wal")
36    }
37
38    /// Create a WAL using the default path (`~/.aegis/wal`).
39    pub fn with_default_path() -> Self {
40        Self::new(&Self::default_path())
41    }
42
43    /// Append a WAL entry (creates directories and file if needed).
44    pub async fn append(&self, entry: &WalEntry) -> Result<()> {
45        if let Some(parent) = self.path.parent() {
46            tokio::fs::create_dir_all(parent).await?;
47        }
48        let mut line = serde_json::to_string(entry)?;
49        line.push('\n');
50        let mut file = tokio::fs::OpenOptions::new()
51            .create(true)
52            .append(true)
53            .open(&self.path)
54            .await?;
55        file.write_all(line.as_bytes()).await?;
56        file.flush().await?;
57        Ok(())
58    }
59
60    /// Read all WAL entries.
61    pub async fn read_all(&self) -> Result<Vec<WalEntry>> {
62        if !self.path.exists() {
63            return Ok(Vec::new());
64        }
65        let content = tokio::fs::read_to_string(&self.path).await?;
66        let entries: Vec<WalEntry> = content
67            .lines()
68            .filter(|l| !l.is_empty())
69            .filter_map(|l| serde_json::from_str(l).ok())
70            .collect();
71        Ok(entries)
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use tempfile::TempDir;
79
80    fn make_wal() -> (WriteAheadLog, TempDir) {
81        let dir = tempfile::tempdir().unwrap();
82        let wal = WriteAheadLog::new(dir.path());
83        (wal, dir)
84    }
85
86    #[tokio::test]
87    async fn wal_append_and_read() {
88        let (wal, _dir) = make_wal();
89        let entry = WalEntry {
90            timestamp: chrono::Utc::now(),
91            operation: "write".into(),
92            path: "/test/file.txt".into(),
93            size: 42,
94            session_id: Some("s1".into()),
95        };
96        wal.append(&entry).await.unwrap();
97
98        let entries = wal.read_all().await.unwrap();
99        assert_eq!(entries.len(), 1);
100        assert_eq!(entries[0].operation, "write");
101        assert_eq!(entries[0].path, "/test/file.txt");
102        assert_eq!(entries[0].size, 42);
103    }
104
105    #[tokio::test]
106    async fn wal_multiple_appends() {
107        let (wal, _dir) = make_wal();
108        for i in 0..5 {
109            wal.append(&WalEntry {
110                timestamp: chrono::Utc::now(),
111                operation: "write".into(),
112                path: format!("/f{}", i),
113                size: i * 10,
114                session_id: None,
115            }).await.unwrap();
116        }
117        let entries = wal.read_all().await.unwrap();
118        assert_eq!(entries.len(), 5);
119    }
120
121    #[tokio::test]
122    async fn wal_read_empty() {
123        let (wal, _dir) = make_wal();
124        let entries = wal.read_all().await.unwrap();
125        assert!(entries.is_empty());
126    }
127
128    #[tokio::test]
129    async fn wal_entry_serialization_roundtrip() {
130        let entry = WalEntry {
131            timestamp: chrono::Utc::now(),
132            operation: "delete".into(),
133            path: "/tmp/test".into(),
134            size: 0,
135            session_id: None,
136        };
137        let json = serde_json::to_string(&entry).unwrap();
138        let deserialized: WalEntry = serde_json::from_str(&json).unwrap();
139        assert_eq!(deserialized.operation, "delete");
140        assert_eq!(deserialized.path, "/tmp/test");
141    }
142
143    #[test]
144    fn wal_default_path() {
145        let path = WriteAheadLog::default_path();
146        assert!(path.to_string_lossy().contains(".aegis"));
147    }
148}