freneng 0.1.2

A useful, async-first file renaming library
Documentation
//! Audit logging for rename operations.
//! 
//! This module provides immutable audit logging for all rename operations,
//! suitable for server administration and compliance requirements.
//! All operations are async and non-blocking.

use serde::{Serialize, Deserialize};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use std::path::PathBuf;
use chrono::{DateTime, Local};
use crate::fs_ops::RenameExecutionResult;
use crate::history::RenameAction;

/// Represents a single audit log entry.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AuditEntry {
    /// Timestamp when the operation occurred
    pub timestamp: DateTime<Local>,
    /// User who performed the operation (if available)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// Working directory where the operation was performed
    pub working_directory: PathBuf,
    /// Command that was executed
    pub command: String,
    /// Pattern used for transformation
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pattern: Option<String>,
    /// Number of files successfully renamed
    pub successful_count: usize,
    /// Number of files skipped
    pub skipped_count: usize,
    /// Number of files that had errors
    pub error_count: usize,
    /// List of successful renames (old_path -> new_path)
    pub successful: Vec<(PathBuf, PathBuf)>,
    /// List of skipped files with reasons
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub skipped: Vec<(PathBuf, String)>,
    /// List of errors with details
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub errors: Vec<(PathBuf, String)>,
}

const AUDIT_FILE: &str = ".fren_audit.log";

/// Gets the current user name if available.
fn get_current_user() -> Option<String> {
    #[cfg(unix)]
    {
        std::env::var("USER").ok()
            .or_else(|| std::env::var("USERNAME").ok())
    }
    #[cfg(windows)]
    {
        std::env::var("USERNAME").ok()
    }
    #[cfg(not(any(unix, windows)))]
    {
        None
    }
}

/// Logs a rename operation to the audit log asynchronously.
/// 
/// The audit log is append-only and uses JSON Lines format (one JSON object per line).
/// This makes it easy to parse and query while maintaining an immutable audit trail.
/// 
/// # Arguments
/// 
/// * `command` - The full command that was executed
/// * `pattern` - The rename pattern used (if any)
/// * `working_directory` - The directory where the operation was performed
/// * `successful` - List of successful renames (old_path, new_path)
/// * `skipped` - List of skipped files with reasons
/// * `errors` - List of errors with details
/// 
/// # Returns
/// 
/// * `Ok(())` - Audit entry logged successfully
/// * `Err(Box<dyn Error>)` - If file I/O fails
/// 
/// # Examples
/// 
/// ```
/// # tokio_test::block_on(async {
/// use freneng::audit::log_audit_entry;
/// use std::path::PathBuf;
/// 
/// let successful = vec![
///     (PathBuf::from("old.txt"), PathBuf::from("new.txt"))
/// ];
/// log_audit_entry(
///     "fren list *.txt transform \"%N_backup.%E\" rename --yes",
///     Some("%N_backup.%E".to_string()),
///     PathBuf::from("."),
///     successful,
///     vec![],
///     vec![],
/// ).await.unwrap();
/// # })
/// ```
pub async fn log_audit_entry(
    command: &str,
    pattern: Option<String>,
    working_directory: PathBuf,
    successful: Vec<(PathBuf, PathBuf)>,
    skipped: Vec<(PathBuf, String)>,
    errors: Vec<(PathBuf, String)>,
) -> Result<(), Box<dyn std::error::Error>> {
    // Append to audit log file (JSON Lines format) in the working directory
    // Ensure the working directory exists (create_dir_all succeeds if it already exists)
    // Note: create_dir_all on empty path succeeds without creating anything
    if working_directory.as_os_str().is_empty() {
        return Err("Working directory cannot be empty".into());
    }
    fs::create_dir_all(&working_directory).await?;
    let audit_path = working_directory.join(AUDIT_FILE);
    
    // Debug: Log path information
    eprintln!("DEBUG: working_directory = {:?}", working_directory);
    eprintln!("DEBUG: audit_path = {:?}", audit_path);
    eprintln!("DEBUG: audit_path exists before write = {}", audit_path.exists());
    eprintln!("DEBUG: working_directory exists = {}", working_directory.exists());
    eprintln!("DEBUG: working_directory is_dir = {}", working_directory.is_dir());
    
    let entry = AuditEntry {
        timestamp: Local::now(),
        user: get_current_user(),
        working_directory: working_directory.clone(),
        command: command.to_string(),
        pattern,
        successful_count: successful.len(),
        skipped_count: skipped.len(),
        error_count: errors.len(),
        successful,
        skipped,
        errors,
    };

    // Serialize to JSON (compact, one line)
    let json = serde_json::to_string(&entry)?;
    
    // Open file with create and append
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&audit_path)
        .await
        .map_err(|e| format!("Failed to open audit file at {:?}: {}", audit_path, e))?;
    
    // Write JSON line with newline
    file.write_all(json.as_bytes()).await
        .map_err(|e| format!("Failed to write to audit file: {}", e))?;
    file.write_all(b"\n").await
        .map_err(|e| format!("Failed to write newline: {}", e))?;
    file.sync_all().await
        .map_err(|e| format!("Failed to sync audit file: {}", e))?;
    
    // Drop the file handle to ensure it's closed
    drop(file);
    
    // Debug: Check file status after write
    eprintln!("DEBUG: audit_path exists after write = {}", audit_path.exists());
    let metadata_result = fs::metadata(&audit_path).await;
    eprintln!("DEBUG: fs::metadata result = {:?}", metadata_result);
    
    // Verify file was created (use async metadata check)
    if metadata_result.is_err() {
        return Err(format!("Audit file was not created at {:?} (after sync delay). Metadata error: {:?}", audit_path, metadata_result.err()).into());
    }
    
    Ok(())
}

