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 belongs
21/// to (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 by application so each suite's text is scanned once per app, not once per module
100    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            // Attribution stays per-module and in modified-module order.
113            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
127/// CrossAppCaller (R5): for each modified non-test module M whose
128/// application is in `library_apps`, suites in *other* applications
129/// that reference M get included.
130pub(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    // group by owning application so each suite's text is scanned once per app, not once per module
139    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
167/// Behaviour-implementer sweep: when a modified module is itself a
168/// behaviour (some other module declares `-behaviour(M)`), every
169/// implementer comes into scope and any suite that references one is
170/// added. `implementer_index` is the behaviour-to-implementers map; an
171/// empty map disables the rule.
172pub(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
204/// UnitOrPropSweep (R3 ∩ R4 refinement): for each application that
205/// has modified source, sweep suites whose name matches the
206/// unit-or-prop pattern AND that reference at least one modified
207/// module from the same application.
208pub(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
243/// True for suite names matching common fast-suite conventions:
244/// `unit_*_SUITE`, `*_unit_*_SUITE`, `prop_*_SUITE`, `*_prop_*_SUITE`.
245fn 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
250/// ConfiguredRule: apply each user-supplied rule.
251pub(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: &regex::Regex, caps: &regex::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: &regex::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
416/// Walk `{name}` placeholders in `template`, calling `push_value` to
417/// append each replacement to the output. Literal text and an unmatched
418/// `{` pass through. `push_value` errors when a placeholder cannot be
419/// resolved.
420fn 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
455/// Validate that every `{name}` placeholder in `template` appears in
456/// `allowed_captures`. Used at config-load time so unknown placeholders
457/// fail fast.
458pub 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}