Skip to main content

pro_core/
lockfile.rs

1//! Lockfile format for Pro (rx.lock)
2//!
3//! The lockfile captures the exact resolved versions of all dependencies,
4//! including transitive dependencies, with download URLs and hashes.
5//!
6//! Features:
7//! - Platform markers for OS/Python-specific dependencies
8//! - Dependency graph tracking (which package depends on which)
9//! - Multiple file variants per platform
10
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16use crate::resolver::{Resolution, ResolvedPackage};
17use crate::{Error, Result};
18
19/// Lockfile format version
20pub const LOCKFILE_VERSION: &str = "2";
21
22/// The lockfile (rx.lock)
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Lockfile {
25    /// Lockfile format version
26    pub version: String,
27
28    /// Metadata about the resolution
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub metadata: Option<LockfileMetadata>,
31
32    /// Locked packages (sorted by name for deterministic output)
33    #[serde(default)]
34    pub packages: BTreeMap<String, LockedPackage>,
35}
36
37/// Metadata about the lockfile resolution
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct LockfileMetadata {
40    /// Python version used for resolution
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub python_version: Option<String>,
43
44    /// Platform used for resolution (e.g., "linux", "darwin", "win32")
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub platform: Option<String>,
47
48    /// Resolution timestamp (ISO 8601)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub resolved_at: Option<String>,
51}
52
53/// A locked package with its exact version and metadata
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct LockedPackage {
56    /// Exact version
57    pub version: String,
58
59    /// Download URL (default/universal)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub url: Option<String>,
62
63    /// Hash for verification (format: "algorithm:hash")
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub hash: Option<String>,
66
67    /// Direct dependencies of this package (normalized names)
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub dependencies: Vec<String>,
70
71    /// Platform markers (PEP 508 environment markers)
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub markers: Option<String>,
74
75    /// Platform-specific files (for packages with binary wheels)
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub files: Vec<PlatformFile>,
78}
79
80/// Platform-specific file information
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct PlatformFile {
83    /// Download URL
84    pub url: String,
85
86    /// Hash for verification
87    pub hash: String,
88
89    /// Platform markers (e.g., "sys_platform == 'win32'")
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub markers: Option<String>,
92
93    /// Python version constraint (e.g., ">=3.8")
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub python: Option<String>,
96
97    /// Wheel tags (e.g., "cp311-cp311-manylinux_2_17_x86_64")
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub tags: Option<String>,
100}
101
102impl Lockfile {
103    /// Create a new empty lockfile
104    pub fn new() -> Self {
105        Self {
106            version: LOCKFILE_VERSION.to_string(),
107            metadata: None,
108            packages: BTreeMap::new(),
109        }
110    }
111
112    /// Create a lockfile from a resolution
113    pub fn from_resolution(resolution: &Resolution) -> Self {
114        let mut packages = BTreeMap::new();
115
116        for pkg in &resolution.packages {
117            packages.insert(
118                pkg.name.clone(),
119                LockedPackage {
120                    version: pkg.version.clone(),
121                    url: if pkg.url.is_empty() {
122                        None
123                    } else {
124                        Some(pkg.url.clone())
125                    },
126                    hash: if pkg.hash.is_empty() {
127                        None
128                    } else {
129                        Some(pkg.hash.clone())
130                    },
131                    dependencies: pkg.dependencies.clone(),
132                    markers: pkg.markers.clone(),
133                    files: pkg
134                        .files
135                        .iter()
136                        .map(|f| PlatformFile {
137                            url: f.url.clone(),
138                            hash: f.hash.clone(),
139                            markers: f.markers.clone(),
140                            python: f.python.clone(),
141                            tags: f.tags.clone(),
142                        })
143                        .collect(),
144                },
145            );
146        }
147
148        // Add metadata
149        let metadata = LockfileMetadata {
150            python_version: None, // Could be filled by resolver
151            platform: Some(std::env::consts::OS.to_string()),
152            resolved_at: Some(chrono::Utc::now().to_rfc3339()),
153        };
154
155        Self {
156            version: LOCKFILE_VERSION.to_string(),
157            metadata: Some(metadata),
158            packages,
159        }
160    }
161
162    /// Load a lockfile from disk
163    pub fn load(path: &Path) -> Result<Self> {
164        let content = std::fs::read_to_string(path).map_err(Error::Io)?;
165        Self::parse(&content)
166    }
167
168    /// Parse lockfile content
169    pub fn parse(content: &str) -> Result<Self> {
170        toml::from_str(content).map_err(Error::TomlParse)
171    }
172
173    /// Save lockfile to disk
174    pub fn save(&self, path: &Path) -> Result<()> {
175        let content = self.to_string()?;
176        std::fs::write(path, content).map_err(Error::Io)
177    }
178
179    /// Convert to TOML string
180    pub fn to_string(&self) -> Result<String> {
181        // Add header comment
182        let mut output = String::new();
183        output.push_str("# This file is automatically generated by Pro.\n");
184        output.push_str("# Do not edit manually.\n\n");
185
186        let toml = toml::to_string_pretty(self).map_err(Error::TomlSerialize)?;
187        output.push_str(&toml);
188
189        Ok(output)
190    }
191
192    /// Convert back to a Resolution for installation
193    pub fn to_resolution(&self) -> Resolution {
194        use crate::resolver::ResolvedFile;
195
196        let packages = self
197            .packages
198            .iter()
199            .map(|(name, pkg)| ResolvedPackage {
200                name: name.clone(),
201                version: pkg.version.clone(),
202                url: pkg.url.clone().unwrap_or_default(),
203                hash: pkg.hash.clone().unwrap_or_default(),
204                dependencies: pkg.dependencies.clone(),
205                markers: pkg.markers.clone(),
206                files: pkg
207                    .files
208                    .iter()
209                    .map(|f| ResolvedFile {
210                        url: f.url.clone(),
211                        hash: f.hash.clone(),
212                        markers: f.markers.clone(),
213                        python: f.python.clone(),
214                        tags: f.tags.clone(),
215                    })
216                    .collect(),
217            })
218            .collect();
219
220        Resolution { packages }
221    }
222
223    /// Get the dependency graph as adjacency list
224    pub fn dependency_graph(&self) -> BTreeMap<String, Vec<String>> {
225        self.packages
226            .iter()
227            .map(|(name, pkg)| (name.clone(), pkg.dependencies.clone()))
228            .collect()
229    }
230
231    /// Get reverse dependencies (which packages depend on a given package)
232    pub fn reverse_dependencies(&self, package: &str) -> Vec<String> {
233        self.packages
234            .iter()
235            .filter(|(_, pkg)| pkg.dependencies.contains(&package.to_string()))
236            .map(|(name, _)| name.clone())
237            .collect()
238    }
239
240    /// Get a locked package by name
241    pub fn get(&self, name: &str) -> Option<&LockedPackage> {
242        self.packages.get(name)
243    }
244
245    /// Check if a package is locked
246    pub fn contains(&self, name: &str) -> bool {
247        self.packages.contains_key(name)
248    }
249
250    /// Number of locked packages
251    pub fn len(&self) -> usize {
252        self.packages.len()
253    }
254
255    /// Check if empty
256    pub fn is_empty(&self) -> bool {
257        self.packages.is_empty()
258    }
259}
260
261impl Default for Lockfile {
262    fn default() -> Self {
263        Self::new()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_new_lockfile() {
273        let lockfile = Lockfile::new();
274        assert_eq!(lockfile.version, LOCKFILE_VERSION);
275        assert!(lockfile.is_empty());
276    }
277
278    #[test]
279    fn test_parse_lockfile() {
280        let content = r#"
281version = "1"
282
283[packages.requests]
284version = "2.28.0"
285url = "https://example.com/requests-2.28.0.whl"
286hash = "sha256:abc123"
287
288[packages.urllib3]
289version = "1.26.0"
290"#;
291
292        let lockfile = Lockfile::parse(content).unwrap();
293        assert_eq!(lockfile.len(), 2);
294        assert!(lockfile.contains("requests"));
295        assert!(lockfile.contains("urllib3"));
296
297        let requests = lockfile.get("requests").unwrap();
298        assert_eq!(requests.version, "2.28.0");
299        assert_eq!(requests.hash, Some("sha256:abc123".to_string()));
300    }
301
302    #[test]
303    fn test_round_trip() {
304        let mut lockfile = Lockfile::new();
305        lockfile.packages.insert(
306            "mypackage".to_string(),
307            LockedPackage {
308                version: "1.0.0".to_string(),
309                url: Some("https://example.com/pkg.whl".to_string()),
310                hash: Some("sha256:abc".to_string()),
311                dependencies: vec!["urllib3".to_string()],
312                markers: None,
313                files: vec![],
314            },
315        );
316
317        let content = lockfile.to_string().unwrap();
318        let parsed = Lockfile::parse(&content).unwrap();
319
320        assert_eq!(parsed.len(), 1);
321        let pkg = parsed.get("mypackage").unwrap();
322        assert_eq!(pkg.version, "1.0.0");
323        assert_eq!(pkg.dependencies, vec!["urllib3".to_string()]);
324    }
325
326    #[test]
327    fn test_dependency_graph() {
328        let mut lockfile = Lockfile::new();
329        lockfile.packages.insert(
330            "requests".to_string(),
331            LockedPackage {
332                version: "2.28.0".to_string(),
333                url: None,
334                hash: None,
335                dependencies: vec!["urllib3".to_string(), "certifi".to_string()],
336                markers: None,
337                files: vec![],
338            },
339        );
340        lockfile.packages.insert(
341            "urllib3".to_string(),
342            LockedPackage {
343                version: "1.26.0".to_string(),
344                url: None,
345                hash: None,
346                dependencies: vec![],
347                markers: None,
348                files: vec![],
349            },
350        );
351
352        let graph = lockfile.dependency_graph();
353        assert_eq!(graph.get("requests").unwrap().len(), 2);
354        assert!(graph
355            .get("requests")
356            .unwrap()
357            .contains(&"urllib3".to_string()));
358
359        let reverse = lockfile.reverse_dependencies("urllib3");
360        assert!(reverse.contains(&"requests".to_string()));
361    }
362
363    #[test]
364    fn test_platform_files() {
365        let mut lockfile = Lockfile::new();
366        lockfile.packages.insert(
367            "numpy".to_string(),
368            LockedPackage {
369                version: "1.24.0".to_string(),
370                url: Some("https://example.com/numpy-universal.whl".to_string()),
371                hash: Some("sha256:abc".to_string()),
372                dependencies: vec![],
373                markers: None,
374                files: vec![
375                    PlatformFile {
376                        url: "https://example.com/numpy-win.whl".to_string(),
377                        hash: "sha256:win".to_string(),
378                        markers: Some("sys_platform == 'win32'".to_string()),
379                        python: Some(">=3.8".to_string()),
380                        tags: Some("cp311-cp311-win_amd64".to_string()),
381                    },
382                    PlatformFile {
383                        url: "https://example.com/numpy-linux.whl".to_string(),
384                        hash: "sha256:linux".to_string(),
385                        markers: Some("sys_platform == 'linux'".to_string()),
386                        python: Some(">=3.8".to_string()),
387                        tags: Some("cp311-cp311-manylinux_2_17_x86_64".to_string()),
388                    },
389                ],
390            },
391        );
392
393        let content = lockfile.to_string().unwrap();
394        let parsed = Lockfile::parse(&content).unwrap();
395
396        let numpy = parsed.get("numpy").unwrap();
397        assert_eq!(numpy.files.len(), 2);
398        assert!(numpy.files[0].markers.is_some());
399    }
400}