1use std::path::Path;
8
9use compact_str::CompactString;
10
11use crate::matcher;
12use crate::pipeline::{score_pair, ExtractedFiles};
13use crate::rule::{GoldenSelector, Rule};
14use crate::similarity::{SimilarityScore, Weights};
15use crate::FunctionRef;
16
17const DEFAULT_THRESHOLD: f64 = 0.7;
18
19#[derive(Clone, Debug)]
20pub struct WhyReport {
21 pub file: String,
22 pub entries: Vec<WhyEntry>,
23}
24
25#[derive(Clone, Debug)]
26pub enum WhyEntry {
27 Skipped { rule_id: String, symbol: CompactString },
29 Scored {
31 rule_id: String,
32 symbol: CompactString,
33 threshold: f64,
34 matches: Vec<WhyMatch>,
35 },
36}
37
38#[derive(Clone, Debug)]
39pub struct WhyMatch {
40 pub golden: GoldenSelector,
41 pub similarity: SimilarityScore,
42}
43
44#[derive(Debug, thiserror::Error)]
45pub enum ExplainError {
46 #[error("{file} did not extract to any function (was it walked?)")]
47 FileNotWalked { file: String },
48}
49
50pub fn build_why_report(
54 rules: &[Rule],
55 files: &ExtractedFiles,
56 target_file: &str,
57) -> Result<WhyReport, ExplainError> {
58 let funcs_in_file = files
59 .get(target_file)
60 .ok_or_else(|| ExplainError::FileNotWalked { file: target_file.to_string() })?;
61
62 let matching = matcher::matching_rules(Path::new(target_file), rules);
63 let mut entries: Vec<WhyEntry> = Vec::new();
64 if matching.is_empty() {
65 return Ok(WhyReport { file: target_file.to_string(), entries });
66 }
67
68 let weights = Weights::default();
69 for rule in matching {
70 let goldens = resolve_goldens(rule, files);
71 let threshold = rule.threshold.unwrap_or(DEFAULT_THRESHOLD);
72 for func in funcs_in_file {
73 if func.ignore.is_some() {
74 entries.push(WhyEntry::Skipped {
75 rule_id: rule.id.clone(),
76 symbol: func.symbol.clone(),
77 });
78 continue;
79 }
80 let matches: Vec<WhyMatch> = goldens
81 .iter()
82 .map(|(gsel, golden_func)| WhyMatch {
83 golden: gsel.clone(),
84 similarity: score_pair(func, golden_func, weights),
85 })
86 .collect();
87 entries.push(WhyEntry::Scored {
88 rule_id: rule.id.clone(),
89 symbol: func.symbol.clone(),
90 threshold,
91 matches,
92 });
93 }
94 }
95
96 Ok(WhyReport { file: target_file.to_string(), entries })
97}
98
99fn resolve_goldens<'f>(
100 rule: &Rule,
101 files: &'f ExtractedFiles,
102) -> Vec<(GoldenSelector, &'f FunctionRef)> {
103 rule.goldens
104 .iter()
105 .filter_map(|g| {
106 files.get(&g.file).and_then(|funcs| {
107 funcs
108 .iter()
109 .find(|f| f.symbol.as_str() == g.symbol)
110 .map(|f| (g.clone(), f))
111 })
112 })
113 .collect()
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::tree::TreeNode;
120 use crate::{FunctionKind, Signature};
121 use globset::{Glob, GlobSet, GlobSetBuilder};
122 use std::collections::HashMap;
123
124 fn glob_set(patterns: &[&str]) -> GlobSet {
125 let mut b = GlobSetBuilder::new();
126 for p in patterns {
127 b.add(Glob::new(p).expect("valid glob"));
128 }
129 b.build().expect("build glob set")
130 }
131
132 fn func(symbol: &str) -> FunctionRef {
133 let mut tree = TreeNode::branch(crate::tree::NodeKind::Block, vec![]);
134 tree.finalize();
135 let ast_hash = tree.canonical_hash();
136 FunctionRef {
137 symbol: symbol.into(),
138 kind: FunctionKind::FunctionDeclaration,
139 start_line: 0,
140 end_line: 0,
141 byte_range: (0, 0),
142 tree,
143 signature: Signature { param_count: 0 },
144 calls: vec![],
145 imports: vec![],
146 ast_hash,
147 ignore: None,
148 }
149 }
150
151 #[test]
152 fn missing_target_file_returns_error() {
153 let rules: Vec<Rule> = vec![];
154 let files: ExtractedFiles = HashMap::new();
155 let err = build_why_report(&rules, &files, "missing.ts").unwrap_err();
156 match err {
157 ExplainError::FileNotWalked { file } => assert_eq!(file, "missing.ts"),
158 }
159 }
160
161 #[test]
162 fn no_rule_match_yields_empty_entries() {
163 let mut files: ExtractedFiles = HashMap::new();
164 files.insert("src/x.ts".into(), vec![func("foo")]);
165 let rule = Rule {
166 id: "repos".into(),
167 goldens: vec![],
168 apply_to: glob_set(&["src/repos/**/*.ts"]),
169 ignore: glob_set(&[]),
170 threshold: None,
171 disabled: false,
172 };
173 let r = build_why_report(&[rule], &files, "src/x.ts").unwrap();
174 assert!(r.entries.is_empty());
175 }
176
177 #[test]
178 fn scored_entry_per_function_with_one_match_per_golden() {
179 let mut files: ExtractedFiles = HashMap::new();
180 files.insert("src/x.ts".into(), vec![func("a"), func("b")]);
181 files.insert("src/g.ts".into(), vec![func("g")]);
182 let rule = Rule {
183 id: "r".into(),
184 goldens: vec![GoldenSelector { file: "src/g.ts".into(), symbol: "g".into() }],
185 apply_to: glob_set(&["src/x.ts"]),
186 ignore: glob_set(&[]),
187 threshold: Some(0.5),
188 disabled: false,
189 };
190 let r = build_why_report(&[rule], &files, "src/x.ts").unwrap();
191 assert_eq!(r.entries.len(), 2);
192 for e in &r.entries {
193 match e {
194 WhyEntry::Scored { matches, threshold, .. } => {
195 assert_eq!(matches.len(), 1);
196 assert!((threshold - 0.5).abs() < f64::EPSILON);
197 }
198 WhyEntry::Skipped { .. } => panic!("unexpected skipped"),
199 }
200 }
201 }
202}