use anyhow::Result;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use super::dedup::{jaccard, normalized_lines};
const MIN_FN_LINES: usize = 6;
const SIMILARITY_THRESHOLD: f64 = 0.85;
#[derive(Debug, Clone, Serialize)]
pub struct FnLocation {
pub name: String,
pub path: String,
pub line: usize,
pub lines: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DuplicateFnPair {
pub kind: String,
pub similarity: f64,
pub first: FnLocation,
pub second: FnLocation,
}
struct FnBody {
loc: FnLocation,
hash: String,
lines: HashSet<String>,
}
pub struct FnDedupAnalyzer {
root: PathBuf,
}
impl FnDedupAnalyzer {
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
}
}
pub fn find(&self) -> Result<Vec<DuplicateFnPair>> {
let mut fns: Vec<FnBody> = Vec::new();
for entry in WalkDir::new(&self.root).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if p.extension().is_some_and(|e| e == "rs") {
if let Ok(text) = std::fs::read_to_string(p) {
let rel = p
.strip_prefix(&self.root)
.unwrap_or(p)
.to_string_lossy()
.replace('\\', "/");
extract_functions(&text, &rel, &mut fns);
}
}
}
let mut pairs: Vec<DuplicateFnPair> = Vec::new();
let mut by_hash: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, f) in fns.iter().enumerate() {
by_hash.entry(f.hash.as_str()).or_default().push(i);
}
let mut exact_pairs: HashSet<(usize, usize)> = HashSet::new();
for group in by_hash.values().filter(|g| g.len() > 1) {
for w in 0..group.len() {
for x in (w + 1)..group.len() {
let (a, b) = (group[w], group[x]);
exact_pairs.insert((a.min(b), a.max(b)));
pairs.push(DuplicateFnPair {
kind: "exact".to_string(),
similarity: 1.0,
first: fns[a].loc.clone(),
second: fns[b].loc.clone(),
});
}
}
}
let mut by_bucket: HashMap<usize, Vec<usize>> = HashMap::new();
for (i, f) in fns.iter().enumerate() {
by_bucket.entry(f.loc.lines / 5).or_default().push(i);
}
for (&bucket, members) in &by_bucket {
let mut candidates = members.clone();
if let Some(adjacent) = by_bucket.get(&(bucket + 1)) {
candidates.extend(adjacent);
}
for &a in members {
for &b in candidates.iter().filter(|&&c| c > a) {
let key = (a.min(b), a.max(b));
if exact_pairs.contains(&key) {
continue;
}
let sim = jaccard(&fns[a].lines, &fns[b].lines);
if sim >= SIMILARITY_THRESHOLD {
pairs.push(DuplicateFnPair {
kind: "near".to_string(),
similarity: (sim * 100.0).round() / 100.0,
first: fns[a].loc.clone(),
second: fns[b].loc.clone(),
});
}
}
}
}
pairs.sort_by(|a, b| {
let sa = a.similarity * a.first.lines as f64;
let sb = b.similarity * b.first.lines as f64;
sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
});
Ok(pairs)
}
}
fn extract_functions(text: &str, rel_path: &str, out: &mut Vec<FnBody>) {
for f in super::context_reduce::scan_fn_bodies(text) {
let body = &text[f.open..f.close];
let norm = normalized_lines(body);
if norm.len() >= MIN_FN_LINES {
let line = text[..f.start].bytes().filter(|&b| b == b'\n').count() + 1;
let mut concat: Vec<&String> = norm.iter().collect();
concat.sort();
let joined = concat
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("\n");
out.push(FnBody {
loc: FnLocation {
name: f.name,
path: rel_path.to_string(),
line,
lines: body.lines().count(),
},
hash: format!("{:x}", Sha256::digest(joined.as_bytes())),
lines: norm,
});
}
}
}