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 aegis_types::paths::config_dir()
33 .join("wal")
34 }
35
36 pub fn with_default_path() -> Self {
38 Self::new(&Self::default_path())
39 }
40
41 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 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}