Skip to main content

backhopper_core/model/verdict/
touched.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//! `InapplicableReason`, `TouchedKinds`, and `FileKind`.
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::model::names::{ProjectName, RelativePath};
12
13#[non_exhaustive]
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
16#[serde(tag = "reason", rename_all = "snake_case")]
17#[allow(clippy::enum_variant_names)]
18pub enum InapplicableReason {
19    NoErlangSurfaceTouched,
20    OnlyDocsTouched,
21    OnlyTestFixturesTouched,
22    OnlySchemaTouched,
23    OnlyMakefileTouched,
24    OnlyMixExsTouched,
25    OnlyCiWorkflowTouched,
26    OnlyAppSrcTouched,
27    OnlyRebarConfigTouched,
28    /// Every changed `.erl` hunk is a Variant A unwrap: dropping
29    /// `-ifdef(TEST).` or `-endif.` around an `-export(...)` directive,
30    /// or rewriting `-compile(export_all).` to an explicit
31    /// `-export([...])`. No call sites, no specs, no bodies touched —
32    /// only test-visibility metadata.
33    OnlyTestVisibilityChanged,
34    /// The patch touches Erlang surface but every referenced symbol
35    /// resolves to the self-pin or to a local definition: every
36    /// tracked-dep pin sees zero in-scope references. Without this
37    /// reason the operator would see a wall of vacuous `Compatible`
38    /// rows for a patch that backhopper genuinely has no signal on.
39    OnlySelfSurfaceTouched,
40    /// Every touched path lives in a sibling project's scope, not this
41    /// pin's. The pin has nothing to say about the patch. `project`
42    /// names the first sibling project (alphabetical) that owns the
43    /// out-of-scope paths.
44    OutOfScopeFor {
45        project: ProjectName,
46    },
47    /// Every touched path is unclaimed by any configured project's
48    /// scan_paths or app_roots. Vendored files, docs, build scripts.
49    Untracked,
50    /// `--target-repo-dir-path` was supplied and every touched path
51    /// is missing from the target tree, with no configured
52    /// translation mapping any of them. The patch as written does
53    /// not apply to the target.
54    PathsMissingOnTarget {
55        paths: Vec<RelativePath>,
56    },
57}
58
59impl InapplicableReason {
60    pub fn as_str(&self) -> &'static str {
61        match self {
62            Self::NoErlangSurfaceTouched => "no_erlang_surface_touched",
63            Self::OnlyDocsTouched => "only_docs_touched",
64            Self::OnlyTestFixturesTouched => "only_test_fixtures_touched",
65            Self::OnlySchemaTouched => "only_schema_touched",
66            Self::OnlyMakefileTouched => "only_makefile_touched",
67            Self::OnlyMixExsTouched => "only_mix_exs_touched",
68            Self::OnlyCiWorkflowTouched => "only_ci_workflow_touched",
69            Self::OnlyAppSrcTouched => "only_app_src_touched",
70            Self::OnlyRebarConfigTouched => "only_rebar_config_touched",
71            Self::OnlyTestVisibilityChanged => "only_test_visibility_changed",
72            Self::OnlySelfSurfaceTouched => "only_self_surface_touched",
73            Self::OutOfScopeFor { .. } => "out_of_scope_for",
74            Self::Untracked => "untracked",
75            Self::PathsMissingOnTarget { .. } => "paths_missing_on_target",
76        }
77    }
78}
79
80/// Per-pin tally of file kinds the patch touched. Lets `promote_inapplicable`
81/// tell a real green check from a diff with nothing analyzable to check.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
84pub struct TouchedKinds {
85    #[serde(default)]
86    pub erl: u32,
87    #[serde(default)]
88    pub hrl: u32,
89    #[serde(default)]
90    pub schema: u32,
91    #[serde(default)]
92    pub docs: u32,
93    #[serde(default)]
94    pub tests: u32,
95    #[serde(default)]
96    pub makefile: u32,
97    #[serde(default)]
98    pub mix_exs: u32,
99    #[serde(default)]
100    pub ci_workflow: u32,
101    #[serde(default)]
102    pub app_src: u32,
103    #[serde(default)]
104    pub rebar_config: u32,
105    #[serde(default)]
106    pub other: u32,
107    /// Every changed `.erl` hunk is a Variant A unwrap of test-only
108    /// `-ifdef(TEST)` machinery around an `-export` directive (see
109    /// `InapplicableReason::OnlyTestVisibilityChanged`). When true,
110    /// the patch touches Erlang surface but adds no semantic
111    /// content, so an empty reason set should promote to
112    /// `Inapplicable` instead of `Compatible`.
113    #[serde(default, skip_serializing_if = "is_false")]
114    pub only_test_visibility: bool,
115    /// The patch touches Erlang surface but references no tracked-dep
116    /// API (every non-self pin sees zero in-scope references). The CLI
117    /// sets this after `evaluate_series` when a self-pin is present
118    /// and every non-self pin's `tracked_refs` is empty; see
119    /// `InapplicableReason::OnlySelfSurfaceTouched`.
120    #[serde(default, skip_serializing_if = "is_false")]
121    pub only_self_surface: bool,
122}
123
124// `&bool` is required by serde's `skip_serializing_if` predicate shape.
125#[allow(clippy::trivially_copy_pass_by_ref)]
126fn is_false(b: &bool) -> bool {
127    !*b
128}
129
130impl TouchedKinds {
131    pub fn is_empty(&self) -> bool {
132        // Destructure so adding a new field forces this method to be updated.
133        let Self {
134            erl,
135            hrl,
136            schema,
137            docs,
138            tests,
139            makefile,
140            mix_exs,
141            ci_workflow,
142            app_src,
143            rebar_config,
144            other,
145            only_test_visibility,
146            only_self_surface,
147        } = self;
148        *erl == 0
149            && *hrl == 0
150            && *schema == 0
151            && *docs == 0
152            && *tests == 0
153            && *makefile == 0
154            && *mix_exs == 0
155            && *ci_workflow == 0
156            && *app_src == 0
157            && *rebar_config == 0
158            && *other == 0
159            && !*only_test_visibility
160            && !*only_self_surface
161    }
162
163    pub fn classify(path: &Path) -> FileKind {
164        let p = path.to_string_lossy();
165        let lower = p.to_ascii_lowercase();
166        let is_erl_source = lower.ends_with(".erl") || lower.ends_with(".hrl");
167        // a docs/ path component must not reclassify real Erlang source as
168        // docs: that would vacuously skip an API-affecting change
169        if lower.ends_with(".md")
170            || lower.ends_with(".adoc")
171            || lower.ends_with(".rst")
172            || lower.ends_with(".txt")
173            || (!is_erl_source && (lower.contains("/docs/") || lower.starts_with("docs/")))
174        {
175            return FileKind::Docs;
176        }
177        if lower.contains("_suite_data/")
178            || lower.contains("/test/")
179            || lower.contains("/tests/")
180            || lower.starts_with("test/")
181            || lower.starts_with("tests/")
182            || lower.ends_with("_suite.erl")
183        {
184            return FileKind::Tests;
185        }
186        if lower.ends_with(".schema") || lower.ends_with(".snippets") {
187            return FileKind::Schema;
188        }
189        if lower.ends_with(".erl") {
190            return FileKind::Erl;
191        }
192        if lower.ends_with(".hrl") {
193            return FileKind::Hrl;
194        }
195        if lower.ends_with(".app.src") || lower.ends_with(".app.src.script") {
196            return FileKind::AppSrc;
197        }
198        if lower.ends_with("rebar.config") || lower.ends_with("rebar3.config") {
199            return FileKind::RebarConfig;
200        }
201        if lower.ends_with("/mix.exs")
202            || lower == "mix.exs"
203            || lower.ends_with("/mix.lock")
204            || lower == "mix.lock"
205        {
206            return FileKind::MixExs;
207        }
208        if Self::is_ci_workflow(&lower) {
209            return FileKind::CiWorkflow;
210        }
211        if Self::is_makefile(&lower) {
212            return FileKind::Makefile;
213        }
214        FileKind::Other
215    }
216
217    fn is_ci_workflow(lower: &str) -> bool {
218        if lower.contains(".github/workflows/") {
219            return lower.ends_with(".yml") || lower.ends_with(".yaml");
220        }
221        lower.contains(".github/actions/")
222    }
223
224    fn is_makefile(lower: &str) -> bool {
225        if lower.ends_with(".mk") {
226            return true;
227        }
228        let basename = lower.rsplit('/').next().unwrap_or(lower);
229        matches!(
230            basename,
231            "makefile" | "erlang.mk" | "rabbitmq-components.mk"
232        )
233    }
234
235    pub fn record(&mut self, kind: FileKind) {
236        match kind {
237            FileKind::Erl => self.erl += 1,
238            FileKind::Hrl => self.hrl += 1,
239            FileKind::Schema => self.schema += 1,
240            FileKind::Docs => self.docs += 1,
241            FileKind::Tests => self.tests += 1,
242            FileKind::Makefile => self.makefile += 1,
243            FileKind::MixExs => self.mix_exs += 1,
244            FileKind::CiWorkflow => self.ci_workflow += 1,
245            FileKind::AppSrc => self.app_src += 1,
246            FileKind::RebarConfig => self.rebar_config += 1,
247            FileKind::Other => self.other += 1,
248        }
249    }
250
251    pub fn from_paths<I, P>(paths: I) -> Self
252    where
253        I: IntoIterator<Item = P>,
254        P: AsRef<Path>,
255    {
256        let mut tk = Self::default();
257        for p in paths {
258            tk.record(Self::classify(p.as_ref()));
259        }
260        tk
261    }
262
263    /// `None` when any `.erl` or `.hrl` was touched: an analyzable diff with
264    /// zero reasons is real `Compatible`, not `Inapplicable`.
265    pub fn inapplicable_reason(&self) -> Option<InapplicableReason> {
266        if self.only_test_visibility {
267            return Some(InapplicableReason::OnlyTestVisibilityChanged);
268        }
269        if self.only_self_surface {
270            return Some(InapplicableReason::OnlySelfSurfaceTouched);
271        }
272        // `.schema` files carry MFA references in their fun bodies, so a
273        // schema-touching diff is analyzable surface, not inapplicable.
274        if self.erl > 0 || self.hrl > 0 || self.schema > 0 {
275            return None;
276        }
277        if self.is_empty() {
278            return Some(InapplicableReason::NoErlangSurfaceTouched);
279        }
280        if self.is_only(|tk| tk.docs > 0) {
281            return Some(InapplicableReason::OnlyDocsTouched);
282        }
283        if self.is_only(|tk| tk.tests > 0) {
284            return Some(InapplicableReason::OnlyTestFixturesTouched);
285        }
286        if self.is_only(|tk| tk.makefile > 0) {
287            return Some(InapplicableReason::OnlyMakefileTouched);
288        }
289        if self.is_only(|tk| tk.mix_exs > 0) {
290            return Some(InapplicableReason::OnlyMixExsTouched);
291        }
292        if self.is_only(|tk| tk.ci_workflow > 0) {
293            return Some(InapplicableReason::OnlyCiWorkflowTouched);
294        }
295        if self.is_only(|tk| tk.app_src > 0) {
296            return Some(InapplicableReason::OnlyAppSrcTouched);
297        }
298        if self.is_only(|tk| tk.rebar_config > 0) {
299            return Some(InapplicableReason::OnlyRebarConfigTouched);
300        }
301        Some(InapplicableReason::NoErlangSurfaceTouched)
302    }
303
304    /// True when exactly the bucket selected by `selector` is non-zero
305    /// and every other (non-Erlang-surface) bucket is zero.
306    fn is_only<F: Fn(&Self) -> bool>(&self, selector: F) -> bool {
307        if !selector(self) {
308            return false;
309        }
310        let buckets = [
311            self.docs > 0,
312            self.tests > 0,
313            self.makefile > 0,
314            self.mix_exs > 0,
315            self.ci_workflow > 0,
316            self.app_src > 0,
317            self.rebar_config > 0,
318            self.other > 0,
319        ];
320        let active = buckets.iter().filter(|b| **b).count();
321        active == 1
322    }
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum FileKind {
327    Erl,
328    Hrl,
329    Schema,
330    Docs,
331    Tests,
332    Makefile,
333    MixExs,
334    CiWorkflow,
335    AppSrc,
336    RebarConfig,
337    Other,
338}