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