backhopper_core/model/verdict/
touched.rs1use 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 OnlyTestVisibilityChanged,
34 OnlySelfSurfaceTouched,
40 OutOfScopeFor {
45 project: ProjectName,
46 },
47 Untracked,
50 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#[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 #[serde(default, skip_serializing_if = "is_false")]
114 pub only_test_visibility: bool,
115 #[serde(default, skip_serializing_if = "is_false")]
121 pub only_self_surface: bool,
122}
123
124#[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 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 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 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 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 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}