freneng/audit.rs
1//! Audit logging for rename operations.
2//!
3//! This module provides immutable audit logging for all rename operations,
4//! suitable for server administration and compliance requirements.
5//! All operations are async and non-blocking.
6
7use serde::{Serialize, Deserialize};
8use tokio::fs;
9use tokio::io::AsyncWriteExt;
10use std::path::PathBuf;
11use chrono::{DateTime, Local};
12use crate::fs_ops::RenameExecutionResult;
13use crate::history::RenameAction;
14
15/// Represents a single audit log entry.
16#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct AuditEntry {
18 /// Timestamp when the operation occurred
19 pub timestamp: DateTime<Local>,
20 /// User who performed the operation (if available)
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub user: Option<String>,
23 /// Working directory where the operation was performed
24 pub working_directory: PathBuf,
25 /// Command that was executed
26 pub command: String,
27 /// Pattern used for transformation
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub pattern: Option<String>,
30 /// Number of files successfully renamed
31 pub successful_count: usize,
32 /// Number of files skipped
33 pub skipped_count: usize,
34 /// Number of files that had errors
35 pub error_count: usize,
36 /// List of successful renames (old_path -> new_path)
37 pub successful: Vec<(PathBuf, PathBuf)>,
38 /// List of skipped files with reasons
39 #[serde(skip_serializing_if = "Vec::is_empty", default)]
40 pub skipped: Vec<(PathBuf, String)>,
41 /// List of errors with details
42 #[serde(skip_serializing_if = "Vec::is_empty", default)]
43 pub errors: Vec<(PathBuf, String)>,
44}
45
46const AUDIT_FILE: &str = ".fren_audit.log";
47
48/// Gets the current user name if available.
49fn get_current_user() -> Option<String> {
50 #[cfg(unix)]
51 {
52 std::env::var("USER").ok()
53 .or_else(|| std::env::var("USERNAME").ok())
54 }
55 #[cfg(windows)]
56 {
57 std::env::var("USERNAME").ok()
58 }
59 #[cfg(not(any(unix, windows)))]
60 {
61 None
62 }
63}
64
65/// Logs a rename operation to the audit log asynchronously.
66///
67/// The audit log is append-only and uses JSON Lines format (one JSON object per line).
68/// This makes it easy to parse and query while maintaining an immutable audit trail.
69///
70/// # Arguments
71///
72/// * `command` - The full command that was executed
73/// * `pattern` - The rename pattern used (if any)
74/// * `working_directory` - The directory where the operation was performed
75/// * `successful` - List of successful renames (old_path, new_path)
76/// * `skipped` - List of skipped files with reasons
77/// * `errors` - List of errors with details
78///
79/// # Returns
80///
81/// * `Ok(())` - Audit entry logged successfully
82/// * `Err(Box<dyn Error>)` - If file I/O fails
83///
84/// # Examples
85///
86/// ```
87/// # tokio_test::block_on(async {
88/// use freneng::audit::log_audit_entry;
89/// use std::path::PathBuf;
90///
91/// let successful = vec![
92/// (PathBuf::from("old.txt"), PathBuf::from("new.txt"))
93/// ];
94/// log_audit_entry(
95/// "fren list *.txt transform \"%N_backup.%E\" rename --yes",
96/// Some("%N_backup.%E".to_string()),
97/// PathBuf::from("."),
98/// successful,
99/// vec![],
100/// vec![],
101/// ).await.unwrap();
102/// # })
103/// ```
104pub async fn log_audit_entry(
105 command: &str,
106 pattern: Option<String>,
107 working_directory: PathBuf,
108 successful: Vec<(PathBuf, PathBuf)>,
109 skipped: Vec<(PathBuf, String)>,
110 errors: Vec<(PathBuf, String)>,
111) -> Result<(), Box<dyn std::error::Error>> {
112 // Append to audit log file (JSON Lines format) in the working directory
113 // Ensure the working directory exists (create_dir_all succeeds if it already exists)
114 // Note: create_dir_all on empty path succeeds without creating anything
115 if working_directory.as_os_str().is_empty() {
116 return Err("Working directory cannot be empty".into());
117 }
118 fs::create_dir_all(&working_directory).await?;
119 let audit_path = working_directory.join(AUDIT_FILE);
120
121 // Debug: Log path information
122 eprintln!("DEBUG: working_directory = {:?}", working_directory);
123 eprintln!("DEBUG: audit_path = {:?}", audit_path);
124 eprintln!("DEBUG: audit_path exists before write = {}", audit_path.exists());
125 eprintln!("DEBUG: working_directory exists = {}", working_directory.exists());
126 eprintln!("DEBUG: working_directory is_dir = {}", working_directory.is_dir());
127
128 let entry = AuditEntry {
129 timestamp: Local::now(),
130 user: get_current_user(),
131 working_directory: working_directory.clone(),
132 command: command.to_string(),
133 pattern,
134 successful_count: successful.len(),
135 skipped_count: skipped.len(),
136 error_count: errors.len(),
137 successful,
138 skipped,
139 errors,
140 };
141
142 // Serialize to JSON (compact, one line)
143 let json = serde_json::to_string(&entry)?;
144
145 // Open file with create and append
146 let mut file = fs::OpenOptions::new()
147 .create(true)
148 .append(true)
149 .open(&audit_path)
150 .await
151 .map_err(|e| format!("Failed to open audit file at {:?}: {}", audit_path, e))?;
152
153 // Write JSON line with newline
154 file.write_all(json.as_bytes()).await
155 .map_err(|e| format!("Failed to write to audit file: {}", e))?;
156 file.write_all(b"\n").await
157 .map_err(|e| format!("Failed to write newline: {}", e))?;
158 file.sync_all().await
159 .map_err(|e| format!("Failed to sync audit file: {}", e))?;
160
161 // Drop the file handle to ensure it's closed
162 drop(file);
163
164 // Debug: Check file status after write
165 eprintln!("DEBUG: audit_path exists after write = {}", audit_path.exists());
166 let metadata_result = fs::metadata(&audit_path).await;
167 eprintln!("DEBUG: fs::metadata result = {:?}", metadata_result);
168
169 // Verify file was created (use async metadata check)
170 if metadata_result.is_err() {
171 return Err(format!("Audit file was not created at {:?} (after sync delay). Metadata error: {:?}", audit_path, metadata_result.err()).into());
172 }
173
174 Ok(())
175}
176
177/// Reads audit log entries from the audit log file asynchronously.
178///
179/// Reads from `.fren_audit.log` in the current working directory.
180///
181/// # Returns
182///
183/// * `Ok(Vec<AuditEntry>)` - List of audit entries (most recent first)
184/// * `Err(Box<dyn Error>)` - If file I/O or parsing fails
185///
186/// # Examples
187///
188/// ```
189/// # tokio_test::block_on(async {
190/// use freneng::audit::read_audit_log;
191///
192/// let entries = read_audit_log().await.unwrap();
193/// println!("Found {} audit entries", entries.len());
194/// # })
195/// ```
196pub async fn read_audit_log() -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
197 // Read from current directory (for CLI compatibility)
198 let audit_path = std::env::current_dir()?.join(AUDIT_FILE);
199 if fs::metadata(&audit_path).await.is_err() {
200 return Ok(Vec::new());
201 }
202
203 let content = fs::read_to_string(&audit_path).await?;
204 let mut entries = Vec::new();
205
206 // Parse JSON Lines format (one JSON object per line)
207 for line in content.lines() {
208 if line.trim().is_empty() {
209 continue;
210 }
211 let entry: AuditEntry = serde_json::from_str(line)?;
212 entries.push(entry);
213 }
214
215 // Return most recent first
216 entries.reverse();
217 Ok(entries)
218}
219
220/// Logs a rename operation to the audit log from a `RenameExecutionResult`.
221///
222/// This is a convenience function that converts a `RenameExecutionResult` into
223/// the format needed for audit logging. It's the recommended way to log audit
224/// entries after performing rename operations.
225///
226/// # Arguments
227///
228/// * `command` - The full command that was executed
229/// * `pattern` - The rename pattern used (if any)
230/// * `working_directory` - The directory where the operation was performed
231/// * `result` - The result from `perform_renames()`
232///
233/// # Returns
234///
235/// * `Ok(())` - Audit entry logged successfully
236/// * `Err(Box<dyn Error>)` - If file I/O fails
237///
238/// # Examples
239///
240/// ```
241/// # tokio_test::block_on(async {
242/// use freneng::audit::log_audit_from_result;
243/// use freneng::perform_renames;
244/// use freneng::FileRename;
245/// use std::path::PathBuf;
246///
247/// let renames = vec![FileRename {
248/// old_path: PathBuf::from("old.txt"),
249/// new_path: PathBuf::from("new.txt"),
250/// new_name: "new.txt".to_string(),
251/// }];
252///
253/// let result = perform_renames(&renames, false).await.unwrap();
254/// log_audit_from_result(
255/// "fren list *.txt transform \"%N_backup.%E\" rename --yes",
256/// Some("%N_backup.%E".to_string()),
257/// PathBuf::from("."),
258/// &result,
259/// ).await.unwrap();
260/// # })
261/// ```
262pub async fn log_audit_from_result(
263 command: &str,
264 pattern: Option<String>,
265 working_directory: PathBuf,
266 result: &RenameExecutionResult,
267) -> Result<(), Box<dyn std::error::Error>> {
268 // Convert RenameAction to (PathBuf, PathBuf) tuples
269 let successful: Vec<(PathBuf, PathBuf)> = result.successful.iter()
270 .map(|action: &RenameAction| (action.old_path.clone(), action.new_path.clone()))
271 .collect();
272
273 log_audit_entry(
274 command,
275 pattern,
276 working_directory,
277 successful,
278 result.skipped.clone(),
279 result.errors.clone(),
280 ).await
281}
282
283/// Clears the audit log by deleting the audit file asynchronously.
284///
285/// Deletes `.fren_audit.log` from the current working directory.
286///
287/// **Warning**: This should be used with caution as it removes the audit trail.
288/// Consider archiving the log instead of deleting it.
289///
290/// # Returns
291///
292/// * `Ok(())` - Audit log cleared (or didn't exist)
293/// * `Err(Box<dyn Error>)` - If file deletion fails
294pub async fn clear_audit_log() -> Result<(), Box<dyn std::error::Error>> {
295 let audit_path = std::env::current_dir()?.join(AUDIT_FILE);
296 if fs::metadata(&audit_path).await.is_ok() {
297 fs::remove_file(&audit_path).await?;
298 }
299 Ok(())
300}
301