Skip to main content

ctx/
score.rs

1//! `ctx score` engine: a quality scorecard for the changes between a git
2//! reference and the current working tree.
3//!
4//! The pipeline is:
5//!
6//! 1. Refresh the index **incrementally** (never a full reindex).
7//! 2. Collect the changed-file set relative to the reference.
8//! 3. For every changed file, compute the same metrics on two sides:
9//!    - **current**: from the index, restricted to the file (per-file
10//!      queries only -- no global scans).
11//!    - **baseline**: by parsing the file's content at the reference
12//!      **in memory** (no database writes).
13//! 4. Diff the two sides into deltas, detect newly introduced duplication,
14//!    and run the architecture-rules check.
15//!
16//! ## Approximation
17//!
18//! Per-function complexity is `2 * fan_out + same_file_fan_in`. The baseline
19//! side is parsed in isolation, so cross-file callers are unknowable there;
20//! fan-in is therefore approximated as *same-file* callers on **both** sides
21//! so the delta compares like with like. This is surfaced as a note in both
22//! human and JSON output.
23
24use std::collections::{HashMap, HashSet};
25use std::fmt;
26use std::path::Path;
27
28use crate::check;
29use crate::db::{Database, ParseResult, Symbol, SymbolKind};
30use crate::error::{CtxError, Result};
31use crate::fingerprint;
32use crate::gitutil;
33use crate::index::Indexer;
34use crate::parser::{CodeParser, Language};
35use crate::rules;
36use crate::walker::WalkerConfig;
37
38/// Jaccard threshold used for the `new_duplication` metric.
39pub const DUP_THRESHOLD: f64 = 0.85;
40
41/// Minimum normalized token count for the `new_duplication` metric.
42pub const DUP_MIN_TOKENS: i64 = 50;
43
44/// Note attached whenever complexity deltas are reported.
45pub const FAN_IN_NOTE: &str = "fan_in approximated as same-file callers for baseline comparability";
46
47/// Note attached when `.ctx/rules.toml` does not exist.
48pub const NO_RULES_NOTE: &str = "no rules file";
49
50// ============================================================================
51// Metrics
52// ============================================================================
53
54/// The flat metric set of a score run. Field names are the exact metric
55/// names accepted by `--fail-on` and emitted under `data.metrics` in JSON.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub struct Metrics {
58    pub complexity_delta: i64,
59    pub fan_out_delta: i64,
60    pub new_duplication: i64,
61    pub check_violations: i64,
62    pub symbols_added: i64,
63    pub symbols_removed: i64,
64    pub files_changed: i64,
65}
66
67impl Metrics {
68    /// All valid metric names, in scorecard order.
69    pub const NAMES: [&'static str; 7] = [
70        "complexity_delta",
71        "fan_out_delta",
72        "new_duplication",
73        "check_violations",
74        "symbols_added",
75        "symbols_removed",
76        "files_changed",
77    ];
78
79    /// Look up a metric by its `--fail-on` / JSON name.
80    pub fn get(&self, name: &str) -> Option<i64> {
81        match name {
82            "complexity_delta" => Some(self.complexity_delta),
83            "fan_out_delta" => Some(self.fan_out_delta),
84            "new_duplication" => Some(self.new_duplication),
85            "check_violations" => Some(self.check_violations),
86            "symbols_added" => Some(self.symbols_added),
87            "symbols_removed" => Some(self.symbols_removed),
88            "files_changed" => Some(self.files_changed),
89            _ => None,
90        }
91    }
92}
93
94/// Per-changed-file breakdown (both sides of the delta metrics).
95#[derive(Debug, Clone)]
96pub struct FileScore {
97    pub path: String,
98    pub complexity_baseline: i64,
99    pub complexity_current: i64,
100    pub fan_out_baseline: i64,
101    pub fan_out_current: i64,
102    pub symbols_added: i64,
103    pub symbols_removed: i64,
104}
105
106/// The complete result of a score run.
107#[derive(Debug)]
108pub struct ScoreReport {
109    /// The git reference the working tree was compared against.
110    pub against: String,
111    /// How many files the internal incremental index refresh reindexed.
112    pub files_reindexed: usize,
113    /// Summed baseline complexity over changed files.
114    pub complexity_baseline: i64,
115    /// Summed current complexity over changed files.
116    pub complexity_current: i64,
117    /// Summed baseline fan-out over changed files.
118    pub fan_out_baseline: i64,
119    /// Summed current fan-out over changed files.
120    pub fan_out_current: i64,
121    pub metrics: Metrics,
122    pub per_file: Vec<FileScore>,
123    /// Set when `check_violations` is 0 because no rules file exists.
124    pub check_violations_note: Option<String>,
125    /// Caveats surfaced in both human and JSON output.
126    pub notes: Vec<String>,
127}
128
129// ============================================================================
130// Engine
131// ============================================================================
132
133/// Compute the score of the working tree (plus commits since the merge base)
134/// against `reference`.
135///
136/// `root` is the project root (also the directory git commands run in).
137pub fn compute_score(root: &Path, reference: &str) -> Result<ScoreReport> {
138    if !gitutil::is_git_repo_in(root) {
139        return Err(CtxError::NotGitRepo);
140    }
141
142    // Incremental index refresh: only changed files are re-parsed; the
143    // existing database is never cleared.
144    let mut indexer = Indexer::with_config(root, false, WalkerConfig::default())?;
145    let index_result = indexer.index()?;
146    let db = indexer.db;
147
148    let indexed: HashSet<String> = db.get_indexed_files()?.into_iter().collect();
149
150    // Changed files, filtered to supported source files that are either
151    // indexed (exist) or gone from disk (deleted). Supported files that
152    // exist but are excluded from the index (ignore patterns) are skipped.
153    let mut changed: Vec<String> = gitutil::changed_files_against_in(root, reference)?
154        .into_iter()
155        .filter(|f| CodeParser::is_supported_static(Path::new(f)))
156        .filter(|f| indexed.contains(f) || !root.join(f).exists())
157        .collect();
158    changed.sort();
159    let changed_set: HashSet<String> = changed.iter().cloned().collect();
160
161    // Per-file two-sided metrics.
162    let mut parser = CodeParser::new();
163    let mut per_file = Vec::with_capacity(changed.len());
164    for path in &changed {
165        per_file.push(score_file(
166            root,
167            &db,
168            &mut parser,
169            reference,
170            path,
171            &indexed,
172        )?);
173    }
174
175    // Newly introduced near-duplicate pairs.
176    let new_duplication = new_duplication(root, &db, &mut parser, reference, &changed_set)?;
177
178    // Architecture rules, scoped to the same reference.
179    let rules_path = root.join(rules::DEFAULT_RULES_PATH);
180    let (check_violations, check_violations_note) = if rules_path.exists() {
181        let context = check::load_context(root, None)?;
182        let violations = check::collect_violations(root, &context, Some(reference))?;
183        (violations.len() as i64, None)
184    } else {
185        (0, Some(NO_RULES_NOTE.to_string()))
186    };
187
188    // Aggregate.
189    let sum = |f: fn(&FileScore) -> i64| per_file.iter().map(f).sum::<i64>();
190    let complexity_baseline = sum(|f| f.complexity_baseline);
191    let complexity_current = sum(|f| f.complexity_current);
192    let fan_out_baseline = sum(|f| f.fan_out_baseline);
193    let fan_out_current = sum(|f| f.fan_out_current);
194
195    let metrics = Metrics {
196        complexity_delta: complexity_current - complexity_baseline,
197        fan_out_delta: fan_out_current - fan_out_baseline,
198        new_duplication,
199        check_violations,
200        symbols_added: sum(|f| f.symbols_added),
201        symbols_removed: sum(|f| f.symbols_removed),
202        files_changed: changed.len() as i64,
203    };
204
205    let mut notes = vec![FAN_IN_NOTE.to_string()];
206    if let Some(ref note) = check_violations_note {
207        notes.push(format!(
208            "check_violations: {} ({})",
209            note,
210            rules::DEFAULT_RULES_PATH
211        ));
212    }
213
214    Ok(ScoreReport {
215        against: reference.to_string(),
216        files_reindexed: index_result.files_indexed,
217        complexity_baseline,
218        complexity_current,
219        fan_out_baseline,
220        fan_out_current,
221        metrics,
222        per_file,
223        check_violations_note,
224        notes,
225    })
226}
227
228/// One side (baseline or current) of a changed file's metrics.
229#[derive(Debug, Default)]
230struct Side {
231    complexity: i64,
232    fan_out: i64,
233    /// `(parent_name, symbol_name)` keys of every symbol in the file.
234    keys: HashSet<(Option<String>, String)>,
235}
236
237/// Compute both sides of one changed file.
238fn score_file(
239    root: &Path,
240    db: &Database,
241    parser: &mut CodeParser,
242    reference: &str,
243    path: &str,
244    indexed: &HashSet<String>,
245) -> Result<FileScore> {
246    // Current side: from the index, restricted to this file. A file that is
247    // no longer indexed (deleted) contributes an empty side.
248    let current = if indexed.contains(path) {
249        let symbols = db.get_file_symbols(path)?;
250        let call_edges = db.file_call_edges(path)?;
251        side_metrics(&symbols, &call_edges)
252    } else {
253        Side::default()
254    };
255
256    // Baseline side: parse the content at the reference in memory. A file
257    // missing at the reference (added) contributes an empty side.
258    let baseline = match gitutil::show_file_in(root, reference, path)? {
259        Some(source) => match parser.parse(Path::new(path), &source) {
260            Some(parse) => parse_side_metrics(&parse),
261            None => Side::default(),
262        },
263        None => Side::default(),
264    };
265
266    Ok(FileScore {
267        path: path.to_string(),
268        complexity_baseline: baseline.complexity,
269        complexity_current: current.complexity,
270        fan_out_baseline: baseline.fan_out,
271        fan_out_current: current.fan_out,
272        symbols_added: current.keys.difference(&baseline.keys).count() as i64,
273        symbols_removed: baseline.keys.difference(&current.keys).count() as i64,
274    })
275}
276
277/// Last `::` segment of a symbol's parent id (the parent's simple name).
278fn parent_name(parent_id: Option<&str>) -> Option<String> {
279    parent_id.map(|p| p.rsplit("::").next().unwrap_or(p).to_string())
280}
281
282/// The `(parent_name, name)` key used to match symbols across sides.
283/// Never match by symbol id: ids embed line numbers, which shift.
284fn symbol_key(symbol: &Symbol) -> (Option<String>, String) {
285    (
286        parent_name(symbol.parent_id.as_deref()),
287        symbol.name.clone(),
288    )
289}
290
291/// Metrics for one side, from a symbol list plus the `calls` edges sourced
292/// in the file as `(source_symbol_id, target_name)` pairs. Used identically
293/// for both sides so deltas are honest.
294fn side_metrics(symbols: &[Symbol], call_edges: &[(String, String)]) -> Side {
295    let mut fan_out_by_source: HashMap<&str, i64> = HashMap::new();
296    let mut calls_by_target_name: HashMap<&str, i64> = HashMap::new();
297    for (source_id, target_name) in call_edges {
298        *fan_out_by_source.entry(source_id.as_str()).or_insert(0) += 1;
299        *calls_by_target_name
300            .entry(target_name.as_str())
301            .or_insert(0) += 1;
302    }
303
304    let mut side = Side {
305        fan_out: call_edges.len() as i64,
306        ..Side::default()
307    };
308    for symbol in symbols {
309        side.keys.insert(symbol_key(symbol));
310        if !matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
311            continue;
312        }
313        let fan_out = fan_out_by_source
314            .get(symbol.id.as_str())
315            .copied()
316            .unwrap_or(0);
317        let same_file_fan_in = calls_by_target_name
318            .get(symbol.name.as_str())
319            .copied()
320            .unwrap_or(0);
321        side.complexity += 2 * fan_out + same_file_fan_in;
322    }
323    side
324}
325
326/// [`side_metrics`] over an in-memory parse result (baseline side).
327fn parse_side_metrics(parse: &ParseResult) -> Side {
328    let call_edges: Vec<(String, String)> = parse
329        .edges
330        .iter()
331        .filter(|e| matches!(e.kind, crate::db::EdgeKind::Calls))
332        .map(|e| (e.source_id.clone(), e.target_name.clone()))
333        .collect();
334    side_metrics(&parse.symbols, &call_edges)
335}
336
337// ============================================================================
338// New duplication
339// ============================================================================
340
341/// Count verified near-duplicate pairs (threshold [`DUP_THRESHOLD`],
342/// min-tokens [`DUP_MIN_TOKENS`], at least one endpoint in a changed file)
343/// that did **not** exist at the baseline.
344///
345/// A pair existed at the baseline iff both endpoints map to baseline
346/// counterparts -- matched by `(file, parent, name)`, never by symbol id --
347/// and the baseline exact Jaccard similarity is still at or above the
348/// threshold. Any unmatched endpoint makes the pair new.
349fn new_duplication(
350    root: &Path,
351    db: &Database,
352    parser: &mut CodeParser,
353    reference: &str,
354    changed: &HashSet<String>,
355) -> Result<i64> {
356    let pairs =
357        fingerprint::find_near_duplicates(db, DUP_THRESHOLD, DUP_MIN_TOKENS, Some(changed))?;
358    if pairs.is_empty() {
359        return Ok(0);
360    }
361
362    // Baseline shingle sets for every function/method in the changed files,
363    // keyed by (parent, name). Duplicate keys (rare: cfg variants, overloads)
364    // keep all candidates.
365    type KeyedShingles = HashMap<(Option<String>, String), Vec<HashSet<u64>>>;
366    let mut baseline: HashMap<String, KeyedShingles> = HashMap::new();
367    for path in changed {
368        let mut keyed = KeyedShingles::new();
369        if let Some(source) = gitutil::show_file_in(root, reference, path)? {
370            let lang = Language::from_path(Path::new(path));
371            let tokens = fingerprint::tokenize(lang, &source);
372            let parse = parser.parse(Path::new(path), &source);
373            if let (Some(tokens), Some(parse)) = (tokens, parse) {
374                for symbol in &parse.symbols {
375                    if !matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
376                        continue;
377                    }
378                    let symbol_tokens: Vec<fingerprint::Tok> = tokens
379                        .iter()
380                        .filter(|t| t.line >= symbol.line_start && t.line <= symbol.line_end)
381                        .cloned()
382                        .collect();
383                    keyed
384                        .entry(symbol_key(symbol))
385                        .or_default()
386                        .push(fingerprint::shingle_set(&symbol_tokens));
387                }
388            }
389        }
390        baseline.insert(path.clone(), keyed);
391    }
392
393    // Baseline shingle sets for one current endpoint: from the baseline
394    // parse for changed files, from the stored (unchanged) snippet otherwise.
395    let baseline_sets = |symbol: &Symbol| -> Option<Vec<HashSet<u64>>> {
396        if changed.contains(&symbol.file_path) {
397            baseline
398                .get(&symbol.file_path)
399                .and_then(|keyed| keyed.get(&symbol_key(symbol)))
400                .cloned()
401        } else {
402            fingerprint::symbol_shingles(symbol).map(|s| vec![s])
403        }
404    };
405
406    let mut new_pairs = 0;
407    for pair in &pairs {
408        let existed = match (baseline_sets(&pair.a), baseline_sets(&pair.b)) {
409            (Some(a_sets), Some(b_sets)) => a_sets.iter().any(|sa| {
410                b_sets
411                    .iter()
412                    .any(|sb| fingerprint::jaccard(sa, sb) >= DUP_THRESHOLD)
413            }),
414            _ => false, // an unmatched endpoint means the pair is new
415        };
416        if !existed {
417            new_pairs += 1;
418        }
419    }
420    Ok(new_pairs)
421}
422
423// ============================================================================
424// --fail-on conditions
425// ============================================================================
426
427/// Comparison operator in a `--fail-on` condition.
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub enum Op {
430    Ge,
431    Le,
432    Gt,
433    Lt,
434}
435
436impl Op {
437    fn as_str(self) -> &'static str {
438        match self {
439            Op::Ge => ">=",
440            Op::Le => "<=",
441            Op::Gt => ">",
442            Op::Lt => "<",
443        }
444    }
445
446    fn holds(self, lhs: i64, rhs: i64) -> bool {
447        match self {
448            Op::Ge => lhs >= rhs,
449            Op::Le => lhs <= rhs,
450            Op::Gt => lhs > rhs,
451            Op::Lt => lhs < rhs,
452        }
453    }
454}
455
456/// One parsed `--fail-on` condition (`metric OP value`).
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct FailCondition {
459    pub metric: String,
460    pub op: Op,
461    pub value: i64,
462}
463
464impl FailCondition {
465    /// Whether this condition is satisfied (i.e. the gate fails) for the
466    /// given metrics.
467    pub fn holds(&self, metrics: &Metrics) -> bool {
468        metrics
469            .get(&self.metric)
470            .map(|actual| self.op.holds(actual, self.value))
471            .unwrap_or(false)
472    }
473}
474
475impl fmt::Display for FailCondition {
476    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477        write!(f, "{} {} {}", self.metric, self.op.as_str(), self.value)
478    }
479}
480
481/// Parse a comma-separated `--fail-on` expression like
482/// `"new_duplication>0, complexity_delta >= 10"`.
483///
484/// Metric names are the JSON metric keys exactly ([`Metrics::NAMES`]);
485/// operators are `>=`, `<=`, `>`, `<`. Unknown metrics or malformed
486/// conditions are errors (exit code 2).
487pub fn parse_fail_on(expr: &str) -> Result<Vec<FailCondition>> {
488    let mut conditions = Vec::new();
489    for part in expr.split(',') {
490        let part = part.trim();
491        if part.is_empty() {
492            return Err(CtxError::Other(format!(
493                "invalid --fail-on expression {:?}: empty condition",
494                expr
495            )));
496        }
497        // Two-character operators must be tried first.
498        let (op, idx, len) = if let Some(i) = part.find(">=") {
499            (Op::Ge, i, 2)
500        } else if let Some(i) = part.find("<=") {
501            (Op::Le, i, 2)
502        } else if let Some(i) = part.find('>') {
503            (Op::Gt, i, 1)
504        } else if let Some(i) = part.find('<') {
505            (Op::Lt, i, 1)
506        } else {
507            return Err(CtxError::Other(format!(
508                "invalid --fail-on condition {:?}: expected `metric OP value` with OP one of >=, <=, >, <",
509                part
510            )));
511        };
512
513        let metric = part[..idx].trim();
514        if !Metrics::NAMES.contains(&metric) {
515            return Err(CtxError::Other(format!(
516                "unknown --fail-on metric {:?} (valid metrics: {})",
517                metric,
518                Metrics::NAMES.join(", ")
519            )));
520        }
521
522        let value_str = part[idx + len..].trim();
523        let value: i64 = value_str.parse().map_err(|_| {
524            CtxError::Other(format!(
525                "invalid --fail-on condition {:?}: {:?} is not an integer",
526                part, value_str
527            ))
528        })?;
529
530        conditions.push(FailCondition {
531            metric: metric.to_string(),
532            op,
533            value,
534        });
535    }
536    Ok(conditions)
537}
538
539/// The subset of `conditions` satisfied by `metrics` (each one fails the gate).
540pub fn failed_conditions(conditions: &[FailCondition], metrics: &Metrics) -> Vec<FailCondition> {
541    conditions
542        .iter()
543        .filter(|c| c.holds(metrics))
544        .cloned()
545        .collect()
546}
547
548// ============================================================================
549// Tests
550// ============================================================================
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::testutil::GitRepo;
556    use crate::walker::WalkerConfig;
557    use std::fs;
558    use tempfile::TempDir;
559
560    // ---------- --fail-on parser ----------
561
562    #[test]
563    fn test_parse_fail_on_all_operators() {
564        let conds = parse_fail_on(
565            "complexity_delta>=10,new_duplication>0,fan_out_delta<=5,symbols_removed<3",
566        )
567        .unwrap();
568        assert_eq!(conds.len(), 4);
569        assert_eq!(conds[0].metric, "complexity_delta");
570        assert_eq!(conds[0].op, Op::Ge);
571        assert_eq!(conds[0].value, 10);
572        assert_eq!(conds[1].op, Op::Gt);
573        assert_eq!(conds[1].value, 0);
574        assert_eq!(conds[2].op, Op::Le);
575        assert_eq!(conds[2].value, 5);
576        assert_eq!(conds[3].op, Op::Lt);
577        assert_eq!(conds[3].value, 3);
578    }
579
580    #[test]
581    fn test_parse_fail_on_whitespace_tolerance() {
582        let conds = parse_fail_on("  new_duplication  >  0  ,  check_violations >= 1 ").unwrap();
583        assert_eq!(conds.len(), 2);
584        assert_eq!(conds[0].metric, "new_duplication");
585        assert_eq!(conds[0].op, Op::Gt);
586        assert_eq!(conds[1].metric, "check_violations");
587        assert_eq!(conds[1].op, Op::Ge);
588        assert_eq!(conds[0].to_string(), "new_duplication > 0");
589    }
590
591    #[test]
592    fn test_parse_fail_on_negative_values() {
593        let conds = parse_fail_on("complexity_delta>=-5").unwrap();
594        assert_eq!(conds[0].value, -5);
595    }
596
597    #[test]
598    fn test_parse_fail_on_unknown_metric_is_error() {
599        let err = parse_fail_on("bogus_metric>0").unwrap_err();
600        let msg = err.to_string();
601        assert!(msg.contains("bogus_metric"), "msg: {}", msg);
602        assert!(msg.contains("complexity_delta"), "msg: {}", msg); // lists valid names
603    }
604
605    #[test]
606    fn test_parse_fail_on_malformed_is_error() {
607        for bad in [
608            "new_duplication",
609            "new_duplication>",
610            ">0",
611            "new_duplication>x",
612            "",
613            " , ",
614        ] {
615            assert!(parse_fail_on(bad).is_err(), "expected error for {:?}", bad);
616        }
617        // One bad condition poisons the whole expression.
618        assert!(parse_fail_on("new_duplication>0,nope").is_err());
619    }
620
621    #[test]
622    fn test_failed_conditions_evaluation() {
623        let metrics = Metrics {
624            complexity_delta: 7,
625            new_duplication: 1,
626            ..Metrics::default()
627        };
628        let conds =
629            parse_fail_on("complexity_delta>=10,new_duplication>0,files_changed<1").unwrap();
630        let failed = failed_conditions(&conds, &metrics);
631        assert_eq!(failed.len(), 2);
632        assert_eq!(failed[0].to_string(), "new_duplication > 0");
633        assert_eq!(failed[1].to_string(), "files_changed < 1");
634    }
635
636    // ---------- end-to-end scoring ----------
637
638    const V1: &str = r#"
639pub fn alpha() -> i64 {
640    beta()
641}
642
643pub fn beta() -> i64 {
644    1
645}
646"#;
647
648    /// V1 plus a new function and an extra call to it inside alpha().
649    /// (Nested `fn` items are not extracted as symbols by the Rust parser,
650    /// so the new function is top-level.)
651    const V2: &str = r#"
652pub fn alpha() -> i64 {
653    beta() + gamma()
654}
655
656pub fn beta() -> i64 {
657    1
658}
659
660pub fn gamma() -> i64 {
661    2
662}
663"#;
664
665    /// A function with > 50 normalized tokens (fingerprintable).
666    const BIG_FN: &str = r#"
667pub fn process_orders(items: &[i64]) -> i64 {
668    let mut total = 0;
669    for item in items {
670        if *item > 10 {
671            total += *item * 2;
672        } else {
673            total += *item + 1;
674        }
675    }
676    println!("processed the batch: {}", total);
677    total
678}
679"#;
680
681    /// A structural copy of [`BIG_FN`] with renamed identifiers and changed
682    /// literals (still a near-duplicate under normalization).
683    const BIG_FN_COPY: &str = r#"
684pub fn sum_invoices(entries: &[i64]) -> i64 {
685    let mut acc = 0;
686    for entry in entries {
687        if *entry > 99 {
688            acc += *entry * 7;
689        } else {
690            acc += *entry + 3;
691        }
692    }
693    println!("done with invoices: {}", acc);
694    acc
695}
696"#;
697
698    /// A structurally unrelated function, also > 50 tokens.
699    const UNRELATED: &str = r#"
700pub fn render_table(headers: &[String], widths: &[usize]) -> String {
701    let mut out = String::new();
702    for (header, width) in headers.iter().zip(widths.iter()) {
703        out.push('|');
704        out.push_str(header);
705        while out.len() < *width {
706            out.push(' ');
707        }
708    }
709    out.push('\n');
710    for width in widths {
711        out.push_str(&"-".repeat(*width));
712        out.push('+');
713    }
714    out
715}
716"#;
717
718    fn setup_repo() -> (TempDir, GitRepo) {
719        let temp = TempDir::new().unwrap();
720        let repo = GitRepo::init(temp.path());
721        (temp, repo)
722    }
723
724    /// Build the initial index (part of test setup, not of what's measured).
725    fn index_once(root: &Path) {
726        let mut indexer = Indexer::with_config(root, false, WalkerConfig::default()).unwrap();
727        indexer.index().unwrap();
728    }
729
730    #[test]
731    fn test_score_added_symbols_and_calls() {
732        let (_temp, repo) = setup_repo();
733        repo.commit_file("src/a.rs", V1, "v1");
734        index_once(&repo.root);
735
736        // Uncommitted change: new function + extra call.
737        repo.write("src/a.rs", V2);
738        let report = compute_score(&repo.root, "HEAD").unwrap();
739
740        assert_eq!(report.metrics.files_changed, 1);
741        assert!(
742            report.metrics.complexity_delta > 0,
743            "complexity_delta: {}",
744            report.metrics.complexity_delta
745        );
746        assert!(
747            report.metrics.fan_out_delta >= 1,
748            "fan_out_delta: {}",
749            report.metrics.fan_out_delta
750        );
751        assert!(
752            report.metrics.symbols_added >= 1,
753            "symbols_added: {}",
754            report.metrics.symbols_added
755        );
756        assert_eq!(report.metrics.symbols_removed, 0);
757        assert_eq!(report.metrics.new_duplication, 0);
758        // No rules file: value 0 plus a note.
759        assert_eq!(report.metrics.check_violations, 0);
760        assert_eq!(report.check_violations_note.as_deref(), Some(NO_RULES_NOTE));
761        assert!(report.notes.iter().any(|n| n == FAN_IN_NOTE));
762
763        // Per-file entry carries both sides.
764        assert_eq!(report.per_file.len(), 1);
765        let file = &report.per_file[0];
766        assert_eq!(file.path, "src/a.rs");
767        assert!(file.complexity_current > file.complexity_baseline);
768        assert!(file.fan_out_current > file.fan_out_baseline);
769    }
770
771    #[test]
772    fn test_score_reverted_change_has_zero_deltas() {
773        let (_temp, repo) = setup_repo();
774        repo.commit_file("src/a.rs", V1, "v1");
775        index_once(&repo.root);
776
777        // A change that touches the file but not its structure: the file is
778        // still reported as changed, but every delta must be exactly zero.
779        repo.write("src/a.rs", &format!("{}\n// a trailing comment\n", V1));
780        let report = compute_score(&repo.root, "HEAD").unwrap();
781
782        assert_eq!(report.metrics.files_changed, 1);
783        assert_eq!(report.metrics.complexity_delta, 0);
784        assert_eq!(report.metrics.fan_out_delta, 0);
785        assert_eq!(report.metrics.symbols_added, 0);
786        assert_eq!(report.metrics.symbols_removed, 0);
787        assert_eq!(report.metrics.new_duplication, 0);
788
789        // Full revert: nothing is changed at all.
790        repo.write("src/a.rs", V1);
791        let report = compute_score(&repo.root, "HEAD").unwrap();
792        assert_eq!(report.metrics.files_changed, 0);
793        assert_eq!(report.metrics.complexity_delta, 0);
794        assert_eq!(report.metrics.fan_out_delta, 0);
795        assert_eq!(report.metrics.symbols_added, 0);
796        assert_eq!(report.metrics.symbols_removed, 0);
797    }
798
799    #[test]
800    fn test_score_new_duplication_detected_and_cleared() {
801        let (_temp, repo) = setup_repo();
802        repo.write("src/a.rs", BIG_FN);
803        repo.write("src/b.rs", UNRELATED);
804        repo.commit_all("v1");
805        index_once(&repo.root);
806
807        // Copy-paste (with renames) the big function into a changed file.
808        repo.write("src/b.rs", &format!("{}\n{}", UNRELATED, BIG_FN_COPY));
809        let report = compute_score(&repo.root, "HEAD").unwrap();
810        assert!(
811            report.metrics.new_duplication >= 1,
812            "new_duplication: {}",
813            report.metrics.new_duplication
814        );
815        assert!(report.metrics.symbols_added >= 1);
816
817        // --fail-on gate trips on the duplication.
818        let conds = parse_fail_on("new_duplication>0").unwrap();
819        assert_eq!(failed_conditions(&conds, &report.metrics).len(), 1);
820
821        // Deduplicate: back to clean.
822        repo.write("src/b.rs", UNRELATED);
823        let report = compute_score(&repo.root, "HEAD").unwrap();
824        assert_eq!(report.metrics.new_duplication, 0);
825        assert!(failed_conditions(&conds, &report.metrics).is_empty());
826    }
827
828    #[test]
829    fn test_score_preexisting_duplication_is_not_new() {
830        let (_temp, repo) = setup_repo();
831        // The duplicate pair already exists at the baseline.
832        repo.write("src/a.rs", BIG_FN);
833        repo.write("src/b.rs", BIG_FN_COPY);
834        repo.commit_all("v1 with existing duplication");
835        index_once(&repo.root);
836
837        // Touch one endpoint's file without changing the duplicate.
838        repo.write("src/b.rs", &format!("{}\n// touched\n", BIG_FN_COPY));
839        let report = compute_score(&repo.root, "HEAD").unwrap();
840        assert_eq!(
841            report.metrics.new_duplication, 0,
842            "pre-existing pair must not count as new"
843        );
844    }
845
846    #[test]
847    fn test_score_added_and_deleted_files() {
848        let (_temp, repo) = setup_repo();
849        repo.write("src/a.rs", V1);
850        repo.write("src/b.rs", UNRELATED);
851        repo.commit_all("v1");
852        index_once(&repo.root);
853
854        // Add a new untracked file: empty baseline side.
855        repo.write("src/c.rs", BIG_FN);
856        // Delete a tracked file: empty current side.
857        fs::remove_file(repo.root.join("src/b.rs")).unwrap();
858
859        let report = compute_score(&repo.root, "HEAD").unwrap();
860        assert_eq!(report.metrics.files_changed, 2);
861        assert!(report.metrics.symbols_added >= 1, "added file symbols");
862        assert!(report.metrics.symbols_removed >= 1, "deleted file symbols");
863
864        let added = report
865            .per_file
866            .iter()
867            .find(|f| f.path == "src/c.rs")
868            .unwrap();
869        assert_eq!(added.complexity_baseline, 0);
870        assert_eq!(added.fan_out_baseline, 0);
871        assert_eq!(added.symbols_removed, 0);
872
873        let deleted = report
874            .per_file
875            .iter()
876            .find(|f| f.path == "src/b.rs")
877            .unwrap();
878        assert_eq!(deleted.complexity_current, 0);
879        assert_eq!(deleted.fan_out_current, 0);
880        assert_eq!(deleted.symbols_added, 0);
881        assert!(deleted.symbols_removed >= 1);
882    }
883
884    #[test]
885    fn test_score_errors() {
886        // Not a git repository -> operational error.
887        let temp = TempDir::new().unwrap();
888        let err = compute_score(temp.path(), "HEAD").unwrap_err();
889        assert!(matches!(err, CtxError::NotGitRepo));
890
891        // Bad reference -> operational error.
892        let (_temp2, repo) = setup_repo();
893        repo.commit_file("src/a.rs", V1, "v1");
894        let err = compute_score(&repo.root, "no-such-ref").unwrap_err();
895        assert!(matches!(err, CtxError::InvalidRevision(_)), "err: {}", err);
896
897        // Present but invalid rules file -> operational error (not swallowed).
898        repo.write(".ctx/rules.toml", "[layers\nbroken = [");
899        let err = compute_score(&repo.root, "HEAD").unwrap_err();
900        assert!(
901            err.to_string().contains("invalid rules file"),
902            "err: {}",
903            err
904        );
905    }
906
907    /// Performance guard: scoring 3 changed files in a ~2000-file repo must
908    /// finish in under 2 seconds (per-file queries, fingerprints loaded
909    /// once, no O(n^2) work).
910    ///
911    /// Run manually with:
912    /// `cargo test --release test_score_benchmark_2000_files -- --ignored --nocapture`
913    #[test]
914    #[ignore = "benchmark; run manually with --ignored"]
915    fn test_score_benchmark_2000_files() {
916        let (_temp, repo) = setup_repo();
917
918        // ~2000 small, distinct functions (< 50 tokens: excluded from the
919        // duplication scan by min-tokens, as typical glue code would be).
920        for i in 0..2000 {
921            repo.write(
922                &format!("src/gen/f{:04}.rs", i),
923                &format!(
924                    "pub fn func_{i}(x: i64) -> i64 {{\n    let y = x * {i} + 1;\n    y - {i}\n}}\n",
925                    i = i
926                ),
927            );
928        }
929        repo.write("src/big.rs", BIG_FN);
930        repo.commit_all("v1");
931        index_once(&repo.root);
932
933        // Change 3 files.
934        for i in [7, 900, 1999] {
935            repo.write(
936                &format!("src/gen/f{:04}.rs", i),
937                &format!(
938                    "pub fn func_{i}(x: i64) -> i64 {{\n    let y = x * {i} + 2;\n    y + {i}\n}}\n",
939                    i = i
940                ),
941            );
942        }
943
944        let start = std::time::Instant::now();
945        let report = compute_score(&repo.root, "HEAD").unwrap();
946        let elapsed = start.elapsed();
947
948        assert_eq!(report.metrics.files_changed, 3);
949        eprintln!(
950            "score over 2001-file repo with 3 changed files: {:?}",
951            elapsed
952        );
953        assert!(
954            elapsed < std::time::Duration::from_secs(2),
955            "ctx score took {:?} (budget: 2s)",
956            elapsed
957        );
958    }
959}