use std::collections::BTreeSet;
use std::path::{Component, Path};
use super::{is_contract_yaml, parse_contract, Contract};
use crate::error::{Severity, Violation};
use crate::lint::{collect_yaml_files, UNWALKED_DIRS};
pub const NO_TEST_DEBT: &str = include_str!("no_test_debt.txt");
pub const UNWALKED_CAP: usize = 15;
#[must_use]
pub fn debt_entries() -> BTreeSet<&'static str> {
NO_TEST_DEBT
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
}
#[must_use]
pub fn debt_cap(version: &str) -> Option<usize> {
let mut parts = version.split('.');
let major: u64 = parts.next()?.parse().ok()?;
let minor: u64 = parts.next()?.parse().ok()?;
if major > 0 {
return Some(0);
}
Some(match minor {
0..=71 => 803,
72 => 602,
73 => 401,
74 => 200,
_ => 0,
})
}
#[must_use]
pub fn rel_to_contracts(path: &Path) -> Option<String> {
if !is_contract_yaml(path) {
return None;
}
let parts: Vec<&str> = path
.components()
.filter_map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect();
let at = parts.iter().rposition(|p| *p == "contracts")?;
let rel = &parts[at + 1..];
let (_, dirs) = rel.split_last()?;
if dirs.iter().any(|d| UNWALKED_DIRS.contains(d)) {
return None;
}
Some(rel.join("/"))
}
pub const CENSUS_MARKER: &str = "census.json";
#[must_use]
pub fn in_census_tree(path: &Path) -> bool {
path.ancestors()
.skip(1)
.find(|a| a.file_name().and_then(|n| n.to_str()) == Some("contracts"))
.is_some_and(|root| root.join(CENSUS_MARKER).is_file())
}
#[must_use]
pub fn check(path: &Path, contract: &Contract) -> Option<Violation> {
if !in_census_tree(path) {
return None;
}
check_in_tree(path, contract)
}
#[must_use]
pub fn check_in_tree(path: &Path, contract: &Contract) -> Option<Violation> {
if !contract.falsification_tests.is_empty() {
return None;
}
let rel = rel_to_contracts(path)?;
if debt_entries().contains(rel.as_str()) {
return None;
}
Some(Violation {
severity: Severity::Error,
rule: "SCHEMA-026".to_string(),
message: format!(
"contracts/{rel} has no falsification_tests and is not on the #5291 no-test debt \
list; a new contract must ship at least one test"
),
location: Some("falsification_tests".to_string()),
})
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct DebtReport {
pub no_falsification_tests: usize,
pub unlisted: Vec<String>,
pub stale: Vec<String>,
pub unparsed: Vec<String>,
pub unwalked: usize,
}
#[must_use]
pub fn measure(dir: &Path) -> DebtReport {
let mut files = Vec::new();
collect_yaml_files(dir, &mut files);
files.sort();
let listed = debt_entries();
let mut report = DebtReport::default();
let mut zero = BTreeSet::new();
for file in &files {
let rel = file
.strip_prefix(dir)
.map_or_else(|_| file.display().to_string(), |r| r.display().to_string());
match parse_contract(file) {
Ok(c) if c.falsification_tests.is_empty() => {
report.no_falsification_tests += 1;
if !listed.contains(rel.as_str()) {
report.unlisted.push(rel.clone());
}
zero.insert(rel);
}
Ok(_) => {}
Err(_) => report.unparsed.push(rel),
}
}
report.stale = listed
.iter()
.filter(|l| !zero.contains(**l))
.map(|l| (*l).to_string())
.collect();
report.unwalked = count_yaml(dir).saturating_sub(files.len());
report
}
#[must_use]
pub fn unwalked(dir: &Path) -> usize {
let mut files = Vec::new();
collect_yaml_files(dir, &mut files);
count_yaml(dir).saturating_sub(files.len())
}
fn count_yaml(dir: &Path) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut n = 0;
for path in entries.flatten().map(|e| e.path()) {
if path.is_dir() {
if path.file_name().and_then(|n| n.to_str()) != Some("quarantine") {
n += count_yaml(&path);
}
} else if matches!(
path.extension().and_then(|e| e.to_str()),
Some("yaml" | "yml")
) {
n += 1;
}
}
n
}
#[cfg(test)]
#[path = "no_test_debt_tests.rs"]
mod tests;