use std::path::Path;
use ctx::error::Result;
use ctx::exit::Outcome;
use ctx::gatelog;
use ctx::score::{self, FailCondition, Metrics, ScoreReport};
pub fn run_score(against: &str, fail_on: Option<&str>, json: bool) -> Result<Outcome> {
let root = std::env::current_dir()?;
run_score_in(&root, against, fail_on, json)
}
fn run_score_in(
root: &Path,
against: &str,
fail_on: Option<&str>,
json_mode: bool,
) -> Result<Outcome> {
let conditions = match fail_on {
Some(expr) => score::parse_fail_on(expr)?,
None => Vec::new(),
};
let report = score::compute_score(root, against)?;
eprintln!(
"note: index refreshed ({} files reindexed)",
report.files_reindexed
);
let failed = score::failed_conditions(&conditions, &report.metrics);
if let Some(log_path) = gatelog::gate_log_target(root) {
let record = gatelog::GateRecord {
schema_version: gatelog::GATE_LOG_SCHEMA_VERSION,
ts: gatelog::now_rfc3339(),
ctx_version: env!("CARGO_PKG_VERSION").to_string(),
source: "score".to_string(),
against: against.to_string(),
fail_on: fail_on.map(str::to_string),
metrics: metrics_value(&report.metrics),
failed_conditions: failed.iter().map(|c| c.to_string()).collect(),
outcome: if failed.is_empty() { "pass" } else { "fail" }.to_string(),
blocking: gatelog::blocking_enabled(),
session_id: gatelog::session_id(),
};
if let Err(err) = gatelog::append(&log_path, &record) {
eprintln!(
"warning: could not append gate log {}: {}",
log_path.display(),
err
);
}
}
if json_mode {
ctx::json::emit("score", score_data(&report, &failed))?;
} else {
print_human(&report, &failed);
}
if failed.is_empty() {
Ok(Outcome::Clean)
} else {
eprintln!("failed conditions:");
for condition in &failed {
eprintln!(
" {} (actual {})",
condition,
report.metrics.get(&condition.metric).unwrap_or(0)
);
}
Ok(Outcome::Findings)
}
}
fn metrics_value(m: &Metrics) -> serde_json::Value {
serde_json::json!({
"complexity_delta": m.complexity_delta,
"fan_out_delta": m.fan_out_delta,
"new_duplication": m.new_duplication,
"check_violations": m.check_violations,
"symbols_added": m.symbols_added,
"symbols_removed": m.symbols_removed,
"files_changed": m.files_changed,
})
}
fn score_data(report: &ScoreReport, failed: &[FailCondition]) -> serde_json::Value {
let m = &report.metrics;
let per_file: Vec<serde_json::Value> = report
.per_file
.iter()
.map(|f| {
serde_json::json!({
"path": f.path,
"complexity_baseline": f.complexity_baseline,
"complexity_current": f.complexity_current,
"fan_out_baseline": f.fan_out_baseline,
"fan_out_current": f.fan_out_current,
"symbols_added": f.symbols_added,
"symbols_removed": f.symbols_removed,
})
})
.collect();
serde_json::json!({
"against": report.against,
"files_changed": m.files_changed,
"metrics": metrics_value(m),
"check_violations_note": report.check_violations_note,
"per_file": per_file,
"failed_conditions": failed.iter().map(|c| c.to_string()).collect::<Vec<_>>(),
"notes": report.notes,
})
}
fn marker(delta: i64) -> &'static str {
match delta.cmp(&0) {
std::cmp::Ordering::Greater => "▲",
std::cmp::Ordering::Less => "▼",
std::cmp::Ordering::Equal => "=",
}
}
fn print_human(report: &ScoreReport, failed: &[FailCondition]) {
let m = &report.metrics;
println!(
"Score vs {} ({} file{} changed)",
report.against,
m.files_changed,
if m.files_changed == 1 { "" } else { "s" }
);
println!();
let delta_rows = [
(
"complexity_delta",
report.complexity_baseline,
report.complexity_current,
m.complexity_delta,
),
(
"fan_out_delta",
report.fan_out_baseline,
report.fan_out_current,
m.fan_out_delta,
),
];
for (name, baseline, current, delta) in delta_rows {
println!(
" {:<18} {:>6} → {:<6} {} {:+}",
name,
baseline,
current,
marker(delta),
delta
);
}
let count_rows = [
("new_duplication", m.new_duplication, None),
(
"check_violations",
m.check_violations,
report.check_violations_note.as_deref(),
),
("symbols_added", m.symbols_added, None),
("symbols_removed", m.symbols_removed, None),
];
for (name, value, note) in count_rows {
let note = note.map(|n| format!(" ({})", n)).unwrap_or_default();
println!(
" {:<18} {:>6} {}{}",
name,
value,
marker(value),
note
);
}
if !report.notes.is_empty() {
println!();
println!("Notes:");
for note in &report.notes {
println!(" - {}", note);
}
}
if !failed.is_empty() {
println!();
println!("Failed conditions (exit 1):");
for condition in failed {
println!(
" - {} (actual {})",
condition,
report.metrics.get(&condition.metric).unwrap_or(0)
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ctx::index::Indexer;
use ctx::testutil::GitRepo;
use ctx::walker::WalkerConfig;
use tempfile::TempDir;
const BIG_FN: &str = r#"
pub fn process_orders(items: &[i64]) -> i64 {
let mut total = 0;
for item in items {
if *item > 10 {
total += *item * 2;
} else {
total += *item + 1;
}
}
println!("processed the batch: {}", total);
total
}
"#;
const BIG_FN_COPY: &str = r#"
pub fn sum_invoices(entries: &[i64]) -> i64 {
let mut acc = 0;
for entry in entries {
if *entry > 99 {
acc += *entry * 7;
} else {
acc += *entry + 3;
}
}
println!("done with invoices: {}", acc);
acc
}
"#;
fn index_once(root: &std::path::Path) {
let mut indexer = Indexer::with_config(root, false, WalkerConfig::default()).unwrap();
indexer.index().unwrap();
}
#[test]
fn test_run_score_fail_on_outcome_mapping() {
let temp = TempDir::new().unwrap();
let repo = GitRepo::init(temp.path());
repo.write("src/a.rs", BIG_FN);
repo.write("src/b.rs", "pub fn tiny() -> i64 { 1 }\n");
repo.commit_all("v1");
index_once(&repo.root);
repo.write(
"src/b.rs",
&format!("pub fn tiny() -> i64 {{ 1 }}\n{}", BIG_FN_COPY),
);
let outcome = run_score_in(&repo.root, "HEAD", Some("new_duplication>0"), false).unwrap();
assert_eq!(outcome, Outcome::Findings);
let outcome = run_score_in(&repo.root, "HEAD", None, false).unwrap();
assert_eq!(outcome, Outcome::Clean);
repo.write("src/b.rs", "pub fn tiny() -> i64 { 1 }\n");
let outcome = run_score_in(&repo.root, "HEAD", Some("new_duplication>0"), false).unwrap();
assert_eq!(outcome, Outcome::Clean);
let err = run_score_in(&repo.root, "HEAD", Some("nope>1"), false).unwrap_err();
assert!(err.to_string().contains("nope"), "err: {}", err);
}
#[test]
fn test_score_json_payload_shape() {
let temp = TempDir::new().unwrap();
let repo = GitRepo::init(temp.path());
repo.commit_file("src/a.rs", "pub fn a() -> i64 { 1 }\n", "v1");
index_once(&repo.root);
repo.write(
"src/a.rs",
"pub fn a() -> i64 { b() }\npub fn b() -> i64 { 2 }\n",
);
let report = ctx::score::compute_score(&repo.root, "HEAD").unwrap();
let conditions = ctx::score::parse_fail_on("symbols_added>0").unwrap();
let failed = ctx::score::failed_conditions(&conditions, &report.metrics);
let data = score_data(&report, &failed);
assert_eq!(data["against"], "HEAD");
assert_eq!(data["files_changed"], 1);
let metrics = data["metrics"].as_object().unwrap();
let mut keys: Vec<&str> = metrics.keys().map(|k| k.as_str()).collect();
keys.sort_unstable();
let mut expected = ctx::score::Metrics::NAMES.to_vec();
expected.sort_unstable();
assert_eq!(keys, expected);
assert!(metrics["symbols_added"].as_i64().unwrap() >= 1);
assert!(metrics["fan_out_delta"].as_i64().unwrap() >= 1);
let per_file = data["per_file"].as_array().unwrap();
assert_eq!(per_file.len(), 1);
let entry = per_file[0].as_object().unwrap();
for key in [
"path",
"complexity_baseline",
"complexity_current",
"fan_out_baseline",
"fan_out_current",
"symbols_added",
"symbols_removed",
] {
assert!(entry.contains_key(key), "missing per_file key {}", key);
}
assert_eq!(entry["path"], "src/a.rs");
assert_eq!(data["failed_conditions"][0], "symbols_added > 0");
assert!(data["notes"]
.as_array()
.unwrap()
.iter()
.any(|n| n == ctx::score::FAN_IN_NOTE));
assert_eq!(data["check_violations_note"], ctx::score::NO_RULES_NOTE);
assert_eq!(data["metrics"]["check_violations"], 0);
let envelope = ctx::json::envelope("score", data);
assert_eq!(envelope["command"], "score");
assert!(envelope["data"]["metrics"].is_object());
}
#[test]
fn test_marker() {
assert_eq!(marker(3), "▲");
assert_eq!(marker(-2), "▼");
assert_eq!(marker(0), "=");
}
}