use std::path::Path;
use serde_json::Value;
use crate::install::{from_lockfile_observed, InstallEvent};
use crate::package_json::{lock, manifest, spec};
use crate::registry::{PackumentDetail, Registry, ResolveEvent, Resolved};
use crate::Result;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub name: String,
pub from: String,
pub to: String,
}
pub fn read_manifest(dir: &Path) -> Result<Value> {
let path = dir.join("package.json");
let text =
std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
let doc: Value =
serde_json::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
if !doc.is_object() {
return Err(format!("{} is not a JSON object", path.display()).into());
}
Ok(doc)
}
pub fn write_manifest(dir: &Path, doc: &Value) -> Result<()> {
std::fs::write(dir.join("package.json"), manifest::to_pretty(doc))?;
Ok(())
}
pub fn sync(dir: &Path, doc: &Value, detail: PackumentDetail) -> Result<Vec<Resolved>> {
sync_observed(dir, doc, detail, |_| {}, |_| {})
}
pub(crate) fn sync_observed(
dir: &Path,
doc: &Value,
detail: PackumentDetail,
on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
on_install: impl FnMut(InstallEvent<'_>),
) -> Result<Vec<Resolved>> {
let lockfile = dir.join("package-lock.json");
std::fs::write(
&lockfile,
lock::render_v3_from_manifest_observed(
doc,
&Registry::npm().with_detail(detail),
on_resolve,
)?,
)?;
from_lockfile_observed(&lockfile, dir, on_install)
}
pub fn plan_upgrade(doc: &Value, packages: &[String], registry: &Registry) -> Result<Vec<Change>> {
plan_upgrade_with(doc, packages, |name, range| registry.resolve(name, range))
}
fn plan_upgrade_with<F>(doc: &Value, packages: &[String], mut resolve: F) -> Result<Vec<Change>>
where
F: FnMut(&str, &spec::Range) -> Result<Resolved>,
{
let mut changes = Vec::new();
for (name, range) in manifest::dependencies(doc) {
if !packages.is_empty() && !packages.contains(&name) {
continue;
}
if !spec::Spec::parse(&range).is_registry() {
continue; }
let resolved = resolve(&name, &spec::Range::parse(&range)?)?;
if let Some(bumped) = bump_floor(&range, &resolved.version) {
if bumped != range {
changes.push(Change {
name,
from: range,
to: bumped,
});
}
}
}
Ok(changes)
}
pub fn upgrade(
dir: &Path,
packages: &[String],
detail: PackumentDetail,
) -> Result<(Vec<Change>, Vec<Resolved>)> {
upgrade_observed(dir, packages, detail, |_| {}, |_| {})
}
pub(crate) fn upgrade_observed(
dir: &Path,
packages: &[String],
detail: PackumentDetail,
on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
on_install: impl FnMut(InstallEvent<'_>),
) -> Result<(Vec<Change>, Vec<Resolved>)> {
let mut doc = read_manifest(dir)?;
let changes = plan_upgrade(&doc, packages, &Registry::npm())?;
for change in &changes {
manifest::upsert_dependency(&mut doc, &change.name, &change.to);
}
if !changes.is_empty() {
write_manifest(dir, &doc)?;
}
let installed = sync_observed(dir, &doc, detail, on_resolve, on_install)?;
Ok((changes, installed))
}
pub fn remove(
dir: &Path,
names: &[String],
detail: PackumentDetail,
) -> Result<(Vec<String>, Vec<Resolved>)> {
remove_observed(dir, names, detail, |_| {}, |_| {})
}
pub(crate) fn remove_observed(
dir: &Path,
names: &[String],
detail: PackumentDetail,
on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
on_install: impl FnMut(InstallEvent<'_>),
) -> Result<(Vec<String>, Vec<Resolved>)> {
let mut doc = read_manifest(dir)?;
let mut removed = Vec::new();
for name in names {
if manifest::remove_dependency(&mut doc, name) {
removed.push(name.clone());
}
}
write_manifest(dir, &doc)?;
let installed = sync_observed(dir, &doc, detail, on_resolve, on_install)?;
Ok((removed, installed))
}
pub fn bump_floor(range: &str, version: &semver::Version) -> Option<String> {
match range.chars().next() {
Some(prefix @ ('^' | '~')) => Some(format!("{prefix}{version}")),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bump_floor_only_moves_floating_ranges() {
let v = semver::Version::parse("3.4.2").unwrap();
assert_eq!(bump_floor("^3.1.0", &v).as_deref(), Some("^3.4.2"));
assert_eq!(bump_floor("~3.1.0", &v).as_deref(), Some("~3.4.2"));
assert_eq!(bump_floor("3.1.0", &v), None);
assert_eq!(bump_floor("*", &v), None);
assert_eq!(bump_floor(">=3 <4", &v), None);
}
fn resolved_at(version: &str) -> Resolved {
Resolved {
name: String::new(),
version: semver::Version::parse(version).unwrap(),
tarball_url: String::new(),
integrity: None,
license: None,
}
}
#[test]
fn plan_upgrade_bumps_floating_and_skips_pinned_filtered_and_non_registry() {
let doc: Value = serde_json::from_str(
r#"{"name":"app","version":"1.0.0","dependencies":{
"lit":"^3.0.0","ms":"~2.1.0","pinned":"1.2.3","gitdep":"github:o/r"
}}"#,
)
.unwrap();
let all = plan_upgrade_with(&doc, &[], |_n, _r| Ok(resolved_at("9.9.9"))).unwrap();
assert_eq!(
all,
vec![
Change {
name: "lit".into(),
from: "^3.0.0".into(),
to: "^9.9.9".into()
},
Change {
name: "ms".into(),
from: "~2.1.0".into(),
to: "~9.9.9".into()
},
]
);
let filtered =
plan_upgrade_with(&doc, &["lit".into()], |_n, _r| Ok(resolved_at("9.9.9"))).unwrap();
assert_eq!(
filtered,
vec![Change {
name: "lit".into(),
from: "^3.0.0".into(),
to: "^9.9.9".into()
}]
);
}
#[test]
fn plan_upgrade_is_empty_when_nothing_floats() {
let doc: Value =
serde_json::from_str(r#"{"name":"app","dependencies":{"lit":"^9.9.9"}}"#).unwrap();
let changes = plan_upgrade_with(&doc, &[], |_n, _r| Ok(resolved_at("9.9.9"))).unwrap();
assert!(
changes.is_empty(),
"no floor move → empty plan: {changes:?}"
);
}
#[test]
fn upgrade_noop_leaves_the_manifest_byte_identical() {
let dir = tempfile::tempdir().unwrap();
let original = r#"{
"name": "demo",
"version": "1.0.0"
}"#;
std::fs::write(dir.path().join("package.json"), original).unwrap();
let (changes, _installed) = upgrade(dir.path(), &[], PackumentDetail::Abbreviated).unwrap();
assert!(changes.is_empty());
let after = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
assert_eq!(
after, original,
"a no-op upgrade must not rewrite package.json"
);
}
}