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 has zero in-scope references. Without this
37    /// reason the operator would see many vacuous `Compatible`
38    /// rows for a patch that backhopper genuinely has no signal on.
39    OnlySelfSurfaceTouched,
40    /// Every touched path is 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)` guards 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 has 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 docs
168        if lower.ends_with(".md")
169            || lower.ends_with(".adoc")
170            || lower.ends_with(".rst")
171            || lower.ends_with(".txt")
172            || (!is_erl_source && (lower.contains("/docs/") || lower.starts_with("docs/")))
173        {
174            return FileKind::Docs;
175        }
176        if lower.contains("_suite_data/")
177            || lower.contains("/test/")
178            || lower.contains("/tests/")
179            || lower.starts_with("test/")
180            || lower.starts_with("tests/")
181            || lower.ends_with("_suite.erl")
182        {
183            return FileKind::Tests;
184        }
185        if lower.ends_with(".schema") || lower.ends_with(".snippets") {
186            return FileKind::Schema;
187        }
188        if lower.ends_with(".erl") {
189            return FileKind::Erl;
190        }
191        if lower.ends_with(".hrl") {
192            return FileKind::Hrl;
193        }
194        if lower.ends_with(".app.src") || lower.ends_with(".app.src.script") {
195            return FileKind::AppSrc;
196        }
197        if lower.ends_with("rebar.config") || lower.ends_with("rebar3.config") {
198            return FileKind::RebarConfig;
199        }
200        if lower.ends_with("/mix.exs")
201            || lower == "mix.exs"
202            || lower.ends_with("/mix.lock")
203            || lower == "mix.lock"
204        {
205            return FileKind::MixExs;
206        }
207        if Self::is_ci_workflow(&lower) {
208            return FileKind::CiWorkflow;
209        }
210        if Self::is_makefile(&lower) {
211            return FileKind::Makefile;
212        }
213        FileKind::Other
214    }
215
216    fn is_ci_workflow(lower: &str) -> bool {
217        if lower.contains(".github/workflows/") {
218            return lower.ends_with(".yml") || lower.ends_with(".yaml");
219        }
220        lower.contains(".github/actions/")
221    }
222
223    fn is_makefile(lower: &str) -> bool {
224        if lower.ends_with(".mk") {
225            return true;
226        }
227        let basename = lower.rsplit('/').next().unwrap_or(lower);
228        matches!(
229            basename,
230            "makefile" | "erlang.mk" | "rabbitmq-components.mk"
231        )
232    }
233
234    pub fn record(&mut self, kind: FileKind) {
235        match kind {
236            FileKind::Erl => self.erl += 1,
237            FileKind::Hrl => self.hrl += 1,
238            FileKind::Schema => self.schema += 1,
239            FileKind::Docs => self.docs += 1,
240            FileKind::Tests => self.tests += 1,
241            FileKind::Makefile => self.makefile += 1,
242            FileKind::MixExs => self.mix_exs += 1,
243            FileKind::CiWorkflow => self.ci_workflow += 1,
244            FileKind::AppSrc => self.app_src += 1,
245            FileKind::RebarConfig => self.rebar_config += 1,
246            FileKind::Other => self.other += 1,
247        }
248    }
249
250    pub fn from_paths<I, P>(paths: I) -> Self
251    where
252        I: IntoIterator<Item = P>,
253        P: AsRef<Path>,
254    {
255        let mut tk = Self::default();
256        for p in paths {
257            tk.record(Self::classify(p.as_ref()));
258        }
259        tk
260    }
261
262    /// `None` when any `.erl` or `.hrl` was touched: an analyzable diff with
263    /// zero reasons is real `Compatible`, not `Inapplicable`.
264    pub fn inapplicable_reason(&self) -> Option<InapplicableReason> {
265        if self.only_test_visibility {
266            return Some(InapplicableReason::OnlyTestVisibilityChanged);
267        }
268        if self.only_self_surface {
269            return Some(InapplicableReason::OnlySelfSurfaceTouched);
270        }
271        // .schema files carry MFA references in their fun bodies, so a
272        // schema-touching diff is analyzable surface, not inapplicable.
273        if self.erl > 0 || self.hrl > 0 || self.schema > 0 {
274            return None;
275        }
276        if self.is_empty() {
277            return Some(InapplicableReason::NoErlangSurfaceTouched);
278        }
279        if self.is_only(|tk| tk.docs > 0) {
280            return Some(InapplicableReason::OnlyDocsTouched);
281        }
282        if self.is_only(|tk| tk.tests > 0) {
283            return Some(InapplicableReason::OnlyTestFixturesTouched);
284        }
285        if self.is_only(|tk| tk.makefile > 0) {
286            return Some(InapplicableReason::OnlyMakefileTouched);
287        }
288        if self.is_only(|tk| tk.mix_exs > 0) {
289            return Some(InapplicableReason::OnlyMixExsTouched);
290        }
291        if self.is_only(|tk| tk.ci_workflow > 0) {
292            return Some(InapplicableReason::OnlyCiWorkflowTouched);
293        }
294        if self.is_only(|tk| tk.app_src > 0) {
295            return Some(InapplicableReason::OnlyAppSrcTouched);
296        }
297        if self.is_only(|tk| tk.rebar_config > 0) {
298            return Some(InapplicableReason::OnlyRebarConfigTouched);
299        }
300        Some(InapplicableReason::NoErlangSurfaceTouched)
301    }
302
303    /// True when exactly the bucket selected by `selector` is non-zero
304    /// and every other (non-Erlang-surface) bucket is zero.
305    fn is_only<F: Fn(&Self) -> bool>(&self, selector: F) -> bool {
306        if !selector(self) {
307            return false;
308        }
309        let buckets = [
310            self.docs > 0,
311            self.tests > 0,
312            self.makefile > 0,
313            self.mix_exs > 0,
314            self.ci_workflow > 0,
315            self.app_src > 0,
316            self.rebar_config > 0,
317            self.other > 0,
318        ];
319        let active = buckets.iter().filter(|b| **b).count();
320        active == 1
321    }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum FileKind {
326    Erl,
327    Hrl,
328    Schema,
329    Docs,
330    Tests,
331    Makefile,
332    MixExs,
333    CiWorkflow,
334    AppSrc,
335    RebarConfig,
336    Other,
337}