#![cfg_attr(coverage_nightly, coverage(off))]
use crate::services::popper_score::orchestrator::PopperOrchestrator;
use crate::services::repo_score::aggregator::ScoreAggregator;
use crate::services::repo_score::scorers::ScorerConfig;
use crate::services::rust_project_score::models::ScoringMode;
use crate::services::rust_project_score::orchestrator::RustProjectScoreOrchestrator;
use std::path::Path;
use std::time::Duration;
use super::types::{CategoryScore, CategoryWeights, PerfectionScoreResult};
pub(super) type Measured = Result<f64, String>;
pub(super) fn push_category(
categories: &mut Vec<CategoryScore>,
name: &str,
weight: u16,
score: Measured,
) {
match score {
Ok(value) => categories.push(CategoryScore::new(name, value, weight)),
Err(why) => {
let mut skipped = CategoryScore::new(name, 0.0, 0)
.with_details(&format!("Not measured — {why} (excluded from total)"));
skipped.grade = "N/A".to_string();
categories.push(skipped);
}
}
}
pub(super) const CATEGORY_BUDGET: Duration = Duration::from_secs(100);
pub(super) const TOTAL_BUDGET: Duration = Duration::from_secs(120);
pub(super) async fn guard_total<F>(
project_path: &Path,
budget: Duration,
inner: F,
) -> anyhow::Result<PerfectionScoreResult>
where
F: std::future::Future<Output = anyhow::Result<PerfectionScoreResult>>,
{
match tokio::time::timeout(budget, inner).await {
Ok(result) => result,
Err(_elapsed) => Err(anyhow::anyhow!(
"perfection-score measured nothing: the run exceeded its {}s budget on {}. \
No score is reported — a category that never ran is not a category that scored zero. \
Re-run with --fast, or on a smaller path.",
budget.as_secs(),
project_path.display(),
)),
}
}
pub(super) async fn within_budget<F: std::future::Future<Output = Measured>>(fut: F) -> Measured {
match tokio::time::timeout(CATEGORY_BUDGET, fut).await {
Ok(measured) => measured,
Err(_elapsed) => Err(format!(
"it did not finish within {}s",
CATEGORY_BUDGET.as_secs()
)),
}
}
pub struct PerfectionScoreCalculator {
pub(super) weights: CategoryWeights,
pub(super) fast_mode: bool,
}
impl Default for PerfectionScoreCalculator {
fn default() -> Self {
Self::new()
}
}
impl PerfectionScoreCalculator {
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn new() -> Self {
Self {
weights: CategoryWeights::default(),
fast_mode: false,
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn fast_mode(mut self, fast: bool) -> Self {
self.fast_mode = fast;
self
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn calculate(&self, project_path: &Path) -> anyhow::Result<PerfectionScoreResult> {
guard_total(
project_path,
TOTAL_BUDGET,
self.calculate_inner(project_path),
)
.await
}
async fn calculate_inner(&self, project_path: &Path) -> anyhow::Result<PerfectionScoreResult> {
let (tdg_score, repo_score, rust_score, popper_score) = tokio::join!(
within_budget(self.get_tdg_score(project_path)),
within_budget(self.get_repo_score(project_path)),
within_budget(self.get_rust_project_score(project_path)),
within_budget(self.get_popper_score(project_path)),
);
let mut categories = Vec::new();
push_category(
&mut categories,
"Technical Debt Grade",
self.weights.tdg,
tdg_score,
);
push_category(
&mut categories,
"Repository Health",
self.weights.repo_score,
repo_score,
);
push_category(
&mut categories,
"Rust Project Quality",
self.weights.rust_score,
rust_score,
);
push_category(
&mut categories,
"Popperian Falsifiability",
self.weights.popper_score,
popper_score,
);
let coverage_score = self.get_coverage_score(project_path).await;
push_category(
&mut categories,
"Test Coverage",
self.weights.test_coverage,
coverage_score,
);
let mutation_score = if self.fast_mode {
Err("--fast skips mutation testing".to_string())
} else {
self.get_mutation_score(project_path).await
};
push_category(
&mut categories,
"Mutation Testing",
self.weights.mutation,
mutation_score,
);
let (doc_score, doc_details) = self.get_documentation_score(project_path).await;
categories.push(
CategoryScore::new("Documentation", doc_score, self.weights.documentation)
.with_details(&doc_details),
);
let perf_score = self.get_performance_score(project_path).await;
push_category(
&mut categories,
"Performance",
self.weights.performance,
perf_score,
);
let mut result = PerfectionScoreResult::new(categories);
let measured_max: u16 = result.categories.iter().map(|c| c.max_points).sum();
if measured_max > 0 && measured_max != result.max_score {
result.max_score = measured_max;
let scaled = result.total_score * f64::from(super::types::MAX_PERFECTION_SCORE)
/ f64::from(measured_max);
result.grade = PerfectionScoreResult::calculate_overall_grade(scaled);
}
Ok(result)
}
pub(super) async fn get_tdg_score(&self, project_path: &Path) -> Measured {
let analyzer = crate::tdg::TdgAnalyzer::new()
.map_err(|e| format!("the TDG analyzer could not start: {e}"))?;
let project = analyzer
.analyze_project(project_path)
.await
.map_err(|e| format!("TDG analysis failed: {e}"))?;
project.average_score.map(f64::from).ok_or_else(|| {
format!(
"no gradable source files under {} (`pmat tdg` reports the same)",
project_path.display()
)
})
}
pub(super) async fn get_repo_score(&self, project_path: &Path) -> Measured {
let aggregator = ScoreAggregator::new();
let config = ScorerConfig {
verbose: false,
timeout_seconds: 60,
skip_slow_checks: self.fast_mode,
deep: !self.fast_mode,
};
aggregator
.aggregate(project_path, &config)
.await
.map(|score| score.total_score)
.map_err(|e| format!("repo-score failed: {e}"))
}
pub(super) async fn get_rust_project_score(&self, project_path: &Path) -> Measured {
let orchestrator = RustProjectScoreOrchestrator::new();
let mode = if self.fast_mode {
ScoringMode::Quick
} else {
ScoringMode::Fast
};
orchestrator
.score_with_mode(project_path, mode)
.map(|score| normalize_rps_percentage(score.total_earned, score.total_possible))
.map_err(|e| format!("rust-project-score failed: {e}"))
}
pub(super) async fn get_popper_score(&self, project_path: &Path) -> Measured {
let orchestrator = PopperOrchestrator::new();
orchestrator
.score(project_path)
.map(|result| result.normalized_score)
.map_err(|e| format!("popper-score failed: {e}"))
}
pub(super) async fn get_coverage_score(&self, project_path: &Path) -> Measured {
let cache_paths = [
project_path.join(".pmat-metrics").join("coverage.json"),
project_path.join("server/.pmat-metrics/coverage.json"),
];
for metrics_file in &cache_paths {
if metrics_file.exists() {
if let Ok(content) = std::fs::read_to_string(metrics_file) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(coverage) = json.get("coverage").and_then(|v| v.as_f64()) {
return Ok(coverage);
}
}
}
}
}
Err(format!(
"no coverage data — run `cargo llvm-cov` (or `make coverage`) to write {}",
project_path.join(".pmat-metrics/coverage.json").display()
))
}
pub(super) async fn get_mutation_score(&self, project_path: &Path) -> Measured {
for root in [project_path.to_path_buf(), project_path.join("server")] {
let out_dir = root.join("mutants.out");
if let Some((caught, viable)) = read_mutation_outcomes(&out_dir) {
if viable == 0 {
return Err(format!("{} records no viable mutants", out_dir.display()));
}
return Ok((caught as f64 / viable as f64) * 100.0);
}
}
Err(format!(
"no mutation results — run `cargo mutants` to produce {}",
project_path.join("mutants.out/outcomes.json").display()
))
}
pub(super) async fn get_documentation_score(&self, project_path: &Path) -> (f64, String) {
let readme = if project_path.join("README.md").exists() {
project_path.join("README.md")
} else {
project_path.join("readme.md")
};
let parts = [
score_doc_file("README", &readme, 40.0, readme_has_structure),
score_doc_file(
"CHANGELOG",
&project_path.join("CHANGELOG.md"),
20.0,
has_version_entry,
),
score_docs_dir(&project_path.join("docs"), 25.0),
score_doc_file(
"CONTRIBUTING",
&project_path.join("CONTRIBUTING.md"),
15.0,
has_heading,
),
];
let score = parts.iter().fold(0.0_f64, |acc, p| acc + p.earned);
let breakdown: Vec<String> = parts.iter().map(DocPart::to_string).collect();
(
score.min(100.0),
format!(
"content, not filenames: {} (an empty file earns nothing)",
breakdown.join(", ")
),
)
}
pub(super) async fn get_performance_score(&self, project_path: &Path) -> Measured {
for root in [project_path.to_path_buf(), project_path.join("server")] {
let criterion_dir = root.join("target").join("criterion");
if let Some((regressed, compared)) = read_criterion_changes(&criterion_dir) {
return Ok(((compared - regressed) as f64 / compared as f64) * 100.0);
}
}
Err(format!(
"no benchmark comparisons — run `cargo bench` twice to produce {}",
project_path
.join("target/criterion/<bench>/change/estimates.json")
.display()
))
}
}
pub(super) struct DocPart {
label: &'static str,
earned: f64,
max: f64,
why: String,
}
impl std::fmt::Display for DocPart {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} {:.0}/{:.0} ({})",
self.label, self.earned, self.max, self.why
)
}
}
pub(super) const MIN_SUBSTANTIVE_CHARS: usize = 200;
const THIN_CREDIT: f64 = 0.4;
const UNSTRUCTURED_CREDIT: f64 = 0.7;
pub(super) fn score_doc_file(
label: &'static str,
path: &Path,
max: f64,
structure: fn(&str) -> bool,
) -> DocPart {
let part = |earned: f64, why: &str| DocPart {
label,
earned,
max,
why: why.to_string(),
};
let Ok(text) = std::fs::read_to_string(path) else {
return part(0.0, "missing");
};
let chars = text.chars().filter(|c| !c.is_whitespace()).count();
if chars == 0 {
return part(0.0, "empty");
}
if chars < MIN_SUBSTANTIVE_CHARS {
return part(max * THIN_CREDIT, "under a paragraph");
}
if !structure(&text) {
return part(max * UNSTRUCTURED_CREDIT, "prose only, no structure");
}
part(max, "substantive")
}
pub(super) fn score_docs_dir(dir: &Path, max: f64) -> DocPart {
const PER_FILE: f64 = 5.0;
const DOC_EXTENSIONS: [&str; 5] = ["md", "rst", "txt", "adoc", "org"];
let files = walkdir::WalkDir::new(dir)
.max_depth(4)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| DOC_EXTENSIONS.contains(&x.to_lowercase().as_str()))
})
.filter(|e| e.metadata().map(|m| m.len() > 0).unwrap_or(false))
.count();
let why = if !dir.is_dir() {
"missing".to_string()
} else {
format!("{files} non-empty file(s)")
};
DocPart {
label: "docs/",
earned: (files as f64 * PER_FILE).min(max),
max,
why,
}
}
pub(super) fn has_heading(text: &str) -> bool {
text.lines().any(|l| l.trim_start().starts_with('#'))
}
pub(super) fn readme_has_structure(text: &str) -> bool {
let headings = text
.lines()
.filter(|l| l.trim_start().starts_with('#'))
.count();
let has_example = text.matches("```").count() >= 2;
headings >= 2 && has_example
}
pub(super) fn has_version_entry(text: &str) -> bool {
text.lines()
.filter(|l| l.trim_start().starts_with('#'))
.any(looks_like_version)
}
fn looks_like_version(line: &str) -> bool {
let bytes = line.as_bytes();
bytes
.windows(3)
.any(|w| w[0].is_ascii_digit() && w[1] == b'.' && w[2].is_ascii_digit())
}
fn read_mutation_outcomes(out_dir: &Path) -> Option<(usize, usize)> {
if let Some(counts) = read_mutation_outcomes_json(&out_dir.join("outcomes.json")) {
return Some(counts);
}
let count_lines = |name: &str| -> Option<usize> {
std::fs::read_to_string(out_dir.join(name))
.ok()
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
};
let caught = count_lines("caught.txt");
let missed = count_lines("missed.txt");
let timeout = count_lines("timeout.txt").unwrap_or(0);
match (caught, missed) {
(None, None) => None,
(caught, missed) => {
let caught = caught.unwrap_or(0);
Some((caught, caught + missed.unwrap_or(0) + timeout))
}
}
}
fn read_mutation_outcomes_json(path: &Path) -> Option<(usize, usize)> {
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
let outcomes = json.get("outcomes")?.as_array()?;
let mut caught = 0;
let mut viable = 0;
for outcome in outcomes {
if outcome
.get("scenario")
.and_then(|s| s.as_str())
.is_some_and(|s| s.eq_ignore_ascii_case("baseline"))
{
continue;
}
match outcome.get("summary").and_then(|s| s.as_str()) {
Some("CaughtMutant") => {
caught += 1;
viable += 1;
}
Some("MissedMutant" | "Timeout") => viable += 1,
_ => {}
}
}
(viable > 0).then_some((caught, viable))
}
fn read_criterion_changes(criterion_dir: &Path) -> Option<(usize, usize)> {
if !criterion_dir.is_dir() {
return None;
}
const REGRESSION_THRESHOLD: f64 = 0.05;
let mut regressed = 0;
let mut compared = 0;
for entry in walkdir::WalkDir::new(criterion_dir)
.max_depth(6)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name() == "estimates.json")
.filter(|e| {
e.path()
.parent()
.and_then(|p| p.file_name())
.is_some_and(|n| n == "change")
})
{
let Ok(content) = std::fs::read_to_string(entry.path()) else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let Some(mean) = json
.get("mean")
.and_then(|m| m.get("point_estimate"))
.and_then(serde_json::Value::as_f64)
else {
continue;
};
compared += 1;
if mean > REGRESSION_THRESHOLD {
regressed += 1;
}
}
(compared > 0).then_some((regressed, compared))
}
pub(super) fn normalize_rps_percentage(total_earned: f64, total_possible: f64) -> f64 {
if total_possible > 0.0 {
((total_earned / total_possible) * 100.0).clamp(0.0, 100.0)
} else {
0.0
}
}