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, TagGlob, TagName};
19use crate::store::SnapshotStore;
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>(&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(path)` when this pin carries a `repo_dir_path` override.
153    /// Returns `None` for non-self pins and for self pins that should
154    /// fall back to the CLI `--repo-dir-path` argument.
155    pub fn self_repo_override(&self) -> Option<&Path> {
156        match self {
157            Self::SelfRef { repo_dir_path, .. } => repo_dir_path.as_deref(),
158            _ => None,
159        }
160    }
161}
162
163/// Resolve a slice of `PinSpec`s into concrete `Pin`s by consulting `store`.
164/// Returns at the first resolution failure.
165pub fn resolve_all<M>(
166    specs: &[PinSpec],
167    store: &SnapshotStore<M>,
168) -> Result<Vec<Pin>, ConfigError> {
169    specs.iter().map(|s| s.resolve(store)).collect()
170}
171
172impl fmt::Display for PinSpec {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match self {
175            Self::Literal { project, tag } => write!(f, "{project}@{tag}"),
176            Self::Pattern {
177                project,
178                pattern,
179                select,
180            } => {
181                let select_label = match select {
182                    PinSelect::Latest => "latest",
183                    PinSelect::Oldest => "oldest",
184                };
185                write!(f, "{project}@{pattern} ({select_label})")
186            }
187            Self::SelfRef {
188                project, git_ref, ..
189            } => write!(f, "{project}@{git_ref} (self)"),
190        }
191    }
192}