1use std::collections::BTreeMap;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15
16use crate::resolver::{Resolution, ResolvedPackage};
17use crate::{Error, Result};
18
19pub const LOCKFILE_VERSION: &str = "2";
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Lockfile {
25 pub version: String,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub metadata: Option<LockfileMetadata>,
31
32 #[serde(default)]
34 pub packages: BTreeMap<String, LockedPackage>,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct LockfileMetadata {
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub python_version: Option<String>,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub platform: Option<String>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub resolved_at: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct LockedPackage {
56 pub version: String,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub url: Option<String>,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub hash: Option<String>,
66
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub dependencies: Vec<String>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub markers: Option<String>,
74
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub files: Vec<PlatformFile>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct PlatformFile {
83 pub url: String,
85
86 pub hash: String,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub markers: Option<String>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub python: Option<String>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub tags: Option<String>,
100}
101
102impl Lockfile {
103 pub fn new() -> Self {
105 Self {
106 version: LOCKFILE_VERSION.to_string(),
107 metadata: None,
108 packages: BTreeMap::new(),
109 }
110 }
111
112 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 let metadata = LockfileMetadata {
150 python_version: None, 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 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 pub fn parse(content: &str) -> Result<Self> {
170 toml::from_str(content).map_err(Error::TomlParse)
171 }
172
173 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 pub fn to_string(&self) -> Result<String> {
181 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 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 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 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 pub fn get(&self, name: &str) -> Option<&LockedPackage> {
242 self.packages.get(name)
243 }
244
245 pub fn contains(&self, name: &str) -> bool {
247 self.packages.contains_key(name)
248 }
249
250 pub fn len(&self) -> usize {
252 self.packages.len()
253 }
254
255 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}