1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Change {
22 pub name: String,
24 pub from: String,
26 pub to: String,
28}
29
30pub 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
43pub fn write_manifest(dir: &Path, doc: &Value) -> Result<()> {
45 std::fs::write(dir.join("package.json"), manifest::to_pretty(doc))?;
46 Ok(())
47}
48
49pub fn sync(dir: &Path, doc: &Value, detail: PackumentDetail) -> Result<Vec<Resolved>> {
54 sync_observed(dir, doc, detail, |_| {}, |_| {})
55}
56
57pub(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
79pub 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
87fn 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; }
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
115pub 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
127pub(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 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
151pub 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
167pub(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
187pub 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 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 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 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 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 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 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}