use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::Instant;
use super::rules::RuleSeverity;
use super::finding::LintFinding;
use super::{GateDetail, GateExtra, GateResult};
pub const BASELINE_REL_PATH: &str = "scripts/contract_duplicate_stem_baseline.txt";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuplicateStem {
pub stem: String,
pub paths: Vec<String>,
pub variants: usize,
}
pub fn scan_duplicate_stems(dir: &Path) -> Vec<DuplicateStem> {
let mut paths = Vec::new();
super::gates::collect_yaml_files(dir, &mut paths);
paths.sort();
let mut by_stem: BTreeMap<String, Vec<std::path::PathBuf>> = BTreeMap::new();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
by_stem.entry(stem.to_string()).or_default().push(path);
}
by_stem
.into_iter()
.filter(|(_, ps)| ps.len() > 1)
.filter_map(|(stem, ps)| build_duplicate(&stem, &ps))
.collect()
}
fn build_duplicate(stem: &str, paths: &[std::path::PathBuf]) -> Option<DuplicateStem> {
let mut contents: BTreeSet<Vec<u8>> = BTreeSet::new();
for p in paths {
contents.insert(std::fs::read(p).unwrap_or_default());
}
if contents.len() < 2 {
return None;
}
Some(DuplicateStem {
stem: stem.to_string(),
paths: paths.iter().map(|p| p.display().to_string()).collect(),
variants: contents.len(),
})
}
pub fn ambiguous_stems(duplicates: &[DuplicateStem]) -> BTreeSet<String> {
duplicates.iter().map(|d| d.stem.clone()).collect()
}
pub fn read_baseline(project_root: &Path) -> BTreeSet<String> {
let path = project_root.join(BASELINE_REL_PATH);
let Ok(text) = std::fs::read_to_string(path) else {
return BTreeSet::new();
};
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(ToString::to_string)
.collect()
}
pub(crate) fn run_duplicate_stem_gate(
duplicates: &[DuplicateStem],
baseline: &BTreeSet<String>,
) -> (GateResult, Vec<LintFinding>) {
let start = Instant::now();
let found: BTreeSet<String> = ambiguous_stems(duplicates);
let unbaselined: Vec<String> = found.difference(baseline).cloned().collect();
let stale: Vec<String> = baseline.difference(&found).cloned().collect();
let passed = unbaselined.is_empty() && stale.is_empty();
let mut findings = Vec::new();
for stem in &unbaselined {
let paths = duplicates
.iter()
.find(|d| &d.stem == stem)
.map_or_else(String::new, |d| d.paths.join(", "));
findings.push(
LintFinding::new(
"PV-DUP-001",
RuleSeverity::Error,
format!(
"Stem `{stem}` is claimed by multiple files with DIVERGENT content, \
so it cannot be resolved to one contract: {paths}"
),
format!("contracts/{stem}.yaml"),
)
.with_stem(stem.clone()),
);
}
for stem in &stale {
findings.push(LintFinding::new(
"PV-DUP-002",
RuleSeverity::Error,
format!(
"Stem `{stem}` no longer diverges — remove it from {BASELINE_REL_PATH}. \
The ratchet only turns one way."
),
BASELINE_REL_PATH.to_string(),
));
}
let implicated_files = duplicates.iter().map(|d| d.paths.len()).sum();
let error_messages: Vec<String> = findings.iter().map(|f| f.message.clone()).collect();
let result = GateResult {
name: "duplicate-stems".into(),
passed,
skipped: false,
duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(0),
detail: GateDetail::Validate {
contracts: implicated_files,
errors: unbaselined.len() + stale.len(),
warnings: found.intersection(baseline).count(),
error_messages,
},
extra: Some(GateExtra::DuplicateStems {
divergent: duplicates.len(),
baselined: found.intersection(baseline).count(),
unbaselined,
stale,
divergent_stems: duplicates
.iter()
.map(|d| {
format!(
"{} [{} variants] {}",
d.stem,
d.variants,
d.paths.join(" | ")
)
})
.collect(),
}),
};
(result, findings)
}
#[cfg(test)]
#[path = "duplicate_stems_tests.rs"]
mod tests;