Skip to main content

backhopper_core/suites/
model.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//! Types for suite selection.
6
7use std::collections::BTreeMap;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::model::serde_util::{is_false, is_zero};
13
14use crate::app_src::AppSrcSpec;
15use crate::model::names::{ApplicationName, ModuleName};
16
17/// A test-suite module reference. `module` ends in `_SUITE` by
18/// convention but the constructor does not enforce this.
19#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
21pub struct SuiteRef {
22    pub application: ApplicationName,
23    pub module: ModuleName,
24    pub path: PathBuf,
25}
26
27/// Why a suite was included in the plan. A suite can appear under
28/// multiple reasons when more than one rule fires.
29#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(tag = "kind", rename_all = "snake_case")]
32pub enum SuiteInclusionReason {
33    /// The suite file itself appears in the diff.
34    TestModified { path: PathBuf },
35    /// Fast unit or property suite swept because its application has
36    /// modified source and the suite references at least one of the
37    /// modified modules.
38    UnitOrPropSweep {
39        application: ApplicationName,
40        triggering_modules: Vec<ModuleName>,
41    },
42    /// Suite is in the same application as a modified module and
43    /// references that module by name.
44    SameAppCaller {
45        application: ApplicationName,
46        module: ModuleName,
47    },
48    /// Suite is in a different application from a modified library
49    /// module and references that module by name.
50    CrossAppCaller {
51        library_application: ApplicationName,
52        module: ModuleName,
53    },
54    /// Suite was included by a user-configured rule.
55    ConfiguredRule {
56        rule_name: String,
57        triggering_path: PathBuf,
58    },
59    /// A modified `.erl` is itself a behaviour module; this suite
60    /// references one of its implementers.
61    BehaviourImplementerSweep {
62        behaviour: ModuleName,
63        implementer: ModuleName,
64    },
65}
66
67/// Plan input. The caller assembles diff, discovered apps, and any
68/// extra rules from config.
69#[derive(Debug, Clone, Default)]
70pub struct PlanInput {
71    pub repo_root: PathBuf,
72    pub modified_paths: Vec<PathBuf>,
73    pub apps: Vec<AppSrcSpec>,
74    pub library_apps: Vec<ApplicationName>,
75    pub extra_rules: Vec<ExtraRule>,
76    /// Behaviour-to-implementers reverse map. When a modified `.erl`
77    /// names a behaviour module, every implementer is pulled into scope
78    /// and suites that reference them are added to the plan. Empty by
79    /// default; populated from a snapshot when richer triage is wanted.
80    pub implementer_index: BTreeMap<ModuleName, Vec<ModuleName>>,
81    pub dep_module_index: BTreeMap<String, Vec<ModuleName>>,
82}
83
84/// One entry in the plan: the suite plus every rule that included it.
85#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct SuitePlanEntry {
88    pub suite: SuiteRef,
89    pub reasons: Vec<SuiteInclusionReason>,
90}
91
92/// An application with modified source modules that no plan entry
93/// covers. Advisory only: the operator picks suites from `suites`
94/// (or learns the application has none) instead of discovering the
95/// gap from a silent empty plan.
96#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct UncoveredApplication {
99    pub application: ApplicationName,
100    pub modified_modules: usize,
101    pub discovered_suites: usize,
102    /// The application's discovered suites: the operator's candidate
103    /// list. Empty means the application has no tests at all.
104    pub suites: Vec<ModuleName>,
105}
106
107/// A modified module whose suite fanout is an outsized share of all
108/// discovered suites. The cap drops the suites this module alone pulled
109/// in and records this row instead, so a near-full selection driven by
110/// one helper is never silent.
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct BroadImpactModule {
114    pub module: ModuleName,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub application: Option<ApplicationName>,
117    pub suite_fanout: usize,
118    pub total_suites: usize,
119}
120
121/// Output of `plan()`. Entries are sorted by suite; reasons within
122/// each entry are in rule-application order.
123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125pub struct SuitePlan {
126    pub entries: Vec<SuitePlanEntry>,
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub uncovered: Vec<UncoveredApplication>,
129    /// Modified paths that resolve to no application, so a plan with
130    /// fewer reasons than inputs is still fully accounted for.
131    #[serde(default, skip_serializing_if = "is_zero")]
132    pub unattributed_paths: usize,
133    /// Modules whose suite fanout was capped: each removed the suites it
134    /// alone pulled in, replaced by this row.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub broad_impact: Vec<BroadImpactModule>,
137}
138
139impl SuitePlan {
140    pub fn is_empty(&self) -> bool {
141        self.entries.is_empty()
142    }
143
144    pub fn len(&self) -> usize {
145        self.entries.len()
146    }
147}
148
149/// User-configured rule. Accepts both the literal `include_suites`
150/// (application + module) and templated module names that resolve named
151/// regex captures from the trigger pattern (e.g. `{plugin}_config_schema_SUITE`).
152/// Templated includes look up the suite by module name across all
153/// discovered applications.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct ExtraRule {
156    pub name: String,
157    pub trigger: ExtraRuleTrigger,
158    #[serde(default)]
159    pub include_suites: Vec<SuiteRefSpec>,
160    #[serde(default)]
161    pub include_suite_templates: Vec<String>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub line_match: Option<LineMatch>,
164    #[serde(default, skip_serializing_if = "is_false")]
165    pub include_suite_for_dep_modules: bool,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct LineMatch {
170    pub pattern: String,
171    #[serde(default)]
172    pub captures: Vec<String>,
173}
174
175/// Path-glob trigger expression. `PathRegex` supports named captures
176/// (`(?P<name>...)`) that templates referenced in
177/// `ExtraRule::include_suite_templates` may substitute.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(tag = "kind", rename_all = "snake_case")]
180#[allow(clippy::enum_variant_names)]
181pub enum ExtraRuleTrigger {
182    /// Fires when any modified path's file name matches `suffix`.
183    PathSuffix { suffix: String },
184    /// Fires when any modified path contains `fragment`.
185    PathContains { fragment: String },
186    /// Fires when any modified path matches `pattern`. The `captures`
187    /// list enumerates the named capture groups templates may reference.
188    /// Validated against the pattern at config-load time so unknown
189    /// `{placeholder}`s fail fast instead of silently dropping at runtime.
190    PathRegex {
191        pattern: String,
192        #[serde(default)]
193        captures: Vec<String>,
194    },
195}
196
197/// A suite to include when an `ExtraRule` fires. The caller resolves
198/// these against the discovered tree at plan time.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct SuiteRefSpec {
201    pub application: ApplicationName,
202    pub module: ModuleName,
203}