Skip to main content

backhopper_core/jigsaw/
plan.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;
7
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::config::Host;
12use crate::digest::DigestWriter;
13use crate::errors::{ConfigError, StoreError};
14use crate::jigsaw::digest::host_facts_digest;
15use crate::jigsaw::releases::{HasHostTag, SeriesReleases, SeriesReleasesError};
16use crate::jigsaw::tags::{OtpLine, OtpLineError};
17use crate::model::names::{
18    ApplicationName, CommitSha, DependencyName, ExtractorVersion, HostFactsDigest, HostName,
19    PlanCacheKey, ProjectName, TagName, vocabulary,
20};
21use crate::model::non_empty::NonEmpty;
22use crate::model::pin::Pin;
23use crate::model::release_version::{
24    ErlangMajor, ErlangVersionRange, HostSeries, HostTag, OtpLineVersion,
25};
26use crate::model::snapshot::FORMAT_VERSION;
27
28const CACHE_KEY_DIGEST_BYTES: usize = 8;
29
30vocabulary!(
31    pub enum TagSourceKind: "tag source" {
32        Repository => "repository",
33        Store => "store",
34    }
35);
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct HostRelease {
39    pub tag: HostTag,
40    pub commit: CommitSha,
41    pub pins: BTreeMap<ProjectName, TagName>,
42    pub erlang_support: ErlangVersionRange,
43}
44
45impl HasHostTag for HostRelease {
46    fn host_tag(&self) -> &HostTag {
47        &self.tag
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ReleaseFacts {
53    pub tag: HostTag,
54    pub commit: CommitSha,
55    pub pins: BTreeMap<ProjectName, TagName>,
56}
57
58impl HasHostTag for ReleaseFacts {
59    fn host_tag(&self) -> &HostTag {
60        &self.tag
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct UnsupportedMajor {
66    pub release: TagName,
67    pub range: ErlangVersionRange,
68    pub line: OtpLineVersion,
69}
70
71impl fmt::Display for UnsupportedMajor {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(
74            f,
75            "{} supports Erlang {}, which excludes {} (major {})",
76            self.release,
77            self.range,
78            self.line,
79            self.line.major()
80        )
81    }
82}
83
84fn join_failures(failures: &[UnsupportedMajor]) -> String {
85    failures
86        .iter()
87        .map(ToString::to_string)
88        .collect::<Vec<_>>()
89        .join("; ")
90}
91
92#[derive(Debug, Error)]
93pub enum PlanError {
94    #[error("no release of series {series} matches the host's tag pattern")]
95    EmptySeries { series: HostSeries },
96
97    #[error("--up-to {up_to} is not a release of series {series}")]
98    UpToNotInSeries { up_to: TagName, series: HostSeries },
99
100    #[error("no OTP release tag for Erlang major {major}")]
101    NoOtpTags { major: ErlangMajor },
102
103    #[error("{}", join_failures(failures))]
104    ErlangMajorUnsupported { failures: Vec<UnsupportedMajor> },
105
106    #[error("release {tag}: {detail}")]
107    ReleaseUnreadable { tag: TagName, detail: String },
108
109    #[error("plan field {field}: {detail}")]
110    Wire { field: &'static str, detail: String },
111
112    #[error(transparent)]
113    Releases(#[from] SeriesReleasesError),
114
115    #[error(transparent)]
116    OtpLine(#[from] OtpLineError),
117
118    #[error(transparent)]
119    Config(#[from] ConfigError),
120
121    #[error(transparent)]
122    Store(#[from] StoreError),
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct JigsawPlan {
127    host: HostName,
128    host_project: ProjectName,
129    otp_project: ProjectName,
130    releases: SeriesReleases<HostRelease>,
131    otp_lines: NonEmpty<OtpLine>,
132    untracked_dependencies: BTreeSet<DependencyName>,
133    excluded_otp_apps: BTreeSet<ApplicationName>,
134    host_facts: HostFactsDigest,
135    tag_source: TagSourceKind,
136    extractor_version: ExtractorVersion,
137    cache_key: PlanCacheKey,
138}
139
140pub(super) struct PlanParts {
141    pub(super) host: HostName,
142    pub(super) host_project: ProjectName,
143    pub(super) otp_project: ProjectName,
144    pub(super) releases: SeriesReleases<HostRelease>,
145    pub(super) otp_lines: NonEmpty<OtpLine>,
146    pub(super) untracked_dependencies: BTreeSet<DependencyName>,
147    pub(super) excluded_otp_apps: BTreeSet<ApplicationName>,
148    pub(super) host_facts: HostFactsDigest,
149    pub(super) tag_source: TagSourceKind,
150    pub(super) extractor_version: ExtractorVersion,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct DraftInputs {
155    pub releases: SeriesReleases<ReleaseFacts>,
156    pub otp_lines: NonEmpty<OtpLine>,
157    pub untracked_dependencies: BTreeSet<DependencyName>,
158    pub excluded_otp_apps: BTreeSet<ApplicationName>,
159    pub tag_source: TagSourceKind,
160    pub extractor_version: ExtractorVersion,
161}
162
163pub fn draft_plan(host: &Host, inputs: DraftInputs) -> Result<JigsawPlan, PlanError> {
164    let releases = inputs.releases.map(|facts| HostRelease {
165        erlang_support: host.erlang_support.range_for(facts.tag.version()).clone(),
166        tag: facts.tag,
167        commit: facts.commit,
168        pins: facts.pins,
169    })?;
170    JigsawPlan::assemble(PlanParts {
171        host: host.name.clone(),
172        host_project: host.project.clone(),
173        otp_project: host.otp_project.clone(),
174        releases,
175        otp_lines: inputs.otp_lines,
176        untracked_dependencies: inputs.untracked_dependencies,
177        excluded_otp_apps: inputs.excluded_otp_apps,
178        host_facts: host_facts_digest(&host.pins, &host.release_roots),
179        tag_source: inputs.tag_source,
180        extractor_version: inputs.extractor_version,
181    })
182}
183
184impl JigsawPlan {
185    pub(super) fn assemble(parts: PlanParts) -> Result<Self, PlanError> {
186        let failures: Vec<UnsupportedMajor> = parts
187            .releases
188            .iter()
189            .flat_map(|release| {
190                parts
191                    .otp_lines
192                    .iter()
193                    .filter(|line| !release.erlang_support.contains(line.line()))
194                    .map(|line| UnsupportedMajor {
195                        release: release.tag.tag().clone(),
196                        range: release.erlang_support.clone(),
197                        line: line.line(),
198                    })
199            })
200            .collect();
201        if !failures.is_empty() {
202            return Err(PlanError::ErlangMajorUnsupported { failures });
203        }
204        let required = required_pins(
205            &parts.host_project,
206            &parts.releases,
207            &parts.otp_project,
208            &parts.otp_lines,
209        );
210        let cache_key = cache_key(&parts, &required);
211        Ok(Self {
212            host: parts.host,
213            host_project: parts.host_project,
214            otp_project: parts.otp_project,
215            releases: parts.releases,
216            otp_lines: parts.otp_lines,
217            untracked_dependencies: parts.untracked_dependencies,
218            excluded_otp_apps: parts.excluded_otp_apps,
219            host_facts: parts.host_facts,
220            tag_source: parts.tag_source,
221            extractor_version: parts.extractor_version,
222            cache_key,
223        })
224    }
225
226    #[must_use]
227    pub fn host(&self) -> &HostName {
228        &self.host
229    }
230
231    #[must_use]
232    pub fn host_project(&self) -> &ProjectName {
233        &self.host_project
234    }
235
236    #[must_use]
237    pub fn otp_project(&self) -> &ProjectName {
238        &self.otp_project
239    }
240
241    #[must_use]
242    pub fn series(&self) -> HostSeries {
243        self.releases.series()
244    }
245
246    #[must_use]
247    pub fn releases(&self) -> &SeriesReleases<HostRelease> {
248        &self.releases
249    }
250
251    #[must_use]
252    pub fn otp_lines(&self) -> &NonEmpty<OtpLine> {
253        &self.otp_lines
254    }
255
256    #[must_use]
257    pub fn untracked_dependencies(&self) -> &BTreeSet<DependencyName> {
258        &self.untracked_dependencies
259    }
260
261    #[must_use]
262    pub fn excluded_otp_apps(&self) -> &BTreeSet<ApplicationName> {
263        &self.excluded_otp_apps
264    }
265
266    #[must_use]
267    pub fn host_facts(&self) -> &HostFactsDigest {
268        &self.host_facts
269    }
270
271    #[must_use]
272    pub fn tag_source(&self) -> TagSourceKind {
273        self.tag_source
274    }
275
276    #[must_use]
277    pub fn extractor_version(&self) -> &ExtractorVersion {
278        &self.extractor_version
279    }
280
281    #[must_use]
282    pub fn cache_key(&self) -> &PlanCacheKey {
283        &self.cache_key
284    }
285
286    #[must_use]
287    pub fn erlang_majors(&self) -> BTreeSet<ErlangMajor> {
288        erlang_majors(&self.otp_lines)
289    }
290
291    #[must_use]
292    pub fn required(&self) -> BTreeSet<Pin> {
293        required_pins(
294            &self.host_project,
295            &self.releases,
296            &self.otp_project,
297            &self.otp_lines,
298        )
299    }
300}
301
302fn required_pins(
303    host_project: &ProjectName,
304    releases: &SeriesReleases<HostRelease>,
305    otp_project: &ProjectName,
306    otp_lines: &NonEmpty<OtpLine>,
307) -> BTreeSet<Pin> {
308    let mut pins = BTreeSet::new();
309    for release in releases {
310        pins.insert(Pin::new(host_project.clone(), release.tag.tag().clone()));
311        for (project, tag) in &release.pins {
312            pins.insert(Pin::new(project.clone(), tag.clone()));
313        }
314    }
315    for line in otp_lines {
316        for tag in line.tags() {
317            pins.insert(Pin::new(otp_project.clone(), tag.tag().clone()));
318        }
319    }
320    pins
321}
322
323fn erlang_majors(otp_lines: &NonEmpty<OtpLine>) -> BTreeSet<ErlangMajor> {
324    otp_lines.iter().map(|line| line.line().major()).collect()
325}
326
327fn cache_key(parts: &PlanParts, required: &BTreeSet<Pin>) -> PlanCacheKey {
328    let mut writer = DigestWriter::new("jigsaw plan cache key");
329    writer.count(required.len());
330    for pin in required {
331        writer.text(pin.project.as_str()).text(pin.tag.as_str());
332    }
333    let majors = erlang_majors(&parts.otp_lines);
334    writer.count(majors.len());
335    for major in majors {
336        writer.text(&major.to_string());
337    }
338    writer.text(parts.host_facts.as_str());
339    let digest = writer.finish();
340    let mut short = [0; CACHE_KEY_DIGEST_BYTES];
341    short.copy_from_slice(&digest[..CACHE_KEY_DIGEST_BYTES]);
342    PlanCacheKey::compose(
343        &parts.host,
344        parts.releases.series(),
345        FORMAT_VERSION,
346        &parts.extractor_version,
347        short,
348    )
349}