rprobe 0.9.0

A simple tool to probe a remote host http or https connection
Documentation
// File: clean.rs
// SPDX-License-Identifier: MIT OR Apache-2.0
//
// Copyright (c) 2023-2025
// - Volker Schwaberow <volker@schwaberow.de>

use anyhow::Result;
use chrono::{DateTime, Utc};
use colored::*;
use std::io::{self, Write};

use super::{format_file_size, print_info, print_success, print_warning};
use crate::cli::CleanArgs;
use crate::storage::HistoryDatabase;

pub async fn execute(args: &CleanArgs, db: &HistoryDatabase) -> Result<()> {
    let cutoff_date = args.calculate_cutoff_date().ok_or_else(|| {
        anyhow::anyhow!("No cutoff date specified. Use --before YYYY-MM-DD or --days N")
    })?;

    print_info(&format!(
        "Preparing to clean data before: {}",
        cutoff_date.format("%Y-%m-%d %H:%M:%S UTC")
    ));

    if args.dry_run {
        print_info("Running in dry-run mode - no data will be deleted");
        perform_dry_run(db, cutoff_date).await?;
        return Ok(());
    }

    let stats_before = db.get_database_stats()?;
    print_info(&format!(
        "Database size before cleanup: {}",
        format_file_size(*stats_before.get("size_bytes").unwrap_or(&0))
    ));

    if !args.confirm && !confirm_deletion(cutoff_date)? {
        print_warning("Operation cancelled by user");
        return Ok(());
    }

    print_info("Starting cleanup operation...");
    let deleted_count = db.clean_old_data(cutoff_date)?;

    if deleted_count > 0 {
        print_success(&format!(
            "Successfully deleted {} old scan records",
            deleted_count
        ));
    } else {
        print_info("No old records found to delete");
    }

    if args.compact {
        print_info("Compacting database...");
        db.compact_database()?;
        print_success("Database compaction completed");
    }

    let stats_after = db.get_database_stats()?;
    let size_before = stats_before.get("size_bytes").unwrap_or(&0);
    let size_after = stats_after.get("size_bytes").unwrap_or(&0);

    if size_before > size_after {
        let savings = size_before - size_after;
        let percentage = (savings as f64 / *size_before as f64 * 100.0) as u8;
        print_success(&format!(
            "Space saved: {} ({:.1}%)",
            format_file_size(savings),
            percentage
        ));
    }

    print_info(&format!(
        "Final database size: {}",
        format_file_size(*size_after)
    ));

    Ok(())
}

async fn perform_dry_run(db: &HistoryDatabase, cutoff_date: DateTime<Utc>) -> Result<()> {
    print_info("Analyzing data to be deleted...");

    let all_scans = db.query_scans(&crate::storage::HistoryQuery {
        end_date: Some(cutoff_date),
        limit: None,
        ..Default::default()
    })?;

    if all_scans.is_empty() {
        print_info("No records found before the cutoff date");
        return Ok(());
    }

    println!();
    println!("{}", "DRY RUN ANALYSIS".bold().bright_white());
    println!("{}", "".repeat(60).bright_black());

    print_summary_stats(&all_scans, cutoff_date);
    print_url_breakdown(&all_scans);
    print_date_breakdown(&all_scans);

    println!("{}", "".repeat(60).bright_black());
    print_warning(&format!(
        "Would delete {} records in actual run",
        all_scans.len()
    ));

    Ok(())
}

fn print_summary_stats(scans: &[crate::storage::ScanRecord], cutoff_date: DateTime<Utc>) {
    let total_records = scans.len();
    let unique_urls = scans
        .iter()
        .map(|s| s.url.as_str())
        .collect::<std::collections::HashSet<_>>()
        .len();

    let date_range = if !scans.is_empty() {
        let oldest = scans.iter().map(|s| s.timestamp).min().unwrap();
        let newest = scans.iter().map(|s| s.timestamp).max().unwrap();
        format!(
            "{} to {}",
            oldest.format("%Y-%m-%d"),
            newest.format("%Y-%m-%d")
        )
    } else {
        "N/A".to_string()
    };

    println!(
        "Records to delete:    {}",
        total_records.to_string().red().bold()
    );
    println!("Unique URLs affected: {}", unique_urls.to_string().yellow());
    println!("Date range:           {}", date_range);
    println!(
        "Cutoff date:          {}",
        format!("{}", cutoff_date.format("%Y-%m-%d %H:%M:%S UTC")).cyan()
    );
}

