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")
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 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 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 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}