Skip to main content

backhopper_core/suites/
rules.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Built-in suite-selection rules.
6
7use 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/// A modified module: the file path that was changed, plus the
20/// module name (basename minus `.erl`) and the application it lives
21/// in (when resolvable).
22#[derive(Debug, Clone)]
23pub(crate) struct ModifiedModule {
24    pub module: ModuleName,
25    pub application: Option<ApplicationName>,
26}
27
28/// Classify modified paths into test-files, source-modules, and
29/// "other" buckets the built-in rules don't act on.
30pub(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
70/// TestModified (R1): if a `_SUITE.erl` appears in the diff, include
71/// the matching suite from the discovered suite list.
72pub(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
91/// SameAppCaller (R4): each modified non-test module M; suites in
92/// the same application that reference M get included.
93pub(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    // Group the modified modules by application so each suite's text is
100    // scanned once against every same-app module, not once per module.
101    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            // Attribution stays per-module and in modified-module order.
114            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
128/// CrossAppCaller (R5): for each modified non-test module M whose
129/// application is in `library_apps`, suites in *other* applications
130/// that reference M get included.
131pub(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    // Group library-app modules so each suite's text is scanned once per
140    // owning application, not once per modified module.
141    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
169/// Behaviour-implementer sweep: when a modified module is itself a
170/// behaviour (some other module declares `-behaviour(M)`), every
171/// implementer comes into scope and any suite that references one is
172/// added. `implementer_index` is the behaviour-to-implementers map; an
173/// empty map disables the rule.
174pub(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
206/// UnitOrPropSweep (R3 ∩ R4 refinement): for each application that
207/// has modified source, sweep suites whose name matches the
208/// unit-or-prop pattern AND that reference at least one modified
209/// module from the same application.
210pub(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
245/// True for suite names matching common fast-suite conventions:
246/// `unit_*_SUITE`, `*_unit_*_SUITE`, `prop_*_SUITE`, `*_prop_*_SUITE`.
247fn 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
252/// ConfiguredRule: apply each user-supplied rule.
253pub(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: &regex::Regex, caps: &regex::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: &regex::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
418/// Walk `{name}` placeholders in `template`, calling `push_value` to
419/// append each replacement to the output. Literal text and an unmatched
420/// `{` pass through. `push_value` errors when a placeholder cannot be
421/// resolved.
422fn 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
457/// Validate that every `{name}` placeholder in `template` appears in
458/// `allowed_captures`. Used at config-load time so unknown placeholders
459/// fail fast.
460pub 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}