fn print_url_breakdown(scans: &[crate::storage::ScanRecord]) {
    let mut url_counts = std::collections::HashMap::new();
    for scan in scans {
        *url_counts.entry(scan.url.clone()).or_insert(0) += 1;
    }

    if url_counts.is_empty() {
        return;
    }

    println!();
    println!("{}", "URL BREAKDOWN (Top 10):".bold());
    let mut sorted_urls: Vec<_> = url_counts.iter().collect();
    sorted_urls.sort_by(|a, b| b.1.cmp(a.1));

    for (i, (url, count)) in sorted_urls.iter().take(10).enumerate() {
        let url_display = if url.len() > 50 {
            format!("{}...", &url[..47])
        } else {
            url.to_string()
        };

        println!(
            "  {:<2} {:<50} {}",
            format!("{}.", i + 1).dimmed(),
            url_display,
            count.to_string().cyan()
        );
    }

    if sorted_urls.len() > 10 {
        println!("  ... and {} more URLs", sorted_urls.len() - 10);
    }
}

fn print_date_breakdown(scans: &[crate::storage::ScanRecord]) {
    let mut date_counts = std::collections::HashMap::new();
    for scan in scans {
        let date = scan.timestamp.format("%Y-%m").to_string();
        *date_counts.entry(date).or_insert(0) += 1;
    }

    if date_counts.is_empty() {
        return;
    }

    println!();
    println!("{}", "DATE BREAKDOWN (by month):".bold());
    let mut sorted_dates: Vec<_> = date_counts.iter().collect();
    sorted_dates.sort_by(|a, b| a.0.cmp(b.0));

    for (date, count) in sorted_dates {
        println!("  {}: {} records", date.cyan(), count.to_string().yellow());
    }
}

fn confirm_deletion(cutoff_date: DateTime<Utc>) -> Result<bool> {
    println!();
    print_warning("This operation will permanently delete scan data!");
    println!(
        "Data before {} will be removed",
        format!("{}", cutoff_date.format("%Y-%m-%d %H:%M:%S UTC")).red()
    );
    println!();

    loop {
        print!("Are you sure you want to continue? [y/N]: ");
        io::stdout().flush()?;

        let mut input = String::new();
        io::stdin().read_line(&mut input)?;

        match input.trim().to_lowercase().as_str() {
            "y" | "yes" => return Ok(true),
            "n" | "no" | "" => return Ok(false),
            _ => {
                println!("Please enter 'y' for yes or 'n' for no");
                continue;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage::{HistoryDatabase, ScanConfig, ScanRecord};
    use std::collections::HashMap;
    use tempfile::TempDir;
    use uuid::Uuid;

    fn create_test_db() -> (HistoryDatabase, TempDir) {
        let temp_dir = TempDir::new().unwrap();
        let db = HistoryDatabase::new(Some(temp_dir.path().to_path_buf())).unwrap();
        (db, temp_dir)
    }

    fn create_test_scan(url: &str, timestamp: DateTime<Utc>) -> ScanRecord {
        ScanRecord {
            id: Uuid::new_v4().to_string(),
            timestamp,
            url: url.to_string(),
            status: "200".to_string(),
            detections: vec!["Nginx: Web Server".to_string()],
            content_findings: vec![],
            tls_info: HashMap::new(),
            response_time_ms: Some(150),
            response_headers: HashMap::new(),
            content_length: Some(1024),
            desync_results: vec![],
            screenshot_path: None,
            robots_txt_content: None,
            scan_config: ScanConfig {
                timeout: 10,
                http: true,
                https: true,
                detect_all: true,
                content_analysis: false,
                tls_analysis: false,
                comprehensive_tls: false,
                screenshot: false,
                download_robots: false,
                desync: false,
                plugin_name: None,
            },
        }
    }

    #[tokio::test]
    async fn test_dry_run_analysis() {
        let (db, _temp_dir) = create_test_db();
        let now = Utc::now();

        let old_scan = create_test_scan("https://old.com", now - chrono::Duration::days(10));
        let new_scan = create_test_scan("https://new.com", now);

        db.store_scan(&old_scan).unwrap();
        db.store_scan(&new_scan).unwrap();

        let cutoff = now - chrono::Duration::days(5);
        let result = perform_dry_run(&db, cutoff).await;
        assert!(result.is_ok());
    }

    #[test]
    fn test_print_summary_stats() {
        let now = Utc::now();
        let scans = vec![
            create_test_scan("https://example1.com", now - chrono::Duration::days(10)),
            create_test_scan("https://example2.com", now - chrono::Duration::days(8)),
            create_test_scan("https://example1.com", now - chrono::Duration::days(6)),
        ];

        print_summary_stats(&scans, now - chrono::Duration::days(5));
    }
}