Skip to main content

backhopper_core/model/
pin.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//! Pins as a two-stage type: `PinSpec` at config load, `Pin` after resolution.
6//!
7//! A `PinSpec` either names a concrete tag (`Literal`) or describes a set of
8//! candidate tags via a glob plus a selection rule (`Pattern`). Resolution
9//! against a snapshot store turns either form into a `Pin`, which the rest of
10//! the compatibility pipeline treats as the only pin type.
11
12use std::fmt;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::errors::ConfigError;
18use crate::model::names::{GitRef, ProjectName, SeriesName, TagGlob, TagName};
19use crate::store::{SnapshotStore, StoreMode};
20use crate::versions::version_cmp;
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
24pub struct Pin {
25    pub project: ProjectName,
26    pub tag: TagName,
27}
28
29impl Pin {
30    pub fn new(project: ProjectName, tag: TagName) -> Self {
31        Self { project, tag }
32    }
33}
34
35impl fmt::Display for Pin {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(f, "{}@{}", self.project, self.tag)
38    }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43#[serde(rename_all = "snake_case")]
44pub enum PinSelect {
45    Latest,
46    Oldest,
47}
48
49impl PinSelect {
50    /// Pick the tag matching the selection from an iterator of candidates.
51    pub fn pick<'a, I>(self, tags: I) -> Option<&'a TagName>
52    where
53        I: IntoIterator<Item = &'a TagName>,
54    {
55        let iter = tags.into_iter();
56        // version_cmp orders descending: newer is Less, older is Greater
57        match self {
58            Self::Latest => iter.min_by(|a, b| version_cmp(a.as_str(), b.as_str())),
59            Self::Oldest => iter.max_by(|a, b| version_cmp(a.as_str(), b.as_str())),
60        }
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
66#[serde(untagged)]
67pub enum PinSpec {
68    Literal {
69        project: ProjectName,
70        tag: TagName,
71    },
72    Pattern {
73        project: ProjectName,
74        #[serde(rename = "tag_pattern")]
75        pattern: TagGlob,
76        select: PinSelect,
77    },
78    /// Self-project pin. `git_ref` is a branch, tag, or SHA resolved in the
79    /// working repo at evaluation time. `repo_dir_path`, when set, overrides
80    /// the CLI `--repo-dir-path` fallback: needed when one logical project
81    /// is split across several physical git directories (one per maintained
82    /// branch).
83    SelfRef {
84        project: ProjectName,
85        git_ref: GitRef,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        repo_dir_path: Option<PathBuf>,
88    },
89}
90
91impl PinSpec {
92    pub fn literal(project: ProjectName, tag: TagName) -> Self {
93        Self::Literal { project, tag }
94    }
95
96    pub fn pattern(project: ProjectName, pattern: TagGlob, select: PinSelect) -> Self {
97        Self::Pattern {
98            project,
99            pattern,
100            select,
101        }
102    }
103
104    pub fn project(&self) -> &ProjectName {
105        match self {
106            Self::Literal { project, .. }
107            | Self::Pattern { project, .. }
108            | Self::SelfRef { project, .. } => project,
109        }
110    }
111
112    pub fn is_self(&self) -> bool {
113        matches!(self, Self::SelfRef { .. })
114    }
115
116    /// `SelfRef` pins cannot be resolved here: the working repo is outside
117    /// the snapshot store. The CLI resolves them via the `--repo-dir-path`
118    /// before calling into the evaluation pipeline.
119    pub fn resolve<M: StoreMode>(&self, store: &SnapshotStore<M>) -> Result<Pin, ConfigError> {
120        match self {
121            Self::Literal { project, tag } => Ok(Pin::new(project.clone(), tag.clone())),
122            Self::Pattern {
123                project,
124                pattern,
125                select,
126            } => {
127                let stored =
128                    store
129                        .list_tags(project)
130                        .map_err(|e| ConfigError::PinPatternStore {
131                            project: project.to_string(),
132                            pattern: pattern.to_string(),
133                            detail: e.to_string(),
134                        })?;
135                let chosen = select
136                    .pick(stored.iter().filter(|t| pattern.matches(t)))
137                    .ok_or_else(|| ConfigError::PinPatternNoMatch {
138                        project: project.to_string(),
139                        pattern: pattern.to_string(),
140                    })?;
141                Ok(Pin::new(project.clone(), chosen.clone()))
142            }
143            Self::SelfRef {
144                project, git_ref, ..
145            } => Err(ConfigError::SelfPinNeedsRepoDirPath {
146                project: project.to_string(),
147                git_ref: git_ref.to_string(),
148            }),
149        }
150    }
151
152    /// `Some` with the self-ref payload for a `SelfRef` spec, `None` for
153    /// `Literal` and `Pattern`, which have no repo to resolve against.
154    pub fn as_self_pin(&self) -> Option<SelfPin<'_>> {
155        match self {
156            Self::SelfRef {
157                project,
158                git_ref,
159                repo_dir_path,
160            } => Some(SelfPin {
161                project,
162                git_ref,
163                repo_dir_path: repo_dir_path.as_deref(),
164            }),
165            Self::Literal { .. } | Self::Pattern { .. } => None,
166        }
167    }
168}
169
170/// The self-ref half of a `PinSpec`, handed to the one function that
171/// resolves it so a caller cannot pass a literal or pattern spec by mistake.
172#[derive(Debug, Clone, Copy)]
173pub struct SelfPin<'a> {
174    pub project: &'a ProjectName,
175    pub git_ref: &'a GitRef,
176    /// `Some` when the pin overrides the CLI `--repo-dir-path` fallback.
177    pub repo_dir_path: Option<&'a Path>,
178}
179
180/// Either a named series or a single `(project, tag)` pin: the "where" a
181/// check runs, addressed by `--series` or by `--project` and `--tag`.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum PinSelector {
184    Series(SeriesName),
185    Pin { project: ProjectName, tag: TagName },
186}
187
188impl PinSelector {
189    pub fn series(name: impl Into<SeriesName>) -> Self {
190        Self::Series(name.into())
191    }
192
193    /// The series a `Series` selector names; `None` for a single pin.
194    #[must_use]
195    pub fn series_name(&self) -> Option<&SeriesName> {
196        match self {
197            Self::Series(name) => Some(name),
198            Self::Pin { .. } => None,
199        }
200    }
201
202    pub fn pin(project: impl Into<ProjectName>, tag: impl Into<TagName>) -> Self {
203        Self::Pin {
204            project: project.into(),
205            tag: tag.into(),
206        }
207    }
208}
209
210impl From<SeriesName> for PinSelector {
211    fn from(s: SeriesName) -> Self {
212        Self::Series(s)
213    }
214}
215
216impl From<(ProjectName, TagName)> for PinSelector {
217    fn from((project, tag): (ProjectName, TagName)) -> Self {
218        Self::Pin { project, tag }
219    }
220}
221
222/// Resolve a slice of `PinSpec`s into concrete `Pin`s by consulting `store`.
223/// Returns at the first resolution failure.
224pub fn resolve_all<M: StoreMode>(
225    specs: &[PinSpec],
226    store: &SnapshotStore<M>,
227) -> Result<Vec<Pin>, ConfigError> {
228    specs.iter().map(|s| s.resolve(store)).collect()
229}
230
231impl fmt::Display for PinSpec {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        match self {
234            Self::Literal { project, tag } => write!(f, "{project}@{tag}"),
235            Self::Pattern {
236                project,
237                pattern,
238                select,
239            } => {
240                let select_label = match select {
241                    PinSelect::Latest => "latest",
242                    PinSelect::Oldest => "oldest",
243                };
244                write!(f, "{project}@{pattern} ({select_label})")
245            }
246            Self::SelfRef {
247                project, git_ref, ..
248            } => write!(f, "{project}@{git_ref} (self)"),
249        }
250    }
251}