use std::path::PathBuf;
use std::process::Command;
use crate::spec_core::{
Constraint, ConstraintCategory, Evidence, ResolvedSpec, ScenarioResult, Section, SpecResult,
StepVerdict, Verdict,
};
use super::{VerificationContext, Verifier};
pub struct ComplexityVerifier;
impl Verifier for ComplexityVerifier {
fn name(&self) -> &str {
"complexity"
}
fn verify(&self, ctx: &VerificationContext) -> SpecResult<Vec<ScenarioResult>> {
let constraints = extract_quality_constraints(&ctx.resolved_spec);
if constraints.is_empty() {
return Ok(vec![]);
}
if ctx.change_paths.is_empty() {
return Ok(vec![]);
}
let mut results = vec![];
for qc in &constraints {
match qc {
QualityConstraint::LineRatio { max_ratio } => {
let result = check_line_ratio(ctx, *max_ratio);
results.push(result);
}
}
}
Ok(results)
}
}
#[derive(Debug, Clone, PartialEq)]
enum QualityConstraint {
LineRatio { max_ratio: f64 },
}
fn all_constraints(spec: &ResolvedSpec) -> Vec<Constraint> {
let mut result = spec.inherited_constraints.clone();
for section in &spec.task.sections {
if let Section::Constraints { items, .. } = section {
result.extend(items.clone());
}
}
result
}
fn extract_quality_constraints(spec: &ResolvedSpec) -> Vec<QualityConstraint> {
let mut result = vec![];
for constraint in all_constraints(spec) {
if constraint.category == ConstraintCategory::Must
&& let Some(ratio) = parse_line_ratio(&constraint.text)
{
result.push(QualityConstraint::LineRatio { max_ratio: ratio });
}
}
result
}
fn parse_line_ratio(text: &str) -> Option<f64> {
if (text.contains("不超过") || text.contains("不应超过"))
&& text.contains("删除")
&& text.contains("倍")
{
return extract_quoted_number(text).or_else(|| extract_trailing_number_before(text, "倍"));
}
let lower = text.to_lowercase();
if lower.contains("exceed") && lower.contains("times") && lower.contains("deleted") {
return extract_quoted_number(text)
.or_else(|| extract_number_before_keyword(&lower, "times"));
}
if (lower.contains("line ratio") || lower.contains("line_ratio"))
&& let Some(idx) = lower.find("<=")
{
let after = &lower[idx + 2..];
if let Some(n) = parse_first_number(after) {
return Some(n);
}
}
None
}
fn extract_quoted_number(text: &str) -> Option<f64> {
if let Some(start) = text.find('"') {
let rest = &text[start + 1..];
if let Some(end) = rest.find('"') {
let inside = &rest[..end];
if let Ok(n) = inside.trim().parse::<f64>() {
return Some(n);
}
}
}
if let Some(start) = text.find('\u{201C}') {
let rest = &text[start + '\u{201C}'.len_utf8()..];
if let Some(end) = rest.find('\u{201D}') {
let inside = &rest[..end];
if let Ok(n) = inside.trim().parse::<f64>() {
return Some(n);
}
}
}
None
}
fn extract_trailing_number_before(text: &str, keyword: &str) -> Option<f64> {
if let Some(idx) = text.find(keyword) {
let before = text[..idx].trim_end();
parse_trailing_number(before)
} else {
None
}
}
fn extract_number_before_keyword(text: &str, keyword: &str) -> Option<f64> {
if let Some(idx) = text.find(keyword) {
let before = text[..idx].trim_end();
parse_trailing_number(before)
} else {
None
}
}
fn parse_trailing_number(s: &str) -> Option<f64> {
let num_start = s
.rfind(|c: char| !c.is_ascii_digit() && c != '.')
.map(|i| i + 1)
.unwrap_or(0);
let candidate = &s[num_start..];
candidate.parse::<f64>().ok()
}
fn parse_first_number(s: &str) -> Option<f64> {
let trimmed = s.trim();
let end = trimmed
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(trimmed.len());
let candidate = &trimmed[..end];
if candidate.is_empty() {
None
} else {
candidate.parse::<f64>().ok()
}
}
fn check_line_ratio(ctx: &VerificationContext, max_ratio: f64) -> ScenarioResult {
let (added, deleted) = compute_diff_stats(&ctx.change_paths);
let actual_ratio = if deleted == 0 {
if added == 0 { 0.0 } else { f64::INFINITY }
} else {
added as f64 / deleted as f64
};
let passed = actual_ratio <= max_ratio;
let verdict = if passed { Verdict::Pass } else { Verdict::Fail };
ScenarioResult {
scenario_name: "[complexity] code quality gate".to_string(),
verdict,
step_results: vec![StepVerdict {
step_text: format!("line ratio {actual_ratio:.1}x <= {max_ratio:.1}x"),
verdict,
reason: format!(
"added {added} lines, deleted {deleted} lines, ratio {actual_ratio:.1}x (max {max_ratio:.1}x)"
),
}],
evidence: vec![Evidence::PatternMatch {
pattern: format!("line_ratio <= {max_ratio}"),
matched: passed,
locations: ctx
.change_paths
.iter()
.map(|p| p.display().to_string())
.collect(),
}],
duration_ms: 0,
provenance: None,
}
}
fn compute_diff_stats(change_paths: &[PathBuf]) -> (usize, usize) {
if change_paths.is_empty() {
return (0, 0);
}
let result = try_git_numstat(&["--cached"], change_paths)
.or_else(|| try_git_numstat(&["HEAD"], change_paths));
result.unwrap_or((0, 0))
}
fn try_git_numstat(extra_args: &[&str], paths: &[PathBuf]) -> Option<(usize, usize)> {
let mut cmd = Command::new("git");
cmd.arg("diff");
for arg in extra_args {
cmd.arg(arg);
}
cmd.arg("--numstat");
cmd.arg("--");
for p in paths {
cmd.arg(p);
}
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
Some(parse_numstat_output(&stdout))
}
fn parse_numstat_output(output: &str) -> (usize, usize) {
let mut total_added: usize = 0;
let mut total_deleted: usize = 0;
for line in output.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
continue;
}
let added = parts[0].parse::<usize>();
let deleted = parts[1].parse::<usize>();
if let (Ok(a), Ok(d)) = (added, deleted) {
total_added += a;
total_deleted += d;
}
}
(total_added, total_deleted)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::path::PathBuf;
use crate::spec_core::{
Constraint, ConstraintCategory, ResolvedSpec, Section, Span, SpecDocument, SpecLevel,
SpecMeta, Verdict,
};
use crate::spec_verify::{AiMode, VerificationContext, Verifier};
use super::{
ComplexityVerifier, QualityConstraint, extract_quality_constraints, parse_line_ratio,
parse_numstat_output,
};
fn make_resolved_spec(constraints: Vec<Constraint>) -> ResolvedSpec {
ResolvedSpec {
task: SpecDocument {
meta: SpecMeta {
level: SpecLevel::Task,
name: "test-complexity".into(),
inherits: None,
lang: vec![],
tags: vec![],
depends: vec![],
estimate: None,
capability: None,
},
sections: vec![Section::Constraints {
items: constraints,
span: Span::line(1),
}],
lint_acks: vec![],
source_path: PathBuf::new(),
},
inherited_constraints: vec![],
inherited_decisions: vec![],
all_scenarios: vec![],
}
}
fn make_ctx(constraints: Vec<Constraint>, change_paths: Vec<PathBuf>) -> VerificationContext {
VerificationContext {
code_paths: vec![PathBuf::from(".")],
change_paths,
ai_mode: AiMode::Off,
resolved_spec: make_resolved_spec(constraints),
}
}
fn line_ratio_constraint(ratio: &str) -> Constraint {
Constraint {
text: format!("新增代码行数不应超过删除行数的 \"{ratio}\" 倍"),
category: ConstraintCategory::Must,
span: Span::line(1),
}
}
#[test]
fn test_complexity_verifier_fails_on_line_ratio_exceeded() {
let constraints = vec![line_ratio_constraint("3")];
let extracted = extract_quality_constraints(&make_resolved_spec(constraints));
assert_eq!(extracted.len(), 1);
assert_eq!(
extracted[0],
QualityConstraint::LineRatio { max_ratio: 3.0 }
);
let (added, deleted) = (100_usize, 10_usize);
let ratio = added as f64 / deleted as f64;
assert!(ratio > 3.0, "ratio should exceed 3.0");
let numstat_output = "80\t5\tsrc/foo.rs\n20\t5\tsrc/bar.rs\n";
let (a, d) = parse_numstat_output(numstat_output);
assert_eq!(a, 100);
assert_eq!(d, 10);
let actual_ratio = a as f64 / d as f64;
assert!(actual_ratio > 3.0);
let ctx = make_ctx(
vec![line_ratio_constraint("3")],
vec![PathBuf::from("nonexistent_file_for_test.rs")],
);
let verifier = ComplexityVerifier;
let results = verifier.verify(&ctx).unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].scenario_name, "[complexity] code quality gate");
}
#[test]
fn test_complexity_verifier_silent_without_constraints() {
let ctx = make_ctx(vec![], vec![PathBuf::from("some_file.rs")]);
let verifier = ComplexityVerifier;
let results = verifier.verify(&ctx).unwrap();
assert!(
results.is_empty(),
"should produce no results when there are no quality constraints"
);
}
#[test]
fn test_complexity_verifier_passes_on_acceptable_ratio() {
let constraints = vec![line_ratio_constraint("3")];
let extracted = extract_quality_constraints(&make_resolved_spec(constraints));
assert_eq!(
extracted[0],
QualityConstraint::LineRatio { max_ratio: 3.0 }
);
let numstat_output = "15\t5\tsrc/foo.rs\n5\t5\tsrc/bar.rs\n";
let (a, d) = parse_numstat_output(numstat_output);
assert_eq!(a, 20);
assert_eq!(d, 10);
let actual_ratio = a as f64 / d as f64;
assert!(actual_ratio <= 3.0, "ratio should be within limit");
let ctx = make_ctx(
vec![line_ratio_constraint("3")],
vec![PathBuf::from("nonexistent_file_for_test.rs")],
);
let verifier = ComplexityVerifier;
let results = verifier.verify(&ctx).unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].verdict, Verdict::Pass);
}
#[test]
fn test_complexity_verifier_uses_git_diff_stats() {
let output = "10\t5\tsrc/main.rs\n20\t3\tsrc/lib.rs\n-\t-\timage.png\n";
let (added, deleted) = parse_numstat_output(output);
assert_eq!(added, 30, "should sum added lines from text files");
assert_eq!(deleted, 8, "should sum deleted lines from text files");
let binary_only = "-\t-\tbinary.dat\n";
let (a, d) = parse_numstat_output(binary_only);
assert_eq!(a, 0);
assert_eq!(d, 0);
let (a, d) = parse_numstat_output("");
assert_eq!(a, 0);
assert_eq!(d, 0);
}
#[test]
fn test_parse_line_ratio_chinese() {
assert_eq!(
parse_line_ratio("新增行数不超过删除行数的 \"3\" 倍"),
Some(3.0)
);
assert_eq!(
parse_line_ratio("新增代码行数不应超过删除行数的 \"5\" 倍"),
Some(5.0)
);
assert_eq!(
parse_line_ratio("新增代码行数不应超过删除行数的 \"3.5\" 倍"),
Some(3.5)
);
}
#[test]
fn test_parse_line_ratio_english() {
assert_eq!(
parse_line_ratio("net lines added must not exceed \"3\" times lines deleted"),
Some(3.0)
);
}
#[test]
fn test_parse_line_ratio_no_match() {
assert_eq!(parse_line_ratio("use clippy for linting"), None);
assert_eq!(parse_line_ratio("no .unwrap() calls allowed"), None);
}
#[test]
fn test_no_constraints_no_change_paths() {
let ctx = make_ctx(vec![line_ratio_constraint("3")], vec![]);
let verifier = ComplexityVerifier;
let results = verifier.verify(&ctx).unwrap();
assert!(results.is_empty());
}
}