Skip to main content

cabin_lockfile/
model.rs

1use camino::Utf8PathBuf;
2
3use cabin_core::PackageName;
4
5/// Current `cabin.lock` schema version. Bumping this requires a
6/// migration path in [`crate::io`].
7pub const LOCKFILE_VERSION: u32 = 1;
8
9/// In-memory representation of a `cabin.lock`.
10///
11/// Constructed by [`crate::io::parse_lockfile_str`] / [`crate::io::read_lockfile`]
12/// and serialized by [`crate::io::render_lockfile`] /
13/// [`crate::io::write_lockfile`]. The lockfile only records resolved
14/// **registry** dependencies — local path packages are intentionally
15/// not included.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Lockfile {
18    /// Schema version (currently always [`LOCKFILE_VERSION`]).
19    pub version: u32,
20    /// Resolved registry packages, sorted by name for determinism.
21    pub packages: Vec<LockedPackage>,
22    /// Active patch entries recorded for stale-detection under
23    /// `--locked`. Empty for projects with no patches; old
24    /// lockfiles that omit the `[[patch]]` array continue to
25    /// parse cleanly thanks to `#[serde(default)]` on the raw
26    /// shape.
27    pub patches: Vec<LockedPatch>,
28    /// Active source-replacement entries, recorded for the same
29    /// reason. Empty for projects with no replacements.
30    pub source_replacements: Vec<LockedSourceReplacement>,
31}
32
33impl Default for Lockfile {
34    fn default() -> Self {
35        Self::empty()
36    }
37}
38
39impl Lockfile {
40    /// An empty lockfile at the current schema version.
41    pub fn empty() -> Self {
42        Self {
43            version: LOCKFILE_VERSION,
44            packages: Vec::new(),
45            patches: Vec::new(),
46            source_replacements: Vec::new(),
47        }
48    }
49
50    /// Look up a locked package by name. Linear scan — typical
51    /// lockfiles are small enough that this stays cheap.
52    pub fn find(&self, name: &PackageName) -> Option<&LockedPackage> {
53        self.packages.iter().find(|p| &p.name == name)
54    }
55
56    /// Whether the lockfile's recorded patch + source-replacement
57    /// arrays equal the supplied active policy. Used by
58    /// `cabin <command> --locked` to detect that the user changed
59    /// patch policy since the lockfile was last written: the
60    /// recorded arrays already serialize deterministically (sorted
61    /// in [`crate::io::render_lockfile`]), so a slice comparison
62    /// is the canonical staleness check and lives next to the
63    /// types it compares.
64    pub fn matches_patch_state(
65        &self,
66        active_patches: &[LockedPatch],
67        active_source_replacements: &[LockedSourceReplacement],
68    ) -> bool {
69        active_patches == self.patches.as_slice()
70            && active_source_replacements == self.source_replacements.as_slice()
71    }
72}
73
74/// One resolved package recorded in the lockfile.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct LockedPackage {
77    pub name: PackageName,
78    pub version: semver::Version,
79    pub source: LockedSource,
80    /// Optional content hash copied from the index. Used by the
81    /// fetch / artifact-verification path; absent for index entries
82    /// that predate checksum support.
83    pub checksum: Option<String>,
84    /// Names of other locked packages this one depends on.
85    pub dependencies: Vec<PackageName>,
86}
87
88/// Where a locked package was sourced from.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum LockedSource {
91    /// Resolved from the local JSON package index.
92    Index,
93}
94
95impl LockedSource {
96    pub fn as_str(self) -> &'static str {
97        match self {
98            LockedSource::Index => "index",
99        }
100    }
101}
102
103/// One active patch entry recorded for stale-detection under
104/// `--locked`. Carries enough information to reproduce the
105/// patch decision: package name, patched version, source kind,
106/// and the path *as written* in the declaring file (resolved
107/// relative to the declaring file's directory at apply time).
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct LockedPatch {
110    pub package: PackageName,
111    pub version: semver::Version,
112    pub kind: LockedPatchKind,
113    pub provenance: String,
114    pub path: Utf8PathBuf,
115}
116
117/// Source kind of a locked patch entry. Mirrors
118/// [`cabin_core::PatchSourceKind`] but stays in this crate so the
119/// lockfile model is self-contained.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum LockedPatchKind {
122    /// Local path patch.
123    Path,
124}
125
126impl LockedPatchKind {
127    pub fn as_str(self) -> &'static str {
128        match self {
129            LockedPatchKind::Path => "path",
130        }
131    }
132}
133
134/// One active source-replacement entry recorded for the same
135/// stale-detection reason.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct LockedSourceReplacement {
138    pub original: String,
139    pub original_kind: LockedSourceLocatorKind,
140    pub replacement: String,
141    pub replacement_kind: LockedSourceLocatorKind,
142    pub provenance: String,
143}
144
145/// Stable locator-kind label for the lockfile.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum LockedSourceLocatorKind {
148    IndexPath,
149    IndexUrl,
150}
151
152impl LockedSourceLocatorKind {
153    pub fn as_str(self) -> &'static str {
154        match self {
155            LockedSourceLocatorKind::IndexPath => "index-path",
156            LockedSourceLocatorKind::IndexUrl => "index-url",
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn pkg(name: &str) -> PackageName {
166        PackageName::new(name).unwrap()
167    }
168
169    fn ver(s: &str) -> semver::Version {
170        semver::Version::parse(s).unwrap()
171    }
172
173    fn patch(name: &str, version: &str) -> LockedPatch {
174        LockedPatch {
175            package: pkg(name),
176            version: ver(version),
177            kind: LockedPatchKind::Path,
178            provenance: "manifest".into(),
179            path: Utf8PathBuf::from("../").join(name),
180        }
181    }
182
183    fn replacement(original: &str, replacement: &str) -> LockedSourceReplacement {
184        LockedSourceReplacement {
185            original: original.into(),
186            original_kind: LockedSourceLocatorKind::IndexUrl,
187            replacement: replacement.into(),
188            replacement_kind: LockedSourceLocatorKind::IndexPath,
189            provenance: "user-config".into(),
190        }
191    }
192
193    #[test]
194    fn matches_patch_state_returns_true_for_equal_slices() {
195        let lock = Lockfile {
196            version: LOCKFILE_VERSION,
197            packages: Vec::new(),
198            patches: vec![patch("fmt", "10.2.1")],
199            source_replacements: vec![replacement("https://example.com/index", "../mirror")],
200        };
201        assert!(lock.matches_patch_state(
202            &[patch("fmt", "10.2.1")],
203            &[replacement("https://example.com/index", "../mirror")],
204        ));
205    }
206
207    #[test]
208    fn matches_patch_state_detects_added_patch() {
209        let lock = Lockfile {
210            version: LOCKFILE_VERSION,
211            packages: Vec::new(),
212            patches: Vec::new(),
213            source_replacements: Vec::new(),
214        };
215        assert!(!lock.matches_patch_state(&[patch("fmt", "10.2.1")], &[]));
216    }
217
218    #[test]
219    fn matches_patch_state_detects_removed_replacement() {
220        let lock = Lockfile {
221            version: LOCKFILE_VERSION,
222            packages: Vec::new(),
223            patches: Vec::new(),
224            source_replacements: vec![replacement("https://example.com/index", "../mirror")],
225        };
226        assert!(!lock.matches_patch_state(&[], &[]));
227    }
228
229    #[test]
230    fn matches_patch_state_is_order_sensitive() {
231        // The lockfile's render path sorts both arrays (see
232        // `render_lockfile`), so callers should always supply
233        // sorted active state. A raw out-of-order comparison
234        // surfacing as "stale" is the correct, conservative
235        // outcome — silent acceptance would let two semantically
236        // different policies look equal.
237        let lock = Lockfile {
238            version: LOCKFILE_VERSION,
239            packages: Vec::new(),
240            patches: vec![patch("a", "1.0.0"), patch("b", "1.0.0")],
241            source_replacements: Vec::new(),
242        };
243        assert!(!lock.matches_patch_state(&[patch("b", "1.0.0"), patch("a", "1.0.0")], &[],));
244    }
245}