use anyhow::Result;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use super::Graph;
const SIMILARITY_THRESHOLD: f64 = 0.8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DuplicateKind {
Exact,
Near,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuplicatePair {
pub first: String,
pub second: String,
pub kind: DuplicateKind,
}
pub struct DeduplicationAnalyzer {}
impl DeduplicationAnalyzer {
pub fn new() -> Self {
Self {}
}
pub fn find_duplicates(&self, graph: &Graph) -> Result<Vec<DuplicatePair>> {
let mut hashes: HashMap<String, String> = HashMap::new();
let mut duplicates = Vec::new();
let mut line_sets: Vec<(String, HashSet<String>)> = Vec::new();
for node in &graph.nodes {
let Some(ref path) = node.path else {
continue;
};
let Some(content) = read_content(Path::new(path)) else {
continue;
};
let hash = format!("{:x}", Sha256::digest(content.as_bytes()));
if let Some(existing) = hashes.get(&hash) {
duplicates.push(DuplicatePair {
first: existing.clone(),
second: node.id.clone(),
kind: DuplicateKind::Exact,
});
continue;
}
hashes.insert(hash, node.id.clone());
let lines = normalized_lines(&content);
for (existing_id, existing_lines) in &line_sets {
if jaccard(existing_lines, &lines) >= SIMILARITY_THRESHOLD {
duplicates.push(DuplicatePair {
first: existing_id.clone(),
second: node.id.clone(),
kind: DuplicateKind::Near,
});
break;
}
}
line_sets.push((node.id.clone(), lines));
}
Ok(duplicates)
}
}
impl Default for DeduplicationAnalyzer {
fn default() -> Self {
Self::new()
}
}
fn read_content(path: &Path) -> Option<String> {
if path.is_file() {
let content = std::fs::read_to_string(path).ok()?;
return (!content.is_empty()).then_some(content);
}
if path.is_dir() {
let mut files: Vec<_> = walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "rs"))
.map(|e| e.path().to_path_buf())
.collect();
files.sort();
let mut buf = String::new();
for file in files {
buf.push_str(&std::fs::read_to_string(file).unwrap_or_default());
}
return (!buf.is_empty()).then_some(buf);
}
None
}
pub(crate) fn normalized_lines(content: &str) -> HashSet<String> {
content
.lines()
.map(|l| l.split_whitespace().collect::<String>())
.filter(|l| !l.is_empty())
.collect()
}
pub fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
if a.is_empty() && b.is_empty() {
return 0.0;
}
let intersection = a.intersection(b).count() as f64;
let union = a.union(b).count() as f64;
intersection / union
}