Skip to main content

backhopper_core/model/
snapshot_diff.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//! Wire payloads of the `snapshots diff` verbs.
6//!
7//! Modeled here so the CLI emits and the driver parses one definition.
8//! The name-bearing fields carry the same serde-transparent newtypes the
9//! rest of core uses, so the serialized form is unchanged and a consumer
10//! stops re-validating identifiers it round-trips: headers through
11//! `RelativePath`, wire-constant macro names through `MacroName`.
12//! `fun_arity` and `type_arity` travel through `FunArity` and
13//! `TypeArity`'s `Display` form (`serde_util::display_from_str`), so the
14//! wire still spells them `f/2`.
15
16use serde::{Deserialize, Serialize};
17
18use crate::model::names::{
19    MacroName, ModuleName, ProjectName, RecordName, RelativePath, SeriesName, TagName,
20};
21use crate::model::snapshot::{FunArity, TypeArity};
22
23/// API delta between two tags of one project, oriented `from -> to`:
24/// `*_added` are present at `to` and absent at `from`; `*_removed` are
25/// present at `from` and absent at `to`.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct DiffPayload {
28    pub project: ProjectName,
29    /// The older tag: the baseline of the delta.
30    pub from: TagName,
31    /// The newer tag: the target of the delta.
32    pub to: TagName,
33    pub modules_added: Vec<ModuleName>,
34    pub modules_removed: Vec<ModuleName>,
35    pub exports_added: Vec<QualifiedFunArity>,
36    pub exports_removed: Vec<QualifiedFunArity>,
37    pub types_added: Vec<QualifiedTypeArity>,
38    pub types_removed: Vec<QualifiedTypeArity>,
39    pub callbacks_added: Vec<QualifiedFunArity>,
40    pub callbacks_removed: Vec<QualifiedFunArity>,
41    pub headers_added: Vec<RelativePath>,
42    pub headers_removed: Vec<RelativePath>,
43    pub records_added: Vec<QualifiedRecord>,
44    pub records_removed: Vec<QualifiedRecord>,
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub versioned_machine_version_changes: Vec<VersionedMachineVersionChange>,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub wire_constant_changes: Vec<WireConstantChange>,
49}
50
51impl DiffPayload {
52    /// True when the diff records no API change on any tracked axis.
53    #[must_use]
54    pub fn is_empty(&self) -> bool {
55        self.modules_added.is_empty()
56            && self.modules_removed.is_empty()
57            && self.exports_added.is_empty()
58            && self.exports_removed.is_empty()
59            && self.types_added.is_empty()
60            && self.types_removed.is_empty()
61            && self.callbacks_added.is_empty()
62            && self.callbacks_removed.is_empty()
63            && self.headers_added.is_empty()
64            && self.headers_removed.is_empty()
65            && self.records_added.is_empty()
66            && self.records_removed.is_empty()
67            && self.versioned_machine_version_changes.is_empty()
68            && self.wire_constant_changes.is_empty()
69    }
70
71    /// Count of removed symbols: the breaking side of an API delta.
72    #[must_use]
73    pub fn breaking_removal_count(&self) -> usize {
74        self.modules_removed.len()
75            + self.exports_removed.len()
76            + self.callbacks_removed.len()
77            + self.types_removed.len()
78            + self.records_removed.len()
79            + self.headers_removed.len()
80    }
81}
82
83/// A versioned-machine version that was added, removed, or moved.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "snake_case", tag = "kind")]
86pub enum VersionedMachineVersionChange {
87    Missing {
88        module: ModuleName,
89        side: String,
90    },
91    Drift {
92        module: ModuleName,
93        from: Option<u64>,
94        to: Option<u64>,
95    },
96}
97
98/// A wire-constant macro that was added, removed, or moved.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(rename_all = "snake_case", tag = "kind")]
101pub enum WireConstantChange {
102    Missing {
103        module: ModuleName,
104        side: String,
105        macros: Vec<MacroName>,
106    },
107    Drift {
108        module: ModuleName,
109        macro_name: MacroName,
110        from: String,
111        to: String,
112    },
113}
114
115/// A function or callback named `module:fun/arity`.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct QualifiedFunArity {
118    pub module: ModuleName,
119    #[serde(with = "crate::model::serde_util::display_from_str")]
120    pub fun_arity: FunArity,
121}
122
123/// A type named `module:type/arity`.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct QualifiedTypeArity {
126    pub module: ModuleName,
127    #[serde(with = "crate::model::serde_util::display_from_str")]
128    pub type_arity: TypeArity,
129}
130
131/// A record declared in a header.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct QualifiedRecord {
134    pub header: RelativePath,
135    pub record: RecordName,
136}
137
138/// Per-project diffs between the pins of two series, each oriented
139/// `from_series -> to_series` (see [`DiffPayload`] for the per-project
140/// `added`/`removed` orientation).
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct CrossSeriesDiffPayload {
143    /// The baseline series.
144    pub from_series: SeriesName,
145    /// The target series.
146    pub to_series: SeriesName,
147    /// One entry per project that differs between the two series.
148    pub projects: Vec<DiffPayload>,
149}