Skip to main content

backhopper_core/jigsaw/
findings.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
5use std::collections::BTreeSet;
6use std::fmt::{self, Display};
7
8use serde::{Deserialize, Serialize};
9
10use crate::model::app_manifest::ManifestUnreadable;
11use crate::model::macro_decl::MacroKey;
12use crate::model::names::{
13    ApplicationName, Arity, BehaviourName, ConfKey, FieldName, Mfa, ModuleName, RecordName,
14    SpecText, TypeName, ValidatorName, vocabulary,
15};
16use crate::model::non_empty::NonEmpty;
17use crate::model::normalised::{NormalisedDefault, NormalisedMacroBody};
18use crate::model::schema_files::PartialRef;
19use crate::model::snapshot::{FunArity, HeaderRef};
20
21vocabulary!(
22    /// The wire label of a `Finding` variant.
23    #[derive(PartialOrd, Ord, Hash)]
24    pub enum JigsawFindingKind: "finding kind" {
25        ModuleMissing => "module_missing",
26        FunctionMissing => "function_missing",
27        ReturnShapeChanged => "return_shape_changed",
28        HeaderMissing => "header_missing",
29        RecordMissing => "record_missing",
30        RecordFieldsChanged => "record_fields_changed",
31        MacroMissing => "macro_missing",
32        MacroChanged => "macro_changed",
33        CallbackRequired => "callback_required",
34        BehaviourMissing => "behaviour_missing",
35        TypeMissing => "type_missing",
36        PartialMissing => "partial_missing",
37        PartialKeyMissing => "partial_key_missing",
38        ValidatorMissing => "validator_missing",
39        AppMissing => "app_missing",
40    }
41);
42
43vocabulary!(
44    /// The wire label of a `Warning` variant.
45    #[derive(PartialOrd, Ord, Hash)]
46    pub enum JigsawWarningKind: "warning kind" {
47        BundledAppShippedByHost => "bundled_app_shipped_by_host",
48        RecordDefaultChanged => "record_default_changed",
49        MacroTextChanged => "macro_text_changed",
50        ManifestUnreadable => "manifest_unreadable",
51        AppNotTracked => "app_not_tracked",
52    }
53);
54
55vocabulary!(
56    /// Why a comparison was not made. Each is counted in the report.
57    #[derive(PartialOrd, Ord, Hash)]
58    pub enum WithheldReason: "withheld reason" {
59        ConditionalMacro => "conditional_macro",
60        UnknownSpec => "unknown_spec",
61        UnreadableManifest => "unreadable_manifest",
62        UntrackedApp => "untracked_app",
63        ExcludedOtpApp => "excluded_otp_app",
64    }
65);
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
68pub enum Stop {
69    UnreadableManifest,
70    UntrackedApp,
71}
72
73impl Stop {
74    #[must_use]
75    pub fn reason(self) -> WithheldReason {
76        match self {
77            Self::UnreadableManifest => WithheldReason::UnreadableManifest,
78            Self::UntrackedApp => WithheldReason::UntrackedApp,
79        }
80    }
81}
82
83/// The variant decides the reason, so a symbol cannot be listed under another.
84#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
85pub enum WithheldSymbol {
86    ConditionalMacro { header: HeaderRef, key: MacroKey },
87    UnknownSpec(Mfa),
88    ExcludedOtpApp(ApplicationName),
89}
90
91impl WithheldSymbol {
92    #[must_use]
93    pub fn reason(&self) -> WithheldReason {
94        match self {
95            Self::ConditionalMacro { .. } => WithheldReason::ConditionalMacro,
96            Self::UnknownSpec(_) => WithheldReason::UnknownSpec,
97            Self::ExcludedOtpApp(_) => WithheldReason::ExcludedOtpApp,
98        }
99    }
100}
101
102impl fmt::Display for WithheldSymbol {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::ConditionalMacro { header, key } => write!(f, "?{key} in {header}"),
106            Self::UnknownSpec(mfa) => write!(f, "{mfa}"),
107            Self::ExcludedOtpApp(app) => write!(f, "{app}"),
108        }
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
113pub enum Withheld {
114    Symbol(WithheldSymbol),
115    Stopped(Stop),
116}
117
118/// A reference the build patch satisfies and a target does not. Every
119/// variant is a blocker, so there is no severity to read.
120#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
121pub enum Finding {
122    ModuleMissing {
123        module: ModuleName,
124    },
125    FunctionMissing {
126        mfa: Mfa,
127        other_arities: BTreeSet<Arity>,
128    },
129    ReturnShapeChanged {
130        mfa: Mfa,
131        build_spec: SpecText,
132        target_spec: SpecText,
133    },
134    HeaderMissing {
135        header: HeaderRef,
136    },
137    RecordMissing {
138        header: HeaderRef,
139        name: RecordName,
140    },
141    RecordFieldsChanged {
142        header: HeaderRef,
143        name: RecordName,
144        build: Vec<FieldName>,
145        target: Vec<FieldName>,
146    },
147    MacroMissing {
148        header: HeaderRef,
149        key: MacroKey,
150    },
151    MacroChanged {
152        header: HeaderRef,
153        key: MacroKey,
154        build: NormalisedMacroBody,
155        target: NormalisedMacroBody,
156    },
157    CallbackRequired {
158        behaviour: BehaviourName,
159        implementer: ModuleName,
160        callback: FunArity,
161    },
162    BehaviourMissing {
163        behaviour: BehaviourName,
164    },
165    TypeMissing {
166        module: ModuleName,
167        name: TypeName,
168        arity: Arity,
169    },
170    PartialMissing {
171        partial: PartialRef,
172    },
173    PartialKeyMissing {
174        partial: PartialRef,
175        key: ConfKey,
176    },
177    ValidatorMissing {
178        name: ValidatorName,
179    },
180    AppMissing {
181        app: ApplicationName,
182        required_by: NonEmpty<ApplicationName>,
183    },
184}
185
186impl Finding {
187    #[must_use]
188    pub fn kind(&self) -> JigsawFindingKind {
189        match self {
190            Self::ModuleMissing { .. } => JigsawFindingKind::ModuleMissing,
191            Self::FunctionMissing { .. } => JigsawFindingKind::FunctionMissing,
192            Self::ReturnShapeChanged { .. } => JigsawFindingKind::ReturnShapeChanged,
193            Self::HeaderMissing { .. } => JigsawFindingKind::HeaderMissing,
194            Self::RecordMissing { .. } => JigsawFindingKind::RecordMissing,
195            Self::RecordFieldsChanged { .. } => JigsawFindingKind::RecordFieldsChanged,
196            Self::MacroMissing { .. } => JigsawFindingKind::MacroMissing,
197            Self::MacroChanged { .. } => JigsawFindingKind::MacroChanged,
198            Self::CallbackRequired { .. } => JigsawFindingKind::CallbackRequired,
199            Self::BehaviourMissing { .. } => JigsawFindingKind::BehaviourMissing,
200            Self::TypeMissing { .. } => JigsawFindingKind::TypeMissing,
201            Self::PartialMissing { .. } => JigsawFindingKind::PartialMissing,
202            Self::PartialKeyMissing { .. } => JigsawFindingKind::PartialKeyMissing,
203            Self::ValidatorMissing { .. } => JigsawFindingKind::ValidatorMissing,
204            Self::AppMissing { .. } => JigsawFindingKind::AppMissing,
205        }
206    }
207}
208
209impl Finding {
210    /// The subject, printed the way a reader would search for it.
211    #[must_use]
212    pub fn symbol(&self) -> String {
213        match self {
214            Self::ModuleMissing { module } => module.to_string(),
215            Self::FunctionMissing { mfa, .. } | Self::ReturnShapeChanged { mfa, .. } => {
216                mfa.to_string()
217            }
218            Self::HeaderMissing { header } => header.to_string(),
219            Self::RecordMissing { header, name }
220            | Self::RecordFieldsChanged { header, name, .. } => {
221                format!("#{name}{{}} in {header}")
222            }
223            Self::MacroMissing { header, key } | Self::MacroChanged { header, key, .. } => {
224                format!("?{key} in {header}")
225            }
226            Self::CallbackRequired {
227                behaviour,
228                callback,
229                ..
230            } => format!("{behaviour}:{callback}"),
231            Self::BehaviourMissing { behaviour } => behaviour.to_string(),
232            Self::TypeMissing {
233                module,
234                name,
235                arity,
236            } => format!("{module}:{name}/{arity}"),
237            Self::PartialMissing { partial } => partial.to_string(),
238            Self::PartialKeyMissing { partial, key } => format!("{partial} key {key}"),
239            Self::ValidatorMissing { name } => name.to_string(),
240            Self::AppMissing { app, .. } => app.to_string(),
241        }
242    }
243
244    /// The build and target sides, where the variant has them.
245    #[must_use]
246    pub fn detail(&self) -> Option<String> {
247        match self {
248            Self::FunctionMissing { other_arities, .. } if !other_arities.is_empty() => {
249                let arities: Vec<String> = other_arities.iter().map(|a| format!("/{a}")).collect();
250                Some(format!("target has {}", arities.join(", ")))
251            }
252            Self::ReturnShapeChanged {
253                build_spec,
254                target_spec,
255                ..
256            } => Some(format!("build: {build_spec}; target: {target_spec}")),
257            Self::RecordFieldsChanged { build, target, .. } => Some(format!(
258                "build: {}; target: {}",
259                join(build.iter()),
260                join(target.iter())
261            )),
262            Self::MacroChanged { build, target, .. } => {
263                Some(format!("build: {build}; target: {target}"))
264            }
265            Self::CallbackRequired { implementer, .. } => {
266                Some(format!("{implementer} does not export it"))
267            }
268            Self::AppMissing { required_by, .. } => {
269                Some(format!("required by {}", join(required_by.iter())))
270            }
271            Self::ModuleMissing { .. }
272            | Self::FunctionMissing { .. }
273            | Self::HeaderMissing { .. }
274            | Self::RecordMissing { .. }
275            | Self::MacroMissing { .. }
276            | Self::BehaviourMissing { .. }
277            | Self::TypeMissing { .. }
278            | Self::PartialMissing { .. }
279            | Self::PartialKeyMissing { .. }
280            | Self::ValidatorMissing { .. } => None,
281        }
282    }
283}
284
285fn join<T: Display>(items: impl Iterator<Item = T>) -> String {
286    items
287        .map(|item| item.to_string())
288        .collect::<Vec<_>>()
289        .join(", ")
290}
291
292/// Something worth reading that never fails the run. A warning and a
293/// finding never share a list.
294#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
295pub enum Warning {
296    BundledAppShippedByHost {
297        app: ApplicationName,
298    },
299    RecordDefaultChanged {
300        header: HeaderRef,
301        record: RecordName,
302        field: FieldName,
303        build: Option<NormalisedDefault>,
304        target: Option<NormalisedDefault>,
305    },
306    MacroTextChanged {
307        header: HeaderRef,
308        key: MacroKey,
309        build: NormalisedMacroBody,
310        target: NormalisedMacroBody,
311    },
312    ManifestUnreadable {
313        app: ApplicationName,
314        reason: ManifestUnreadable,
315    },
316    AppNotTracked {
317        app: ApplicationName,
318    },
319}
320
321impl Warning {
322    #[must_use]
323    pub fn kind(&self) -> JigsawWarningKind {
324        match self {
325            Self::BundledAppShippedByHost { .. } => JigsawWarningKind::BundledAppShippedByHost,
326            Self::RecordDefaultChanged { .. } => JigsawWarningKind::RecordDefaultChanged,
327            Self::MacroTextChanged { .. } => JigsawWarningKind::MacroTextChanged,
328            Self::ManifestUnreadable { .. } => JigsawWarningKind::ManifestUnreadable,
329            Self::AppNotTracked { .. } => JigsawWarningKind::AppNotTracked,
330        }
331    }
332}
333
334impl Warning {
335    #[must_use]
336    pub fn symbol(&self) -> String {
337        match self {
338            Self::BundledAppShippedByHost { app } => app.to_string(),
339            Self::RecordDefaultChanged { record, field, .. } => format!("#{record}.{field}"),
340            Self::MacroTextChanged { header, key, .. } => format!("?{key} in {header}"),
341            Self::ManifestUnreadable { app, .. } | Self::AppNotTracked { app } => app.to_string(),
342        }
343    }
344
345    #[must_use]
346    pub fn detail(&self) -> Option<String> {
347        match self {
348            Self::BundledAppShippedByHost { .. } => None,
349            Self::RecordDefaultChanged { build, target, .. } => {
350                let show = |default: &Option<NormalisedDefault>| {
351                    default
352                        .as_ref()
353                        .map_or_else(|| "undefined".to_owned(), ToString::to_string)
354                };
355                Some(format!("build: {}; target: {}", show(build), show(target)))
356            }
357            Self::MacroTextChanged { build, target, .. } => {
358                Some(format!("build: {build}; target: {target}"))
359            }
360            Self::ManifestUnreadable { reason, .. } => Some(match reason {
361                ManifestUnreadable::Missing => {
362                    "no manifest file found: check the project's `manifest` setting".to_owned()
363                }
364                ManifestUnreadable::UnexpandedVariable(variable) => {
365                    format!("the manifest uses the unexpanded variable {variable}")
366                }
367                ManifestUnreadable::ParseFailed => "the manifest could not be parsed".to_owned(),
368            }),
369            Self::AppNotTracked { .. } => {
370                Some("no `[[project]]` for the dependency that provides it".to_owned())
371            }
372        }
373    }
374}
375
376/// What checking one anchor at one target produced.
377#[derive(Debug, Clone, Default, PartialEq, Eq)]
378pub struct Checked {
379    pub findings: Vec<Finding>,
380    pub warnings: Vec<Warning>,
381    pub withheld: BTreeSet<Withheld>,
382}
383
384impl Checked {
385    #[must_use]
386    pub fn satisfied() -> Self {
387        Self::default()
388    }
389
390    #[must_use]
391    pub fn broken(finding: Finding) -> Self {
392        Self {
393            findings: vec![finding],
394            ..Self::default()
395        }
396    }
397
398    #[must_use]
399    pub fn withheld(withheld: Withheld) -> Self {
400        Self {
401            withheld: BTreeSet::from([withheld]),
402            ..Self::default()
403        }
404    }
405
406    pub fn merge(&mut self, other: Self) {
407        self.findings.extend(other.findings);
408        self.warnings.extend(other.warnings);
409        self.withheld.extend(other.withheld);
410    }
411}