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();
101 for m in &classification.modified_modules {
102 if let Some(app) = &m.application {
103 by_app.entry(app).or_default().push(m.module.clone());
104 }
105 }
106 for (app, modules) in &by_app {
107 for suite in discovered.iter().filter(|s| &s.application == *app) {
108 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
109 if refs.is_empty() {
110 continue;
111 }
112 for m in &classification.modified_modules {
114 if m.application.as_ref() == Some(*app) && refs.contains(&m.module) {
115 out.entry(suite.clone()).or_default().push(
116 SuiteInclusionReason::SameAppCaller {
117 application: (*app).clone(),
118 module: m.module.clone(),
119 },
120 );
121 }
122 }
123 }
124 }
125}
126
127pub(crate) fn apply_cross_app_caller(
131 classification: &ModifiedClassification,
132 discovered: &[SuiteRef],
133 library_apps: &[ApplicationName],
134 matcher: &mut dyn SuiteMatcher,
135 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
136) {
137 let library_set: BTreeSet<&ApplicationName> = library_apps.iter().collect();
138 let mut by_app: BTreeMap<&ApplicationName, Vec<ModuleName>> = BTreeMap::new();
140 for m in &classification.modified_modules {
141 if let Some(app) = &m.application
142 && library_set.contains(app)
143 {
144 by_app.entry(app).or_default().push(m.module.clone());
145 }
146 }
147 for (app, modules) in &by_app {
148 for suite in discovered.iter().filter(|s| &s.application != *app) {
149 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
150 if refs.is_empty() {
151 continue;
152 }
153 for m in &classification.modified_modules {
154 if m.application.as_ref() == Some(*app) && refs.contains(&m.module) {
155 out.entry(suite.clone()).or_default().push(
156 SuiteInclusionReason::CrossAppCaller {
157 library_application: (*app).clone(),
158 module: m.module.clone(),
159 },
160 );
161 }
162 }
163 }
164 }
165}
166
167pub(crate) fn apply_behaviour_implementer_sweep(
173 classification: &ModifiedClassification,
174 discovered: &[SuiteRef],
175 implementer_index: &BTreeMap<ModuleName, Vec<ModuleName>>,
176 matcher: &mut dyn SuiteMatcher,
177 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
178) {
179 if implementer_index.is_empty() {
180 return;
181 }
182 for m in &classification.modified_modules {
183 let Some(implementers) = implementer_index.get(&m.module) else {
184 continue;
185 };
186 for implementer in implementers {
187 for suite in discovered.iter() {
188 let refs =
189 matcher.modules_referenced_in_suite(&suite.path, slice::from_ref(implementer));
190 if refs.is_empty() {
191 continue;
192 }
193 out.entry(suite.clone()).or_default().push(
194 SuiteInclusionReason::BehaviourImplementerSweep {
195 behaviour: m.module.clone(),
196 implementer: implementer.clone(),
197 },
198 );
199 }
200 }
201 }
202}
203
204pub(crate) fn apply_unit_or_prop_sweep(
209 classification: &ModifiedClassification,
210 discovered: &[SuiteRef],
211 matcher: &mut dyn SuiteMatcher,
212 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
213) {
214 let mut by_app: BTreeMap<ApplicationName, Vec<ModuleName>> = BTreeMap::new();
215 for m in &classification.modified_modules {
216 if let Some(app) = &m.application {
217 by_app
218 .entry(app.clone())
219 .or_default()
220 .push(m.module.clone());
221 }
222 }
223 for (app, modules) in &by_app {
224 for suite in discovered
225 .iter()
226 .filter(|s| &s.application == app)
227 .filter(|s| is_unit_or_prop_suite(&s.module))
228 {
229 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
230 if refs.is_empty() {
231 continue;
232 }
233 out.entry(suite.clone())
234 .or_default()
235 .push(SuiteInclusionReason::UnitOrPropSweep {
236 application: app.clone(),
237 triggering_modules: refs.into_iter().collect(),
238 });
239 }
240 }
241}
242
243fn is_unit_or_prop_suite(module: &ModuleName) -> bool {
246 let n = module.as_str();
247 n.starts_with("unit_") || n.starts_with("prop_") || n.contains("_unit_") || n.contains("_prop_")
248}
249
250pub(crate) fn apply_configured_rules(
252 rules: &[ExtraRule],
253 modified_paths: &[PathBuf],
254 discovered: &[SuiteRef],
255 repo_root: &Path,
256 dep_module_index: &BTreeMap<String, Vec<ModuleName>>,
257 matcher: &mut dyn SuiteMatcher,
258 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
259) {
260 for rule in rules {
261 let path_regex = match &rule.trigger {
262 ExtraRuleTrigger::PathRegex { pattern, .. } => regex::Regex::new(pattern).ok(),
263 _ => None,
264 };
265 let line_regex = rule
266 .line_match
267 .as_ref()
268 .and_then(|m| regex::Regex::new(&m.pattern).ok());
269 for path in modified_paths {
270 let s = path.to_string_lossy();
271 let path_captures = path_regex.as_ref().and_then(|r| r.captures(&s));
272 let fires = match &rule.trigger {
273 ExtraRuleTrigger::PathSuffix { suffix } => s.ends_with(suffix.as_str()),
274 ExtraRuleTrigger::PathContains { fragment } => s.contains(fragment.as_str()),
275 ExtraRuleTrigger::PathRegex { .. } => path_captures.is_some(),
276 };
277 if !fires {
278 continue;
279 }
280 let path_caps_owned = match (&path_regex, &path_captures) {
281 (Some(rx), Some(caps)) => captures_to_owned(rx, caps),
282 _ => BTreeMap::new(),
283 };
284
285 let line_caps_iter: Vec<BTreeMap<String, String>> = if let Some(rx) = &line_regex {
286 read_matched_lines(repo_root, path, rx)
287 } else {
288 vec![BTreeMap::new()]
289 };
290
291 for line_caps in &line_caps_iter {
292 let merged = merge_captures(&path_caps_owned, line_caps);
293 fire_rule_once(
294 rule,
295 path,
296 &merged,
297 discovered,
298 dep_module_index,
299 matcher,
300 out,
301 );
302 }
303 }
304 }
305}
306
307fn fire_rule_once(
308 rule: &ExtraRule,
309 path: &Path,
310 captures: &BTreeMap<String, String>,
311 discovered: &[SuiteRef],
312 dep_module_index: &BTreeMap<String, Vec<ModuleName>>,
313 matcher: &mut dyn SuiteMatcher,
314 out: &mut BTreeMap<SuiteRef, Vec<SuiteInclusionReason>>,
315) {
316 for spec in &rule.include_suites {
317 let Some(suite) = discovered
318 .iter()
319 .find(|s| s.application == spec.application && s.module == spec.module)
320 else {
321 continue;
322 };
323 out.entry(suite.clone())
324 .or_default()
325 .push(SuiteInclusionReason::ConfiguredRule {
326 rule_name: rule.name.clone(),
327 triggering_path: path.to_path_buf(),
328 });
329 }
330 for template in &rule.include_suite_templates {
331 let module_name = match expand_template_named(template, captures) {
332 Ok(m) => m,
333 Err(_) => continue,
334 };
335 for suite in discovered
336 .iter()
337 .filter(|s| s.module.as_str() == module_name)
338 {
339 out.entry(suite.clone())
340 .or_default()
341 .push(SuiteInclusionReason::ConfiguredRule {
342 rule_name: rule.name.clone(),
343 triggering_path: path.to_path_buf(),
344 });
345 }
346 }
347 if rule.include_suite_for_dep_modules {
348 let Some(dep_name) = captures.get("dep") else {
349 return;
350 };
351 let Some(modules) = dep_module_index.get(dep_name) else {
352 return;
353 };
354 if modules.is_empty() {
355 return;
356 }
357 for suite in discovered {
358 let refs = matcher.modules_referenced_in_suite(&suite.path, modules);
359 if refs.is_empty() {
360 continue;
361 }
362 out.entry(suite.clone())
363 .or_default()
364 .push(SuiteInclusionReason::ConfiguredRule {
365 rule_name: rule.name.clone(),
366 triggering_path: path.to_path_buf(),
367 });
368 }
369 }
370}
371
372fn captures_to_owned(rx: ®ex::Regex, caps: ®ex::Captures<'_>) -> BTreeMap<String, String> {
373 let mut out = BTreeMap::new();
374 for name in rx.capture_names().flatten() {
375 if let Some(m) = caps.name(name) {
376 out.insert(name.to_owned(), m.as_str().to_owned());
377 }
378 }
379 out
380}
381
382fn merge_captures(
383 base: &BTreeMap<String, String>,
384 extra: &BTreeMap<String, String>,
385) -> BTreeMap<String, String> {
386 let mut out = base.clone();
387 for (k, v) in extra {
388 out.insert(k.clone(), v.clone());
389 }
390 out
391}
392
393fn read_matched_lines(
394 repo_root: &Path,
395 path: &Path,
396 line_regex: ®ex::Regex,
397) -> Vec<BTreeMap<String, String>> {
398 let full = repo_root.join(path);
399 let Ok(content) = fs::read_to_string(&full) else {
400 return Vec::new();
401 };
402 let mut out = Vec::new();
403 for line in content.lines() {
404 if let Some(caps) = line_regex.captures(line) {
405 out.push(captures_to_owned(line_regex, &caps));
406 }
407 }
408 out
409}
410
411#[derive(Debug)]
412pub struct TemplateError {
413 pub placeholder: String,
414}
415
416fn substitute_placeholders(
421 template: &str,
422 mut push_value: impl FnMut(&str, &mut String) -> Result<(), TemplateError>,
423) -> Result<String, TemplateError> {
424 let mut out = String::with_capacity(template.len());
425 let mut rest = template;
426 while let Some(start) = rest.find('{') {
427 out.push_str(&rest[..start]);
428 let after = &rest[start + 1..];
429 let Some(end) = after.find('}') else {
430 out.push('{');
431 rest = after;
432 continue;
433 };
434 let name = &after[..end];
435 push_value(name, &mut out)?;
436 rest = &after[end + 1..];
437 }
438 out.push_str(rest);
439 Ok(out)
440}
441
442fn expand_template_named(
443 template: &str,
444 captures: &BTreeMap<String, String>,
445) -> Result<String, TemplateError> {
446 substitute_placeholders(template, |name, out| {
447 let v = captures.get(name).ok_or_else(|| TemplateError {
448 placeholder: name.to_owned(),
449 })?;
450 out.push_str(v);
451 Ok(())
452 })
453}
454
455pub fn validate_template_placeholders(
459 template: &str,
460 allowed_captures: &[String],
461) -> Result<(), TemplateError> {
462 substitute_placeholders(template, |name, _out| {
463 if allowed_captures.iter().any(|c| c == name) {
464 Ok(())
465 } else {
466 Err(TemplateError {
467 placeholder: name.to_owned(),
468 })
469 }
470 })
471 .map(|_| ())
472}