guardy 0.2.4

Fast, secure git hooks in Rust with secret scanning and protected file synchronization
Documentation
//! Core report writer for concurrent file output
//!
//! This module provides thread-safe report generation for multiple formats
//! without accumulating matches in memory. Workers write directly to report files
//! using Arc<Mutex<File>> for atomic operations.

use std::{
    fs::File,
    io::Write,
    path::Path,
    sync::{Arc, Mutex},
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Context, Result};
use serde_json::json;

use crate::scan::types::SecretMatch;

/// Report formats supported by the streaming writer
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ReportFormat {
    Html,
    Json,
    Text,
    Csv,
}

impl ReportFormat {
    /// Detect format from file extension
    pub fn from_extension(filename: &str) -> Self {
        if filename.ends_with(".json") {
            ReportFormat::Json
        } else if filename.ends_with(".html") || filename.ends_with(".htm") {
            ReportFormat::Html
        } else if filename.ends_with(".txt") {
            ReportFormat::Text
        } else if filename.ends_with(".csv") {
            ReportFormat::Csv
        } else {
            // Default to JSON if no extension or unknown extension
            ReportFormat::Json
        }
    }
}

/// Thread-safe report writer
pub struct ReportWriter {
    file: Arc<Mutex<File>>,
    format: ReportFormat,
    first_entry: Arc<Mutex<bool>>, // Track first entry for JSON comma handling
}

impl ReportWriter {
    /// Create a new streaming report writer with format detection
    pub fn new(report_path: &str) -> Result<Self> {
        let format = ReportFormat::from_extension(report_path);
        let mut file = File::create(report_path)
            .with_context(|| format!("Failed to create report file: {report_path}"))?;

        // Write format-specific prefix
        match format {
            ReportFormat::Json => {
                writeln!(file, "[")?;
            }
            ReportFormat::Html => {
                writeln!(file, "{}", Self::html_prefix())?;
            }
            ReportFormat::Csv => {
                writeln!(
                    file,
                    "file,pattern,line_number,start_pos,end_pos,line_content,matched_text"
                )?;
            }
            ReportFormat::Text => {
                writeln!(file, "Guardy Security Scan Report")?;
                writeln!(
                    file,
                    "Generated at: {}",
                    SystemTime::now()
                        .duration_since(UNIX_EPOCH)
                        .unwrap()
                        .as_secs()
                )?;
                writeln!(file, "Version: {}", env!("CARGO_PKG_VERSION"))?;
                writeln!(file, "{}", "=".repeat(50))?;
                writeln!(file)?;
            }
        }

        Ok(Self {
            file: Arc::new(Mutex::new(file)),
            format,
            first_entry: Arc::new(Mutex::new(true)),
        })
    }

    /// Get a cloneable atomic handle for worker threads
    pub fn get_atomic_handle(&self) -> AtomicHandle {
        AtomicHandle {
            file: self.file.clone(),
            format: self.format,
            first_entry: self.first_entry.clone(),
        }
    }

    /// Finalize the report (write suffix and close)
    pub fn finalize(self) -> Result<()> {
        let mut file = self.file.lock().unwrap();

        match self.format {
            ReportFormat::Json => {
                // Remove trailing comma and close array
                writeln!(file, "]")?;
            }
            ReportFormat::Html => {
                writeln!(file, "{}", Self::html_suffix())?;
            }
            ReportFormat::Text | ReportFormat::Csv => {
                // No special suffix needed
            }
        }

        file.flush()?;
        Ok(())
    }

