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        aegis_types::paths::config_dir()
33            .join("wal")
34    }
35
36    /// Create a WAL using the default path (`~/.aegis/wal`).
37    pub fn with_default_path() -> Self {
38        Self::new(&Self::default_path())
39    }
40
41    /// Append a WAL entry (creates directories and file if needed).
42    pub async fn append(&self, entry: &WalEntry) -> Result<()> {
43        if let Some(parent) = self.path.parent() {
44            tokio::fs::create_dir_all(parent).await?;
45        }
46        let mut line = serde_json::to_string(entry)?;
47        line.push('\n');
48        let mut file = tokio::fs::OpenOptions::new()
49            .create(true)
50            .append(true)
51            .open(&self.path)
52            .await?;
53        file.write_all(line.as_bytes()).await?;
54        file.flush().await?;
55        Ok(())
56    }
57
58    /// Read all WAL entries.
59    pub async fn read_all(&self) -> Result<Vec<WalEntry>> {
60        if !self.path.exists() {
61            return Ok(Vec::new());
62        }
63        let content = tokio::fs::read_to_string(&self.path).await?;
64        let entries: Vec<WalEntry> = content
65            .lines()
66            .filter(|l| !l.is_empty())
67            .filter_map(|l| serde_json::from_str(l).ok())
68            .collect();
69        Ok(entries)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use tempfile::TempDir;
77
78    fn make_wal() -> (WriteAheadLog, TempDir) {
79        let dir = tempfile::tempdir().unwrap();
80        let wal = WriteAheadLog::new(dir.path());
81        (wal, dir)
82    }
83
84    #[tokio::test]
85    async fn wal_append_and_read() {
86        let (wal, _dir) = make_wal();
87        let entry = WalEntry {
88            timestamp: chrono::Utc::now(),
89            operation: "write".into(),
90            path: "/test/file.txt".into(),
91            size: 42,
92            session_id: Some("s1".into()),
93        };
94        wal.append(&entry).await.unwrap();
95
96        let entries = wal.read_all().await.unwrap();
97        assert_eq!(entries.len(), 1);
98        assert_eq!(entries[0].operation, "write");
99        assert_eq!(entries[0].path, "/test/file.txt");
100        assert_eq!(entries[0].size, 42);
101    }
102
103    #[tokio::test]
104    async fn wal_multiple_appends() {
105        let (wal, _dir) = make_wal();
106        for i in 0..5 {
107            wal.append(&WalEntry {
108                timestamp: chrono::Utc::now(),
109                operation: "write".into(),
110                path: format!("/f{}", i),
111                size: i * 10,
112                session_id: None,
113            }).await.unwrap();
114        }
115        let entries = wal.read_all().await.unwrap();
116        assert_eq!(entries.len(), 5);
117    }
118
119    #[tokio::test]
120    async fn wal_read_empty() {
121        let (wal, _dir) = make_wal();
122        let entries = wal.read_all().await.unwrap();
123        assert!(entries.is_empty());
124    }
125
126    #[tokio::test]
127    async fn wal_entry_serialization_roundtrip() {
128        let entry = WalEntry {
129            timestamp: chrono::Utc::now(),
130            operation: "delete".into(),
131            path: "/tmp/test".into(),
132            size: 0,
133            session_id: None,
134        };
135        let json = serde_json::to_string(&entry).unwrap();
136        let deserialized: WalEntry = serde_json::from_str(&json).unwrap();
137        assert_eq!(deserialized.operation, "delete");
138        assert_eq!(deserialized.path, "/tmp/test");
139    }
140
141    #[test]
142    fn wal_default_path() {
143        let path = WriteAheadLog::default_path();
144        assert!(path.to_string_lossy().contains(".aegis"));
145    }
146}