use crate::{DepType, DirectDep, Error, LocalSource, LockedPackage, LockfileGraph};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::Path;
#[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 = "Vec::is_empty")]
os: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cpu: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
libc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
funding: Option<WriteNpmFunding<'a>>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
link: bool,
#[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 mut canonical = crate::build_canonical_map(graph);
for pkg in graph
.packages
.values()
.filter(|pkg| matches!(pkg.local_source, Some(LocalSource::Git(_))))
{
canonical
.entry(super::canonical_key_from_dep_path(&pkg.dep_path))
.or_insert(pkg);
}
let roots = graph.importers.get(".").cloned().unwrap_or_default();
let all_roots: Vec<DirectDep> = graph
.importers
.values()
.flat_map(|deps| deps.iter().cloned())
.collect();
let prod_reach = reachable_from(&canonical, &all_roots, DepType::Production);
let dev_reach = reachable_from(&canonical, &all_roots, DepType::Dev);
let opt_reach = reachable_from(&canonical, &all_roots, DepType::Optional);
let root_tree_roots = non_link_roots(graph, &roots);
let tree = super::build_hoist_tree(&canonical, &root_tree_roots);
let mut placed: BTreeMap<String, String> = tree
.into_iter()
.map(|(segs, key)| (super::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 (importer_path, importer_roots) in graph.importers.iter().filter(|(path, _)| *path != ".") {
let Some(workspace_pkg) = workspace_package_for_importer(graph, importer_path) else {
continue;
};
let (dependencies, dev_dependencies, optional_dependencies) =
dep_sections_from_direct_deps(importer_roots);
packages.insert(
importer_path.clone(),
WriteNpmPackage {
name: Some(workspace_pkg.name.as_str()),
version: Some(workspace_pkg.version.as_str()),
dependencies,
dev_dependencies,
optional_dependencies,
peer_dependencies: workspace_pkg
.peer_dependencies
.iter()
.map(|(n, v)| (n.as_str(), v.as_str()))
.collect(),
..Default::default()
},
);
packages.insert(
format!("node_modules/{}", workspace_pkg.name),
WriteNpmPackage {
resolved: Some(importer_path.clone()),
link: true,
..Default::default()
},
);
let workspace_tree_roots = non_link_roots(graph, importer_roots);
let workspace_tree = super::build_hoist_tree(&canonical, &workspace_tree_roots);
let redundant_tops: BTreeSet<String> = workspace_tree
.iter()
.filter(|(segs, key)| {
segs.len() == 1
&& placed
.get(&format!("node_modules/{}", segs[0]))
.is_some_and(|root_key| root_key == *key)
})
.map(|(segs, _)| segs[0].clone())
.collect();
for (segs, canonical_key) in workspace_tree {
if redundant_tops.contains(&segs[0]) {
continue;
}
let install_path =
format!("{importer_path}/{}", super::segments_to_install_path(&segs));
placed.entry(install_path).or_insert(canonical_key);
}
}
for (install_path, canonical_key) in &placed {
let Some(pkg) = canonical.get(canonical_key).copied() else {
continue;
};
let optional_deps: BTreeMap<&str, &str> = pkg
.optional_dependencies
.iter()
.filter(|(n, value)| canonical.contains_key(&super::child_canonical_key(n, value)))
.map(|(n, value)| {
let rendered = pkg
.declared_dependencies
.get(n)
.map(String::as_str)
.unwrap_or_else(|| super::dep_value_as_version(n, value));
(n.as_str(), rendered)
})
.collect();
let deps: BTreeMap<&str, &str> = pkg
.dependencies
.iter()
.filter(|(n, value)| {
!pkg.optional_dependencies.contains_key(*n)
&& canonical.contains_key(&super::child_canonical_key(n, value))
})
.map(|(n, value)| {
let rendered = pkg
.declared_dependencies
.get(n)
.map(String::as_str)
.unwrap_or_else(|| super::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 = super::source::npm_resolved_field(pkg);
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,
optional_dependencies: optional_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(),
os: pkg.os.to_vec(),
cpu: pkg.cpu.to_vec(),
libc: pkg.libc.to_vec(),
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, e.to_string()))?;
body.push('\n');
crate::atomic_write_lockfile(path, body.as_bytes())?;
Ok(())
}
fn workspace_package_for_importer<'a>(
graph: &'a LockfileGraph,
importer_path: &str,
) -> Option<&'a LockedPackage> {
graph.packages.values().find(|pkg| {
matches!(
&pkg.local_source,
Some(LocalSource::Link(path)) if path == Path::new(importer_path)
)
})
}
fn non_link_roots(graph: &LockfileGraph, roots: &[DirectDep]) -> Vec<DirectDep> {
roots
.iter()
.filter(|dep| {
!graph
.packages
.get(&dep.dep_path)
.is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))))
})
.cloned()
.collect()
}
type DepSections<'a> = (
BTreeMap<&'a str, &'a str>,
BTreeMap<&'a str, &'a str>,
BTreeMap<&'a str, &'a str>,
);
fn dep_sections_from_direct_deps(deps: &[DirectDep]) -> DepSections<'_> {
let mut dependencies = BTreeMap::new();
let mut dev_dependencies = BTreeMap::new();
let mut optional_dependencies = BTreeMap::new();
for dep in deps {
let rendered = dep.specifier.as_deref().unwrap_or_else(|| {
super::dep_value_as_version(&dep.name, super::dep_path_tail(&dep.name, &dep.dep_path))
});
match dep.dep_type {
DepType::Production => {
dependencies.insert(dep.name.as_str(), rendered);
}
DepType::Dev => {
dev_dependencies.insert(dep.name.as_str(), rendered);
}
DepType::Optional => {
optional_dependencies.insert(dep.name.as_str(), rendered);
}
}
}
(dependencies, dev_dependencies, optional_dependencies)
}
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 = super::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 = super::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 borrow_map(m: &BTreeMap<String, String>) -> BTreeMap<&str, &str> {
m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
}