1use 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
38pub const DUP_THRESHOLD: f64 = 0.85;
40
41pub const DUP_MIN_TOKENS: i64 = 50;
43
44pub const FAN_IN_NOTE: &str = "fan_in approximated as same-file callers for baseline comparability";
46
47pub const NO_RULES_NOTE: &str = "no rules file";
49
50#[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 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 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#[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#[derive(Debug)]
108pub struct ScoreReport {
109 pub against: String,
111 pub files_reindexed: usize,
113 pub complexity_baseline: i64,
115 pub complexity_current: i64,
117 pub fan_out_baseline: i64,
119 pub fan_out_current: i64,
121 pub metrics: Metrics,
122 pub per_file: Vec<FileScore>,
123 pub check_violations_note: Option<String>,
125 pub notes: Vec<String>,
127}
128
129pub 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 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 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 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 let new_duplication = new_duplication(root, &db, &mut parser, reference, &changed_set)?;
177
178 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 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#[derive(Debug, Default)]
230struct Side {
231 complexity: i64,
232 fan_out: i64,
233 keys: HashSet<(Option<String>, String)>,
235}
236
237fn 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 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 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(¤t.keys).count() as i64,
274 })
275}
276
277fn parent_name(parent_id: Option<&str>) -> Option<String> {
279 parent_id.map(|p| p.rsplit("::").next().unwrap_or(p).to_string())
280}
281
282fn symbol_key(symbol: &Symbol) -> (Option<String>, String) {
285 (
286 parent_name(symbol.parent_id.as_deref()),
287 symbol.name.clone(),
288 )
289}
290
291fn 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
326fn 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
337fn 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 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 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, };
416 if !existed {
417 new_pairs += 1;
418 }
419 }
420 Ok(new_pairs)
421}
422
423#[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#[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 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
481pub 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 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
539pub 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#[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 #[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); }
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 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 const V1: &str = r#"
639pub fn alpha() -> i64 {
640 beta()
641}
642
643pub fn beta() -> i64 {
644 1
645}
646"#;
647
648 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 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 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 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 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 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 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 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 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 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 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 let conds = parse_fail_on("new_duplication>0").unwrap();
819 assert_eq!(failed_conditions(&conds, &report.metrics).len(), 1);
820
821 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 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 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 repo.write("src/c.rs", BIG_FN);
856 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 let temp = TempDir::new().unwrap();
888 let err = compute_score(temp.path(), "HEAD").unwrap_err();
889 assert!(matches!(err, CtxError::NotGitRepo));
890
891 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 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 #[test]
914 #[ignore = "benchmark; run manually with --ignored"]
915 fn test_score_benchmark_2000_files() {
916 let (_temp, repo) = setup_repo();
917
918 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 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}