Skip to main content

backhopper_core/jigsaw/
wire.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::{BTreeMap, BTreeSet};
6use std::fmt::Display;
7use std::str::FromStr;
8
9use serde::{Deserialize, Serialize};
10
11use crate::config::Config;
12use crate::jigsaw::loaded::{SnapshotGap, StoreStatus};
13use crate::jigsaw::plan::{HostRelease, JigsawPlan, PlanError, PlanParts, TagSourceKind};
14use crate::jigsaw::releases::SeriesReleases;
15use crate::jigsaw::tags::OtpLine;
16use crate::model::names::{
17    ApplicationName, CommitSha, DependencyName, ExtractorVersion, HostFactsDigest, HostName,
18    ProjectName, TagName,
19};
20use crate::model::non_empty::NonEmpty;
21use crate::model::pin::Pin;
22use crate::model::release_version::{
23    ErlangVersionRange, HostSeries, HostTag, OtpLineVersion, OtpTag,
24};
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
28pub struct JigsawPlanPayload {
29    pub host: String,
30    pub host_project: String,
31    pub otp_project: String,
32    pub series: String,
33    pub tag_source: String,
34    pub extractor_version: String,
35    pub host_facts_digest: String,
36    pub releases: Vec<PlanReleasePayload>,
37    pub otp_lines: Vec<PlanOtpLinePayload>,
38    pub untracked_dependencies: Vec<String>,
39    pub excluded_otp_apps: Vec<String>,
40    pub required_snapshots: Vec<Pin>,
41    pub missing_snapshots: Vec<MissingSnapshotPayload>,
42    pub cache_key: String,
43    pub restore_key: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
48pub struct PlanReleasePayload {
49    pub tag: String,
50    pub commit: String,
51    pub pins: BTreeMap<String, String>,
52    pub erlang_support: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
57pub struct PlanOtpLinePayload {
58    pub line: String,
59    pub tags: Vec<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
64pub struct MissingSnapshotPayload {
65    pub project: String,
66    pub tag: String,
67    pub reason: SnapshotGap,
68    pub detail: Option<String>,
69}
70
71fn wire<E: Display>(field: &'static str) -> impl Fn(E) -> PlanError {
72    move |error| PlanError::Wire {
73        field,
74        detail: error.to_string(),
75    }
76}
77
78fn parse<T: FromStr>(field: &'static str, value: &str) -> Result<T, PlanError>
79where
80    T::Err: Display,
81{
82    value.parse().map_err(wire(field))
83}
84
85impl JigsawPlan {
86    #[must_use]
87    pub fn to_wire(&self, status: &StoreStatus) -> JigsawPlanPayload {
88        let missing_snapshots = match status {
89            StoreStatus::Stocked => Vec::new(),
90            StoreStatus::Unstocked(unstocked) => unstocked
91                .gaps()
92                .into_iter()
93                .map(|(pin, gap)| MissingSnapshotPayload {
94                    project: pin.project.to_string(),
95                    tag: pin.tag.to_string(),
96                    reason: gap,
97                    detail: unstocked.detail(&pin),
98                })
99                .collect(),
100        };
101        JigsawPlanPayload {
102            host: self.host().to_string(),
103            host_project: self.host_project().to_string(),
104            otp_project: self.otp_project().to_string(),
105            series: self.series().to_string(),
106            tag_source: self.tag_source().to_string(),
107            extractor_version: self.extractor_version().to_string(),
108            host_facts_digest: self.host_facts().to_string(),
109            releases: self
110                .releases()
111                .iter()
112                .map(|release| PlanReleasePayload {
113                    tag: release.tag.tag().to_string(),
114                    commit: release.commit.to_string(),
115                    pins: release
116                        .pins
117                        .iter()
118                        .map(|(project, tag)| (project.to_string(), tag.to_string()))
119                        .collect(),
120                    erlang_support: release.erlang_support.to_string(),
121                })
122                .collect(),
123            otp_lines: self
124                .otp_lines()
125                .iter()
126                .map(|line| PlanOtpLinePayload {
127                    line: line.line().to_string(),
128                    tags: line
129                        .tags()
130                        .iter()
131                        .map(|tag| tag.tag().to_string())
132                        .collect(),
133                })
134                .collect(),
135            untracked_dependencies: self
136                .untracked_dependencies()
137                .iter()
138                .map(ToString::to_string)
139                .collect(),
140            excluded_otp_apps: self
141                .excluded_otp_apps()
142                .iter()
143                .map(ToString::to_string)
144                .collect(),
145            required_snapshots: self.required().into_iter().collect(),
146            missing_snapshots,
147            cache_key: self.cache_key().to_string(),
148            restore_key: self.cache_key().restore_prefix().to_owned(),
149        }
150    }
151
152    /// The payload's `cache_key`, `restore_key`, `required_snapshots` and
153    /// `missing_snapshots` are derived, so they are not read.
154    pub fn from_wire(payload: &JigsawPlanPayload, config: &Config) -> Result<Self, PlanError> {
155        let host_project_name: ProjectName = parse("host_project", &payload.host_project)?;
156        let otp_project_name: ProjectName = parse("otp_project", &payload.otp_project)?;
157        let host_project = config.project(&host_project_name)?;
158        let otp_project = config.project(&otp_project_name)?;
159        let releases = payload
160            .releases
161            .iter()
162            .map(|release| {
163                let tag: TagName = parse("releases.tag", &release.tag)?;
164                let pins = release
165                    .pins
166                    .iter()
167                    .map(|(project, tag)| {
168                        Ok((
169                            parse::<ProjectName>("releases.pins", project)?,
170                            parse::<TagName>("releases.pins", tag)?,
171                        ))
172                    })
173                    .collect::<Result<_, PlanError>>()?;
174                Ok(HostRelease {
175                    tag: HostTag::parse(host_project, tag).map_err(wire("releases.tag"))?,
176                    commit: parse::<CommitSha>("releases.commit", &release.commit)?,
177                    pins,
178                    erlang_support: parse::<ErlangVersionRange>(
179                        "releases.erlang_support",
180                        &release.erlang_support,
181                    )?,
182                })
183            })
184            .collect::<Result<Vec<_>, PlanError>>()?;
185        let releases = SeriesReleases::new(releases)?;
186        let series: HostSeries = parse("series", &payload.series)?;
187        if releases.series() != series {
188            return Err(PlanError::Wire {
189                field: "series",
190                detail: format!("releases are of series {}, not {series}", releases.series()),
191            });
192        }
193        let otp_lines = payload
194            .otp_lines
195            .iter()
196            .map(|line| {
197                let tags = line
198                    .tags
199                    .iter()
200                    .map(|tag| {
201                        OtpTag::parse(otp_project, parse("otp_lines.tags", tag)?)
202                            .map_err(wire("otp_lines.tags"))
203                    })
204                    .collect::<Result<Vec<_>, PlanError>>()?;
205                Ok(OtpLine::new(
206                    parse::<OtpLineVersion>("otp_lines.line", &line.line)?,
207                    tags,
208                )?)
209            })
210            .collect::<Result<Vec<_>, PlanError>>()?;
211        let otp_lines = NonEmpty::try_from(otp_lines).map_err(wire("otp_lines"))?;
212        JigsawPlan::assemble(PlanParts {
213            host: parse::<HostName>("host", &payload.host)?,
214            host_project: host_project_name,
215            otp_project: otp_project_name,
216            releases,
217            otp_lines,
218            untracked_dependencies: parse_set::<DependencyName>(
219                "untracked_dependencies",
220                &payload.untracked_dependencies,
221            )?,
222            excluded_otp_apps: parse_set::<ApplicationName>(
223                "excluded_otp_apps",
224                &payload.excluded_otp_apps,
225            )?,
226            host_facts: parse::<HostFactsDigest>("host_facts_digest", &payload.host_facts_digest)?,
227            tag_source: parse::<TagSourceKind>("tag_source", &payload.tag_source)?,
228            extractor_version: parse::<ExtractorVersion>(
229                "extractor_version",
230                &payload.extractor_version,
231            )?,
232        })
233    }
234}
235
236fn parse_set<T: FromStr + Ord>(
237    field: &'static str,
238    values: &[String],
239) -> Result<BTreeSet<T>, PlanError>
240where
241    T::Err: Display,
242{
243    values.iter().map(|value| parse(field, value)).collect()
244}