    /// HTML report prefix
    fn html_prefix() -> String {
        format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Guardy Security Scan Report</title>
    <style>
        body {{ font-family: Arial, sans-serif; margin: 20px; }}
        .header {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
        .match {{ border: 1px solid #ddd; margin: 10px 0; padding: 10px; }}
        .file-path {{ font-weight: bold; color: #0066cc; }}
        .pattern {{ color: #cc6600; }}
        .line-number {{ color: #666; }}
        .content {{ background: #f5f5f5; padding: 5px; font-family: monospace; }}
    </style>
</head>
<body>
    <div class="header">
        <h1>🔍 Guardy Security Scan Report</h1>
        <p>Generated at: {}</p>
        <p>Version: {}</p>
    </div>
    <div class="matches">
"#,
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_secs(),
            env!("CARGO_PKG_VERSION")
        )
    }

    /// HTML report suffix
    fn html_suffix() -> &'static str {
        r#"    </div>
</body>
</html>"#
    }
}

/// Atomic handle for worker threads to write to the report
#[derive(Clone)]
pub struct AtomicHandle {
    file: Arc<Mutex<File>>,
    format: ReportFormat,
    first_entry: Arc<Mutex<bool>>,
}

impl AtomicHandle {
    /// Write matches from a single file to the report
    pub fn write_matches(&self, matches: &[SecretMatch], redacted: bool) -> Result<()> {
        if matches.is_empty() {
            return Ok(());
        }

        // Get file_path from the first match - all matches share the same Arc<PathBuf>
        let file_path = matches[0].file_path.as_ref();

        let content = match self.format {
            ReportFormat::Json => self.format_json_matches(matches, file_path, redacted)?,
            ReportFormat::Html => self.format_html_matches(matches, file_path, redacted),
            ReportFormat::Text => self.format_text_matches(matches, file_path, redacted),
            ReportFormat::Csv => self.format_csv_matches(matches, file_path, redacted),
        };

        let mut file = self.file.lock().unwrap();
        write!(file, "{content}")?;
        file.flush()?;

        Ok(())
    }

    /// Format matches as JSON
    fn format_json_matches(
        &self,
        matches: &[SecretMatch],
        file_path: &Path,
        redacted: bool,
    ) -> Result<String> {
        let mut is_first = self.first_entry.lock().unwrap();
        let comma = if *is_first {
            *is_first = false;
            ""
        } else {
            ","
        };

        let file_entry = json!({
            "file": file_path.to_string_lossy(),
            "matches": matches.iter().map(|m| {
                json!({
                    "pattern": m.pattern.name,
                    "line_number": m.line_number,
                    "start_pos": m.start_pos,
                    "end_pos": m.end_pos,
                    "line_content": if redacted { "[REDACTED]" } else { &m.line_content },
                    "matched_text": if redacted { "[REDACTED]" } else { &m.matched_text },
                    "entropy": m.entropy
                })
            }).collect::<Vec<_>>()
        });

        Ok(format!(
            "{}{}",
            comma,
            serde_json::to_string_pretty(&file_entry)?
        ))
    }

    /// Format matches as HTML
    fn format_html_matches(
        &self,
        matches: &[SecretMatch],
        file_path: &Path,
        redacted: bool,
    ) -> String {
        let mut html = format!(
            r#"        <div class="match">
            <div class="file-path">📄 {}</div>
"#,
            file_path.display()
        );

        for m in matches {
            let content = if redacted {
                "[REDACTED]"
            } else {
                &m.line_content
            };
            let matched = if redacted {
                "[REDACTED]"
            } else {
                &m.matched_text
            };

            html.push_str(&format!(
                r#"            <div class="match-detail">
                <span class="pattern">🔍 {}</span> 
                <span class="line-number">Line {}:{}</span><br>
                <div class="content">{}</div>
                <div class="matched">Matched: {} (length: {})</div>
            </div>
"#,
                m.pattern.name,
                m.line_number,
                m.start_pos,
                content,
                matched,
                m.end_pos - m.start_pos
            ));
        }

        html.push_str("        </div>\n");
        html
    }

    /// Format matches as plain text
    fn format_text_matches(
        &self,
        matches: &[SecretMatch],
        file_path: &Path,
        redacted: bool,
    ) -> String {
        let mut text = format!("📄 {}\n", file_path.display());

        for m in matches {
            let content = if redacted {
                "[REDACTED]"
            } else {
                &m.line_content
            };
            text.push_str(&format!(
                "  🔍 Line {}:{} {} {}\n",
                m.line_number,
                m.start_pos,
                m.pattern.name,
                content.trim()
            ));
        }

        text.push('\n');
        text
    }

    /// Format matches as CSV
    fn format_csv_matches(
        &self,
        matches: &[SecretMatch],
        file_path: &Path,
        redacted: bool,
    ) -> String {
        let mut csv = String::new();

        for m in matches {
            let content = if redacted {
                "[REDACTED]"
            } else {
                &m.line_content
            };
            let matched = if redacted {
                "[REDACTED]"
            } else {
                &m.matched_text
            };

            csv.push_str(&format!(
                "{},{},{},{},{},{},{}\n",
                Self::escape_csv_field(&file_path.to_string_lossy()),
                Self::escape_csv_field(&m.pattern.name),
                m.line_number,
                m.start_pos,
                m.end_pos,
                Self::escape_csv_field(content.trim()),
                Self::escape_csv_field(matched)
            ));
        }

        csv
    }

    /// Escape CSV field (handle commas, quotes, newlines)
    fn escape_csv_field(field: &str) -> String {
        if field.contains(',') || field.contains('"') || field.contains('\n') {
            format!("\"{}\"", field.replace('"', "\"\""))
        } else {
            field.to_string()
        }
    }
}