use crate::{DepType, DirectDep, Error, LocalSource, LockedPackage, LockfileGraph};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct RawNpmLockfile {
#[serde(rename = "lockfileVersion")]
lockfile_version: u32,
#[serde(default)]
packages: BTreeMap<String, RawNpmPackage>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawNpmPackage {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
integrity: Option<String>,
#[serde(default)]
resolved: Option<String>,
#[serde(default)]
link: bool,
#[serde(default)]
dependencies: BTreeMap<String, String>,
#[serde(default)]
dev_dependencies: BTreeMap<String, String>,
#[serde(default)]
optional_dependencies: BTreeMap<String, String>,
#[serde(default)]
peer_dependencies: BTreeMap<String, String>,
#[serde(default)]
peer_dependencies_meta: BTreeMap<String, RawNpmPeerDepMeta>,
#[serde(default, deserialize_with = "aube_manifest::engines_tolerant")]
engines: BTreeMap<String, String>,
#[serde(default)]
bin: BTreeMap<String, String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
funding: Option<RawNpmFunding>,
}
#[derive(Clone)]
struct InstallPathInfo {
name: String,
dep_path: String,
}
#[derive(Debug, Clone, Default)]
struct RawNpmFunding {
url: Option<String>,
}
impl<'de> Deserialize<'de> for RawNpmFunding {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{MapAccess, SeqAccess, Visitor};
use std::fmt;
struct FundingVisitor;
impl<'de> Visitor<'de> for FundingVisitor {
type Value = RawNpmFunding;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a funding URL string, a {url: ...} object, or an array of either")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(RawNpmFunding {
url: Some(v.to_owned()),
})
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(RawNpmFunding { url: Some(v) })
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut url: Option<String> = None;
while let Some(key) = map.next_key::<String>()? {
if key == "url" {
url = map.next_value::<Option<String>>()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
}
Ok(RawNpmFunding { url })
}
fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
where
S: SeqAccess<'de>,
{
let mut chosen: Option<String> = None;
while let Some(item) = seq.next_element::<RawNpmFunding>()? {
if chosen.is_none() {
chosen = item.url;
}
}
Ok(RawNpmFunding { url: chosen })
}
}
deserializer.deserialize_any(FundingVisitor)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
struct RawNpmPeerDepMeta {
#[serde(default)]
optional: bool,
}
pub fn parse(path: &Path) -> Result<LockfileGraph, Error> {
let content = crate::read_lockfile(path)?;
let raw: RawNpmLockfile = crate::parse_json(path, content)?;
if raw.lockfile_version < 2 {
return Err(Error::Parse(
path.to_path_buf(),
format!(
"package-lock.json lockfileVersion {} is not supported (need v2 or v3)",
raw.lockfile_version
),
));
}
let mut graph = LockfileGraph {
importers: BTreeMap::new(),
packages: BTreeMap::new(),
..Default::default()
};
let link_targets: BTreeSet<String> = raw
.packages
.values()
.filter_map(|entry| entry.link.then(|| entry.resolved.clone()).flatten())
.collect();
let mut install_path_info: BTreeMap<String, InstallPathInfo> = BTreeMap::new();
for (install_path, entry) in &raw.packages {
if install_path.is_empty() {
continue; }
if link_targets.contains(install_path) {
continue;
}
let install_name = package_name_from_install_path(install_path)
.or_else(|| entry.name.clone())
.ok_or_else(|| {
Error::Parse(
path.to_path_buf(),
format!("could not determine package name for '{install_path}'"),
)
})?;
let alias_of = entry
.name
.as_ref()
.filter(|real| real.as_str() != install_name.as_str())
.cloned();
let (package_entry, version, dep_path, local_source) = if entry.link {
let target = entry.resolved.as_ref().ok_or_else(|| {
Error::Parse(
path.to_path_buf(),
format!("linked package '{install_name}' has no resolved target"),
)
})?;
let target_entry = raw.packages.get(target).ok_or_else(|| {
Error::Parse(
path.to_path_buf(),
format!("linked package '{install_name}' points to missing target '{target}'"),
)
})?;
let version = target_entry.version.clone().ok_or_else(|| {
Error::Parse(
path.to_path_buf(),
format!("linked package '{install_name}' target '{target}' has no version"),
)
})?;
let local = LocalSource::Link(PathBuf::from(target));
(
target_entry,
version,
local.dep_path(&install_name),
Some(local),
)
} else {
let version = entry.version.clone().ok_or_else(|| {
Error::Parse(
path.to_path_buf(),
format!("package '{install_name}' has no version"),
)
})?;
(
entry,
version.clone(),
format!("{install_name}@{version}"),
None,
)
};
install_path_info.insert(
install_path.clone(),
InstallPathInfo {
name: install_name.clone(),
dep_path: dep_path.clone(),
},
);
if graph.packages.contains_key(&dep_path) {
continue;
}
let mut deps: BTreeMap<String, String> = BTreeMap::new();
for dep_name in package_entry
.dependencies
.keys()
.chain(package_entry.optional_dependencies.keys())
{
deps.insert(dep_name.clone(), String::new());
}
let mut declared: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in package_entry
.dependencies
.iter()
.chain(package_entry.optional_dependencies.iter())
{
declared.insert(k.clone(), v.clone());
}
let tarball_url = package_entry
.resolved
.as_ref()
.filter(|u| u.starts_with("http://") || u.starts_with("https://"))
.cloned();
let peer_dependencies = package_entry.peer_dependencies.clone();
let peer_dependencies_meta: BTreeMap<String, crate::PeerDepMeta> = package_entry
.peer_dependencies_meta
.iter()
.map(|(k, v)| {
(
k.clone(),
crate::PeerDepMeta {
optional: v.optional,
},
)
})
.collect();
graph.packages.insert(
dep_path.clone(),
LockedPackage {
name: install_name,
version,
integrity: package_entry.integrity.clone(),
dependencies: deps,
peer_dependencies,
peer_dependencies_meta,
dep_path,
local_source,
alias_of,
tarball_url,
declared_dependencies: declared,
engines: package_entry.engines.clone(),
bin: package_entry.bin.clone(),
license: package_entry.license.clone(),
funding_url: package_entry.funding.as_ref().and_then(|f| f.url.clone()),
..Default::default()
},
);
}
let mut resolved_by_dep_path: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (install_path, entry) in &raw.packages {
if install_path.is_empty() {
continue;
}
if link_targets.contains(install_path) {
continue;
}
let Some(info) = install_path_info.get(install_path) else {
continue;
};
let package_entry = if entry.link {
let Some(target) = entry.resolved.as_ref() else {
continue;
};
let Some(target_entry) = raw.packages.get(target) else {
unreachable!("first pass validates that linked package target '{target}' exists");
};
target_entry
} else {
entry
};
let dep_path = info.dep_path.clone();
let lookup_path = if entry.link {
entry.resolved.as_deref().unwrap_or(install_path.as_str())
} else {
install_path.as_str()
};
if resolved_by_dep_path.contains_key(&dep_path) {
continue;
}
let mut resolved: BTreeMap<String, String> = BTreeMap::new();
for dep_name in package_entry
.dependencies
.keys()
.chain(package_entry.optional_dependencies.keys())
{
if let Some(target_install_path) =
resolve_nested(lookup_path, dep_name, &install_path_info)
&& let Some(target_info) = install_path_info.get(&target_install_path)
{
resolved.insert(
dep_name.clone(),
dep_path_tail(&target_info.name, &target_info.dep_path).to_string(),
);
}
}
resolved_by_dep_path.insert(dep_path, resolved);
}
for (dep_path, deps) in resolved_by_dep_path {
if let Some(pkg) = graph.packages.get_mut(&dep_path) {
pkg.dependencies = deps;
}
}
let root = raw.packages.get("").cloned().unwrap_or_default();
let mut direct: Vec<DirectDep> = Vec::new();
let push_direct = |dep_name: &str, dep_type: DepType, direct: &mut Vec<DirectDep>| {
let root_path = format!("node_modules/{dep_name}");
if let Some(info) = install_path_info.get(&root_path) {
direct.push(DirectDep {
name: info.name.clone(),
dep_path: info.dep_path.clone(),
dep_type,
specifier: None,
});
}
};
for dep_name in root.dependencies.keys() {
push_direct(dep_name, DepType::Production, &mut direct);
}
for dep_name in root.dev_dependencies.keys() {
push_direct(dep_name, DepType::Dev, &mut direct);
}
for dep_name in root.optional_dependencies.keys() {
push_direct(dep_name, DepType::Optional, &mut direct);
}
graph.importers.insert(".".to_string(), direct);
Ok(graph)
}
fn dep_path_tail<'a>(name: &str, dep_path: &'a str) -> &'a str {
dep_path
.strip_prefix(name)
.and_then(|rest| rest.strip_prefix('@'))
.unwrap_or_else(|| {
debug_assert!(
false,
"dep_path '{dep_path}' does not start with name '{name}'"
);
dep_path
})
}
fn resolve_nested(
pkg_install_path: &str,
dep_name: &str,
install_paths: &BTreeMap<String, InstallPathInfo>,
) -> Option<String> {
let mut base = pkg_install_path.to_string();
loop {
let candidate = if base.is_empty() {
format!("node_modules/{dep_name}")
} else {
format!("{base}/node_modules/{dep_name}")
};
if install_paths.contains_key(&candidate) {
return Some(candidate);
}
if base.is_empty() {
return None;
}
if let Some(idx) = base.rfind("/node_modules/") {
base.truncate(idx);
} else {
base.clear();
}
}
}
fn package_name_from_install_path(install_path: &str) -> Option<String> {
let nm_idx = install_path.rfind("node_modules/")?;
let tail = &install_path[nm_idx + "node_modules/".len()..];
if tail.is_empty() {
return None;
}
if let Some(rest) = tail.strip_prefix('@') {
let slash = rest.find('/')?;
let scoped_end = slash + 1;
let name_end = rest[scoped_end..]
.find('/')
.map(|i| scoped_end + i)
.unwrap_or(rest.len());
return Some(format!("@{}", &rest[..name_end]));
}
let end = tail.find('/').unwrap_or(tail.len());
Some(tail[..end].to_string())
}
#[derive(Debug, Serialize)]
struct WriteNpmLockfile<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
#[serde(rename = "lockfileVersion")]
lockfile_version: u32,
requires: bool,
packages: BTreeMap<String, WriteNpmPackage<'a>>,
}
#[derive(Debug, Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct WriteNpmPackage<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
resolved: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
integrity: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
license: Option<&'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
dev_dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
optional_dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
peer_dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
peer_dependencies_meta: BTreeMap<&'a str, WriteNpmPeerDepMeta>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
bin: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
engines: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
funding: Option<WriteNpmFunding<'a>>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
dev: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
optional: bool,
#[serde(rename = "devOptional", skip_serializing_if = "std::ops::Not::not")]
dev_optional: bool,
}
#[derive(Debug, Serialize, Default)]
struct WriteNpmFunding<'a> {
url: &'a str,
}
#[derive(Debug, Serialize, Default)]
struct WriteNpmPeerDepMeta {
#[serde(skip_serializing_if = "std::ops::Not::not")]
optional: bool,
}
pub fn write(
path: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<(), Error> {
let canonical = crate::build_canonical_map(graph);
let roots = graph.importers.get(".").cloned().unwrap_or_default();
let prod_reach = reachable_from(&canonical, &roots, DepType::Production);
let dev_reach = reachable_from(&canonical, &roots, DepType::Dev);
let opt_reach = reachable_from(&canonical, &roots, DepType::Optional);
let tree = build_hoist_tree(&canonical, &roots);
let placed: BTreeMap<String, String> = tree
.into_iter()
.map(|(segs, key)| (segments_to_install_path(&segs), key))
.collect();
let root_key = "";
let mut packages: BTreeMap<String, WriteNpmPackage> = BTreeMap::new();
packages.insert(
root_key.to_string(),
WriteNpmPackage {
name: manifest.name.as_deref(),
version: manifest.version.as_deref(),
dependencies: borrow_map(&manifest.dependencies),
dev_dependencies: borrow_map(&manifest.dev_dependencies),
optional_dependencies: borrow_map(&manifest.optional_dependencies),
peer_dependencies: borrow_map(&manifest.peer_dependencies),
..Default::default()
},
);
for (install_path, canonical_key) in &placed {
let Some(pkg) = canonical.get(canonical_key).copied() else {
continue;
};
let deps: BTreeMap<&str, &str> = pkg
.dependencies
.iter()
.filter(|(n, value)| canonical.contains_key(&child_canonical_key(n, value)))
.map(|(n, value)| {
let rendered = pkg
.declared_dependencies
.get(n)
.map(String::as_str)
.unwrap_or_else(|| dep_value_as_version(n, value));
(n.as_str(), rendered)
})
.collect();
let is_prod = prod_reach.contains(canonical_key);
let is_dev = !is_prod && dev_reach.contains(canonical_key);
let is_opt = !is_prod && opt_reach.contains(canonical_key);
let dev_optional = is_dev && is_opt;
let dev = is_dev && !dev_optional;
let optional = is_opt && !dev_optional;
let alias_name = pkg.alias_of.as_deref();
let resolved = pkg.tarball_url.clone();
let peer_deps: BTreeMap<&str, &str> = pkg
.peer_dependencies
.iter()
.map(|(n, v)| (n.as_str(), v.as_str()))
.collect();
let peer_deps_meta: BTreeMap<&str, WriteNpmPeerDepMeta> = pkg
.peer_dependencies_meta
.iter()
.map(|(n, m)| {
(
n.as_str(),
WriteNpmPeerDepMeta {
optional: m.optional,
},
)
})
.collect();
packages.insert(
install_path.clone(),
WriteNpmPackage {
name: alias_name,
version: Some(pkg.version.as_str()),
resolved,
integrity: pkg.integrity.as_deref(),
license: pkg.license.as_deref(),
dependencies: deps,
peer_dependencies: peer_deps,
peer_dependencies_meta: peer_deps_meta,
bin: pkg
.bin
.iter()
.filter(|(k, _)| !k.is_empty())
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect(),
engines: pkg
.engines
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect(),
funding: pkg
.funding_url
.as_deref()
.map(|url| WriteNpmFunding { url }),
dev,
optional,
dev_optional,
..Default::default()
},
);
}
let doc = WriteNpmLockfile {
name: manifest.name.as_deref(),
version: manifest.version.as_deref(),
lockfile_version: 3,
requires: true,
packages,
};
let mut body = serde_json::to_string_pretty(&doc)
.map_err(|e| Error::Parse(path.to_path_buf(), e.to_string()))?;
body.push('\n');
crate::atomic_write_lockfile(path, body.as_bytes())?;
Ok(())
}
pub(crate) fn segments_to_install_path(segs: &[String]) -> String {
if segs.is_empty() {
return String::new();
}
let mut out = String::from("node_modules/");
for (i, s) in segs.iter().enumerate() {
if i > 0 {
out.push_str("/node_modules/");
}
out.push_str(s);
}
out
}
pub(crate) fn build_hoist_tree(
canonical: &BTreeMap<String, &LockedPackage>,
roots: &[DirectDep],
) -> BTreeMap<Vec<String>, String> {
let mut placed: BTreeMap<Vec<String>, String> = BTreeMap::new();
let mut queue: VecDeque<(Vec<String>, String)> = VecDeque::new();
for dep in roots {
let key = canonical_key_from_dep_path(&dep.dep_path);
if !canonical.contains_key(&key) {
continue;
}
let segs = vec![dep.name.clone()];
if placed.insert(segs.clone(), key.clone()).is_none() {
queue.push_back((segs, key));
}
}
while let Some((parent_segs, parent_key)) = queue.pop_front() {
let Some(pkg) = canonical.get(&parent_key).copied() else {
continue;
};
let mut child_entries: Vec<(String, String)> = Vec::new();
for (child_name, child_value) in &pkg.dependencies {
let child_key = child_canonical_key(child_name, child_value);
if !canonical.contains_key(&child_key) {
continue;
}
child_entries.push((child_name.clone(), child_key));
}
for (child_name, child_key) in child_entries {
match ancestor_resolution(&parent_segs, &child_name, &child_key, &placed) {
AncestorHit::Match => continue,
AncestorHit::Shadowed => {
let mut nested = parent_segs.clone();
nested.push(child_name.clone());
if placed.insert(nested.clone(), child_key.clone()).is_none() {
queue.push_back((nested, child_key));
}
}
AncestorHit::Miss => {
let root_slot = vec![child_name.clone()];
if placed
.insert(root_slot.clone(), child_key.clone())
.is_none()
{
queue.push_back((root_slot, child_key));
}
}
}
}
}
placed
}
enum AncestorHit {
Match,
Shadowed,
Miss,
}
fn ancestor_resolution(
parent_segs: &[String],
child_name: &str,
child_key: &str,
placed: &BTreeMap<Vec<String>, String>,
) -> AncestorHit {
for i in (0..=parent_segs.len()).rev() {
let mut candidate: Vec<String> = parent_segs[..i].to_vec();
candidate.push(child_name.to_string());
if let Some(existing) = placed.get(&candidate) {
return if existing == child_key {
AncestorHit::Match
} else {
AncestorHit::Shadowed
};
}
}
AncestorHit::Miss
}
fn reachable_from(
canonical: &BTreeMap<String, &LockedPackage>,
roots: &[DirectDep],
dep_type: DepType,
) -> BTreeSet<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
for dep in roots {
if dep.dep_type != dep_type {
continue;
}
let key = canonical_key_from_dep_path(&dep.dep_path);
if canonical.contains_key(&key) && out.insert(key.clone()) {
queue.push_back(key);
}
}
while let Some(key) = queue.pop_front() {
let Some(pkg) = canonical.get(&key).copied() else {
continue;
};
for (child_name, child_value) in &pkg.dependencies {
let child_key = child_canonical_key(child_name, child_value);
if canonical.contains_key(&child_key) && out.insert(child_key.clone()) {
queue.push_back(child_key);
}
}
}
out
}
fn version_from_tail(tail: &str) -> &str {
tail.split_once('(').map(|(v, _)| v).unwrap_or(tail)
}
pub(crate) fn child_canonical_key(child_name: &str, value: &str) -> String {
let no_peer = version_from_tail(value);
let prefix = format!("{child_name}@");
if no_peer.starts_with(&prefix) {
no_peer.to_string()
} else {
format!("{prefix}{no_peer}")
}
}
pub(crate) fn dep_value_as_version<'a>(child_name: &str, value: &'a str) -> &'a str {
let no_peer = version_from_tail(value);
let prefix = format!("{child_name}@");
if let Some(rest) = no_peer.strip_prefix(&prefix) {
rest
} else {
no_peer
}
}
pub(crate) fn canonical_key_from_dep_path(dep_path: &str) -> String {
let trimmed = version_from_tail(dep_path);
let (name, version) = match trimmed.rfind('@') {
Some(0) | None => return trimmed.to_string(),
Some(idx) => (&trimmed[..idx], &trimmed[idx + 1..]),
};
format!("{name}@{version}")
}
fn borrow_map(m: &BTreeMap<String, String>) -> BTreeMap<&str, &str> {
m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_package_name_from_install_path() {
assert_eq!(
package_name_from_install_path("node_modules/foo"),
Some("foo".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/@scope/pkg"),
Some("@scope/pkg".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/foo/node_modules/bar"),
Some("bar".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/foo/node_modules/@scope/pkg"),
Some("@scope/pkg".to_string())
);
}
#[test]
fn test_parse_simple() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0" },
"devDependencies": { "bar": "^2.0.0" }
},
"node_modules/foo": {
"version": "1.2.3",
"integrity": "sha512-aaa",
"dependencies": { "nested": "^3.0.0" }
},
"node_modules/nested": {
"version": "3.1.0",
"integrity": "sha512-bbb"
},
"node_modules/bar": {
"version": "2.5.0",
"integrity": "sha512-ccc",
"dev": true
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(graph.packages.len(), 3);
assert!(graph.packages.contains_key("foo@1.2.3"));
assert!(graph.packages.contains_key("nested@3.1.0"));
assert!(graph.packages.contains_key("bar@2.5.0"));
let foo = &graph.packages["foo@1.2.3"];
assert_eq!(foo.integrity.as_deref(), Some("sha512-aaa"));
assert_eq!(
foo.dependencies.get("nested").map(String::as_str),
Some("3.1.0")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 2);
assert!(
root.iter()
.any(|d| d.name == "foo" && d.dep_type == DepType::Production)
);
assert!(
root.iter()
.any(|d| d.name == "bar" && d.dep_type == DepType::Dev)
);
}
#[test]
fn test_parse_scoped_package() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "@scope/pkg": "^1.0.0" }
},
"node_modules/@scope/pkg": {
"version": "1.0.0",
"integrity": "sha512-zzz"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert!(graph.packages.contains_key("@scope/pkg@1.0.0"));
let root = graph.importers.get(".").unwrap();
assert_eq!(root[0].name, "@scope/pkg");
assert_eq!(root[0].dep_path, "@scope/pkg@1.0.0");
}
#[test]
fn test_parse_multi_version_nested() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "foo": "^1.0.0", "bar": "^2.0.0" }
},
"node_modules/bar": {
"version": "2.0.0",
"integrity": "sha512-top-bar"
},
"node_modules/foo": {
"version": "1.0.0",
"integrity": "sha512-foo",
"dependencies": { "bar": "^1.0.0" }
},
"node_modules/foo/node_modules/bar": {
"version": "1.0.0",
"integrity": "sha512-nested-bar"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert!(graph.packages.contains_key("bar@2.0.0"));
assert!(graph.packages.contains_key("bar@1.0.0"));
assert!(graph.packages.contains_key("foo@1.0.0"));
let foo = &graph.packages["foo@1.0.0"];
assert_eq!(
foo.dependencies.get("bar").map(String::as_str),
Some("1.0.0")
);
let root = graph.importers.get(".").unwrap();
let root_bar = root.iter().find(|d| d.name == "bar").unwrap();
assert_eq!(root_bar.dep_path, "bar@2.0.0");
}
#[test]
fn test_write_dev_and_optional_reachable_uses_dev_optional() {
let mut graph = LockfileGraph::default();
let mk = |name: &str| LockedPackage {
name: name.to_string(),
version: "1.0.0".to_string(),
integrity: Some(format!("sha512-{name}")),
dep_path: format!("{name}@1.0.0"),
dependencies: [("shared".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
graph
.packages
.insert("dev-root@1.0.0".to_string(), mk("dev-root"));
graph
.packages
.insert("opt-root@1.0.0".to_string(), mk("opt-root"));
graph.packages.insert(
"shared@1.0.0".to_string(),
LockedPackage {
name: "shared".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-shared".to_string()),
dep_path: "shared@1.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "dev-root".to_string(),
dep_path: "dev-root@1.0.0".to_string(),
dep_type: DepType::Dev,
specifier: None,
},
DirectDep {
name: "opt-root".to_string(),
dep_path: "opt-root@1.0.0".to_string(),
dep_type: DepType::Optional,
specifier: None,
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dev_dependencies: [("dev-root".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
optional_dependencies: [("opt-root".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let shared = &json["packages"]["node_modules/shared"];
assert_eq!(shared["devOptional"], true, "expected devOptional flag");
assert!(
shared.get("dev").is_none(),
"must not emit dev: true alongside devOptional",
);
assert!(
shared.get("optional").is_none(),
"must not emit optional: true alongside devOptional",
);
assert_eq!(json["packages"]["node_modules/dev-root"]["dev"], true);
assert_eq!(json["packages"]["node_modules/opt-root"]["optional"], true);
}
#[test]
fn test_write_filters_missing_canonical_deps() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"foo@1.0.0".to_string(),
LockedPackage {
name: "foo".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-foo".to_string()),
dep_path: "foo@1.0.0".to_string(),
dependencies: [("ghost".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
}],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let foo_entry = &json["packages"]["node_modules/foo"];
assert!(
foo_entry
.get("dependencies")
.and_then(|d| d.get("ghost"))
.is_none(),
"writer emitted a ghost dep that has no packages entry: {foo_entry}",
);
assert!(
json["packages"].get("node_modules/ghost").is_none(),
"writer hallucinated a ghost entry",
);
}
#[test]
fn test_nested_shadow_forces_nested_placement() {
let mut graph = LockfileGraph::default();
let mk = |name: &str, version: &str, deps: &[(&str, &str)]| LockedPackage {
name: name.to_string(),
version: version.to_string(),
integrity: Some(format!("sha512-{name}-{version}")),
dep_path: format!("{name}@{version}"),
dependencies: deps
.iter()
.map(|(n, v)| (n.to_string(), (*v).to_string()))
.collect(),
..Default::default()
};
graph.packages.insert(
"foo@1.0.0".to_string(),
mk(
"foo",
"1.0.0",
&[
("bar", "1.0.0"),
("baz", "1.0.0"),
],
),
);
graph.packages.insert(
"baz@1.0.0".to_string(),
mk("baz", "1.0.0", &[("bar", "2.0.0")]),
);
graph
.packages
.insert("bar@1.0.0".to_string(), mk("bar", "1.0.0", &[]));
graph
.packages
.insert("bar@2.0.0".to_string(), mk("bar", "2.0.0", &[]));
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "bar".to_string(),
dep_path: "bar@2.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let reparsed = parse(out.path()).unwrap();
let baz = &reparsed.packages["baz@1.0.0"];
assert_eq!(
baz.dependencies.get("bar").map(String::as_str),
Some("2.0.0"),
"baz's bar dep was shadowed by foo/bar@1.0.0 — shadow-nest fix regressed",
);
}
#[test]
fn test_canonical_key_strips_peer_suffix() {
assert_eq!(canonical_key_from_dep_path("foo@1.0.0"), "foo@1.0.0");
assert_eq!(
canonical_key_from_dep_path("styled-components@6.1.0(react@18.2.0)"),
"styled-components@6.1.0"
);
assert_eq!(
canonical_key_from_dep_path("@scope/pkg@2.0.0(peer@1.0.0)"),
"@scope/pkg@2.0.0"
);
}
fn test_manifest() -> aube_manifest::PackageJson {
aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [
("foo".to_string(), "^1.0.0".to_string()),
("bar".to_string(), "^2.0.0".to_string()),
]
.into_iter()
.collect(),
..Default::default()
}
}
#[test]
fn test_write_roundtrip_multi_version() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0", "bar": "^2.0.0" }
},
"node_modules/bar": {
"version": "2.0.0",
"integrity": "sha512-top-bar"
},
"node_modules/foo": {
"version": "1.0.0",
"integrity": "sha512-foo",
"dependencies": { "bar": "^1.0.0" }
},
"node_modules/foo/node_modules/bar": {
"version": "1.0.0",
"integrity": "sha512-nested-bar"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let reparsed = parse(out.path()).unwrap();
assert!(reparsed.packages.contains_key("bar@1.0.0"));
assert!(reparsed.packages.contains_key("bar@2.0.0"));
assert!(reparsed.packages.contains_key("foo@1.0.0"));
assert_eq!(
reparsed.packages["bar@2.0.0"].integrity.as_deref(),
Some("sha512-top-bar")
);
assert_eq!(
reparsed.packages["bar@1.0.0"].integrity.as_deref(),
Some("sha512-nested-bar")
);
assert_eq!(
reparsed.packages["foo@1.0.0"]
.dependencies
.get("bar")
.map(String::as_str),
Some("1.0.0")
);
}
#[test]
fn test_write_dev_optional_flags() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"foo@1.0.0".to_string(),
LockedPackage {
name: "foo".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-foo".to_string()),
dep_path: "foo@1.0.0".to_string(),
..Default::default()
},
);
graph.packages.insert(
"devdep@1.0.0".to_string(),
LockedPackage {
name: "devdep".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-dev".to_string()),
dep_path: "devdep@1.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "devdep".to_string(),
dep_path: "devdep@1.0.0".to_string(),
dep_type: DepType::Dev,
specifier: None,
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("foo".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
dev_dependencies: [("devdep".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let packages = &json["packages"];
assert_eq!(packages["node_modules/devdep"]["dev"], true);
assert!(packages["node_modules/foo"].get("dev").is_none());
}
#[test]
fn test_reject_v1() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"lockfileVersion": 1,
"dependencies": {}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let err = parse(tmp.path()).unwrap_err();
assert!(matches!(err, Error::Parse(_, msg) if msg.contains("lockfileVersion 1")));
}
#[test]
fn test_parse_legacy_array_engines() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "ansi-html-community": "0.0.8" }
},
"node_modules/ansi-html-community": {
"version": "0.0.8",
"integrity": "sha512-aaa",
"engines": ["node >= 0.8.0"]
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["ansi-html-community@0.0.8"];
assert!(pkg.engines.is_empty());
}
#[test]
fn test_parse_npm_alias_dependency() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "h3-v2": "npm:h3@2.0.1-rc.20" }
},
"node_modules/h3-v2": {
"name": "h3",
"version": "2.0.1-rc.20",
"resolved": "https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz",
"integrity": "sha512-aliased"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(graph.packages.len(), 1);
let pkg = graph
.packages
.get("h3-v2@2.0.1-rc.20")
.expect("aliased entry should be keyed by the alias dep_path");
assert_eq!(pkg.name, "h3-v2");
assert_eq!(pkg.version, "2.0.1-rc.20");
assert_eq!(pkg.alias_of.as_deref(), Some("h3"));
assert_eq!(pkg.registry_name(), "h3");
assert_eq!(
pkg.tarball_url.as_deref(),
Some("https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "h3-v2");
assert_eq!(root[0].dep_path, "h3-v2@2.0.1-rc.20");
}
#[test]
fn test_parse_non_alias_preserves_empty_alias_of() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0" }
},
"node_modules/foo": {
"name": "foo",
"version": "1.2.3",
"integrity": "sha512-foo"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["foo@1.2.3"];
assert_eq!(pkg.name, "foo");
assert!(pkg.alias_of.is_none());
assert_eq!(pkg.registry_name(), "foo");
assert!(pkg.tarball_url.is_none());
}
#[test]
fn test_write_roundtrip_npm_alias() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"h3-v2@2.0.1-rc.20".to_string(),
LockedPackage {
name: "h3-v2".to_string(),
version: "2.0.1-rc.20".to_string(),
integrity: Some("sha512-aliased".to_string()),
dep_path: "h3-v2@2.0.1-rc.20".to_string(),
alias_of: Some("h3".to_string()),
tarball_url: Some("https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz".to_string()),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "h3-v2".to_string(),
dep_path: "h3-v2@2.0.1-rc.20".to_string(),
dep_type: DepType::Production,
specifier: Some("npm:h3@2.0.1-rc.20".to_string()),
}],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains("\"name\": \"h3\""),
"expected `name: h3` emitted for aliased entry; got:\n{body}"
);
assert!(
body.contains("\"resolved\": \"https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz\""),
"expected `resolved:` URL emitted for aliased entry; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let pkg = &reparsed.packages["h3-v2@2.0.1-rc.20"];
assert_eq!(pkg.alias_of.as_deref(), Some("h3"));
assert_eq!(pkg.registry_name(), "h3");
}
#[test]
fn test_parse_peer_dependencies() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "peer-test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "peer-test",
"version": "1.0.0",
"dependencies": { "devtools-vite": "0.6.0", "vite": "8.0.0" }
},
"node_modules/devtools-vite": {
"version": "0.6.0",
"integrity": "sha512-a",
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": { "optional": false }
}
},
"node_modules/vite": {
"version": "8.0.0",
"integrity": "sha512-b"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let devtools = &graph.packages["devtools-vite@0.6.0"];
assert_eq!(
devtools.peer_dependencies.get("vite").map(String::as_str),
Some("^6.0.0 || ^7.0.0 || ^8.0.0")
);
assert_eq!(
devtools
.peer_dependencies_meta
.get("vite")
.map(|m| m.optional),
Some(false)
);
}
#[test]
fn test_parse_no_peer_fields_stays_empty() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "no-peers",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": { "name": "no-peers", "version": "1.0.0", "dependencies": { "foo": "1.0.0" } },
"node_modules/foo": { "version": "1.0.0", "integrity": "sha512-x" }
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let foo = &graph.packages["foo@1.0.0"];
assert!(foo.peer_dependencies.is_empty());
assert!(foo.peer_dependencies_meta.is_empty());
}
#[test]
fn test_write_roundtrip_peer_dependencies() {
let mut graph = LockfileGraph::default();
let mut peer_deps = BTreeMap::new();
peer_deps.insert("vite".to_string(), "^6.0.0 || ^7.0.0 || ^8.0.0".to_string());
let mut peer_deps_meta = BTreeMap::new();
peer_deps_meta.insert("vite".to_string(), crate::PeerDepMeta { optional: true });
graph.packages.insert(
"devtools-vite@0.6.0".to_string(),
LockedPackage {
name: "devtools-vite".to_string(),
version: "0.6.0".to_string(),
integrity: Some("sha512-a".to_string()),
dep_path: "devtools-vite@0.6.0".to_string(),
peer_dependencies: peer_deps,
peer_dependencies_meta: peer_deps_meta,
..Default::default()
},
);
graph.packages.insert(
"vite@8.0.0".to_string(),
LockedPackage {
name: "vite".to_string(),
version: "8.0.0".to_string(),
integrity: Some("sha512-b".to_string()),
dep_path: "vite@8.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "devtools-vite".to_string(),
dep_path: "devtools-vite@0.6.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "vite".to_string(),
dep_path: "vite@8.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains("\"peerDependencies\""),
"expected peerDependencies block to round-trip; got:\n{body}"
);
assert!(
body.contains("\"peerDependenciesMeta\""),
"expected peerDependenciesMeta block to round-trip; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let devtools = &reparsed.packages["devtools-vite@0.6.0"];
assert_eq!(
devtools.peer_dependencies.get("vite").map(String::as_str),
Some("^6.0.0 || ^7.0.0 || ^8.0.0")
);
assert_eq!(
devtools
.peer_dependencies_meta
.get("vite")
.map(|m| m.optional),
Some(true),
"peerDependenciesMeta.optional must survive write → parse round-trip"
);
}
#[test]
fn test_write_byte_identical_to_native_npm() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/npm-native.json");
let original = std::fs::read_to_string(&fixture)
.unwrap()
.replace("\r\n", "\n");
let graph = parse(&fixture).unwrap();
let manifest = aube_manifest::PackageJson {
name: Some("aube-lockfile-stability".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [
("chalk".to_string(), "^4.1.2".to_string()),
("picocolors".to_string(), "^1.1.1".to_string()),
("semver".to_string(), "^7.6.3".to_string()),
]
.into_iter()
.collect(),
..Default::default()
};
let tmp = tempfile::NamedTempFile::new().unwrap();
write(tmp.path(), &graph, &manifest).unwrap();
let written = std::fs::read_to_string(tmp.path()).unwrap();
if written != original {
panic!(
"npm writer drifted from native npm output.\n\n--- expected ---\n{original}\n--- got ---\n{written}"
);
}
}
#[test]
fn test_parse_workspace_links() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "workspace-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "workspace-root",
"version": "1.0.0",
"dependencies": { "@scope/app": "file:packages/app" }
},
"node_modules/@scope/app": {
"resolved": "packages/app",
"link": true
},
"node_modules/chalk": {
"version": "5.4.1",
"integrity": "sha512-chalk"
},
"packages/app": {
"name": "@scope/app",
"version": "0.68.1",
"dependencies": {
"chalk": "^5.4.1"
}
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let dep_path = LocalSource::Link(PathBuf::from("packages/app")).dep_path("@scope/app");
let importer = &graph.importers["."];
assert_eq!(importer.len(), 1);
assert_eq!(importer[0].name, "@scope/app");
assert_eq!(importer[0].dep_path, dep_path);
assert!(matches!(importer[0].dep_type, DepType::Production));
assert!(importer[0].specifier.is_none());
let app = &graph.packages[&importer[0].dep_path];
assert_eq!(app.version, "0.68.1");
assert_eq!(
app.local_source,
Some(LocalSource::Link(PathBuf::from("packages/app")))
);
assert_eq!(
app.dependencies.get("chalk").map(String::as_str),
Some("5.4.1")
);
assert!(!graph.packages.contains_key("@scope/app@0.68.1"));
}
#[test]
fn test_parse_funding_all_shapes() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": {
"string-funding": "1.0.0",
"object-funding": "1.0.0",
"array-funding": "1.0.0",
"mixed-array-funding": "1.0.0",
"no-funding": "1.0.0"
}
},
"node_modules/string-funding": {
"version": "1.0.0",
"integrity": "sha512-aaa",
"funding": "https://example.com/sponsor"
},
"node_modules/object-funding": {
"version": "1.0.0",
"integrity": "sha512-bbb",
"funding": { "type": "github", "url": "https://github.com/sponsors/foo" }
},
"node_modules/array-funding": {
"version": "1.0.0",
"integrity": "sha512-ccc",
"funding": [
{ "type": "github", "url": "https://github.com/sponsors/csstools" },
{ "type": "opencollective", "url": "https://opencollective.com/csstools" }
]
},
"node_modules/mixed-array-funding": {
"version": "1.0.0",
"integrity": "sha512-ddd",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{ "type": "github", "url": "https://github.com/sponsors/fb55" }
]
},
"node_modules/no-funding": {
"version": "1.0.0",
"integrity": "sha512-eee"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(
graph.packages["string-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://example.com/sponsor"),
);
assert_eq!(
graph.packages["object-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://github.com/sponsors/foo"),
);
assert_eq!(
graph.packages["array-funding@1.0.0"].funding_url.as_deref(),
Some("https://github.com/sponsors/csstools"),
);
assert_eq!(
graph.packages["mixed-array-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://github.com/fb55/htmlparser2?sponsor=1"),
);
assert!(graph.packages["no-funding@1.0.0"].funding_url.is_none());
}
}