Skip to main content

bock_pkg/
lockfile.rs

1//! Lockfile (`bock.lock`) generation and reading.
2//!
3//! The lockfile records the exact resolved versions of all dependencies,
4//! enabling reproducible builds.
5
6use std::collections::BTreeMap;
7use std::path::Path;
8
9use semver::Version;
10use serde::{Deserialize, Serialize};
11
12use crate::error::PkgError;
13
14/// A parsed `bock.lock` lockfile.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct Lockfile {
17    /// Lockfile format version.
18    #[serde(default = "default_version")]
19    pub version: u32,
20
21    /// Locked package entries, keyed by package name.
22    #[serde(default, rename = "package")]
23    pub packages: Vec<LockedPackage>,
24}
25
26fn default_version() -> u32 {
27    1
28}
29
30/// A single locked package entry.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct LockedPackage {
33    /// Package name.
34    pub name: String,
35
36    /// Exact resolved version.
37    pub version: String,
38
39    /// Source registry or path.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub source: Option<String>,
42
43    /// Content checksum.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub checksum: Option<String>,
46
47    /// Direct dependencies of this package.
48    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49    pub dependencies: BTreeMap<String, String>,
50}
51
52impl Lockfile {
53    /// Create a new lockfile from resolved dependencies.
54    #[must_use]
55    pub fn from_resolved(resolved: &BTreeMap<String, Version>) -> Self {
56        let packages = resolved
57            .iter()
58            .map(|(name, version)| LockedPackage {
59                name: name.clone(),
60                version: version.to_string(),
61                source: None,
62                checksum: None,
63                dependencies: BTreeMap::new(),
64            })
65            .collect();
66
67        Lockfile {
68            version: 1,
69            packages,
70        }
71    }
72
73    /// Parse a lockfile from a TOML string.
74    pub fn parse(s: &str) -> Result<Self, PkgError> {
75        toml::from_str(s).map_err(|e| PkgError::LockfileParse(e.to_string()))
76    }
77
78    /// Read and parse a lockfile from a file path.
79    pub fn from_file(path: &Path) -> Result<Self, PkgError> {
80        let content = std::fs::read_to_string(path).map_err(|e| PkgError::Io(e.to_string()))?;
81        Self::parse(&content)
82    }
83
84    /// Serialize the lockfile to a TOML string.
85    pub fn to_toml_string(&self) -> Result<String, PkgError> {
86        let mut output = String::new();
87        output.push_str("# This file is automatically generated by `bock pkg`.\n");
88        output.push_str("# Do not edit manually.\n\n");
89        output.push_str(&format!("version = {}\n", self.version));
90
91        for pkg in &self.packages {
92            output.push('\n');
93            output.push_str("[[package]]\n");
94            output.push_str(&format!("name = \"{}\"\n", pkg.name));
95            output.push_str(&format!("version = \"{}\"\n", pkg.version));
96            if let Some(source) = &pkg.source {
97                output.push_str(&format!("source = \"{source}\"\n"));
98            }
99            if let Some(checksum) = &pkg.checksum {
100                output.push_str(&format!("checksum = \"{checksum}\"\n"));
101            }
102            if !pkg.dependencies.is_empty() {
103                output.push_str("\n[package.dependencies]\n");
104                for (name, ver) in &pkg.dependencies {
105                    output.push_str(&format!("{name} = \"{ver}\"\n"));
106                }
107            }
108        }
109
110        Ok(output)
111    }
112
113    /// Write the lockfile to a file.
114    pub fn write_to_file(&self, path: &Path) -> Result<(), PkgError> {
115        let content = self.to_toml_string()?;
116        std::fs::write(path, content).map_err(|e| PkgError::Io(e.to_string()))
117    }
118
119    /// Get the locked version of a specific package.
120    #[must_use]
121    pub fn get_version(&self, name: &str) -> Option<&str> {
122        self.packages
123            .iter()
124            .find(|p| p.name == name)
125            .map(|p| p.version.as_str())
126    }
127
128    /// Convert locked packages back to a resolved dependency map.
129    pub fn to_resolved(&self) -> Result<BTreeMap<String, Version>, PkgError> {
130        let mut result = BTreeMap::new();
131        for pkg in &self.packages {
132            let ver = crate::version::parse_version(&pkg.version)?;
133            result.insert(pkg.name.clone(), ver);
134        }
135        Ok(result)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn create_from_resolved() {
145        let mut resolved = BTreeMap::new();
146        resolved.insert("foo".to_string(), Version::new(1, 2, 3));
147        resolved.insert("bar".to_string(), Version::new(0, 5, 0));
148
149        let lockfile = Lockfile::from_resolved(&resolved);
150        assert_eq!(lockfile.packages.len(), 2);
151        assert_eq!(lockfile.get_version("foo"), Some("1.2.3"));
152        assert_eq!(lockfile.get_version("bar"), Some("0.5.0"));
153    }
154
155    #[test]
156    fn roundtrip_serialize() {
157        let mut resolved = BTreeMap::new();
158        resolved.insert("dep-a".to_string(), Version::new(1, 0, 0));
159        resolved.insert("dep-b".to_string(), Version::new(2, 3, 4));
160
161        let lockfile = Lockfile::from_resolved(&resolved);
162        let toml_str = lockfile.to_toml_string().unwrap();
163
164        // Should be parseable back
165        let reparsed = Lockfile::parse(&toml_str).unwrap();
166        assert_eq!(reparsed.packages.len(), 2);
167        assert_eq!(reparsed.get_version("dep-a"), Some("1.0.0"));
168        assert_eq!(reparsed.get_version("dep-b"), Some("2.3.4"));
169    }
170
171    #[test]
172    fn to_resolved_map() {
173        let lockfile = Lockfile {
174            version: 1,
175            packages: vec![LockedPackage {
176                name: "x".into(),
177                version: "1.0.0".into(),
178                source: None,
179                checksum: None,
180                dependencies: BTreeMap::new(),
181            }],
182        };
183
184        let resolved = lockfile.to_resolved().unwrap();
185        assert_eq!(resolved["x"], Version::new(1, 0, 0));
186    }
187
188    #[test]
189    fn file_roundtrip() {
190        let dir = tempfile::tempdir().unwrap();
191        let path = dir.path().join("bock.lock");
192
193        let mut resolved = BTreeMap::new();
194        resolved.insert("pkg-a".to_string(), Version::new(3, 1, 4));
195
196        let lockfile = Lockfile::from_resolved(&resolved);
197        lockfile.write_to_file(&path).unwrap();
198
199        let loaded = Lockfile::from_file(&path).unwrap();
200        assert_eq!(loaded.get_version("pkg-a"), Some("3.1.4"));
201    }
202}