1use crate::error::FileError;
4use crate::models::AuditEntry;
5use std::fs;
6use std::path::{Path, PathBuf};
7use tracing::{debug, error, info};
8
9#[derive(Debug)]
14pub struct AuditLogger {
15 audit_dir: PathBuf,
17}
18
19impl AuditLogger {
20 pub fn new(audit_dir: PathBuf) -> Self {
30 AuditLogger { audit_dir }
31 }
32
33 pub fn with_default_dir() -> Self {
37 let audit_dir = PathBuf::from(".ricecoder/audit");
38 AuditLogger { audit_dir }
39 }
40
41 pub fn log_operation(&self, entry: AuditEntry) -> Result<(), FileError> {
54 fs::create_dir_all(&self.audit_dir).map_err(|e| {
56 error!("Failed to create audit directory: {}", e);
57 FileError::IoError(e)
58 })?;
59
60 let filename = self.generate_audit_filename(&entry);
62 let filepath = self.audit_dir.join(&filename);
63
64 let json = serde_json::to_string_pretty(&entry).map_err(|e| {
66 error!("Failed to serialize audit entry: {}", e);
67 FileError::InvalidContent(format!("Failed to serialize audit entry: {}", e))
68 })?;
69
70 fs::write(&filepath, json).map_err(|e| {
72 error!(
73 "Failed to write audit entry to {}: {}",
74 filepath.display(),
75 e
76 );
77 FileError::IoError(e)
78 })?;
79
80 debug!(
81 "Logged audit entry for {:?} at {}",
82 entry.path,
83 filepath.display()
84 );
85 Ok(())
86 }
87
88 pub fn get_change_history(&self, path: &Path) -> Result<Vec<AuditEntry>, FileError> {
100 if !self.audit_dir.exists() {
102 debug!("Audit directory does not exist, returning empty history");
103 return Ok(Vec::new());
104 }
105
106 let mut entries = Vec::new();
107
108 let entries_iter = fs::read_dir(&self.audit_dir).map_err(|e| {
110 error!("Failed to read audit directory: {}", e);
111 FileError::IoError(e)
112 })?;
113
114 for entry_result in entries_iter {
115 let entry = entry_result.map_err(|e| {
116 error!("Failed to read audit entry: {}", e);
117 FileError::IoError(e)
118 })?;
119
120 let file_path = entry.path();
121
122 if file_path.is_dir() {
124 continue;
125 }
126
127 let content = fs::read_to_string(&file_path).map_err(|e| {
129 error!("Failed to read audit file {}: {}", file_path.display(), e);
130 FileError::IoError(e)
131 })?;
132
133 match serde_json::from_str::<AuditEntry>(&content) {
134 Ok(audit_entry) => {
135 if audit_entry.path == path {
137 entries.push(audit_entry);
138 }
139 }
140 Err(e) => {
141 error!(
142 "Failed to parse audit entry from {}: {}",
143 file_path.display(),
144 e
145 );
146 }
148 }
149 }
150
151 entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
153
154 info!("Retrieved {} audit entries for {:?}", entries.len(), path);
155 Ok(entries)
156 }
157
158 pub fn get_all_entries(&self) -> Result<Vec<AuditEntry>, FileError> {
166 if !self.audit_dir.exists() {
168 debug!("Audit directory does not exist, returning empty entries");
169 return Ok(Vec::new());
170 }
171
172 let mut entries = Vec::new();
173
174 let entries_iter = fs::read_dir(&self.audit_dir).map_err(|e| {
176 error!("Failed to read audit directory: {}", e);
177 FileError::IoError(e)
178 })?;
179
180 for entry_result in entries_iter {
181 let entry = entry_result.map_err(|e| {
182 error!("Failed to read audit entry: {}", e);
183 FileError::IoError(e)
184 })?;
185
186 let file_path = entry.path();
187
188 if file_path.is_dir() {
190 continue;
191 }
192
193 let content = fs::read_to_string(&file_path).map_err(|e| {
195 error!("Failed to read audit file {}: {}", file_path.display(), e);
196 FileError::IoError(e)
197 })?;
198
199 match serde_json::from_str::<AuditEntry>(&content) {
200 Ok(audit_entry) => {
201 entries.push(audit_entry);
202 }
203 Err(e) => {
204 error!(
205 "Failed to parse audit entry from {}: {}",
206 file_path.display(),
207 e
208 );
209 }
211 }
212 }
213
214 entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
216
217 info!("Retrieved {} total audit entries", entries.len());
218 Ok(entries)
219 }
220
221 fn generate_audit_filename(&self, entry: &AuditEntry) -> String {
225 let timestamp = entry.timestamp.format("%Y%m%d_%H%M%S_%3f");
227 let path_hash = format!("{:x}", fxhash::hash64(&entry.path.to_string_lossy()));
228 format!("audit_{}_{}.json", timestamp, path_hash)
229 }
230}
231
232impl Default for AuditLogger {
233 fn default() -> Self {
234 Self::with_default_dir()
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::models::OperationType;
242 use chrono::Utc;
243 use tempfile::TempDir;
244
245 #[test]
246 fn test_audit_logger_creation() {
247 let temp_dir = TempDir::new().unwrap();
248 let logger = AuditLogger::new(temp_dir.path().to_path_buf());
249 assert_eq!(logger.audit_dir, temp_dir.path());
250 }
251
252 #[test]
253 fn test_log_operation_creates_directory() {
254 let temp_dir = TempDir::new().unwrap();
255 let audit_dir = temp_dir.path().join("audit");
256 let logger = AuditLogger::new(audit_dir.clone());
257
258 let entry = AuditEntry {
259 timestamp: Utc::now(),
260 path: PathBuf::from("test.txt"),
261 operation_type: OperationType::Create,
262 content_hash: "abc123".to_string(),
263 transaction_id: None,
264 };
265
266 logger.log_operation(entry).unwrap();
267 assert!(audit_dir.exists());
268 }
269
270 #[test]
271 fn test_log_operation_writes_json() {
272 let temp_dir = TempDir::new().unwrap();
273 let logger = AuditLogger::new(temp_dir.path().to_path_buf());
274
275 let entry = AuditEntry {
276 timestamp: Utc::now(),
277 path: PathBuf::from("test.txt"),
278 operation_type: OperationType::Create,
279 content_hash: "abc123".to_string(),
280 transaction_id: None,
281 };
282
283 logger.log_operation(entry.clone()).unwrap();
284
285 let files: Vec<_> = fs::read_dir(temp_dir.path())
287 .unwrap()
288 .filter_map(|e| e.ok())
289 .collect();
290 assert!(!files.is_empty());
291 }
292
293 #[test]
294 fn test_get_change_history_empty_directory() {
295 let temp_dir = TempDir::new().unwrap();
296 let logger = AuditLogger::new(temp_dir.path().to_path_buf());
297
298 let history = logger.get_change_history(Path::new("test.txt")).unwrap();
299 assert!(history.is_empty());
300 }
301
302 #[test]
303 fn test_get_change_history_filters_by_path() {
304 let temp_dir = TempDir::new().unwrap();
305 let logger = AuditLogger::new(temp_dir.path().to_path_buf());
306
307 let entry1 = AuditEntry {
308 timestamp: Utc::now(),
309 path: PathBuf::from("file1.txt"),
310 operation_type: OperationType::Create,
311 content_hash: "hash1".to_string(),
312 transaction_id: None,
313 };
314
315 let entry2 = AuditEntry {
316 timestamp: Utc::now(),
317 path: PathBuf::from("file2.txt"),
318 operation_type: OperationType::Update,
319 content_hash: "hash2".to_string(),
320 transaction_id: None,
321 };
322
323 logger.log_operation(entry1).unwrap();
324 logger.log_operation(entry2).unwrap();
325
326 let history = logger.get_change_history(Path::new("file1.txt")).unwrap();
327 assert_eq!(history.len(), 1);
328 assert_eq!(history[0].path, PathBuf::from("file1.txt"));
329 }
330
331 #[test]
332 fn test_get_change_history_ordered_by_timestamp() {
333 let temp_dir = TempDir::new().unwrap();
334 let logger = AuditLogger::new(temp_dir.path().to_path_buf());
335
336 let now = Utc::now();
337 let entry1 = AuditEntry {
338 timestamp: now,
339 path: PathBuf::from("test.txt"),
340 operation_type: OperationType::Create,
341 content_hash: "hash1".to_string(),
342 transaction_id: None,
343 };
344
345 let entry2 = AuditEntry {
346 timestamp: now + chrono::Duration::seconds(1),
347 path: PathBuf::from("test.txt"),
348 operation_type: OperationType::Update,
349 content_hash: "hash2".to_string(),
350 transaction_id: None,
351 };
352
353 logger.log_operation(entry1).unwrap();
354 logger.log_operation(entry2).unwrap();
355
356 let history = logger.get_change_history(Path::new("test.txt")).unwrap();
357 assert_eq!(history.len(), 2);
358 assert!(history[0].timestamp <= history[1].timestamp);
359 }
360
361 #[test]
362 fn test_get_all_entries() {
363 let temp_dir = TempDir::new().unwrap();
364 let logger = AuditLogger::new(temp_dir.path().to_path_buf());
365
366 let entry1 = AuditEntry {
367 timestamp: Utc::now(),
368 path: PathBuf::from("file1.txt"),
369 operation_type: OperationType::Create,
370 content_hash: "hash1".to_string(),
371 transaction_id: None,
372 };
373
374 let entry2 = AuditEntry {
375 timestamp: Utc::now(),
376 path: PathBuf::from("file2.txt"),
377 operation_type: OperationType::Update,
378 content_hash: "hash2".to_string(),
379 transaction_id: None,
380 };
381
382 logger.log_operation(entry1).unwrap();
383 logger.log_operation(entry2).unwrap();
384
385 let all_entries = logger.get_all_entries().unwrap();
386 assert_eq!(all_entries.len(), 2);
387 }
388}