use std::io::Write;
use std::time::SystemTime;
use askama::Template;
use bytesize::ByteSize;
use chrono::{DateTime, Local};
use crate::duplicates::{DuplicateGroup, ScanSummary};
#[derive(Template)]
#[template(path = "report.html")]
pub struct HtmlOutput {
pub timestamp: String,
pub summary: ScanSummary,
pub total_size: String,
pub reclaimable_space: String,
pub groups: Vec<HtmlDuplicateGroup>,
}
pub struct HtmlDuplicateGroup {
pub hash_hex: String,
pub size_formatted: String,
pub files: Vec<HtmlFileEntry>,
}
pub struct HtmlFileEntry {
pub path_display: String,
pub modified_formatted: String,
pub is_reference: bool,
}
impl HtmlOutput {
#[must_use]
pub fn new(groups: &[DuplicateGroup], summary: &ScanSummary) -> Self {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let html_groups = groups
.iter()
.map(|g| HtmlDuplicateGroup {
hash_hex: g.hash_hex(),
size_formatted: ByteSize::b(g.size).to_string(),
files: g
.files
.iter()
.map(|f| HtmlFileEntry {
path_display: f.path.to_string_lossy().into_owned(),
modified_formatted: format_time(f.modified),
is_reference: g.is_in_reference_dir(&f.path),
})
.collect(),
})
.collect();
Self {
timestamp,
summary: summary.clone(),
total_size: ByteSize::b(summary.total_size).to_string(),
reclaimable_space: ByteSize::b(summary.reclaimable_space).to_string(),
groups: html_groups,
}
}
pub fn to_html(&self) -> Result<String, askama::Error> {
self.render()
}
pub fn write_to<W: Write>(&self, writer: &mut W) -> Result<(), HtmlOutputError> {
let html = self.to_html()?;
writer.write_all(html.as_bytes())?;
Ok(())
}
}
fn format_time(time: SystemTime) -> String {
let datetime: DateTime<Local> = time.into();
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
}
#[derive(thiserror::Error, Debug)]
pub enum HtmlOutputError {
#[error("HTML template error: {0}")]
Template(#[from] askama::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::FileEntry;
use std::path::PathBuf;
use std::time::Duration;
#[test]
fn test_html_output_new() {
let now = SystemTime::now();
let groups = vec![DuplicateGroup::new(
[0u8; 32],
1024,
vec![
FileEntry::new(PathBuf::from("/test/file1.txt"), 1024, now),
FileEntry::new(PathBuf::from("/test/file2.txt"), 1024, now),
],
vec![PathBuf::from("/test/file1.txt")],
)];
let summary = ScanSummary {
total_files: 2,
total_size: 2048,
duplicate_groups: 1,
duplicate_files: 1,
reclaimable_space: 1024,
scan_duration: Duration::from_secs(1),
..Default::default()
};
let output = HtmlOutput::new(&groups, &summary);
assert_eq!(output.summary.total_files, 2);
assert_eq!(output.groups.len(), 1);
assert_eq!(output.groups[0].files.len(), 2);
assert!(output.groups[0].files[0].is_reference);
assert!(!output.groups[0].files[1].is_reference);
}
#[test]
fn test_to_html() {
let now = SystemTime::now();
let groups = vec![DuplicateGroup::new(
[0xAB; 32],
1024,
vec![
FileEntry::new(PathBuf::from("/test/file1.txt"), 1024, now),
FileEntry::new(PathBuf::from("/test/file2.txt"), 1024, now),
],
Vec::new(),
)];
let summary = ScanSummary {
total_files: 2,
total_size: 2048,
duplicate_groups: 1,
duplicate_files: 1,
reclaimable_space: 1024,
scan_duration: Duration::from_secs(1),
..Default::default()
};
let output = HtmlOutput::new(&groups, &summary);
let html = output.to_html().expect("Failed to render HTML");
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Duplicate Report"));
assert!(html.contains("2.0") && (html.contains("KiB") || html.contains("KB")));
assert!(html.contains("1.0") && (html.contains("KiB") || html.contains("KB")));
assert!(html.contains("abababab")); assert!(html.contains("file1.txt"));
}
#[test]
fn test_html_escaping() {
let now = SystemTime::now();
let tricky_path = PathBuf::from("/test/<script>alert('xss')</script> & \"quote\".txt");
let groups = vec![DuplicateGroup::new(
[0u8; 32],
1024,
vec![
FileEntry::new(tricky_path, 1024, now),
FileEntry::new(PathBuf::from("/test/file2.txt"), 1024, now),
],
Vec::new(),
)];
let summary = ScanSummary::default();
let output = HtmlOutput::new(&groups, &summary);
let html = output.to_html().expect("Failed to render HTML");
assert!(!html.contains("<script>"));
assert!(html.contains("<script>"));
assert!(html.contains("alert('xss')"));
assert!(html.contains("&"));
assert!(html.contains(""quote""));
}
#[test]
fn test_empty_report() {
let groups = Vec::new();
let summary = ScanSummary::default();
let output = HtmlOutput::new(&groups, &summary);
let html = output.to_html().expect("Failed to render HTML");
assert!(html.contains("Duplicate Report"));
assert!(html.contains("0 files"));
assert!(html.contains("0 B"));
assert!(!html.contains("class=\"group-card\""));
}
#[test]
fn test_summary_stats_rendering() {
let summary = ScanSummary {
total_files: 1234,
total_size: 1024 * 1024 * 10, duplicate_groups: 50,
duplicate_files: 100,
reclaimable_space: 1024 * 1024 * 5, scan_duration: Duration::from_secs(42),
..Default::default()
};
let output = HtmlOutput::new(&[], &summary);
let html = output.to_html().expect("Failed to render HTML");
assert!(html.contains("1234 files"));
assert!(html.contains("10.5") || html.contains("10.0")); assert!(html.contains("50")); assert!(html.contains("5.2") || html.contains("5.0")); }
#[test]
fn test_reference_badge_rendering() {
let now = SystemTime::now();
let ref_path = PathBuf::from("/ref/original.jpg");
let groups = vec![DuplicateGroup::new(
[0u8; 32],
1024,
vec![
FileEntry::new(ref_path.clone(), 1024, now),
FileEntry::new(PathBuf::from("/tmp/dupe.jpg"), 1024, now),
],
vec![ref_path],
)];
let summary = ScanSummary::default();
let output = HtmlOutput::new(&groups, &summary);
let html = output.to_html().expect("Failed to render HTML");
assert!(html.contains("badge-ref"));
assert!(html.contains("Reference"));
}
}