Skip to main content

backhopper_core/model/
snapshot.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//! `Snapshot<S>` with the type-state pattern.
6//!
7//! The two states `Unsorted` and `Canonical` enforce that:
8//!  * the on-disk format writer accepts only `Snapshot<Canonical>`
9//!  * the parser produces `Snapshot<Canonical>` and rejects any
10//!    non-canonical input
11
12use std::collections::BTreeMap;
13use std::fmt;
14use std::marker::PhantomData;
15use std::str::FromStr;
16
17use serde::de::{Deserializer, Error as _};
18use serde::{Deserialize, Serialize};
19use time::OffsetDateTime;
20
21use crate::compat::arg_shape::ArgShape;
22use crate::errors::{NameError, SnapshotError};
23use crate::model::app_manifest::AppManifest;
24use crate::model::macro_decl::MacroDecl;
25use crate::model::names::{
26    ApplicationName, Arity, CommitSha, DependencyName, DependencyVersion, ExtractionSettingsDigest,
27    FieldName, FunctionName, HostFactsDigest, MacroName, ModuleName, ProjectName, RecordName,
28    RelativePath, TagName, TypeName,
29};
30use crate::model::non_empty::NonEmpty;
31use crate::model::normalised::NormalisedDefault;
32use crate::model::schema_files::{PartialFile, SchemaFile};
33use crate::snapshot::sort::{canonicalize_parts, missing_app, out_of_order};
34
35pub mod state {
36    mod private {
37        pub trait Sealed {}
38    }
39
40    /// Sealed so a foreign marker cannot stand in for `Unsorted` or
41    /// `Canonical` and skip the canonicalization the two states pin.
42    pub trait SnapshotState: private::Sealed {}
43
44    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45    pub struct Unsorted;
46
47    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48    pub struct Canonical;
49
50    impl private::Sealed for Unsorted {}
51    impl private::Sealed for Canonical {}
52    impl SnapshotState for Unsorted {}
53    impl SnapshotState for Canonical {}
54}
55
56pub const FORMAT_VERSION: u32 = 5;
57
58/// Format 5 adds facts a re-emitted older snapshot does not hold, so only
59/// regeneration writes it.
60pub const LAST_MIGRATABLE_FORMAT_VERSION: u32 = 4;
61
62pub const SUPPORTED_FORMAT_VERSIONS: &[u32] = &[1, 2, 3, 4, 5];
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct SnapshotHeader {
66    pub project: ProjectName,
67    pub tag: TagName,
68    pub branch: Option<String>,
69    pub commit: CommitSha,
70    pub scanned_paths: Vec<String>,
71    /// Names of Erlang/OTP applications included in this snapshot. Populated
72    /// for `multi_app` and `erlang_otp` projects: empty for `single_app`.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub apps_scanned: Vec<ApplicationName>,
75    pub generated_by: String,
76    #[serde(with = "time::serde::rfc3339")]
77    pub generated_at: OffsetDateTime,
78    /// Version of the extractor that produced this snapshot. Compared against
79    /// the running binary's `EXTRACTOR_VERSION` by `snapshots verify --all`.
80    /// Empty string when reading pre-extractor-versioning snapshots.
81    #[serde(default)]
82    pub extractor_version: String,
83    /// The file's `format-version` header, as parsed. Defaults to 0 when
84    /// deserializing an envelope written before this field existed.
85    #[serde(default)]
86    pub format_version: u32,
87    /// Vendored dependency pins captured from the project's components file
88    /// (e.g. `rabbitmq-components.mk`). Populated for projects whose family
89    /// declares a components-file shape; empty otherwise. Format version 4
90    /// and later.
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub dep_pins: Vec<VendoredDep>,
93    /// The release roots and configuration digest a host snapshot was
94    /// generated with. `None` for every other project. Format version 5
95    /// and later.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub host_facts: Option<HostReleaseFacts>,
98    /// Digest of the project settings the snapshot was extracted with.
99    /// `None` for a file that records none. Format version 5 and later.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub extraction_settings: Option<ExtractionSettingsDigest>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct HostReleaseFacts {
106    pub release_roots: NonEmpty<ApplicationName>,
107    pub digest: HostFactsDigest,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum Visibility {
113    Public,
114    Hidden,
115    TestOnly,
116}
117
118impl Visibility {
119    pub fn keyword(self) -> &'static str {
120        match self {
121            Self::Public => "public",
122            Self::Hidden => "hidden",
123            Self::TestOnly => "test_only",
124        }
125    }
126
127    pub fn from_keyword(s: &str) -> Option<Self> {
128        match s {
129            "public" => Some(Self::Public),
130            "hidden" => Some(Self::Hidden),
131            "test_only" => Some(Self::TestOnly),
132            _ => None,
133        }
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct Module {
139    pub name: ModuleName,
140    pub visibility: Visibility,
141    pub behaviours: Vec<ModuleName>,
142    pub exports: Vec<FunArity>,
143    pub export_types: Vec<TypeArity>,
144    pub callbacks: Vec<CallbackSig>,
145    pub optional_callbacks: Vec<FunArity>,
146    pub specs: Vec<SpecSig>,
147    pub types: Vec<TypeDecl>,
148    pub opaques: Vec<TypeArity>,
149    pub deprecations: Vec<Deprecation>,
150    /// Per-export clause-head patterns: the disjunction of clauses that
151    /// define this function. Used by the analyzer to flag calls whose
152    /// argument shape doesn't satisfy any clause head at the pin.
153    #[serde(
154        default,
155        skip_serializing_if = "BTreeMap::is_empty",
156        with = "crate::model::serde_util::fun_arity_keyed_map"
157    )]
158    pub clause_heads: BTreeMap<FunArity, Vec<Vec<ArgShape>>>,
159    /// Source path relative to the project root. `None` on format
160    /// version 1 snapshots; populated on v2.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub path: Option<RelativePath>,
163    /// Application this module belongs to. `None` on v1 and for
164    /// single-app projects.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub app: Option<ApplicationName>,
167    /// Records declared inline in the `.erl` file. Distinct from
168    /// `HrlFile.records`. v2 and later.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub records: Vec<RecordDecl>,
171    /// Exports declared inside an `-ifdef(TEST)` block. Test-only
172    /// exports drive cascade planning for the unconditional-exports
173    /// refactor: they are not part of the public API at the tag but the
174    /// planner needs them recorded for this branch.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub test_only_exports: Vec<TestOnlyExport>,
177    /// `-define(...)` macros inside `-ifdef(TEST)` *body*
178    /// blocks (the macro itself is only visible under the guard).
179    /// Needed by the Variant B unwrap planner: when the body block
180    /// becomes unconditional, every macro it depends on must move
181    /// out together with the function bodies.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub ifdef_macros: Vec<IfdefMacro>,
184    /// `-ifndef(TEST)/-else/-endif` regions: a function with two
185    /// bodies, a production one and a test one. Recorded for
186    /// surfacing; no verdict rule fires on them.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub variant_c_blocks: Vec<VariantCBlock>,
189    /// Extracted body of the version function for modules that implement
190    /// a `versioned_machine_impl` declared by the project family (e.g.
191    /// `rabbit_fifo:version/0`). `None` when the module is not a declared
192    /// versioned-machine impl or when the body did not resolve. Format
193    /// version 4 and later.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub versioned_machine_version: Option<VersionedMachineVersion>,
196    /// Bound values of the wire-bearing macros declared by the project
197    /// family for this module (e.g. `RA_PROTO_VERSION` on `ra`). Empty
198    /// when the module declares no tracked wire constants. Format
199    /// version 4 and later.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub wire_constants: Vec<WireConstantBinding>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
205pub struct TestOnlyExport {
206    pub function: FunctionName,
207    pub arity: Arity,
208    /// 1-based line of the `-export` directive.
209    pub export_line: usize,
210    /// 1-based line where the function body starts. `None` for
211    /// Variant A (body unconditional), `Some` for Variant B (body
212    /// inside its own `-ifdef(TEST)` block, where the function
213    /// itself is absent from a non-TEST beam).
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub body_line: Option<usize>,
216    pub variant: TestExportVariant,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum TestExportVariant {
222    /// Body is compiled unconditionally; only the `-export` is
223    /// guarded. Stale non-TEST `.beam` still has the function code
224    /// but not in its export table.
225    A,
226    /// Body is also inside an `-ifdef(TEST)` block. The function
227    /// does not exist in a non-TEST `.beam` at all.
228    B,
229}
230
231impl TestExportVariant {
232    pub fn as_label(self) -> &'static str {
233        match self {
234            Self::A => "a",
235            Self::B => "b",
236        }
237    }
238
239    pub fn from_label(s: &str) -> Option<Self> {
240        match s {
241            "a" => Some(Self::A),
242            "b" => Some(Self::B),
243            _ => None,
244        }
245    }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
249pub struct IfdefMacro {
250    pub name: String,
251    /// 1-based line of the `-define` directive.
252    pub line: usize,
253    pub guard_kind: IfdefGuardKind,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum IfdefGuardKind {
259    /// The macro is inside `-ifdef(TEST)`.
260    Test,
261    /// The macro is inside `-ifndef(TEST)`.
262    NotTest,
263    /// The macro is inside some other `-ifdef` or `-if` block.
264    Other,
265}
266
267impl IfdefGuardKind {
268    pub fn as_label(self) -> &'static str {
269        match self {
270            Self::Test => "test",
271            Self::NotTest => "not_test",
272            Self::Other => "other",
273        }
274    }
275
276    pub fn from_label(s: &str) -> Option<Self> {
277        match s {
278            "test" => Some(Self::Test),
279            "not_test" => Some(Self::NotTest),
280            "other" => Some(Self::Other),
281            _ => None,
282        }
283    }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
287pub struct VariantCBlock {
288    /// The `-ifndef(...)` identifier (typically `TEST`).
289    pub guard: String,
290    /// 1-based line of the `-ifndef` directive.
291    pub start_line: usize,
292    /// 1-based line of the matching `-endif.`.
293    pub end_line: usize,
294    /// 1-based line of the `-else.`, when present.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub else_line: Option<usize>,
297}
298
299/// Snapshot-level record of a versioned state-machine's `version/0`
300/// function value, captured from the source at snapshot time. Populated
301/// only for modules the project family declares as versioned-machine
302/// impls (e.g. `rabbit_fifo`).
303#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
304pub struct VersionedMachineVersion {
305    pub function: FunctionName,
306    pub arity: Arity,
307    /// Resolved integer value of the version-function body. `None` when
308    /// the body did not parse to an integer literal (directly or through
309    /// macro expansion).
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub value: Option<u64>,
312    pub provenance: Provenance,
313}
314
315/// How a versioned-machine `version/0` value was resolved at snapshot
316/// time.
317#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case", tag = "kind")]
319pub enum Provenance {
320    /// The body was an integer literal directly in the function clause.
321    Literal,
322    /// The body referenced a `?MACRO` that resolved to an integer.
323    /// `defined_in` is the file path of the source that bound the macro
324    /// (own `.erl`, own `.hrl`, or a transitive include).
325    MacroBody {
326        macro_name: MacroName,
327        #[serde(default, skip_serializing_if = "Option::is_none")]
328        defined_in: Option<String>,
329    },
330}
331
332/// Snapshot-level record of a wire-bearing macro's bound value (e.g.
333/// `RA_PROTO_VERSION`, segment-file `VERSION` and `MAGIC`). Captured
334/// per module declared by the project family as wire-bearing.
335#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
336pub struct WireConstantBinding {
337    pub macro_name: MacroName,
338    pub value: WireValue,
339    /// Source file where the macro was `-define`'d. May be the module's
340    /// own `.erl`, a sibling `.hrl`, or a transitive include.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub defined_in: Option<String>,
343}
344
345/// Type-discriminated wire-constant value. Integers and ASCII bytestrings
346/// are typed; anything else is preserved verbatim as `Opaque`.
347#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
348#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
349pub enum WireValue {
350    /// Unsigned integer literal (`-define(VERSION, 2)`).
351    U64(u64),
352    /// Bytestring literal (`-define(MAGIC, <<"RASG">>)`). Stored as
353    /// the inner bytes without the `<<"...">>` wrapper. The text-format
354    /// round-trip assumes ASCII-printable bytes: non-printable byte
355    /// content should land in `Opaque` instead.
356    Bytes(Vec<u8>),
357    /// Anything else, preserved as the raw token text.
358    Opaque(String),
359}
360
361/// Pinned dependency captured from the project's components file (e.g.
362/// `rabbitmq-components.mk`).
363#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
364pub struct VendoredDep {
365    pub name: DependencyName,
366    pub version: DependencyVersion,
367    pub source: VendoredDepSource,
368}
369
370/// How a vendored dependency is sourced. Mirrors the three real
371/// `rabbitmq-components.mk` line shapes.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum VendoredDepSource {
375    /// `dep_NAME = hex VERSION`.
376    Hex,
377    /// `dep_NAME = git URL TAG`.
378    Git,
379    /// `dep_NAME = git_rmq NAME TAG` (vendored fork).
380    GitRmq,
381}
382
383impl VendoredDepSource {
384    pub fn as_label(self) -> &'static str {
385        match self {
386            Self::Hex => "hex",
387            Self::Git => "git",
388            Self::GitRmq => "git_rmq",
389        }
390    }
391
392    pub fn from_label(s: &str) -> Option<Self> {
393        match s {
394            "hex" => Some(Self::Hex),
395            "git" => Some(Self::Git),
396            "git_rmq" => Some(Self::GitRmq),
397            _ => None,
398        }
399    }
400}
401
402impl Module {
403    pub fn new(name: ModuleName) -> Self {
404        Self {
405            name,
406            visibility: Visibility::Public,
407            behaviours: Vec::new(),
408            exports: Vec::new(),
409            export_types: Vec::new(),
410            callbacks: Vec::new(),
411            optional_callbacks: Vec::new(),
412            specs: Vec::new(),
413            types: Vec::new(),
414            opaques: Vec::new(),
415            deprecations: Vec::new(),
416            clause_heads: BTreeMap::new(),
417            path: None,
418            app: None,
419            records: Vec::new(),
420            test_only_exports: Vec::new(),
421            ifdef_macros: Vec::new(),
422            variant_c_blocks: Vec::new(),
423            versioned_machine_version: None,
424            wire_constants: Vec::new(),
425        }
426    }
427
428    /// Whether the module exports `function/arity`. The export list is
429    /// sorted in a canonical snapshot.
430    pub fn exports_function(&self, function: &FunctionName, arity: Arity) -> bool {
431        let key = FunArity {
432            name: function.clone(),
433            arity,
434        };
435        self.exports.binary_search(&key).is_ok()
436    }
437
438    /// Whether the module lists `name/arity` in `-export_type`.
439    pub fn exports_type(&self, name: &TypeName, arity: Arity) -> bool {
440        self.export_types.contains(&TypeArity {
441            name: name.clone(),
442            arity,
443        })
444    }
445
446    /// Exports that have no matching clause head. An empty
447    /// `clause_heads` (older snapshots without the field populated)
448    /// returns an empty list so this never false-fires on legacy data.
449    pub fn missing_clause_bodies(&self) -> Vec<FunArity> {
450        if self.clause_heads.is_empty() {
451            return Vec::new();
452        }
453        self.exports
454            .iter()
455            .filter(|fa| !self.clause_heads.contains_key(fa))
456            .cloned()
457            .collect()
458    }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
462pub struct HeaderRef {
463    pub app: ApplicationName,
464    pub app_path: RelativePath,
465}
466
467impl fmt::Display for HeaderRef {
468    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469        write!(f, "{}/{}", self.app, self.app_path)
470    }
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct HrlFile {
475    pub path: RelativePath,
476    /// Application the header belongs to. `None` before format version 5.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub app: Option<ApplicationName>,
479    /// The header's own `-include` and `-include_lib` lines. Format
480    /// version 5 and later.
481    #[serde(default, skip_serializing_if = "Vec::is_empty")]
482    pub includes: Vec<HeaderRef>,
483    pub types: Vec<TypeDecl>,
484    pub opaques: Vec<TypeArity>,
485    pub records: Vec<RecordDecl>,
486    /// Macros the header defines outside `-ifdef(TEST)`. Format version 5
487    /// and later.
488    #[serde(default, skip_serializing_if = "Vec::is_empty")]
489    pub defines: Vec<MacroDecl>,
490}
491
492impl HrlFile {
493    pub fn new(path: RelativePath) -> Self {
494        Self {
495            path,
496            app: None,
497            includes: Vec::new(),
498            types: Vec::new(),
499            opaques: Vec::new(),
500            records: Vec::new(),
501            defines: Vec::new(),
502        }
503    }
504
505    /// The part of `path` after the app root, which is the directory named
506    /// like the app: `deps/rabbit_common/include/rabbit.hrl` in
507    /// `rabbit_common` is `include/rabbit.hrl`. A path with no such
508    /// directory is already relative to the app. `None` without an app.
509    #[must_use]
510    pub fn app_path(&self) -> Option<RelativePath> {
511        let app = self.app.as_ref()?;
512        let segments: Vec<&str> = self.path.as_str().split('/').collect();
513        let (_file, dirs) = segments.split_last()?;
514        let rest = match dirs.iter().position(|d| *d == app.as_str()) {
515            Some(root) => segments[root + 1..].join("/"),
516            None => self.path.as_str().to_owned(),
517        };
518        RelativePath::new(rest).ok()
519    }
520
521    #[must_use]
522    pub fn header_ref(&self) -> Option<HeaderRef> {
523        Some(HeaderRef {
524            app: self.app.clone()?,
525            app_path: self.app_path()?,
526        })
527    }
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
531pub struct FunArity {
532    pub name: FunctionName,
533    pub arity: Arity,
534}
535
536impl fmt::Display for FunArity {
537    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
538        write!(f, "{}/{}", self.name, self.arity)
539    }
540}
541
542impl FromStr for FunArity {
543    type Err = NameError;
544
545    fn from_str(s: &str) -> Result<Self, NameError> {
546        let (name, arity) = split_name_arity("fun_arity", s)?;
547        Ok(Self {
548            name: name.parse()?,
549            arity,
550        })
551    }
552}
553
554/// The `name/arity` split `FunArity` and `TypeArity` share; the name half
555/// is returned unparsed because the two kinds validate it differently.
556fn split_name_arity<'a>(kind: &'static str, s: &'a str) -> Result<(&'a str, Arity), NameError> {
557    let invalid = || NameError::Invalid {
558        kind,
559        value: s.to_owned(),
560        reason: "expected 'name/arity'",
561    };
562    let (name, arity) = s.rsplit_once('/').ok_or_else(invalid)?;
563    let arity = arity.parse().map_err(|_| invalid())?;
564    Ok((name, arity))
565}
566
567#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
568pub struct TypeArity {
569    pub name: TypeName,
570    pub arity: Arity,
571}
572
573impl fmt::Display for TypeArity {
574    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
575        write!(f, "{}/{}", self.name, self.arity)
576    }
577}
578
579impl FromStr for TypeArity {
580    type Err = NameError;
581
582    fn from_str(s: &str) -> Result<Self, NameError> {
583        let (name, arity) = split_name_arity("type_arity", s)?;
584        Ok(Self {
585            name: name.parse()?,
586            arity,
587        })
588    }
589}
590
591#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
592pub struct CallbackSig {
593    pub name: FunctionName,
594    pub arity: Arity,
595    pub signature: String,
596}
597
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
599pub struct SpecSig {
600    pub name: FunctionName,
601    pub arity: Arity,
602    pub signature: String,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606pub struct TypeDecl {
607    pub name: TypeName,
608    pub arity: Arity,
609    pub rhs: String,
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
613pub struct RecordDecl {
614    pub name: RecordName,
615    pub fields: Vec<RecordField>,
616}
617
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619pub struct RecordField {
620    pub name: FieldName,
621    pub type_repr: Option<String>,
622    /// `None` means the source wrote no default, which Erlang reads as
623    /// `undefined`; a written `= undefined` is `Some`. Format version 5
624    /// and later.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub default: Option<NormalisedDefault>,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630pub struct Deprecation {
631    pub function: Option<FunctionName>,
632    pub arity_match: ArityMatch,
633    pub since: Option<TagName>,
634    pub replacement: Option<DeprecationReplacement>,
635    pub reason: Option<String>,
636    pub module_wide: bool,
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(rename_all = "snake_case", tag = "kind")]
641pub enum ArityMatch {
642    Exact { arity: Arity },
643    Any,
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647pub struct DeprecationReplacement {
648    pub function: FunctionName,
649    pub arity: Arity,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
653pub(crate) struct SnapshotParts {
654    pub(crate) header: SnapshotHeader,
655    #[serde(default, skip_serializing_if = "Vec::is_empty")]
656    pub(crate) apps: Vec<AppManifest>,
657    pub(crate) modules: Vec<Module>,
658    pub(crate) headers: Vec<HrlFile>,
659    #[serde(default, skip_serializing_if = "Vec::is_empty")]
660    pub(crate) schemas: Vec<SchemaFile>,
661    #[serde(default, skip_serializing_if = "Vec::is_empty")]
662    pub(crate) partials: Vec<PartialFile>,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
666#[serde(bound = "")]
667pub struct Snapshot<S: state::SnapshotState = state::Unsorted> {
668    #[serde(flatten)]
669    parts: SnapshotParts,
670    #[serde(skip)]
671    _state: PhantomData<S>,
672}
673
674impl TryFrom<SnapshotParts> for Snapshot<state::Canonical> {
675    type Error = SnapshotError;
676
677    fn try_from(parts: SnapshotParts) -> Result<Self, SnapshotError> {
678        if let Some(detail) = out_of_order(&parts) {
679            return Err(SnapshotError::NotCanonicalValue { detail });
680        }
681        if parts.header.format_version >= 5
682            && let Some(detail) = missing_app(&parts)
683        {
684            return Err(SnapshotError::NotCanonicalValue { detail });
685        }
686        Ok(Self::from_canonical_parts(parts))
687    }
688}
689
690impl<'de> Deserialize<'de> for Snapshot<state::Canonical> {
691    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
692    where
693        D: Deserializer<'de>,
694    {
695        let parts = SnapshotParts::deserialize(deserializer)?;
696        Snapshot::try_from(parts).map_err(D::Error::custom)
697    }
698}
699
700impl Snapshot<state::Unsorted> {
701    pub fn from_extracted(
702        header: SnapshotHeader,
703        modules: Vec<Module>,
704        headers: Vec<HrlFile>,
705    ) -> Self {
706        Self {
707            parts: SnapshotParts {
708                header,
709                apps: Vec::new(),
710                modules,
711                headers,
712                schemas: Vec::new(),
713                partials: Vec::new(),
714            },
715            _state: PhantomData,
716        }
717    }
718
719    #[must_use]
720    pub fn with_apps(mut self, apps: Vec<AppManifest>) -> Self {
721        self.parts.apps = apps;
722        self
723    }
724
725    #[must_use]
726    pub fn with_cuttlefish(mut self, schemas: Vec<SchemaFile>, partials: Vec<PartialFile>) -> Self {
727        self.parts.schemas = schemas;
728        self.parts.partials = partials;
729        self
730    }
731
732    #[must_use]
733    pub fn into_canonical(mut self) -> Snapshot<state::Canonical> {
734        canonicalize_parts(&mut self.parts);
735        Snapshot {
736            parts: self.parts,
737            _state: PhantomData,
738        }
739    }
740}
741
742impl<S: state::SnapshotState> Snapshot<S> {
743    pub fn header(&self) -> &SnapshotHeader {
744        &self.parts.header
745    }
746
747    pub fn modules(&self) -> &[Module] {
748        &self.parts.modules
749    }
750
751    pub fn headers(&self) -> &[HrlFile] {
752        &self.parts.headers
753    }
754
755    pub fn apps(&self) -> &[AppManifest] {
756        &self.parts.apps
757    }
758
759    pub fn schemas(&self) -> &[SchemaFile] {
760        &self.parts.schemas
761    }
762
763    pub fn partials(&self) -> &[PartialFile] {
764        &self.parts.partials
765    }
766
767    /// Records are declared in headers (.hrl) and inline in modules (.erl): both.
768    pub fn records(&self) -> impl Iterator<Item = &RecordDecl> {
769        self.parts
770            .headers
771            .iter()
772            .flat_map(|h| h.records.iter())
773            .chain(self.parts.modules.iter().flat_map(|m| m.records.iter()))
774    }
775}
776
777impl Snapshot<state::Canonical> {
778    /// True when `module` exports `function/arity`. O(log n): canonical
779    /// snapshots keep `modules` and each module's `exports` sorted.
780    pub fn lookup_export(
781        &self,
782        module: &ModuleName,
783        function: &FunctionName,
784        arity: Arity,
785    ) -> bool {
786        self.module_named(module)
787            .is_some_and(|m| m.exports_function(function, arity))
788    }
789
790    /// Look up a module by name in O(log n): canonical snapshots keep
791    /// `modules` sorted by `name`.
792    pub fn module_named(&self, name: &ModuleName) -> Option<&Module> {
793        self.parts
794            .modules
795            .binary_search_by(|m| m.name.cmp(name))
796            .ok()
797            .map(|idx| &self.parts.modules[idx])
798    }
799
800    /// Every module whose `-behaviour(B).` list contains `behaviour`.
801    /// Reverse index over `Module.behaviours`; built per call (one
802    /// linear scan over `self.modules`). Used by behaviour-conformance
803    /// checks and the R7 suite-implementer sweep.
804    pub fn implementers_of(&self, behaviour: &ModuleName) -> Vec<&Module> {
805        self.parts
806            .modules
807            .iter()
808            .filter(|m| m.behaviours.contains(behaviour))
809            .collect()
810    }
811
812    pub(crate) fn from_canonical_parts(parts: SnapshotParts) -> Self {
813        Self {
814            parts,
815            _state: PhantomData,
816        }
817    }
818
819    #[must_use]
820    pub fn written_format_version(&self) -> u32 {
821        self.parts
822            .header
823            .format_version
824            .clamp(LAST_MIGRATABLE_FORMAT_VERSION, FORMAT_VERSION)
825    }
826
827    #[must_use]
828    pub fn unwritable_reason(&self) -> Option<String> {
829        if self.written_format_version() < 5 {
830            return None;
831        }
832        missing_app(&self.parts)
833    }
834}
835
836impl Default for Snapshot<state::Unsorted> {
837    fn default() -> Self {
838        Self::from_extracted(
839            SnapshotHeader {
840                project: ProjectName::new("placeholder").expect("valid"),
841                tag: TagName::new("v0.0.0").expect("valid"),
842                branch: None,
843                commit: CommitSha::new("0".repeat(40)).expect("valid"),
844                scanned_paths: Vec::new(),
845                apps_scanned: Vec::new(),
846                generated_by: format!("backhopper {}", env!("CARGO_PKG_VERSION")),
847                generated_at: OffsetDateTime::UNIX_EPOCH,
848                extractor_version: String::new(),
849                format_version: FORMAT_VERSION,
850                dep_pins: Vec::new(),
851                host_facts: None,
852                extraction_settings: None,
853            },
854            Vec::new(),
855            Vec::new(),
856        )
857    }
858}