guardy 0.2.4

Fast, secure git hooks in Rust with secret scanning and protected file synchronization
Documentation
use std::{path::PathBuf, time::Instant};

use anyhow::Result;
use clap::Args;
use serde::Serialize;

use crate::{
    cli::output,
    config::CONFIG,
    scan::{Scanner, types::ScanMode},
    shared::time::format_time,
};

#[derive(Args, Clone, Serialize)]
pub struct ScanArgs {
    /// Files or directories to scan
    #[arg(value_name = "PATH")]
    pub paths: Vec<PathBuf>,

    /// Scan all files (including binary files)
    #[arg(long)]
    pub include_binary: Option<bool>,

    /// Maximum file size to scan in MB
    #[arg(long)]
    pub max_file_size: Option<usize>,

    /// Show statistics after scanning
    #[arg(long)]
    pub stats: bool,

    /// Follow symbolic links
    #[arg(long)]
    pub follow_symlinks: Option<bool>,

    /// Disable entropy analysis (faster but less accurate)
    #[arg(long)]
    pub no_entropy: Option<bool>,

    /// Set entropy threshold (default: 0.00001)
    #[arg(long)]
    pub entropy_threshold: Option<f64>,

    /// Additional patterns to ignore (regex)
    #[arg(long, value_delimiter = ',')]
    pub ignore_patterns: Vec<String>,

    /// Additional paths to ignore (glob patterns)
    #[arg(long, value_delimiter = ',')]
    pub ignore_paths: Vec<String>,

    /// Additional comment patterns to ignore
    #[arg(long, value_delimiter = ',')]
    pub ignore_comments: Vec<String>,

    /// Custom secret patterns to add (regex)
    #[arg(long, value_delimiter = ',')]
    pub custom_patterns: Vec<String>,

    /// Output format
    #[arg(long, default_value = "text")]
    pub format: OutputFormat,

    /// Only count matches, don't show details
    #[arg(long)]
    pub count_only: Option<bool>,

    /// Show matched text content (potentially sensitive)
    #[arg(long)]
    pub show_content: Option<bool>,

    /// Show detailed finding information (gitleaks-style format)
    #[arg(long)]
    pub show: Option<bool>,

    /// Show actual secret values (use with caution - very sensitive)
    #[arg(long)]
    pub sensitive: Option<bool>,

    /// Generate reports in specified formats (comma-separated filenames)
    /// Examples: --report=results.json, --report=results.json,report.html
    #[arg(long)]
    pub report: Option<String>,

    /// List all available secret detection patterns and exit
    #[arg(long)]
    pub list_patterns: Option<bool>,

    /// Processing mode: auto (smart default), parallel, or sequential
    #[arg(long, value_enum)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mode: Option<ScanMode>,

    /// Enable TTY progress bars (default: true, use --tty=false to disable)
    #[arg(long)]
    pub tty: Option<bool>,

    /// Use plain output (disable colors and emojis)
    #[arg(long)]
    pub plain: bool,
}

#[derive(Clone, Debug, clap::ValueEnum, serde::Serialize, PartialEq)]
pub enum OutputFormat {
    /// Human-readable text output
    Text,
    /// JSON format
    Json,
    /// CSV format
    Csv,
    /// Simple list of files with secrets
    Files,
}

