Skip to main content

backhopper_core/jigsaw/
series_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};
6
7use crate::config::{Config, Host};
8use crate::erlang_mk::{DepPin, dep_to_tag};
9use crate::jigsaw::plan::{
10    DraftInputs, JigsawPlan, PlanError, ReleaseFacts, TagSourceKind, draft_plan,
11};
12use crate::jigsaw::releases::SeriesReleases;
13use crate::jigsaw::tags::{select_otp_lines, select_series_tags};
14use crate::model::names::{
15    ApplicationName, CommitSha, DependencyName, ExtractorVersion, ProjectName, TagName,
16};
17use crate::model::non_empty::NonEmpty;
18use crate::model::release_version::{ErlangMajor, HostSeries, HostTag};
19use crate::store::{ReadOnly, SnapshotStore};
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ListedTags {
23    pub host: Vec<TagName>,
24    pub otp: Vec<TagName>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SeriesRequest {
29    pub series: HostSeries,
30    pub up_to: Option<TagName>,
31    pub majors: NonEmpty<ErlangMajor>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ResolvedRelease {
36    pub commit: CommitSha,
37    pub pins: Vec<DepPin>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DraftedPlan {
42    pub plan: JigsawPlan,
43    pub skipped_tags: Vec<TagName>,
44}
45
46/// A dependency with no tag-based project is untracked.
47fn release_facts(
48    cfg: &Config,
49    host: &Host,
50    tag: HostTag,
51    resolved: ResolvedRelease,
52    untracked: &mut BTreeSet<DependencyName>,
53) -> Result<ReleaseFacts, PlanError> {
54    let mut pins: BTreeMap<ProjectName, TagName> = BTreeMap::new();
55    for pin in &resolved.pins {
56        let project = ProjectName::new(pin.name.as_str())
57            .ok()
58            .filter(|name| *name != host.project && *name != host.otp_project)
59            .and_then(|name| cfg.project(&name).ok());
60        let Some(project) = project.filter(|project| !project.is_self()) else {
61            untracked.insert(pin.name.clone());
62            continue;
63        };
64        let raw = dep_to_tag(pin, &project.tag_prefix);
65        let dep_tag = TagName::new(raw.clone()).map_err(|e| PlanError::ReleaseUnreadable {
66            tag: tag.tag().clone(),
67            detail: format!("pin {} resolves to {raw:?}: {e}", pin.name),
68        })?;
69        pins.insert(project.name.clone(), dep_tag);
70    }
71    Ok(ReleaseFacts {
72        tag,
73        commit: resolved.commit,
74        pins,
75    })
76}
77
78/// `resolve` is the only part that differs between a repository and the store.
79pub fn plan_series(
80    cfg: &Config,
81    host: &Host,
82    request: &SeriesRequest,
83    listed: &ListedTags,
84    tag_source: TagSourceKind,
85    extractor_version: ExtractorVersion,
86    mut resolve: impl FnMut(&HostTag) -> Result<ResolvedRelease, PlanError>,
87) -> Result<DraftedPlan, PlanError> {
88    let host_project = cfg.project(&host.project)?;
89    let otp_project = cfg.project(&host.otp_project)?;
90    let selected = select_series_tags(
91        host_project,
92        &host.series_tag_pattern,
93        request.series,
94        request.up_to.as_ref(),
95        &listed.host,
96    )?;
97    let otp_lines = select_otp_lines(otp_project, &request.majors, &listed.otp)?;
98    let mut untracked_dependencies = BTreeSet::new();
99    let facts = selected
100        .releases
101        .iter()
102        .map(|tag| {
103            let resolved = resolve(tag)?;
104            release_facts(
105                cfg,
106                host,
107                tag.clone(),
108                resolved,
109                &mut untracked_dependencies,
110            )
111        })
112        .collect::<Result<Vec<_>, PlanError>>()?;
113    let plan = draft_plan(
114        host,
115        DraftInputs {
116            releases: SeriesReleases::new(facts)?,
117            otp_lines,
118            untracked_dependencies,
119            excluded_otp_apps: otp_project
120                .exclude_apps
121                .iter()
122                .cloned()
123                .collect::<BTreeSet<ApplicationName>>(),
124            tag_source,
125            extractor_version,
126        },
127    )?;
128    Ok(DraftedPlan {
129        plan,
130        skipped_tags: selected.skipped_tags,
131    })
132}
133
134/// A release with no host snapshot yet is not in the plan.
135pub fn plan_series_from_store(
136    cfg: &Config,
137    host: &Host,
138    request: &SeriesRequest,
139    store: &SnapshotStore<ReadOnly>,
140    extractor_version: ExtractorVersion,
141) -> Result<DraftedPlan, PlanError> {
142    let listed = ListedTags {
143        host: store.list_tags(&host.project)?,
144        otp: store.list_tags(&host.otp_project)?,
145    };
146    plan_series(
147        cfg,
148        host,
149        request,
150        &listed,
151        TagSourceKind::Store,
152        extractor_version,
153        |tag| {
154            let header = store.read_header(&host.project, tag.tag()).map_err(|e| {
155                PlanError::ReleaseUnreadable {
156                    tag: tag.tag().clone(),
157                    detail: e.to_string(),
158                }
159            })?;
160            Ok(ResolvedRelease {
161                commit: header.commit,
162                pins: header.dep_pins,
163            })
164        },
165    )
166}