1use crate::errors::{DnxError, Result};
2use crate::resolver::{DependencyGraph, ResolvedPackage};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Lockfile {
10 pub metadata: LockfileMetadata,
11 pub packages: Vec<LockedPackage>,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct LockfileMetadata {
16 pub version: u32,
17 pub generated_by: String,
18 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
20 pub workspace_members: HashMap<String, String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct LockedPackage {
25 pub name: String,
26 pub version: String,
27 pub integrity: String,
28 pub resolved: String,
29 #[serde(default)]
30 pub dependencies: Vec<String>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub peer_dependencies: Vec<String>,
33 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34 pub bin: HashMap<String, String>,
35 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36 pub has_install_script: bool,
37}
38
39#[derive(Debug, Clone)]
40pub struct ResolvedPackageInfo {
41 pub name: String,
42 pub version: String,
43 pub integrity: String,
44 pub resolved: String,
45 pub dependencies: Vec<String>,
46 pub peer_dependencies: Vec<String>,
47 pub bin: HashMap<String, String>,
48 pub has_install_script: bool,
49}
50
51#[derive(Debug, Default)]
52pub struct LockfileDiff {
53 pub added: Vec<LockedPackage>,
54 pub removed: Vec<LockedPackage>,
55 pub changed: Vec<(LockedPackage, LockedPackage)>,
56}
57
58impl Lockfile {
59 pub fn new() -> Self {
61 Self {
62 metadata: LockfileMetadata {
63 version: 1,
64 generated_by: format!("dnx@{}", env!("CARGO_PKG_VERSION")),
65 workspace_members: HashMap::new(),
66 },
67 packages: Vec::new(),
68 }
69 }
70
71 pub fn read(path: &Path) -> Result<Self> {
73 let contents = fs::read_to_string(path).map_err(|e| {
74 DnxError::Io(format!(
75 "Failed to read lockfile at {}: {}",
76 path.display(),
77 e
78 ))
79 })?;
80
81 let mut lockfile: Self = toml::from_str(&contents)
82 .map_err(|e| DnxError::Toml(format!("Failed to parse lockfile: {}", e)))?;
83
84 lockfile.packages.sort_by(|a, b| a.name.cmp(&b.name));
86
87 Ok(lockfile)
88 }
89
90 pub fn write(&self, path: &Path) -> Result<()> {
92 let mut sorted_lockfile = self.clone();
93
94 sorted_lockfile
96 .packages
97 .sort_by(|a, b| match a.name.cmp(&b.name) {
98 std::cmp::Ordering::Equal => a.version.cmp(&b.version),
99 other => other,
100 });
101
102 let contents = toml::to_string_pretty(&sorted_lockfile)
103 .map_err(|e| DnxError::Toml(format!("Failed to serialize lockfile: {}", e)))?;
104
105 if let Some(parent) = path.parent() {
107 fs::create_dir_all(parent).map_err(|e| {
108 DnxError::Io(format!(
109 "Failed to create directory {}: {}",
110 parent.display(),
111 e
112 ))
113 })?;
114 }
115
116 fs::write(path, contents).map_err(|e| {
117 DnxError::Io(format!(
118 "Failed to write lockfile to {}: {}",
119 path.display(),
120 e
121 ))
122 })?;
123
124 Ok(())
125 }
126
127 pub fn from_dependency_graph(packages: &[ResolvedPackageInfo]) -> Self {
129 let locked_packages: Vec<LockedPackage> = packages
130 .iter()
131 .map(|pkg| LockedPackage {
132 name: pkg.name.clone(),
133 version: pkg.version.clone(),
134 integrity: pkg.integrity.clone(),
135 resolved: pkg.resolved.clone(),
136 dependencies: pkg.dependencies.clone(),
137 peer_dependencies: pkg.peer_dependencies.clone(),
138 bin: pkg.bin.clone(),
139 has_install_script: pkg.has_install_script,
140 })
141 .collect();
142
143 Self {
144 metadata: LockfileMetadata {
145 version: 1,
146 generated_by: format!("dnx@{}", env!("CARGO_PKG_VERSION")),
147 workspace_members: HashMap::new(),
148 },
149 packages: locked_packages,
150 }
151 }
152
153 pub fn to_dependency_graph(&self) -> DependencyGraph {
156 let packages = self
157 .packages
158 .iter()
159 .map(|locked| ResolvedPackage {
160 name: locked.name.clone(),
161 version: locked.version.clone(),
162 tarball_url: locked.resolved.clone(),
163 integrity: locked.integrity.clone(),
164 dependencies: locked.dependencies.clone(),
165 peer_dependencies: locked.peer_dependencies.clone(),
166 bin: locked.bin.clone(),
167 has_install_script: locked.has_install_script,
168 })
169 .collect();
170 DependencyGraph { packages }
171 }
172
173 pub fn diff(&self, other: &Lockfile) -> LockfileDiff {
175 let mut diff = LockfileDiff::default();
176
177 let self_map: HashMap<(&str, &str), &LockedPackage> = self
179 .packages
180 .iter()
181 .map(|pkg| ((pkg.name.as_str(), pkg.version.as_str()), pkg))
182 .collect();
183
184 let other_map: HashMap<(&str, &str), &LockedPackage> = other
185 .packages
186 .iter()
187 .map(|pkg| ((pkg.name.as_str(), pkg.version.as_str()), pkg))
188 .collect();
189
190 for pkg in &other.packages {
192 let key = (pkg.name.as_str(), pkg.version.as_str());
193 match self_map.get(&key) {
194 None => {
195 diff.added.push(pkg.clone());
197 }
198 Some(self_pkg) => {
199 if pkg != *self_pkg {
201 diff.changed.push(((*self_pkg).clone(), pkg.clone()));
202 }
203 }
204 }
205 }
206
207 for pkg in &self.packages {
209 let key = (pkg.name.as_str(), pkg.version.as_str());
210 if !other_map.contains_key(&key) {
211 diff.removed.push(pkg.clone());
212 }
213 }
214
215 diff
216 }
217}
218
219impl Default for Lockfile {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225pub fn verify_against_package_json(lockfile: &Lockfile, deps: &HashMap<String, String>) -> bool {
228 let mut locked: HashMap<&str, Vec<&str>> = HashMap::new();
230 for pkg in &lockfile.packages {
231 locked
232 .entry(pkg.name.as_str())
233 .or_default()
234 .push(pkg.version.as_str());
235 }
236
237 for (dep_name, range_str) in deps {
240 match locked.get(dep_name.as_str()) {
241 None => return false,
242 Some(locked_versions) => {
243 let range_str = range_str.trim();
245 let effective = match range_str {
246 "" | "*" | "latest" => ">=0.0.0",
247 s => s,
248 };
249 if let Ok(range) = node_semver::Range::parse(effective) {
250 let any_satisfies = locked_versions.iter().any(|ver| {
251 node_semver::Version::parse(ver)
252 .map(|v| range.satisfies(&v))
253 .unwrap_or(true) });
255 if !any_satisfies {
256 return false;
257 }
258 }
259 }
261 }
262 }
263
264 true
265}
266
267pub fn verify_against_workspace(
271 lockfile: &Lockfile,
272 all_external_deps: &HashMap<String, String>,
273) -> bool {
274 if !verify_against_package_json(lockfile, all_external_deps) {
276 return false;
277 }
278
279 true
284}
285
286pub fn verify_workspace_members(
289 lockfile: &Lockfile,
290 actual_members: &HashMap<String, String>,
291) -> bool {
292 if lockfile.metadata.workspace_members.is_empty() && !actual_members.is_empty() {
293 return false;
294 }
295 if lockfile.metadata.workspace_members.len() != actual_members.len() {
296 return false;
297 }
298 for (name, path) in actual_members {
299 match lockfile.metadata.workspace_members.get(name) {
300 Some(locked_path) => {
301 let locked_norm = locked_path.replace('\\', "/");
303 let actual_norm = path.replace('\\', "/");
304 if locked_norm != actual_norm {
305 return false;
306 }
307 }
308 None => return false,
309 }
310 }
311 true
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use std::collections::HashMap;
318
319 #[test]
320 fn test_new_lockfile() {
321 let lockfile = Lockfile::new();
322 assert_eq!(lockfile.metadata.version, 1);
323 assert_eq!(
324 lockfile.metadata.generated_by,
325 format!("dnx@{}", env!("CARGO_PKG_VERSION"))
326 );
327 assert!(lockfile.packages.is_empty());
328 }
329
330 #[test]
331 fn test_from_dependency_graph() {
332 let packages = vec![
333 ResolvedPackageInfo {
334 name: "lodash".to_string(),
335 version: "4.17.21".to_string(),
336 integrity: "sha512-abc123".to_string(),
337 resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
338 dependencies: vec![],
339 peer_dependencies: vec![],
340 bin: HashMap::new(),
341 has_install_script: false,
342 },
343 ResolvedPackageInfo {
344 name: "express".to_string(),
345 version: "4.18.2".to_string(),
346 integrity: "sha512-def456".to_string(),
347 resolved: "https://registry.npmjs.org/express/-/express-4.18.2.tgz".to_string(),
348 dependencies: vec!["accepts@1.3.8".to_string()],
349 peer_dependencies: vec![],
350 bin: HashMap::new(),
351 has_install_script: false,
352 },
353 ];
354
355 let lockfile = Lockfile::from_dependency_graph(&packages);
356 assert_eq!(lockfile.packages.len(), 2);
357 assert_eq!(lockfile.packages[0].name, "lodash");
358 assert_eq!(lockfile.packages[1].name, "express");
359 assert_eq!(lockfile.packages[1].dependencies.len(), 1);
360 }
361
362 #[test]
363 fn test_diff_added() {
364 let old = Lockfile::new();
365 let mut new = Lockfile::new();
366 new.packages.push(LockedPackage {
367 name: "lodash".to_string(),
368 version: "4.17.21".to_string(),
369 integrity: "sha512-abc123".to_string(),
370 resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
371 dependencies: vec![],
372 peer_dependencies: vec![],
373 bin: HashMap::new(),
374 has_install_script: false,
375 });
376
377 let diff = old.diff(&new);
378 assert_eq!(diff.added.len(), 1);
379 assert_eq!(diff.removed.len(), 0);
380 assert_eq!(diff.changed.len(), 0);
381 assert_eq!(diff.added[0].name, "lodash");
382 }
383
384 #[test]
385 fn test_diff_removed() {
386 let mut old = Lockfile::new();
387 old.packages.push(LockedPackage {
388 name: "lodash".to_string(),
389 version: "4.17.21".to_string(),
390 integrity: "sha512-abc123".to_string(),
391 resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
392 dependencies: vec![],
393 peer_dependencies: vec![],
394 bin: HashMap::new(),
395 has_install_script: false,
396 });
397 let new = Lockfile::new();
398
399 let diff = old.diff(&new);
400 assert_eq!(diff.added.len(), 0);
401 assert_eq!(diff.removed.len(), 1);
402 assert_eq!(diff.changed.len(), 0);
403 assert_eq!(diff.removed[0].name, "lodash");
404 }
405
406 #[test]
407 fn test_diff_changed() {
408 let mut old = Lockfile::new();
409 old.packages.push(LockedPackage {
410 name: "lodash".to_string(),
411 version: "4.17.21".to_string(),
412 integrity: "sha512-abc123".to_string(),
413 resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
414 dependencies: vec![],
415 peer_dependencies: vec![],
416 bin: HashMap::new(),
417 has_install_script: false,
418 });
419
420 let mut new = Lockfile::new();
421 new.packages.push(LockedPackage {
422 name: "lodash".to_string(),
423 version: "4.17.21".to_string(),
424 integrity: "sha512-xyz789".to_string(), resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
426 dependencies: vec![],
427 peer_dependencies: vec![],
428 bin: HashMap::new(),
429 has_install_script: false,
430 });
431
432 let diff = old.diff(&new);
433 assert_eq!(diff.added.len(), 0);
434 assert_eq!(diff.removed.len(), 0);
435 assert_eq!(diff.changed.len(), 1);
436 assert_eq!(diff.changed[0].0.integrity, "sha512-abc123");
437 assert_eq!(diff.changed[0].1.integrity, "sha512-xyz789");
438 }
439
440 #[test]
441 fn test_verify_against_package_json() {
442 let mut lockfile = Lockfile::new();
443 lockfile.packages.push(LockedPackage {
444 name: "lodash".to_string(),
445 version: "4.17.21".to_string(),
446 integrity: "sha512-abc123".to_string(),
447 resolved: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz".to_string(),
448 dependencies: vec![],
449 peer_dependencies: vec![],
450 bin: HashMap::new(),
451 has_install_script: false,
452 });
453 lockfile.packages.push(LockedPackage {
454 name: "express".to_string(),
455 version: "4.18.2".to_string(),
456 integrity: "sha512-def456".to_string(),
457 resolved: "https://registry.npmjs.org/express/-/express-4.18.2.tgz".to_string(),
458 dependencies: vec![],
459 peer_dependencies: vec![],
460 bin: HashMap::new(),
461 has_install_script: false,
462 });
463
464 let mut deps = HashMap::new();
465 deps.insert("lodash".to_string(), "^4.17.21".to_string());
466 deps.insert("express".to_string(), "^4.18.2".to_string());
467
468 assert!(verify_against_package_json(&lockfile, &deps));
469
470 deps.insert("react".to_string(), "^18.0.0".to_string());
472 assert!(!verify_against_package_json(&lockfile, &deps));
473 }
474
475 #[test]
476 fn test_verify_empty_deps() {
477 let lockfile = Lockfile::new();
478 let deps = HashMap::new();
479 assert!(verify_against_package_json(&lockfile, &deps));
480 }
481}