pub async fn execute(args: ScanArgs, verbose_level: u8) -> Result<()> {
    use crate::scan::static_data::patterns::get_pattern_library;

    // Get the global pattern library
    let pattern_lib = get_pattern_library();

    // Handle --list-patterns flag
    if args.list_patterns.unwrap_or(false) {
        output::styled!(
            "{} Available Secret Detection Patterns ({} total):",
            ("📋", "info_symbol"),
            (pattern_lib.count().to_string(), "property")
        );
        println!();

        for pattern in pattern_lib.patterns() {
            if verbose_level > 0 {
                output::styled!(
                    "📋 {} - {}",
                    (pattern.name.clone(), "property"),
                    (pattern.description.clone(), "symbol")
                );
            } else {
                output::styled!("  - {}", (pattern.name.clone(), "property"));
            }
        }
        return Ok(());
    }

    // Create scanner using global CONFIG (includes CLI overrides)
    let scanner = Scanner::new()?;

    // Print banner without context
    crate::cli::banner::print_banner(None);
    output::styled!("{} Starting security scan...", ("", "info_symbol"));
    let start_time = Instant::now();

    // Determine paths to scan
    let scan_paths = if args.paths.is_empty() {
        vec![PathBuf::from(".")]
    } else {
        args.paths.clone()
    };

    // Scan all paths
    let stats = scanner.scan(&scan_paths)?;
    let elapsed = start_time.elapsed();

    // For now, we don't have access to individual matches in the new streaming architecture
    // So we'll show a summary based on stats
    let total_files = stats.files_scanned;
    let total_skipped = stats.files_skipped;
    let total_matches = stats.total_matches;
    let processing_errors = stats.processing_errors;

    // Handle count-only mode
    if args.count_only.unwrap_or(false) {
        println!("{}", total_matches);
        if total_matches > 0 {
            std::process::exit(1);
        }
        return Ok(());
    }

    // Handle different output formats
    match args.format {
        OutputFormat::Text => {
            print_text_summary(
                total_files,
                total_skipped,
                total_matches,
                processing_errors,
                elapsed,
                &args,
            )?;
        }
        OutputFormat::Json => {
            println!(
                "{{\"files_scanned\":{},\"files_skipped\":{},\"matches\":{},\"duration_ms\":{}}}",
                total_files,
                total_skipped,
                total_matches,
                elapsed.as_millis()
            );
        }
        OutputFormat::Csv => {
            println!("files_scanned,files_skipped,matches,duration_ms");
            println!(
                "{},{},{},{}",
                total_files,
                total_skipped,
                total_matches,
                elapsed.as_millis()
            );
        }
        OutputFormat::Files => {
            // In streaming mode, we don't have file list readily available
            // This would require architecture changes
            println!("Files-only output not yet supported in streaming mode");
        }
    }

    // Exit with error code if secrets found
    if total_matches > 0 {
        std::process::exit(1);
    }

    Ok(())
}

fn print_text_summary(
    total_files: usize,
    total_skipped: usize,
    total_matches: usize,
    processing_errors: usize,
    elapsed: std::time::Duration,
    args: &ScanArgs,
) -> Result<()> {
    if total_matches == 0 {
        println!();
        output::styled!("{} No secrets detected!", ("", "success_symbol"));

        // Print statistics if requested or by default
        if args.stats || args.format == OutputFormat::Text {
            println!();
            output::styled!(
                "{} {}",
                ("📊", "info_symbol"),
                ("Scan Statistics", "property")
            );
            output::styled!("  Files scanned: {}", (total_files.to_string(), "symbol"));
            if total_skipped > 0 {
                output::styled!("  Files skipped: {}", (total_skipped.to_string(), "symbol"));
            }
            if processing_errors > 0 {
                output::styled!(
                    "  Processing errors: {}",
                    (processing_errors.to_string(), "error")
                );
            }
            output::styled!("  Secrets found: {}", ("0", "symbol"));
            output::styled!("  Scan time: {}", (format_time(elapsed), "symbol"));
        }

        return Ok(());
    }

    // If secrets were found, show summary
    println!();
    output::styled!(
        "{} {} potential secrets found!",
        ("⚠️", "warning_symbol"),
        (total_matches.to_string(), "error")
    );

    if CONFIG.scanner.show {
        output::styled!(
            "{}",
            (
                "   Use --show flag to see detailed findings (already enabled)",
                "muted"
            )
        );
    } else {
        output::styled!(
            "{}",
            ("   Use --show flag to see detailed findings", "muted")
        );
    }

    // Print statistics
    if args.stats || args.format == OutputFormat::Text {
        println!();
        output::styled!(
            "{} {}",
            ("📊", "info_symbol"),
            ("Scan Statistics", "property")
        );
        output::styled!("  Files scanned: {}", (total_files.to_string(), "symbol"));
        if total_skipped > 0 {
            output::styled!("  Files skipped: {}", (total_skipped.to_string(), "symbol"));
        }
        if processing_errors > 0 {
            output::styled!(
                "  Processing errors: {}",
                (processing_errors.to_string(), "error")
            );
        }
        output::styled!("  Secrets found: {}", (total_matches.to_string(), "error"));
        output::styled!("  Scan time: {}", (format_time(elapsed), "symbol"));
    }

    Ok(())
}