1use std::collections::{BTreeMap, BTreeSet};
8use std::path::{Path, PathBuf};
9use std::slice;
10
11use crate::app_src::AppSrcSpec;
12use crate::model::names::{ApplicationName, ModuleName};
13use std::fs;
14
15use crate::suites::matcher::SuiteMatcher;
16use crate::suites::model::{ExtraRule, ExtraRuleTrigger, SuiteInclusionReason, SuiteRef};
17use crate::suites::scan;
18
19#[derive(Debug, Clone)]
23pub(crate) struct ModifiedModule {
24 pub module: ModuleName,
25 pub application: Option<ApplicationName>,
26}
27
28pub(crate) struct ModifiedClassification {
31 pub test_paths: Vec<PathBuf>,
32 pub modified_modules: Vec<ModifiedModule>,
33}
34
35pub(crate) fn classify(
36 modified_paths: &[PathBuf],
37 apps: &[AppSrcSpec],
38 repo_root: &Path,
39) -> ModifiedClassification {
40 let mut test_paths = Vec::new();
41 let mut modified_modules = Vec::new();
42 for p in modified_paths {
43 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
44 if name.ends_with("_SUITE.erl") {
45 test_paths.push(p.clone());
46 continue;
47 }
48 if let Some(base) = name.strip_suffix(".erl")
49 && is_under_src_or_include(p)
50 && let Ok(module) = ModuleName::new(base)
51 {
52 let application = scan::application_of_path(apps, repo_root, p).cloned();
53 modified_modules.push(ModifiedModule {
54 module,
55 application,
56 });
57 }
58 }
59 ModifiedClassification {
60 test_paths,
61 modified_modules,
62 }
63}
64
65fn is_under_src_or_include(p: &Path) -> bool {
66 p.components()
67 .any(|c| c.as_os_str() == "src" || c.as_os_str() == "include")
68}
69
70pub(crate) fn apply_test_modified(
73 classification: &ModifiedClassification,
74 discovered: &[SuiteRef],
75 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
76) {
77 for test_path in &classification.test_paths {
78 let basename = test_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
79 let module_name = basename.strip_suffix(".erl").unwrap_or(basename);
80 let Some(suite) = discovered.iter().find(|s| s.module.as_str() == module_name) else {
81 continue;
82 };
83 out.entry(suite.clone())
84 .or_default()
85 .push(SuiteInclusionReason::TestModified {
86 path: test_path.clone(),
87 });
88 }
89}
90
91pub(crate) fn apply_same_app_caller(
94 classification: &ModifiedClassification,
95 discovered: &[SuiteRef],
96 matcher: &mut dyn SuiteMatcher,
97 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
98) {
99 let mut by_app: BTreeMap<&ApplicationName, Vec<ModuleName>> = BTreeMap::new();
102 for m in &classification.modified_modules {
103 if let Some(app) = &m.application {
104 by_app.entry(app).or_default().push(m.module.clone());
105 }
106 }
107 for (app, modules) in &by_app {
108 for suite in discovered.iter().filter(|s| &s.application == *app) {
109 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
110 if refs.is_empty() {
111 continue;
112 }
113 for m in &classification.modified_modules {
115 if m.application.as_ref() == Some(*app) && refs.contains(&m.module) {
116 out.entry(suite.clone()).or_default().push(
117 SuiteInclusionReason::SameAppCaller {
118 application: (*app).clone(),
119 module: m.module.clone(),
120 },
121 );
122 }
123 }
124 }
125 }
126}
127
128pub(crate) fn apply_cross_app_caller(
132 classification: &ModifiedClassification,
133 discovered: &[SuiteRef],
134 library_apps: &[ApplicationName],
135 matcher: &mut dyn SuiteMatcher,
136 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
137) {
138 let library_set: BTreeSet<&ApplicationName> = library_apps.iter().collect();
139 let mut by_app: BTreeMap<&ApplicationName, Vec<ModuleName>> = BTreeMap::new();
142 for m in &classification.modified_modules {
143 if let Some(app) = &m.application
144 && library_set.contains(app)
145 {
146 by_app.entry(app).or_default().push(m.module.clone());
147 }
148 }
149 for (app, modules) in &by_app {
150 for suite in discovered.iter().filter(|s| &s.application != *app) {
151 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
152 if refs.is_empty() {
153 continue;
154 }
155 for m in &classification.modified_modules {
156 if m.application.as_ref() == Some(*app) && refs.contains(&m.module) {
157 out.entry(suite.clone()).or_default().push(
158 SuiteInclusionReason::CrossAppCaller {
159 library_application: (*app).clone(),
160 module: m.module.clone(),
161 },
162 );
163 }
164 }
165 }
166 }
167}
168
169pub(crate) fn apply_behaviour_implementer_sweep(
175 classification: &ModifiedClassification,
176 discovered: &[SuiteRef],
177 implementer_index: &BTreeMap<ModuleName, Vec<ModuleName>>,
178 matcher: &mut dyn SuiteMatcher,
179 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
180) {
181 if implementer_index.is_empty() {
182 return;
183 }
184 for m in &classification.modified_modules {
185 let Some(implementers) = implementer_index.get(&m.module) else {
186 continue;
187 };
188 for implementer in implementers {
189 for suite in discovered.iter() {
190 let refs =
191 matcher.modules_referenced_in_suite(&suite.path, slice::from_ref(implementer));
192 if refs.is_empty() {
193 continue;
194 }
195 out.entry(suite.clone()).or_default().push(
196 SuiteInclusionReason::BehaviourImplementerSweep {
197 behaviour: m.module.clone(),
198 implementer: implementer.clone(),
199 },
200 );
201 }
202 }
203 }
204}
205
206pub(crate) fn apply_unit_or_prop_sweep(
211 classification: &ModifiedClassification,
212 discovered: &[SuiteRef],
213 matcher: &mut dyn SuiteMatcher,
214 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
215) {
216 let mut by_app: BTreeMap<ApplicationName, Vec<ModuleName>> = BTreeMap::new();
217 for m in &classification.modified_modules {
218 if let Some(app) = &m.application {
219 by_app
220 .entry(app.clone())
221 .or_default()
222 .push(m.module.clone());
223 }
224 }
225 for (app, modules) in &by_app {
226 for suite in discovered
227 .iter()
228 .filter(|s| &s.application == app)
229 .filter(|s| is_unit_or_prop_suite(&s.module))
230 {
231 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
232 if refs.is_empty() {
233 continue;
234 }
235 out.entry(suite.clone())
236 .or_default()
237 .push(SuiteInclusionReason::UnitOrPropSweep {
238 application: app.clone(),
239 triggering_modules: refs.into_iter().collect(),
240 });
241 }
242 }
243}
244
245fn is_unit_or_prop_suite(module: &ModuleName) -> bool {
248 let n = module.as_str();
249 n.starts_with("unit_") || n.starts_with("prop_") || n.contains("_unit_") || n.contains("_prop_")
250}
251
252pub(crate) fn apply_configured_rules(
254 rules: &[ExtraRule],
255 modified_paths: &[PathBuf],
256 discovered: &[SuiteRef],
257 repo_root: &Path,
258 dep_module_index: &BTreeMap<String, Vec<ModuleName>>,
259 matcher: &mut dyn SuiteMatcher,
260 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
261) {
262 for rule in rules {
263 let path_regex = match &rule.trigger {
264 ExtraRuleTrigger::PathRegex { pattern, .. } => regex::Regex::new(pattern).ok(),
265 _ => None,
266 };
267 let line_regex = rule
268 .line_match
269 .as_ref()
270 .and_then(|m| regex::Regex::new(&m.pattern).ok());
271 for path in modified_paths {
272 let s = path.to_string_lossy();
273 let path_captures = path_regex.as_ref().and_then(|r| r.captures(&s));
274 let fires = match &rule.trigger {
275 ExtraRuleTrigger::PathSuffix { suffix } => s.ends_with(suffix.as_str()),
276 ExtraRuleTrigger::PathContains { fragment } => s.contains(fragment.as_str()),
277 ExtraRuleTrigger::PathRegex { .. } => path_captures.is_some(),
278 };
279 if !fires {
280 continue;
281 }
282 let path_caps_owned = match (&path_regex, &path_captures) {
283 (Some(rx), Some(caps)) => captures_to_owned(rx, caps),
284 _ => BTreeMap::new(),
285 };
286
287 let line_caps_iter: Vec<BTreeMap<String, String>> = if let Some(rx) = &line_regex {
288 read_matched_lines(repo_root, path, rx)
289 } else {
290 vec![BTreeMap::new()]
291 };
292
293 for line_caps in &line_caps_iter {
294 let merged = merge_captures(&path_caps_owned, line_caps);
295 fire_rule_once(
296 rule,
297 path,
298 &merged,
299 discovered,
300 dep_module_index,
301 matcher,
302 out,
303 );
304 }
305 }
306 }
307}
308
309fn fire_rule_once(
310 rule: &ExtraRule,
311 path: &Path,
312 captures: &BTreeMap<String, String>,
313 discovered: &[SuiteRef],
314 dep_module_index: &BTreeMap<String, Vec<ModuleName>>,
315 matcher: &mut dyn SuiteMatcher,
316 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
317) {
318 for spec in &rule.include_suites {
319 let Some(suite) = discovered
320 .iter()
321 .find(|s| s.application == spec.application && s.module == spec.module)
322 else {
323 continue;
324 };
325 out.entry(suite.clone())
326 .or_default()
327 .push(SuiteInclusionReason::ConfiguredRule {
328 rule_name: rule.name.clone(),
329 triggering_path: path.to_path_buf(),
330 });
331 }
332 for template in &rule.include_suite_templates {
333 let module_name = match expand_template_named(template, captures) {
334 Ok(m) => m,
335 Err(_) => continue,
336 };
337 for suite in discovered
338 .iter()
339 .filter(|s| s.module.as_str() == module_name)
340 {
341 out.entry(suite.clone())
342 .or_default()
343 .push(SuiteInclusionReason::ConfiguredRule {
344 rule_name: rule.name.clone(),
345 triggering_path: path.to_path_buf(),
346 });
347 }
348 }
349 if rule.include_suite_for_dep_modules {
350 let Some(dep_name) = captures.get("dep") else {
351 return;
352 };
353 let Some(modules) = dep_module_index.get(dep_name) else {
354 return;
355 };
356 if modules.is_empty() {
357 return;
358 }
359 for suite in discovered {
360 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
361 if refs.is_empty() {
362 continue;
363 }
364 out.entry(suite.clone())
365 .or_default()
366 .push(SuiteInclusionReason::ConfiguredRule {
367 rule_name: rule.name.clone(),
368 triggering_path: path.to_path_buf(),
369 });
370 }
371 }
372}
373
374fn captures_to_owned(rx: ®ex::Regex, caps: ®ex::Captures<'_>) -> BTreeMap<String, String> {
375 let mut out = BTreeMap::new();
376 for name in rx.capture_names().flatten() {
377 if let Some(m) = caps.name(name) {
378 out.insert(name.to_owned(), m.as_str().to_owned());
379 }
380 }
381 out
382}
383
384fn merge_captures(
385 base: &BTreeMap<String, String>,
386 extra: &BTreeMap<String, String>,
387) -> BTreeMap<String, String> {
388 let mut out = base.clone();
389 for (k, v) in extra {
390 out.insert(k.clone(), v.clone());
391 }
392 out
393}
394
395fn read_matched_lines(
396 repo_root: &Path,
397 path: &Path,
398 line_regex: ®ex::Regex,
399) -> Vec<BTreeMap<String, String>> {
400 let full = repo_root.join(path);
401 let Ok(content) = fs::read_to_string(&full) else {
402 return Vec::new();
403 };
404 let mut out = Vec::new();
405 for line in content.lines() {
406 if let Some(caps) = line_regex.captures(line) {
407 out.push(captures_to_owned(line_regex, &caps));
408 }
409 }
410 out
411}
412
413#[derive(Debug)]
414pub struct TemplateError {
415 pub placeholder: String,
416}
417
418fn substitute_placeholders(
423 template: &str,
424 mut push_value: impl FnMut(&str, &mut String) -> Result<(), TemplateError>,
425) -> Result<String, TemplateError> {
426 let mut out = String::with_capacity(template.len());
427 let mut rest = template;
428 while let Some(start) = rest.find('{') {
429 out.push_str(&rest[..start]);
430 let after = &rest[start + 1..];
431 let Some(end) = after.find('}') else {
432 out.push('{');
433 rest = after;
434 continue;
435 };
436 let name = &after[..end];
437 push_value(name, &mut out)?;
438 rest = &after[end + 1..];
439 }
440 out.push_str(rest);
441 Ok(out)
442}
443
444fn expand_template_named(
445 template: &str,
446 captures: &BTreeMap<String, String>,
447) -> Result<String, TemplateError> {
448 substitute_placeholders(template, |name, out| {
449 let v = captures.get(name).ok_or_else(|| TemplateError {
450 placeholder: name.to_owned(),
451 })?;
452 out.push_str(v);
453 Ok(())
454 })
455}
456
457pub fn validate_template_placeholders(
461 template: &str,
462 allowed_captures: &[String],
463) -> Result<(), TemplateError> {
464 substitute_placeholders(template, |name, _out| {
465 if allowed_captures.iter().any(|c| c == name) {
466 Ok(())
467 } else {
468 Err(TemplateError {
469 placeholder: name.to_owned(),
470 })
471 }
472 })
473 .map(|_| ())
474}