#![cfg_attr(coverage_nightly, coverage(off))]
use super::types::{PhaseOutcome, QualityProfile, QualityViolation};
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnalysisScope {
Project { root: PathBuf },
SingleFile { file: PathBuf, module_dir: PathBuf },
}
impl AnalysisScope {
#[must_use]
pub fn resolve(project_path: &Path, specific_file: Option<&Path>) -> Self {
match specific_file {
None => Self::Project {
root: project_path.to_path_buf(),
},
Some(f) => {
let file = if f.is_absolute() {
f.to_path_buf()
} else {
project_path.join(f)
};
let module_dir = file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| project_path.to_path_buf(), Path::to_path_buf);
Self::SingleFile { file, module_dir }
}
}
}
#[must_use]
pub fn single_file(&self) -> Option<&Path> {
match self {
Self::Project { .. } => None,
Self::SingleFile { file, .. } => Some(file),
}
}
#[must_use]
pub fn walk_root(&self) -> &Path {
match self {
Self::Project { root } => root,
Self::SingleFile { module_dir, .. } => module_dir,
}
}
#[must_use]
pub fn file_or_root(&self) -> &Path {
match self {
Self::Project { root } => root,
Self::SingleFile { file, .. } => file,
}
}
}
fn capture_path(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
std::env::temp_dir().join(format!(
"pmat-enforce-{tag}-{}-{nanos}.json",
std::process::id()
))
}
fn take_captured_json(path: &Path) -> Option<serde_json::Value> {
let text = std::fs::read_to_string(path).ok();
let _ = std::fs::remove_file(path);
serde_json::from_str(&text?).ok()
}
fn warn_not_measured(kind: &str, path: &Path, reason: &str) -> String {
eprintln!("⚠️ {kind} not measured for {}: {reason}", path.display());
format!("{kind} not measured: {reason}")
}
fn ensure_file_target_readable(file: &Path) -> Result<()> {
if !file.exists() {
anyhow::bail!(
"path not found: {} — enforce cannot report a verdict on a path it cannot read",
file.display()
);
}
if !file.is_file() {
anyhow::bail!(
"--file expects a regular file, but {} is not one — enforce cannot report a file verdict on it",
file.display()
);
}
Ok(())
}
fn unparseable_files(paths: impl Iterator<Item = PathBuf>) -> Vec<String> {
let mut failures: Vec<String> = paths
.filter_map(|p| {
crate::tdg::ensure_parseable(&p)
.err()
.map(|e| format!("{}: {e}", p.display()))
})
.collect();
failures.sort();
failures
}
fn unparseable_reason(failures: &[String]) -> String {
const SHOWN: usize = 3;
let listed: Vec<&str> = failures
.iter()
.take(SHOWN)
.map(std::string::String::as_str)
.collect();
let more = failures.len().saturating_sub(listed.len());
if more > 0 {
format!(
"{} file(s) did not parse, so no AST could be built for them: {} (+{more} more)",
failures.len(),
listed.join("; ")
)
} else {
format!(
"{} file(s) did not parse, so no AST could be built for them: {}",
failures.len(),
listed.join("; ")
)
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn run_complexity_analysis(
project_path: &Path,
profile: &QualityProfile,
specific_file: Option<&Path>,
) -> Result<PhaseOutcome> {
if let Some(file) = specific_file {
ensure_file_target_readable(file)?;
}
let file_metrics = match specific_file {
Some(file) => {
match crate::services::complexity::analyze_file_complexity_uncached(file, None).await {
Ok(m) => vec![m],
Err(e) => {
let reason = warn_not_measured("complexity", file, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
}
}
None => {
match crate::cli::analysis_utilities::analyze_project_files(
project_path,
None,
&[],
profile.complexity_max,
profile.complexity_max,
)
.await
{
Ok(m) => m,
Err(e) => {
let reason = warn_not_measured("complexity", project_path, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
}
}
};
if file_metrics.is_empty() {
return Ok(PhaseOutcome::unmeasured(format!(
"no analysable source files under {}",
project_path.display()
)));
}
let failures = unparseable_files(file_metrics.iter().map(|m| PathBuf::from(&m.path)));
if failures.len() == file_metrics.len() {
let reason = warn_not_measured(
"complexity",
project_path,
&format!("no source file parsed; {}", unparseable_reason(&failures)),
);
return Ok(PhaseOutcome::unmeasured(reason));
}
let severe = profile.complexity_max.saturating_mul(2);
let mut violations = Vec::new();
for file in &file_metrics {
for func in &file.functions {
if func.metrics.cyclomatic > profile.complexity_max {
violations.push(QualityViolation {
violation_type: "complexity".to_string(),
severity: if func.metrics.cyclomatic > severe {
"high".to_string()
} else {
"medium".to_string()
},
location: format!("{}:{}:{}", file.path, func.line_start, func.name),
current: f64::from(func.metrics.cyclomatic),
target: f64::from(profile.complexity_max),
suggestion: "Extract method pattern - split the function into smaller units"
.to_string(),
});
}
}
}
violations.sort_by(|a, b| {
b.current
.total_cmp(&a.current)
.then_with(|| a.location.cmp(&b.location))
});
let files_examined = file_metrics.len();
if failures.is_empty() {
return Ok(PhaseOutcome::measured(violations).over_files(files_examined));
}
let reason = warn_not_measured("complexity", project_path, &unparseable_reason(&failures));
Ok(PhaseOutcome {
violations,
unmeasured: Some(reason),
files_examined: files_examined.saturating_sub(failures.len()),
})
}
fn single_file_satd(
detector: &crate::services::satd_detector::SATDDetector,
file: &Path,
profile: &QualityProfile,
) -> Result<PhaseOutcome> {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
let reason = warn_not_measured("satd", file, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
};
let debts = match detector.extract_from_content(&content, file) {
Ok(d) => d,
Err(e) => {
let reason = warn_not_measured("satd", file, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
};
if debts.len() <= profile.satd_allowed {
return Ok(PhaseOutcome::measured(Vec::new()));
}
Ok(PhaseOutcome::measured(
debts
.iter()
.map(|item| satd_violation(item, debts.len(), profile))
.collect(),
))
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn run_satd_analysis(
project_path: &Path,
profile: &QualityProfile,
specific_file: Option<&Path>,
) -> Result<PhaseOutcome> {
use crate::services::satd_detector::SATDDetector;
let detector = SATDDetector::new();
if let Some(file) = specific_file {
return single_file_satd(&detector, file, profile);
}
let result = match detector.analyze_project(project_path, false).await {
Ok(result) => result,
Err(e) => {
let reason = warn_not_measured("satd", project_path, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
};
if result.total_files_analyzed == 0 {
return Ok(PhaseOutcome::unmeasured(format!(
"no analysable source files under {}",
project_path.display()
)));
}
let found = result.summary.total_items;
if found <= profile.satd_allowed {
return Ok(PhaseOutcome::measured(Vec::new()));
}
Ok(PhaseOutcome::measured(
result
.items
.iter()
.map(|item| satd_violation(item, found, profile))
.collect(),
))
}
fn satd_violation(
item: &crate::services::satd_detector::TechnicalDebt,
found: usize,
profile: &QualityProfile,
) -> QualityViolation {
QualityViolation {
violation_type: "satd".to_string(),
severity: format!("{:?}", item.severity).to_lowercase(),
location: format!("{}:{}:{}", item.file.display(), item.line, item.column),
current: found as f64,
target: profile.satd_allowed as f64,
suggestion: format!(
"Resolve the {:?} debt marker ({}) or track it outside the source",
item.category,
item.text.trim()
),
}
}
#[must_use]
fn tdg_score_floor(profile: &QualityProfile) -> f64 {
(100.0 - profile.tdg_max * 10.0).clamp(0.0, 100.0)
}
fn tdg_violation(
score: &crate::tdg::TdgScore,
floor: f64,
fallback_path: &Path,
) -> QualityViolation {
let total = f64::from(score.total);
let location = score.file_path.as_ref().map_or_else(
|| fallback_path.display().to_string(),
|p| p.display().to_string(),
);
let defects = if score.critical_defects_count > 0 && score.critical_defects_suppressed.is_none()
{
format!(", {} critical defect(s)", score.critical_defects_count)
} else {
String::new()
};
QualityViolation {
violation_type: "tdg".to_string(),
severity: if score.grade == crate::tdg::Grade::F || !defects.is_empty() {
"high".to_string()
} else {
"medium".to_string()
},
location,
current: 100.0 - total,
target: 100.0 - floor,
suggestion: format!(
"TDG {total:.1}/100 (grade {:?}{defects}) is below the {floor:.0}/100 floor this profile requires — the same score `pmat tdg` reports for this file",
score.grade
),
}
}
fn tdg_score_fails(score: &crate::tdg::TdgScore, floor: f64) -> bool {
if f64::from(score.total) < floor {
return true;
}
score.has_critical_defects && score.critical_defects_suppressed.is_none()
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn run_tdg_analysis(
project_path: &Path,
profile: &QualityProfile,
) -> Result<PhaseOutcome> {
let analyzer = match crate::tdg::TdgAnalyzer::new() {
Ok(a) => a,
Err(e) => {
let reason = warn_not_measured("tdg", project_path, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
};
let scores = if project_path.is_dir() {
match analyzer.analyze_project(project_path).await {
Ok(project) => project.files,
Err(e) => {
let reason = warn_not_measured("tdg", project_path, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
}
} else {
match analyzer.analyze_file(project_path).await {
Ok(score) => vec![score],
Err(e) => {
let reason = warn_not_measured("tdg", project_path, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
}
};
if scores.is_empty() {
return Ok(PhaseOutcome::unmeasured(format!(
"no file could be graded under {}",
project_path.display()
)));
}
let floor = tdg_score_floor(profile);
let mut violations: Vec<QualityViolation> = scores
.iter()
.filter(|s| tdg_score_fails(s, floor))
.map(|s| tdg_violation(s, floor, project_path))
.collect();
violations.sort_by(|a, b| {
b.current
.total_cmp(&a.current)
.then_with(|| a.location.cmp(&b.location))
});
Ok(PhaseOutcome::measured(violations))
}
const DEAD_CODE_BUDGET_SECS: u64 = 300;
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn run_dead_code_analysis(
project_path: &Path,
_profile: &QualityProfile,
) -> Result<PhaseOutcome> {
use crate::cli::handlers::dead_code_handlers::handle_analyze_dead_code;
use crate::cli::DeadCodeOutputFormat;
let mut violations = Vec::new();
let capture = capture_path("dead-code");
let ran = handle_analyze_dead_code(
project_path.to_path_buf(),
DeadCodeOutputFormat::Json,
Some(10), true, 5, false, Some(capture.clone()), false, 15.0, DEAD_CODE_BUDGET_SECS, Vec::new(), Vec::new(), 8, )
.await;
match ran {
Ok(()) => match take_captured_json(&capture) {
Some(report) => {
let files = report.get("files").and_then(serde_json::Value::as_array);
for file in files.into_iter().flatten() {
let dead_lines = file
.get("dead_lines")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if dead_lines <= 0.0 {
continue;
}
let path = file
.get("path")
.and_then(serde_json::Value::as_str)
.unwrap_or("<unknown>");
violations.push(QualityViolation {
violation_type: "dead_code".to_string(),
severity: "low".to_string(),
location: path.to_string(),
current: dead_lines,
target: 0.0,
suggestion: "Remove dead code attributes and unused functions".to_string(),
});
}
}
None => {
let reason =
warn_not_measured("dead code", project_path, "no parsable JSON report");
return Ok(PhaseOutcome::unmeasured(reason));
}
},
Err(e) => {
let _ = std::fs::remove_file(&capture);
let reason = warn_not_measured("dead code", project_path, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
}
Ok(PhaseOutcome::measured(violations))
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn run_duplication_analysis(
project_path: &Path,
profile: &QualityProfile,
) -> Result<PhaseOutcome> {
use crate::cli::handlers::duplication_analysis::{
handle_analyze_duplicates, DuplicateAnalysisConfig,
};
use crate::cli::{DuplicateOutputFormat, DuplicateType};
let mut violations = Vec::new();
let capture = capture_path("duplication");
let dup_config = DuplicateAnalysisConfig {
project_path: project_path.to_path_buf(),
detection_type: DuplicateType::Exact,
threshold: 0.8,
min_lines: 10,
max_tokens: 100,
format: DuplicateOutputFormat::Json,
perf: false,
include: None,
exclude: None,
output: Some(capture.clone()),
top_files: 0, };
match handle_analyze_duplicates(dup_config).await {
Ok(()) => match take_captured_json(&capture) {
Some(report) => {
let duplicate_lines = report
.get("duplicate_lines")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if duplicate_lines > profile.duplication_max_lines as f64 {
let percentage = report
.get("duplication_percentage")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
violations.push(QualityViolation {
violation_type: "duplication".to_string(),
severity: if percentage >= 10.0 { "medium" } else { "low" }.to_string(),
location: format!("{} ({percentage:.1}% of lines)", project_path.display()),
current: duplicate_lines,
target: profile.duplication_max_lines as f64,
suggestion: "Extract common code into shared utilities".to_string(),
});
}
}
None => {
let reason =
warn_not_measured("duplication", project_path, "no parsable JSON report");
return Ok(PhaseOutcome::unmeasured(reason));
}
},
Err(e) => {
let _ = std::fs::remove_file(&capture);
let reason = warn_not_measured("duplication", project_path, &e.to_string());
return Ok(PhaseOutcome::unmeasured(reason));
}
}
Ok(PhaseOutcome::measured(violations))
}
fn read_measured_line_coverage(project_path: &Path) -> Option<f64> {
const CANDIDATES: [&str; 5] = [
"lcov.info",
"coverage/lcov.info",
"target/coverage/lcov.info",
"target/llvm-cov/lcov.info",
"target/llvm-cov-target/lcov.info",
];
for rel in CANDIDATES {
let Ok(text) = std::fs::read_to_string(project_path.join(rel)) else {
continue;
};
let mut lines_found: u64 = 0;
let mut lines_hit: u64 = 0;
for line in text.lines() {
if let Some(v) = line.trim().strip_prefix("LF:") {
lines_found += v.trim().parse::<u64>().unwrap_or(0);
} else if let Some(v) = line.trim().strip_prefix("LH:") {
lines_hit += v.trim().parse::<u64>().unwrap_or(0);
}
}
if lines_found > 0 {
return Some(lines_hit as f64 / lines_found as f64 * 100.0);
}
}
None
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn run_coverage_analysis(
project_path: &Path,
profile: &QualityProfile,
) -> Result<PhaseOutcome> {
let Some(coverage) = read_measured_line_coverage(project_path) else {
let reason = warn_not_measured(
"coverage",
project_path,
"no lcov report found (run `cargo llvm-cov --lcov --output-path lcov.info`)",
);
return Ok(PhaseOutcome::unmeasured(reason));
};
let mut violations = Vec::new();
if coverage < profile.coverage_min {
violations.push(QualityViolation {
violation_type: "coverage".to_string(),
severity: "high".to_string(),
location: "project".to_string(),
current: coverage,
target: profile.coverage_min,
suggestion: format!(
"Increase test coverage by {:.1}%",
profile.coverage_min - coverage
),
});
}
Ok(PhaseOutcome::measured(violations))
}
#[cfg(test)]
mod measured_violation_tests {
use super::{
read_measured_line_coverage, run_complexity_analysis, run_coverage_analysis,
run_duplication_analysis, run_tdg_analysis, QualityProfile,
};
#[tokio::test]
async fn test_empty_directory_yields_no_violations() {
let temp = tempfile::TempDir::new().unwrap();
let profile = QualityProfile::default();
let root = temp.path();
assert!(
run_complexity_analysis(root, &profile, None)
.await
.unwrap()
.violations
.is_empty(),
"an empty directory has no functions, so it can have no complexity violations"
);
assert!(
run_tdg_analysis(root, &profile)
.await
.unwrap()
.violations
.is_empty(),
"an empty directory has no files, so it can have no TDG hotspots"
);
assert!(
run_duplication_analysis(root, &profile)
.await
.unwrap()
.violations
.is_empty(),
"an empty directory has no duplicated lines"
);
let coverage = run_coverage_analysis(root, &profile).await.unwrap();
assert!(coverage.violations.is_empty());
assert!(
!coverage.is_measured(),
"absent coverage data must report as unmeasured, not as a clean phase"
);
}
#[tokio::test]
async fn test_complexity_violation_points_at_the_analysed_file() {
let temp = tempfile::TempDir::new().unwrap();
let src = temp.path().join("hot.rs");
let mut body = String::from("pub fn tangled(n: i32) -> i32 {\n let mut acc = 0;\n");
for i in 0..30 {
body.push_str(&format!(" if n == {i} {{ acc += {i}; }}\n"));
}
body.push_str(" acc\n}\n");
std::fs::write(&src, body).unwrap();
let profile = QualityProfile::default();
let outcome = run_complexity_analysis(temp.path(), &profile, Some(&src))
.await
.unwrap();
assert!(outcome.is_measured(), "the file parses, so this phase ran");
let violations = outcome.violations;
assert!(
!violations.is_empty(),
"a 31-branch function must exceed the default max of {}",
profile.complexity_max
);
let v = &violations[0];
assert_eq!(v.violation_type, "complexity");
assert!(
v.location.contains("hot.rs"),
"violation must name the analysed file, got {:?}",
v.location
);
assert!(
!v.location
.contains("server/src/cli/handlers/enforce_handlers.rs"),
"the hardcoded sample location must be gone"
);
assert!(
v.current > f64::from(profile.complexity_max),
"reported complexity {} must be the measured value",
v.current
);
}
#[test]
fn test_coverage_is_read_from_an_lcov_report() {
let temp = tempfile::TempDir::new().unwrap();
assert_eq!(read_measured_line_coverage(temp.path()), None);
std::fs::write(
temp.path().join("lcov.info"),
"SF:src/a.rs\nLF:100\nLH:90\nend_of_record\nSF:src/b.rs\nLF:100\nLH:80\nend_of_record\n",
)
.unwrap();
let measured = read_measured_line_coverage(temp.path()).unwrap();
assert!(
(measured - 85.0).abs() < 1e-9,
"expected the lcov totals (170/200), got {measured}"
);
}
#[tokio::test]
async fn test_coverage_violation_uses_the_measured_value() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("lcov.info"),
"SF:src/a.rs\nLF:100\nLH:42\nend_of_record\n",
)
.unwrap();
let profile = QualityProfile::default();
let outcome = run_coverage_analysis(temp.path(), &profile).await.unwrap();
assert!(
outcome.is_measured(),
"an lcov report exists, so this phase measured"
);
let violations = outcome.violations;
assert_eq!(violations.len(), 1);
assert!(
(violations[0].current - 42.0).abs() < 1e-9,
"coverage must be the measured 42.0, not the old simulated 65.0; got {}",
violations[0].current
);
}
}
#[cfg(test)]
mod scope_tests {
use super::AnalysisScope;
use std::path::{Path, PathBuf};
#[test]
fn test_resolve_without_file_is_project_scope() {
let scope = AnalysisScope::resolve(Path::new("/proj"), None);
assert_eq!(
scope,
AnalysisScope::Project {
root: PathBuf::from("/proj")
}
);
assert_eq!(scope.single_file(), None);
assert_eq!(scope.walk_root(), Path::new("/proj"));
assert_eq!(scope.file_or_root(), Path::new("/proj"));
}
#[test]
fn test_resolve_relative_file_joins_project_root() {
let scope =
AnalysisScope::resolve(Path::new("/proj"), Some(Path::new("src/utils/scratch.rs")));
assert_eq!(
scope.single_file(),
Some(Path::new("/proj/src/utils/scratch.rs"))
);
assert_eq!(scope.walk_root(), Path::new("/proj/src/utils"));
assert_eq!(
scope.file_or_root(),
Path::new("/proj/src/utils/scratch.rs")
);
}
#[test]
fn test_resolve_absolute_file_kept_as_is() {
let scope = AnalysisScope::resolve(Path::new("/proj"), Some(Path::new("/other/lib.rs")));
assert_eq!(scope.single_file(), Some(Path::new("/other/lib.rs")));
assert_eq!(scope.walk_root(), Path::new("/other"));
}
#[test]
fn test_resolve_bare_filename_uses_project_root_as_module_dir() {
let scope = AnalysisScope::resolve(Path::new("/proj"), Some(Path::new("main.rs")));
assert_eq!(scope.single_file(), Some(Path::new("/proj/main.rs")));
assert_eq!(scope.walk_root(), Path::new("/proj"));
}
#[test]
fn test_resolve_empty_parent_falls_back_to_project_root() {
let scope = AnalysisScope::resolve(Path::new(""), Some(Path::new("scratch.rs")));
assert_eq!(scope.walk_root(), Path::new(""));
assert_eq!(scope.single_file(), Some(Path::new("scratch.rs")));
}
}
#[cfg(test)]
mod round4_measurement_contract_tests {
use super::{
ensure_file_target_readable, run_complexity_analysis, run_tdg_analysis, tdg_score_floor,
unparseable_files, QualityProfile,
};
use std::path::PathBuf;
fn write_crate(dir: &std::path::Path, files: &[(&str, &str)]) {
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("write Cargo.toml");
std::fs::create_dir_all(dir.join("src")).expect("create src");
for (name, body) in files {
std::fs::write(dir.join("src").join(name), body).expect("write source");
}
}
#[test]
fn a_missing_file_target_is_an_error_not_a_verdict() {
let err = ensure_file_target_readable(&PathBuf::from("nope/zzz.rs"))
.expect_err("a path that does not exist cannot be enforced against");
let msg = err.to_string();
assert!(
msg.contains("path not found") && msg.contains("cannot read"),
"must reuse the --project-path refusal wording, got {msg}"
);
}
#[test]
fn a_directory_is_not_a_file_target() {
let dir = tempfile::tempdir().expect("tempdir");
let err = ensure_file_target_readable(dir.path())
.expect_err("--file names a file, not a directory");
assert!(err.to_string().contains("regular file"), "got {}", err);
}
#[tokio::test]
async fn complexity_phase_refuses_a_nonexistent_file_target() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("zzz.rs");
let err = run_complexity_analysis(dir.path(), &QualityProfile::default(), Some(&missing))
.await
.expect_err("an unreadable --file is bad input, not a quality failure");
assert!(err.to_string().contains("path not found"), "got {err}");
}
#[test]
fn unparseable_files_names_only_the_files_that_failed() {
let dir = tempfile::tempdir().expect("tempdir");
let good = dir.path().join("good.rs");
let bad = dir.path().join("bad.rs");
std::fs::write(&good, "pub fn a() -> i32 { 1 }\n").expect("write");
std::fs::write(&bad, "fn main( { let x = ;;;\n").expect("write");
let failures = unparseable_files(vec![good, bad].into_iter());
assert_eq!(failures.len(), 1, "only bad.rs fails: {failures:?}");
assert!(failures[0].contains("bad.rs"), "got {failures:?}");
}
#[tokio::test]
async fn a_project_whose_every_source_fails_to_parse_is_unmeasured() {
let dir = tempfile::tempdir().expect("tempdir");
write_crate(dir.path(), &[("main.rs", "fn main( { let x = ;;;\n")]);
let outcome = run_complexity_analysis(dir.path(), &QualityProfile::default(), None)
.await
.expect("phase runs");
assert!(
!outcome.is_measured(),
"the heuristic fallback produced metrics for input the parser rejected; \
that must not read as a clean measurement"
);
let reason = outcome.unmeasured.unwrap_or_default();
assert!(reason.contains("did not parse"), "got {reason}");
}
#[tokio::test]
async fn a_project_with_one_unparseable_file_still_discloses_the_gap() {
let dir = tempfile::tempdir().expect("tempdir");
write_crate(
dir.path(),
&[
("lib.rs", "pub fn ok(a: i32) -> i32 { a * 2 }\n"),
("bad.rs", "fn main( { let x = ;;;\n"),
],
);
let outcome = run_complexity_analysis(dir.path(), &QualityProfile::default(), None)
.await
.expect("phase runs");
assert!(
!outcome.is_measured(),
"one valid file plus one garbage file must not report as fully measured"
);
assert!(
outcome
.unmeasured
.as_deref()
.is_some_and(|r| r.contains("bad.rs")),
"the disclosure must name the file that failed: {:?}",
outcome.unmeasured
);
}
#[test]
fn the_tdg_floor_tracks_the_profile_and_stays_ordered() {
let extreme = QualityProfile::default();
let strict = QualityProfile {
tdg_max: 1.5,
..QualityProfile::default()
};
let standard = QualityProfile {
tdg_max: 2.5,
..QualityProfile::default()
};
assert!((tdg_score_floor(&extreme) - 90.0).abs() < 1e-9);
assert!(tdg_score_floor(&extreme) > tdg_score_floor(&strict));
assert!(tdg_score_floor(&strict) > tdg_score_floor(&standard));
let lax = QualityProfile {
tdg_max: 1000.0,
..QualityProfile::default()
};
assert!((tdg_score_floor(&lax) - 0.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn critical_defects_produce_a_tdg_violation_under_extreme() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("lib.rs");
let mut body = String::new();
for name in ["a", "b", "c", "d", "e"] {
body.push_str(&format!(
"pub fn {name}(s: &str) -> i32 {{\n s.parse::<i32>().unwrap()\n}}\n"
));
}
std::fs::write(&file, body).expect("write");
let outcome = run_tdg_analysis(&file, &QualityProfile::default())
.await
.expect("phase runs");
assert!(outcome.is_measured(), "the file parses, so tdg measured it");
assert_eq!(
outcome.violations.len(),
1,
"a file graded F must produce a tdg violation: {:?}",
outcome.violations
);
let v = &outcome.violations[0];
assert_eq!(v.violation_type, "tdg");
assert_eq!(v.severity, "high");
assert!(
v.suggestion.contains("/100"),
"the suggestion must quote the 0-100 score `pmat tdg` reports, got {}",
v.suggestion
);
assert!(
(v.target - 10.0).abs() < 1e-9,
"expected the extreme profile's 10-point allowance, got {}",
v.target
);
assert!(
v.current > v.target,
"a violating file must overshoot its allowance: {} vs {}",
v.current,
v.target
);
}
#[tokio::test]
async fn a_clean_file_produces_no_tdg_violation() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("clean.rs");
std::fs::write(
&file,
"//! A tidy module.\n\n/// Adds two numbers.\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
)
.expect("write");
let outcome = run_tdg_analysis(&file, &QualityProfile::default())
.await
.expect("phase runs");
assert!(outcome.is_measured());
assert!(
outcome.violations.is_empty(),
"a clean file must not be flagged: {:?}",
outcome.violations
);
}
}