1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use anyhow::Result;
6use regex::Regex;
7
8use crate::models::{RouteDoc, SymbolDoc, TestDoc, make_stable_id};
9use crate::{Framework, config::TestsConfig};
10
11pub fn extract_tests(
12 _repo: &Path,
13 repo_name: &str,
14 framework: Framework,
15 files: &[crate::scanner::ScannedFile],
16) -> Result<Vec<TestDoc>> {
17 let covers_class_re = Regex::new(r#"#\[CoversClass\(([^)]+)::class\)\]"#).unwrap();
18 let covers_method_re =
19 Regex::new(r#"#\[CoversMethod\(([^)]+)::class,\s*['"]([A-Za-z0-9_]+)['"]\)\]"#).unwrap();
20 let covers_doc_re = Regex::new(r#"@covers\s+\\?([A-Za-z0-9_\\:]+)"#).unwrap();
21 let route_call_re = Regex::new(
22 r#"(?:->|::)(?:get|post|put|patch|delete|json)\(\s*(?:['"][A-Z]+['"]\s*,\s*)?['"]([^'"]+)['"]"#,
23 )
24 .unwrap();
25 let test_name_re = Regex::new(r#"function\s+([A-Za-z0-9_]+)\s*\("#).unwrap();
26
27 let mut docs = Vec::new();
28 for file in files.iter().filter(|file| {
29 file.relative_path.starts_with("tests") || file.relative_path.starts_with("test")
30 }) {
31 let contents = fs::read_to_string(&file.absolute_path)?;
32 let fqn = test_name_re
33 .captures(&contents)
34 .and_then(|caps| caps.get(1))
35 .map(|item| item.as_str().to_string())
36 .unwrap_or_else(|| {
37 file.relative_path
38 .file_stem()
39 .and_then(|value| value.to_str())
40 .unwrap_or("UnknownTest")
41 .to_string()
42 });
43 let covered_symbols = covers_class_re
44 .captures_iter(&contents)
45 .filter_map(|caps| caps.get(1).map(|item| item.as_str().to_string()))
46 .chain(
47 covers_method_re
48 .captures_iter(&contents)
49 .filter_map(|caps| {
50 Some(format!(
51 "{}::{}",
52 caps.get(1)?.as_str(),
53 caps.get(2)?.as_str()
54 ))
55 }),
56 )
57 .chain(
58 covers_doc_re
59 .captures_iter(&contents)
60 .filter_map(|caps| caps.get(1).map(|item| item.as_str().to_string())),
61 )
62 .collect::<Vec<_>>();
63 let routes_called = route_call_re
64 .captures_iter(&contents)
65 .filter_map(|caps| caps.get(1).map(|item| item.as_str().to_string()))
66 .collect::<Vec<_>>();
67 let command = match framework {
68 Framework::Hyperf => format!("vendor/bin/co-phpunit --filter {fqn}"),
69 _ => format!("php artisan test --filter {fqn}"),
70 };
71 let path = file.relative_path.to_string_lossy().into_owned();
72 docs.push(TestDoc {
73 id: make_stable_id(&[repo_name, &path, &fqn]),
74 repo: repo_name.to_string(),
75 framework: framework.as_str().to_string(),
76 fqn,
77 path,
78 line_start: 1,
79 covered_symbols,
80 referenced_symbols: Vec::new(),
81 routes_called,
82 command,
83 confidence: 0.0,
84 });
85 }
86 Ok(docs)
87}
88
89pub fn link_symbols_and_routes(
90 symbols: &mut [SymbolDoc],
91 routes: &mut [RouteDoc],
92 tests: &mut [TestDoc],
93 test_config: &TestsConfig,
94) {
95 let mut test_scores: HashMap<String, Vec<(usize, f32)>> = HashMap::new();
96
97 for (test_index, test) in tests.iter_mut().enumerate() {
98 let mut best = 0.0f32;
99 for symbol in symbols.iter() {
100 let score = score_test_against_symbol(test, symbol);
101 if score > 0.0 {
102 test_scores
103 .entry(symbol.id.clone())
104 .or_default()
105 .push((test_index, score));
106 if score > best {
107 best = score;
108 }
109 }
110 }
111 test.confidence = best;
112 }
113
114 for symbol in symbols {
115 let Some(scored_tests) = test_scores.get(&symbol.id) else {
116 symbol.missing_test_warning = Some(format!(
117 "No related test with confidence >= {:.2} was found.",
118 test_config.validate_threshold
119 ));
120 continue;
121 };
122
123 let mut scored = scored_tests.clone();
124 scored.sort_by(|left, right| right.1.partial_cmp(&left.1).unwrap());
125 symbol.related_tests = scored
126 .iter()
127 .map(|(index, _)| tests[*index].fqn.clone())
128 .collect();
129 symbol.related_tests_count = symbol.related_tests.len() as u32;
130 symbol.validation_commands = scored
131 .iter()
132 .filter(|(_, score)| *score >= test_config.validate_threshold)
133 .map(|(index, _)| tests[*index].command.clone())
134 .collect();
135 if symbol.validation_commands.is_empty() {
136 symbol.missing_test_warning = Some(format!(
137 "No related test with confidence >= {:.2} was found.",
138 test_config.validate_threshold
139 ));
140 }
141 }
142
143 for route in routes {
144 route.related_tests = tests
145 .iter()
146 .filter(|test| test.routes_called.iter().any(|called| called == &route.uri))
147 .map(|test| test.fqn.clone())
148 .collect();
149 }
150}
151
152fn score_test_against_symbol(test: &TestDoc, symbol: &SymbolDoc) -> f32 {
153 if test
154 .covered_symbols
155 .iter()
156 .any(|covered| covered == &symbol.fqn)
157 {
158 return 0.95;
159 }
160 if test
161 .covered_symbols
162 .iter()
163 .any(|covered| covered.ends_with(&symbol.short_name))
164 {
165 return 0.8;
166 }
167 if test
168 .routes_called
169 .iter()
170 .any(|called| symbol.path.contains(called.trim_matches('/')))
171 {
172 return 0.65;
173 }
174 0.0
175}
176
177#[cfg(test)]
178mod tests {
179 use crate::config::TestsConfig;
180 use crate::models::{SymbolDoc, TestDoc};
181
182 use super::link_symbols_and_routes;
183
184 fn symbol() -> SymbolDoc {
185 SymbolDoc {
186 id: "symbol".to_string(),
187 stable_key: "symbol".to_string(),
188 repo: "repo".to_string(),
189 framework: "laravel".to_string(),
190 kind: "method".to_string(),
191 short_name: "sign".to_string(),
192 fqn: "App\\Services\\ConsentService::sign".to_string(),
193 owner_class: Some("App\\Services\\ConsentService".to_string()),
194 namespace: Some("App\\Services".to_string()),
195 signature: None,
196 doc_summary: None,
197 doc_description: None,
198 param_docs: Vec::new(),
199 return_doc: None,
200 throws_docs: Vec::new(),
201 magic_methods: Vec::new(),
202 magic_properties: Vec::new(),
203 inline_rule_comments: Vec::new(),
204 comment_keywords: Vec::new(),
205 symbol_tokens: Vec::new(),
206 framework_tags: Vec::new(),
207 risk_tags: Vec::new(),
208 route_ids: Vec::new(),
209 related_symbols: Vec::new(),
210 related_tests: Vec::new(),
211 related_tests_count: 0,
212 references_count: 0,
213 validation_commands: Vec::new(),
214 missing_test_warning: None,
215 package_name: "root/app".to_string(),
216 package_type: None,
217 package_version: None,
218 package_keywords: Vec::new(),
219 is_vendor: false,
220 is_project_code: true,
221 is_test: false,
222 autoloadable: true,
223 extraction_confidence: "fallback".to_string(),
224 path: "app/Services/ConsentService.php".to_string(),
225 absolute_path: "/repo/app/Services/ConsentService.php".to_string(),
226 line_start: 10,
227 line_end: 20,
228 }
229 }
230
231 #[test]
232 fn adds_validation_commands_for_high_confidence_tests() {
233 let mut symbols = vec![symbol()];
234 let mut tests = vec![TestDoc {
235 id: "test".to_string(),
236 repo: "repo".to_string(),
237 framework: "laravel".to_string(),
238 fqn: "PatientConsentTest".to_string(),
239 path: "tests/Feature/PatientConsentTest.php".to_string(),
240 line_start: 1,
241 covered_symbols: vec!["App\\Services\\ConsentService::sign".to_string()],
242 referenced_symbols: Vec::new(),
243 routes_called: Vec::new(),
244 command: "php artisan test --filter PatientConsentTest".to_string(),
245 confidence: 0.0,
246 }];
247
248 link_symbols_and_routes(&mut symbols, &mut [], &mut tests, &TestsConfig::default());
249
250 assert_eq!(
251 symbols[0].validation_commands,
252 vec!["php artisan test --filter PatientConsentTest"]
253 );
254 assert!(symbols[0].missing_test_warning.is_none());
255 }
256}