pub(crate) mod analyzer;
pub(crate) mod changed;
mod collector;
pub(crate) mod diff;
mod diff_report;
mod normalize;
mod report;
mod scoring;
use std::collections::HashMap;
use std::error::Error;
use std::path::{Path, PathBuf};
use crate::dups;
use crate::git::GitRepo;
use crate::walk::WalkConfig;
use crate::cli::{GateScope, OutputMode};
use analyzer::{FileScore, ProjectScore, compute_project_score, score_to_grade};
use collector::{FileMetrics, analyze_single_file};
use report::{print_json, print_report, print_short, print_terse};
use scoring::{build_dimensions, build_empty_dimensions, score_file};
#[derive(Debug, Clone, Copy, Default)]
pub struct ScoreGate {
pub max_drop: Option<f64>,
pub scope: GateScope,
pub fail_below: Option<analyzer::Grade>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScoringModel {
Cognitive,
Legacy,
}
impl ScoringModel {
pub fn from_arg(s: &str) -> Self {
match s {
"legacy" => Self::Legacy,
"cogcom" => Self::Cognitive,
other => {
debug_assert!(false, "unexpected scoring model: {other}");
Self::Cognitive
}
}
}
}
pub fn run(
cfg: &WalkConfig<'_>,
output: OutputMode,
bottom: usize,
min_lines: usize,
model: &str,
) -> Result<(), Box<dyn Error>> {
let scoring_model = ScoringModel::from_arg(model);
let score = compute_score(cfg, bottom, min_lines, &scoring_model)?;
let target = cfg
.path
.to_str()
.filter(|s| *s != ".")
.map(|s| s.to_string());
match output {
OutputMode::Json => print_json(&score, target.as_deref())?,
OutputMode::Short => print_short(&score),
OutputMode::Terse => print_terse(&score),
OutputMode::Github | OutputMode::Codeclimate => {
return Err(crate::cli::ERR_CI_FORMAT_ONLY.into());
}
OutputMode::Table => print_report(&score, bottom, target.as_deref()),
}
Ok(())
}
pub(crate) fn gate_score_worsened(before: f64, after: f64, tolerance: f64) -> bool {
before - after > tolerance
}
pub(crate) fn format_gate_error(before: f64, after: f64, delta: f64, tolerance: f64) -> String {
format!(
"quality gate failed: score dropped {before:.2} → {after:.2} ({delta:+.4}), \
more than the {tolerance} tolerance"
)
}
pub fn run_diff(
cfg: &WalkConfig<'_>,
git_ref: &str,
output: OutputMode,
bottom: usize,
min_lines: usize,
model: &str,
gate: ScoreGate,
) -> Result<(), Box<dyn Error>> {
let scoring_model = ScoringModel::from_arg(model);
let after = compute_snapshot(cfg, bottom, min_lines, &scoring_model)?;
let repo = GitRepo::open(cfg.path)?;
let tmpdir = tempfile::tempdir()?;
repo.extract_tree_to_dir(git_ref, tmpdir.path())?;
let (_, prefix) = repo.walk_prefix(cfg.path)?;
let tmp_path = if prefix.as_os_str().is_empty() {
tmpdir.path().to_path_buf()
} else {
tmpdir.path().join(&prefix)
};
let ref_cfg = WalkConfig::new(&tmp_path, cfg.include_tests, cfg.filter);
let before = compute_snapshot(&ref_cfg, bottom, min_lines, &scoring_model)?;
let mut score_diff = diff::compute_diff(git_ref, &before.score, &after.score);
if gate.scope == GateScope::Changed {
let changes = repo.changes_since_in_workdir(git_ref)?;
score_diff.changed = Some(changed::ChangedScope {
files: changed::changed_files(
&changes,
&prefix,
&before.scores_by_path(&tmp_path),
&after.scores_by_path(cfg.path),
),
duplicated_lines_before: before.duplicated_lines,
duplicated_lines_after: after.duplicated_lines,
});
}
match output {
OutputMode::Json => diff_report::print_json(&score_diff)?,
OutputMode::Short => diff_report::print_short(&score_diff),
OutputMode::Terse => diff_report::print_terse(&score_diff),
OutputMode::Github | OutputMode::Codeclimate => {
return Err(crate::cli::ERR_CI_FORMAT_ONLY.into());
}
OutputMode::Table => diff_report::print_report(&score_diff),
}
match gate_failure(&score_diff, gate) {
Some(msg) => Err(msg.into()),
None => Ok(()),
}
}
fn gate_failure(score_diff: &diff::ScoreDiff, gate: ScoreGate) -> Option<String> {
let overall = &score_diff.overall;
let drop_failure = gate
.max_drop
.and_then(|tolerance| match &score_diff.changed {
Some(scope) => changed::gate_failure(scope, tolerance, overall.before),
None => gate_score_worsened(overall.before, overall.after, tolerance).then(|| {
format_gate_error(overall.before, overall.after, overall.delta, tolerance)
}),
});
drop_failure.or_else(|| {
let threshold = gate.fail_below?;
(score_diff.after_grade.numeric_rank() < threshold.numeric_rank()).then(|| {
format!(
"quality gate failed: score {} is below minimum threshold {}",
score_diff.after_grade, threshold
)
})
})
}
pub(crate) fn compute_score(
cfg: &WalkConfig<'_>,
bottom: usize,
min_lines: usize,
model: &ScoringModel,
) -> Result<ProjectScore, Box<dyn Error>> {
compute_snapshot(cfg, bottom, min_lines, model).map(|s| s.score)
}
struct Snapshot {
score: ProjectScore,
files: Vec<FileScore>,
duplicated_lines: usize,
}
impl Snapshot {
fn scores_by_path(&self, root: &Path) -> HashMap<PathBuf, f64> {
self.files
.iter()
.map(|f| {
let rel = f.path.strip_prefix(root).unwrap_or(&f.path);
(rel.to_path_buf(), f.score)
})
.collect()
}
}
fn compute_snapshot(
cfg: &WalkConfig<'_>,
bottom: usize,
min_lines: usize,
model: &ScoringModel,
) -> Result<Snapshot, Box<dyn Error>> {
let exclude_tests = cfg.exclude_tests();
let mut file_metrics: Vec<FileMetrics> = Vec::new();
let mut dup_files: Vec<dups::detector::NormalizedFile> = Vec::new();
let mut total_code_lines: usize = 0;
for (file_path, spec) in cfg.source_files() {
if let Some(result) = analyze_single_file(&file_path, spec, exclude_tests, model) {
total_code_lines += result.normalized_count;
dup_files.push(result.dup_file);
file_metrics.push(result.metrics);
}
}
let dup_groups = if dup_files.is_empty() {
Vec::new()
} else {
dups::detector::detect_duplicates(&dup_files, min_lines, true)
};
let duplicated_lines: usize = dup_groups.iter().map(|g| g.duplicated_lines()).sum();
let dup_percent = if total_code_lines == 0 {
0.0
} else {
duplicated_lines as f64 / total_code_lines as f64 * 100.0
};
let total_loc: usize = file_metrics.iter().map(|f| f.code_lines).sum();
let files_analyzed = file_metrics.len();
if files_analyzed == 0 {
let dimensions = build_empty_dimensions(model);
return Ok(Snapshot {
score: ProjectScore {
score: 0.0,
grade: score_to_grade(0.0),
files_analyzed: 0,
total_loc: 0,
dimensions,
needs_attention: vec![],
},
files: vec![],
duplicated_lines,
});
}
let dimensions = build_dimensions(&file_metrics, total_loc, dup_percent, model);
let project_score = compute_project_score(&dimensions);
let mut file_scores: Vec<FileScore> =
file_metrics.iter().map(|f| score_file(f, model)).collect();
file_scores.sort_by(|a, b| a.score.total_cmp(&b.score));
let needs_attention = file_scores.iter().take(bottom).cloned().collect();
Ok(Snapshot {
score: ProjectScore {
score: project_score,
grade: score_to_grade(project_score),
files_analyzed,
total_loc,
dimensions,
needs_attention,
},
files: file_scores,
duplicated_lines,
})
}
#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;