Skip to main content

npm_utils/
project.rs

1//! Project-level mutations: operations on a directory that holds a `package.json` — keep the lock
2//! and `node_modules/` in step with the manifest ([`sync`]), upgrade dependencies within their
3//! ranges ([`upgrade`], previewable with [`plan_upgrade`]), and remove them ([`remove`]).
4//!
5//! The manifest/lockfile *transforms* stay pure in [`crate::package_json`]; this module is the file
6//! IO + orchestration that composes them with [`crate::registry`] resolution and [`crate::install`],
7//! the same way the CLI's `add` / `upgrade` verbs do. Each mutating call rewrites `package.json` and
8//! a fresh v3 `package-lock.json`, then installs the locked tree (every tarball sha512-verified).
9
10use std::path::Path;
11
12use serde_json::Value;
13
14use crate::install::{from_lockfile_observed, InstallEvent};
15use crate::package_json::{lock, manifest, spec};
16use crate::registry::{PackumentDetail, Registry, ResolveEvent, Resolved};
17use crate::Result;
18
19/// A single dependency version-range change — the unit of an [`upgrade`] plan (`from` → `to`).
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Change {
22    /// The dependency name.
23    pub name: String,
24    /// The range as written in `package.json` before the upgrade.
25    pub from: String,
26    /// The range after bumping its floor to the newly resolved version.
27    pub to: String,
28}
29
30/// Read and parse `<dir>/package.json`, erroring clearly if it is missing or not a JSON object.
31pub fn read_manifest(dir: &Path) -> Result<Value> {
32    let path = dir.join("package.json");
33    let text =
34        std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
35    let doc: Value =
36        serde_json::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
37    if !doc.is_object() {
38        return Err(format!("{} is not a JSON object", path.display()).into());
39    }
40    Ok(doc)
41}
42
43/// Write a manifest back as pretty JSON (npm's two-space indent + trailing newline).
44pub fn write_manifest(dir: &Path, doc: &Value) -> Result<()> {
45    std::fs::write(dir.join("package.json"), manifest::to_pretty(doc))?;
46    Ok(())
47}
48
49/// Make `package-lock.json` + `node_modules/` a function of the manifest: write a fresh v3 lockfile
50/// from the resolved registry dependency tree (licenses per `detail`), then install from it (every
51/// tarball's sha512 verified). Returns the installed packages. Non-registry deps (git/`file:`) are
52/// recorded in the manifest but not resolved.
53pub fn sync(dir: &Path, doc: &Value, detail: PackumentDetail) -> Result<Vec<Resolved>> {
54    sync_observed(dir, doc, detail, |_| {}, |_| {})
55}
56
57/// [`sync`] with progress observers for its two phases: the resolve walk's
58/// [`ResolveEvent`]s while the lockfile is computed, then one [`InstallEvent`] per package as
59/// the locked tree installs — the CLI's `[resolve]`/`[install]` task pair ticks from them.
60pub(crate) fn sync_observed(
61    dir: &Path,
62    doc: &Value,
63    detail: PackumentDetail,
64    on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
65    on_install: impl FnMut(InstallEvent<'_>),
66) -> Result<Vec<Resolved>> {
67    let lockfile = dir.join("package-lock.json");
68    std::fs::write(
69        &lockfile,
70        lock::render_v3_from_manifest_observed(
71            doc,
72            &Registry::npm().with_detail(detail),
73            on_resolve,
74        )?,
75    )?;
76    from_lockfile_observed(&lockfile, dir, on_install)
77}
78
79/// Compute the upgrade plan **without writing anything** (the dry-run): for each selected registry
80/// dependency, re-resolve within its range and, when the range floats (`^`/`~`), bump its floor to
81/// the resolved version. An empty `packages` means every dependency; exact pins and complex ranges
82/// are left untouched (npm honors them too), so they never appear as a [`Change`].
83pub fn plan_upgrade(doc: &Value, packages: &[String], registry: &Registry) -> Result<Vec<Change>> {
84    plan_upgrade_with(doc, packages, |name, range| registry.resolve(name, range))
85}
86
87/// [`plan_upgrade`] with an injectable resolver, so the plan logic can be unit-tested without the
88/// network (mirroring [`Registry::resolve_tree`]'s test seam).
89fn plan_upgrade_with<F>(doc: &Value, packages: &[String], mut resolve: F) -> Result<Vec<Change>>
90where
91    F: FnMut(&str, &spec::Range) -> Result<Resolved>,
92{
93    let mut changes = Vec::new();
94    for (name, range) in manifest::dependencies(doc) {
95        if !packages.is_empty() && !packages.contains(&name) {
96            continue;
97        }
98        if !spec::Spec::parse(&range).is_registry() {
99            continue; // git / file / tarball — nothing to re-resolve from the registry
100        }
101        let resolved = resolve(&name, &spec::Range::parse(&range)?)?;
102        if let Some(bumped) = bump_floor(&range, &resolved.version) {
103            if bumped != range {
104                changes.push(Change {
105                    name,
106                    from: range,
107                    to: bumped,
108                });
109            }
110        }
111    }
112    Ok(changes)
113}
114
115/// Upgrade dependencies within their ranges (= `npm update`): compute the plan ([`plan_upgrade`]
116/// against the public registry), apply each [`Change`] to `package.json`, then [`sync`]. Returns the
117/// applied changes and the freshly installed tree. A run with no floating updates leaves
118/// `package.json` byte-identical — the manifest is rewritten only when a range actually changed.
119pub fn upgrade(
120    dir: &Path,
121    packages: &[String],
122    detail: PackumentDetail,
123) -> Result<(Vec<Change>, Vec<Resolved>)> {
124    upgrade_observed(dir, packages, detail, |_| {}, |_| {})
125}
126
127/// [`upgrade`] with [`sync_observed`]'s progress observers. The plan phase stays unobserved —
128/// its per-dependency resolves are quick, and their time lands in the `[resolve]` task's
129/// elapsed total anyway.
130pub(crate) fn upgrade_observed(
131    dir: &Path,
132    packages: &[String],
133    detail: PackumentDetail,
134    on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
135    on_install: impl FnMut(InstallEvent<'_>),
136) -> Result<(Vec<Change>, Vec<Resolved>)> {
137    let mut doc = read_manifest(dir)?;
138    let changes = plan_upgrade(&doc, packages, &Registry::npm())?;
139    for change in &changes {
140        manifest::upsert_dependency(&mut doc, &change.name, &change.to);
141    }
142    // Rewrite the manifest only when something changed: an unconditional write would normalize the
143    // file's whitespace/indentation via `to_pretty`, so a no-op upgrade would not be byte-identical.
144    if !changes.is_empty() {
145        write_manifest(dir, &doc)?;
146    }
147    let installed = sync_observed(dir, &doc, detail, on_resolve, on_install)?;
148    Ok((changes, installed))
149}
150
151/// Remove dependencies (= `npm remove`): drop each named dependency from `package.json`, rewrite the
152/// lock, and reinstall the resolved tree. Returns the names actually removed (a name absent from
153/// `dependencies` is skipped) and the reinstalled tree.
154///
155/// Pruning is [`sync`]'s job: it rewrites `node_modules/` to exactly the new lockfile, so a package
156/// no longer in the tree is dropped, while one still required transitively by another dependency is
157/// kept. A removed direct dependency that remains a transitive dependency therefore stays installed,
158/// with its `.bin` links intact.
159pub fn remove(
160    dir: &Path,
161    names: &[String],
162    detail: PackumentDetail,
163) -> Result<(Vec<String>, Vec<Resolved>)> {
164    remove_observed(dir, names, detail, |_| {}, |_| {})
165}
166
167/// [`remove`] with [`sync_observed`]'s progress observers.
168pub(crate) fn remove_observed(
169    dir: &Path,
170    names: &[String],
171    detail: PackumentDetail,
172    on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
173    on_install: impl FnMut(InstallEvent<'_>),
174) -> Result<(Vec<String>, Vec<Resolved>)> {
175    let mut doc = read_manifest(dir)?;
176    let mut removed = Vec::new();
177    for name in names {
178        if manifest::remove_dependency(&mut doc, name) {
179            removed.push(name.clone());
180        }
181    }
182    write_manifest(dir, &doc)?;
183    let installed = sync_observed(dir, &doc, detail, on_resolve, on_install)?;
184    Ok((removed, installed))
185}
186
187/// For a caret/tilde range, return it with the floor set to `version` (`^3.1.0` + 3.4.2 → `^3.4.2`).
188/// `None` for any other shape (exact pin, `*`, comparator range) — left as written.
189pub fn bump_floor(range: &str, version: &semver::Version) -> Option<String> {
190    match range.chars().next() {
191        Some(prefix @ ('^' | '~')) => Some(format!("{prefix}{version}")),
192        _ => None,
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn bump_floor_only_moves_floating_ranges() {
202        let v = semver::Version::parse("3.4.2").unwrap();
203        assert_eq!(bump_floor("^3.1.0", &v).as_deref(), Some("^3.4.2"));
204        assert_eq!(bump_floor("~3.1.0", &v).as_deref(), Some("~3.4.2"));
205        // Exact pins and any non-^/~ shape are left untouched.
206        assert_eq!(bump_floor("3.1.0", &v), None);
207        assert_eq!(bump_floor("*", &v), None);
208        assert_eq!(bump_floor(">=3 <4", &v), None);
209    }
210
211    /// A `Resolved` at `version` — enough to drive [`plan_upgrade_with`]'s bump decision offline.
212    fn resolved_at(version: &str) -> Resolved {
213        Resolved {
214            name: String::new(),
215            version: semver::Version::parse(version).unwrap(),
216            tarball_url: String::new(),
217            integrity: None,
218            license: None,
219        }
220    }
221
222    #[test]
223    fn plan_upgrade_bumps_floating_and_skips_pinned_filtered_and_non_registry() {
224        let doc: Value = serde_json::from_str(
225            r#"{"name":"app","version":"1.0.0","dependencies":{
226                 "lit":"^3.0.0","ms":"~2.1.0","pinned":"1.2.3","gitdep":"github:o/r"
227               }}"#,
228        )
229        .unwrap();
230
231        // Resolve everything to 9.9.9. `dependencies` is sorted (gitdep, lit, ms, pinned): gitdep is
232        // non-registry (skipped, never resolved); lit/ms float and bump; pinned is an exact pin, so
233        // its floor never moves.
234        let all = plan_upgrade_with(&doc, &[], |_n, _r| Ok(resolved_at("9.9.9"))).unwrap();
235        assert_eq!(
236            all,
237            vec![
238                Change {
239                    name: "lit".into(),
240                    from: "^3.0.0".into(),
241                    to: "^9.9.9".into()
242                },
243                Change {
244                    name: "ms".into(),
245                    from: "~2.1.0".into(),
246                    to: "~9.9.9".into()
247                },
248            ]
249        );
250
251        // A package filter narrows the plan to just the named dependency.
252        let filtered =
253            plan_upgrade_with(&doc, &["lit".into()], |_n, _r| Ok(resolved_at("9.9.9"))).unwrap();
254        assert_eq!(
255            filtered,
256            vec![Change {
257                name: "lit".into(),
258                from: "^3.0.0".into(),
259                to: "^9.9.9".into()
260            }]
261        );
262    }
263
264    #[test]
265    fn plan_upgrade_is_empty_when_nothing_floats() {
266        // A floating range already at the resolved version yields no change.
267        let doc: Value =
268            serde_json::from_str(r#"{"name":"app","dependencies":{"lit":"^9.9.9"}}"#).unwrap();
269        let changes = plan_upgrade_with(&doc, &[], |_n, _r| Ok(resolved_at("9.9.9"))).unwrap();
270        assert!(
271            changes.is_empty(),
272            "no floor move → empty plan: {changes:?}"
273        );
274    }
275
276    #[test]
277    fn upgrade_noop_leaves_the_manifest_byte_identical() {
278        // A manifest with non-canonical formatting (4-space indent, no trailing newline) and no
279        // registry dependencies: `plan_upgrade` resolves nothing (offline) and finds no changes, so
280        // the manifest must not be rewritten — otherwise `to_pretty` would reformat it.
281        let dir = tempfile::tempdir().unwrap();
282        let original = r#"{
283    "name": "demo",
284    "version": "1.0.0"
285}"#;
286        std::fs::write(dir.path().join("package.json"), original).unwrap();
287
288        let (changes, _installed) = upgrade(dir.path(), &[], PackumentDetail::Abbreviated).unwrap();
289
290        assert!(changes.is_empty());
291        let after = std::fs::read_to_string(dir.path().join("package.json")).unwrap();
292        assert_eq!(
293            after, original,
294            "a no-op upgrade must not rewrite package.json"
295        );
296    }
297}