/// Reads audit log entries from the audit log file asynchronously.
/// 
/// Reads from `.fren_audit.log` in the current working directory.
/// 
/// # Returns
/// 
/// * `Ok(Vec<AuditEntry>)` - List of audit entries (most recent first)
/// * `Err(Box<dyn Error>)` - If file I/O or parsing fails
/// 
/// # Examples
/// 
/// ```
/// # tokio_test::block_on(async {
/// use freneng::audit::read_audit_log;
/// 
/// let entries = read_audit_log().await.unwrap();
/// println!("Found {} audit entries", entries.len());
/// # })
/// ```
pub async fn read_audit_log() -> Result<Vec<AuditEntry>, Box<dyn std::error::Error>> {
    // Read from current directory (for CLI compatibility)
    let audit_path = std::env::current_dir()?.join(AUDIT_FILE);
    if fs::metadata(&audit_path).await.is_err() {
        return Ok(Vec::new());
    }

    let content = fs::read_to_string(&audit_path).await?;
    let mut entries = Vec::new();
    
    // Parse JSON Lines format (one JSON object per line)
    for line in content.lines() {
        if line.trim().is_empty() {
            continue;
        }
        let entry: AuditEntry = serde_json::from_str(line)?;
        entries.push(entry);
    }
    
    // Return most recent first
    entries.reverse();
    Ok(entries)
}

/// Logs a rename operation to the audit log from a `RenameExecutionResult`.
/// 
/// This is a convenience function that converts a `RenameExecutionResult` into
/// the format needed for audit logging. It's the recommended way to log audit
/// entries after performing rename operations.
/// 
/// # Arguments
/// 
/// * `command` - The full command that was executed
/// * `pattern` - The rename pattern used (if any)
/// * `working_directory` - The directory where the operation was performed
/// * `result` - The result from `perform_renames()`
/// 
/// # Returns
/// 
/// * `Ok(())` - Audit entry logged successfully
/// * `Err(Box<dyn Error>)` - If file I/O fails
/// 
/// # Examples
/// 
/// ```
/// # tokio_test::block_on(async {
/// use freneng::audit::log_audit_from_result;
/// use freneng::perform_renames;
/// use freneng::FileRename;
/// use std::path::PathBuf;
/// 
/// let renames = vec![FileRename {
///     old_path: PathBuf::from("old.txt"),
///     new_path: PathBuf::from("new.txt"),
///     new_name: "new.txt".to_string(),
/// }];
/// 
/// let result = perform_renames(&renames, false).await.unwrap();
/// log_audit_from_result(
///     "fren list *.txt transform \"%N_backup.%E\" rename --yes",
///     Some("%N_backup.%E".to_string()),
///     PathBuf::from("."),
///     &result,
/// ).await.unwrap();
/// # })
/// ```
pub async fn log_audit_from_result(
    command: &str,
    pattern: Option<String>,
    working_directory: PathBuf,
    result: &RenameExecutionResult,
) -> Result<(), Box<dyn std::error::Error>> {
    // Convert RenameAction to (PathBuf, PathBuf) tuples
    let successful: Vec<(PathBuf, PathBuf)> = result.successful.iter()
        .map(|action: &RenameAction| (action.old_path.clone(), action.new_path.clone()))
        .collect();
    
    log_audit_entry(
        command,
        pattern,
        working_directory,
        successful,
        result.skipped.clone(),
        result.errors.clone(),
    ).await
}

/// Clears the audit log by deleting the audit file asynchronously.
/// 
/// Deletes `.fren_audit.log` from the current working directory.
/// 
/// **Warning**: This should be used with caution as it removes the audit trail.
/// Consider archiving the log instead of deleting it.
/// 
/// # Returns
/// 
/// * `Ok(())` - Audit log cleared (or didn't exist)
/// * `Err(Box<dyn Error>)` - If file deletion fails
pub async fn clear_audit_log() -> Result<(), Box<dyn std::error::Error>> {
    let audit_path = std::env::current_dir()?.join(AUDIT_FILE);
    if fs::metadata(&audit_path).await.is_ok() {
        fs::remove_file(&audit_path).await?;
    }
    Ok(())
}