use std::io::Write;
use serde::Serialize;
use crate::duplicates::{DuplicateGroup, ScanSummary};
#[derive(Debug, Clone, Serialize)]
pub struct JsonDuplicateGroup {
pub hash: String,
pub size: u64,
pub files: Vec<String>,
}
impl JsonDuplicateGroup {
#[must_use]
pub fn from_duplicate_group(group: &DuplicateGroup) -> Self {
Self {
hash: group.hash_hex(),
size: group.size,
files: group
.files
.iter()
.map(|f| normalize_path(f.path.as_path()))
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct JsonSummary {
pub total_files: usize,
pub total_size: u64,
pub duplicate_groups: usize,
pub duplicate_files: usize,
pub reclaimable_space: u64,
pub scan_duration_ms: u64,
pub interrupted: bool,
}
impl JsonSummary {
#[must_use]
pub fn from_scan_summary(summary: &ScanSummary) -> Self {
Self {
total_files: summary.total_files,
total_size: summary.total_size,
duplicate_groups: summary.duplicate_groups,
duplicate_files: summary.duplicate_files,
reclaimable_space: summary.reclaimable_space,
scan_duration_ms: summary.scan_duration.as_millis() as u64,
interrupted: summary.interrupted,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct JsonOutput {
pub duplicates: Vec<JsonDuplicateGroup>,
pub summary: JsonSummary,
}
impl JsonOutput {
#[must_use]
pub fn new(groups: &[DuplicateGroup], summary: &ScanSummary) -> Self {
Self {
duplicates: groups
.iter()
.map(JsonDuplicateGroup::from_duplicate_group)
.collect(),
summary: JsonSummary::from_scan_summary(summary),
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn write_to<W: Write>(&self, writer: &mut W, pretty: bool) -> Result<(), JsonOutputError> {
let json = if pretty {
self.to_json_pretty()?
} else {
self.to_json()?
};
writer.write_all(json.as_bytes())?;
writer.write_all(b"\n")?;
Ok(())
}
}
fn normalize_path(path: &std::path::Path) -> String {
match path.canonicalize() {
Ok(canonical) => canonical.to_string_lossy().into_owned(),
Err(_) => path.to_string_lossy().into_owned(),
}
}
#[derive(thiserror::Error, Debug)]
pub enum JsonOutputError {
#[error("JSON serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
fn create_test_summary() -> ScanSummary {
ScanSummary {
total_files: 100,
total_size: 1024 * 1024,
eliminated_by_size: 50,
eliminated_by_prehash: 30,
cache_prehash_hits: 0,
cache_prehash_misses: 0,
cache_fullhash_hits: 0,
cache_fullhash_misses: 0,
duplicate_groups: 5,
duplicate_files: 10,
reclaimable_space: 51200,
scan_duration: Duration::from_millis(1234),
interrupted: false,
}
}
fn create_test_groups() -> Vec<DuplicateGroup> {
let now = std::time::SystemTime::now();
vec![
DuplicateGroup::new(
[0u8; 32],
1024,
vec![
crate::scanner::FileEntry::new(PathBuf::from("/path/to/file1.txt"), 1024, now),
crate::scanner::FileEntry::new(PathBuf::from("/path/to/file2.txt"), 1024, now),
],
Vec::new(),
),
DuplicateGroup::new(
[1u8; 32],
2048,
vec![
crate::scanner::FileEntry::new(PathBuf::from("/path/to/fileA.txt"), 2048, now),
crate::scanner::FileEntry::new(PathBuf::from("/path/to/fileB.txt"), 2048, now),
crate::scanner::FileEntry::new(PathBuf::from("/path/to/fileC.txt"), 2048, now),
],
Vec::new(),
),
]
}
#[test]
fn test_json_output_empty() {
let output = JsonOutput::new(&[], &ScanSummary::default());
assert!(output.duplicates.is_empty());
assert_eq!(output.summary.total_files, 0);
}
#[test]
fn test_json_output_with_groups() {
let groups = create_test_groups();
let summary = create_test_summary();
let output = JsonOutput::new(&groups, &summary);
assert_eq!(output.duplicates.len(), 2);
assert_eq!(output.duplicates[0].files.len(), 2);
assert_eq!(output.duplicates[1].files.len(), 3);
assert_eq!(output.summary.duplicate_groups, 5);
assert_eq!(output.summary.scan_duration_ms, 1234);
}
#[test]
fn test_to_json_compact() {
let output = JsonOutput::new(&[], &ScanSummary::default());
let json = output.to_json().unwrap();
assert!(!json.contains('\n'));
assert!(json.starts_with('{'));
assert!(json.ends_with('}'));
}
#[test]
fn test_to_json_pretty() {
let output = JsonOutput::new(&[], &ScanSummary::default());
let json = output.to_json_pretty().unwrap();
assert!(json.contains('\n'));
assert!(json.starts_with('{'));
}
#[test]
fn test_json_is_valid() {
let groups = create_test_groups();
let summary = create_test_summary();
let output = JsonOutput::new(&groups, &summary);
let json = output.to_json().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("duplicates").is_some());
assert!(parsed.get("summary").is_some());
let duplicates = parsed.get("duplicates").unwrap().as_array().unwrap();
assert_eq!(duplicates.len(), 2);
let summary = parsed.get("summary").unwrap();
assert_eq!(summary.get("total_files").unwrap().as_u64().unwrap(), 100);
}
#[test]
fn test_hash_format() {
let now = std::time::SystemTime::now();
let groups = vec![DuplicateGroup::new(
[0xab; 32],
1024,
vec![crate::scanner::FileEntry::new(
PathBuf::from("/test.txt"),
1024,
now,
)],
Vec::new(),
)];
let output = JsonOutput::new(&groups, &ScanSummary::default());
assert_eq!(output.duplicates[0].hash.len(), 64);
assert!(output.duplicates[0]
.hash
.chars()
.all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_write_to() {
let output = JsonOutput::new(&[], &ScanSummary::default());
let mut buffer = Vec::new();
output.write_to(&mut buffer, false).unwrap();
let written = String::from_utf8(buffer).unwrap();
assert!(written.starts_with('{'));
assert!(written.ends_with("}\n"));
}
#[test]
fn test_json_summary_duration() {
let summary = ScanSummary {
scan_duration: Duration::from_secs(5),
..Default::default()
};
let json_summary = JsonSummary::from_scan_summary(&summary);
assert_eq!(json_summary.scan_duration_ms, 5000);
}
#[test]
fn test_json_summary_interrupted() {
let summary = ScanSummary {
interrupted: true,
..Default::default()
};
let output = JsonOutput::new(&[], &summary);
assert!(output.summary.interrupted);
}
}