1use anyhow::Result;
2use std::path::PathBuf;
3use tokio::io::AsyncWriteExt;
4
5#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct WalEntry {
10 pub timestamp: chrono::DateTime<chrono::Utc>,
11 pub operation: String, pub path: String,
13 pub size: u64,
14 pub session_id: Option<String>,
15}
16
17pub struct WriteAheadLog {
19 path: PathBuf,
20}
21
22impl WriteAheadLog {
23 pub fn new(base_dir: &std::path::Path) -> Self {
25 Self {
26 path: base_dir.join("write_log.jsonl"),
27 }
28 }
29
30 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 pub fn with_default_path() -> Self {
40 Self::new(&Self::default_path())
41 }
42
43 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 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}