1use std::cmp::Ordering;
27use std::time::Duration;
28
29use anyhow::{Context, Result};
30use chrono::Utc;
31
32use crate::config::Registry;
33use crate::constants;
34use crate::output;
35
36pub fn run(offline: bool, install: bool) -> Result<()> {
37 if install {
38 return run_install();
39 }
40 output::print_header("dev-prune version & upgrade");
41
42 output::print_info(&format!("Installed version: v{}", constants::VERSION));
43
44 if offline {
45 output::print_info("Skipping the release check because `--offline` was passed.");
46 } else if let Ok(mut registry) = Registry::load() {
47 if registry.settings.update_check {
48 match refresh_latest(&mut registry) {
51 Ok(latest) => report_comparison(&latest),
52 Err(e) => output::print_warning(&format!(
55 "Could not reach the release API ({e}). The upgrade commands below still apply."
56 )),
57 }
58 let _ = registry.save();
59 } else {
60 output::print_info(
61 "The release check is off (`devp config set update_check true` re-enables it).",
62 );
63 }
64 }
65
66 println!();
67 println!(" Latest releases: {}", constants::RELEASES_URL);
68 println!();
69 print_upgrade_commands();
70
71 Ok(())
72}
73
74pub fn check_now(registry: &mut Registry) -> bool {
83 if !registry.settings.update_check {
84 return false;
85 }
86
87 match refresh_latest(registry) {
88 Ok(latest) => {
89 report_comparison(&latest);
90 if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
91 print_upgrade_commands();
92 }
93 }
94 Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
96 }
97 true
98}
99
100fn print_upgrade_commands() {
102 println!(" Upgrade with whichever channel you installed from:");
103 println!(" cargo binstall dev-prune --force");
104 println!(" cargo install dev-prune --force");
105 println!(" npm install -g dev-prune@latest");
106 println!(" uv tool upgrade dev-prune / pipx upgrade dev-prune");
107 println!(" curl -fsSL {} | sh", constants::INSTALL_SH_URL);
108 println!(" iwr -useb {} | iex", constants::INSTALL_PS1_URL);
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum Channel {
117 Installer,
119 Cargo,
121 Npm,
123 UvTool,
125 Pipx,
127 Unknown,
129}
130
131fn detect_channel(exe: &std::path::Path, managed: Option<&std::path::Path>) -> Channel {
138 if let Some(managed) = managed
139 && exe == managed
140 {
141 return Channel::Installer;
142 }
143 let has_dir = |name: &str| {
144 exe.components()
145 .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
146 };
147 if has_dir(".cargo") {
148 Channel::Cargo
149 } else if has_dir("node_modules") {
150 Channel::Npm
151 } else if has_dir("uv") || has_dir("uv-tool") {
152 Channel::UvTool
153 } else if has_dir("pipx") {
154 Channel::Pipx
155 } else {
156 Channel::Unknown
157 }
158}
159
160fn run_install() -> Result<()> {
162 output::print_header("dev-prune self-update");
163
164 if crate::setup::offline_requested() {
165 anyhow::bail!(
166 "{} is set — an install needs the network by definition.",
167 constants::ENV_OFFLINE
168 );
169 }
170
171 let mut registry = Registry::load()?;
175 let latest = refresh_latest(&mut registry)?;
176 let _ = registry.save();
177 if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
178 output::print_success(&format!(
179 "v{} is already the latest release — nothing to install.",
180 constants::VERSION
181 ));
182 return Ok(());
183 }
184 output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
185
186 let exe = std::env::current_exe().context("could not locate the running binary")?;
187 let managed = crate::setup::managed_exe_path().ok();
188 let channel = detect_channel(&exe, managed.as_deref());
189
190 #[cfg(windows)]
195 let aside = {
196 let aside = exe.with_extension("exe.old");
197 let _ = std::fs::remove_file(&aside);
198 std::fs::rename(&exe, &aside).ok().map(|_| aside)
199 };
200
201 let result = spawn_channel_upgrade(channel);
202
203 #[cfg(windows)]
204 if let Some(aside) = aside {
205 if result.is_ok() {
206 let _ = std::fs::remove_file(&aside);
209 } else if !exe.exists() {
210 let _ = std::fs::rename(&aside, &exe);
213 }
214 }
215 result?;
216
217 output::print_success(&format!("dev-prune v{latest} installed."));
218 output::print_info(
219 "The scheduled pass was not interrupted: it runs the managed copy, which \
220 refreshes itself from the new binary on its next run.",
221 );
222 Ok(())
223}
224
225fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
228 let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
229 let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
230 let argv: Vec<&str> = match channel {
231 Channel::Cargo => {
232 if crate::adapters::binary_available("cargo-binstall") {
235 vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
236 } else {
237 vec!["cargo", "install", "dev-prune", "--force"]
238 }
239 }
240 Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
241 Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
242 Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
243 Channel::Installer => {
244 if cfg!(windows) {
245 vec!["powershell", "-NoProfile", "-Command", &install_ps1]
246 } else {
247 vec!["sh", "-c", &install_sh]
248 }
249 }
250 Channel::Unknown => {
251 output::print_warning(
252 "Could not tell which channel installed this binary, so nothing was \
253 changed. Upgrade it yourself with one of:",
254 );
255 print_upgrade_commands();
256 anyhow::bail!("unrecognised install channel");
257 }
258 };
259
260 output::print_info(&format!("Running: {}", argv.join(" ")));
261 let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
262 .args(&argv[1..])
263 .status()
264 .with_context(|| format!("could not start `{}`", argv[0]))?;
265 if !status.success() {
266 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
267 }
268 Ok(())
269}
270
271pub fn maybe_auto_update(registry: &Registry) {
277 if !registry.settings.auto_update
278 || crate::setup::offline_requested()
279 || crate::setup::no_auto_setup_requested()
280 {
281 return;
282 }
283 let Some(latest) = registry.latest_known_version.as_deref() else {
284 return;
285 };
286 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
287 return;
288 }
289 println!();
290 if let Err(e) = run_install() {
291 output::print_warning(&format!(
292 "Automatic update failed ({e}). Run `devp update --install` yourself, or \
293 `devp config set auto_update false` to stop trying."
294 ));
295 }
296}
297
298pub fn notify_if_outdated(registry: &mut Registry) -> bool {
304 if !registry.settings.update_check {
305 return false;
306 }
307
308 let interval = registry.settings.update_check_interval_days;
309 let due = registry
310 .last_update_check
311 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
312
313 if due {
314 let _ = refresh_latest(registry);
318 }
319
320 if let Some(latest) = registry.latest_known_version.as_deref()
321 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
322 {
323 output::print_info(&format!(
324 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
325 `devp config set update_check false` silences this.",
326 constants::VERSION
327 ));
328 }
329
330 due
331}
332
333fn refresh_latest(registry: &mut Registry) -> Result<String> {
338 let result = latest_release(registry.settings.update_check_timeout_secs);
339 registry.last_update_check = Some(Utc::now());
340 let latest = result?;
341 registry.latest_known_version = Some(latest.clone());
342 Ok(latest)
343}
344
345fn report_comparison(latest: &str) {
347 let installed = constants::VERSION;
348 match compare_versions(installed, latest) {
349 Some(Ordering::Less) => {
350 output::print_warning(&format!(
351 "Latest release: v{latest} — an upgrade is available."
352 ));
353 }
354 Some(Ordering::Equal) => {
355 output::print_success(&format!(
356 "Latest release: v{latest} — you are up to date."
357 ));
358 }
359 Some(Ordering::Greater) => {
360 output::print_info(&format!(
362 "Latest release: v{latest} — your build is newer than the last published one."
363 ));
364 }
365 None => {
366 output::print_info(&format!(
367 "Latest release: v{latest} (could not compare it to v{installed})."
368 ));
369 }
370 }
371}
372
373fn latest_release(timeout_secs: u64) -> Result<String> {
378 if crate::setup::offline_requested() {
379 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
380 }
381 let body = ureq::get(constants::LATEST_RELEASE_API_URL)
382 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
383 .header("Accept", "application/vnd.github+json")
384 .config()
385 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
386 .build()
387 .call()
388 .context("request failed")?
389 .body_mut()
390 .read_to_string()
391 .context("could not read the response")?;
392
393 let json: serde_json::Value =
394 serde_json::from_str(&body).context("the response was not JSON")?;
395 let tag = json
396 .get("tag_name")
397 .and_then(|v| v.as_str())
398 .context("the response carried no tag_name")?;
399
400 Ok(tag.trim_start_matches('v').to_string())
401}
402
403fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
409 let parse = |v: &str| -> Option<[u64; 3]> {
410 let core = v.split(['-', '+']).next()?;
411 let mut parts = core.split('.');
412 let out = [
413 parts.next()?.parse().ok()?,
414 parts.next()?.parse().ok()?,
415 parts.next()?.parse().ok()?,
416 ];
417 if parts.next().is_some() {
419 return None;
420 }
421 Some(out)
422 };
423 Some(parse(a)?.cmp(&parse(b)?))
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use chrono::Duration as ChronoDuration;
430
431 #[test]
432 fn orders_by_component_not_lexically() {
433 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
435 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
436 assert_eq!(
437 compare_versions("2.0.0", "1.99.99"),
438 Some(Ordering::Greater)
439 );
440 }
441
442 #[test]
443 fn pre_release_suffixes_compare_by_their_core() {
444 assert_eq!(
445 compare_versions("1.0.0", "1.0.0-rc.1"),
446 Some(Ordering::Equal)
447 );
448 assert_eq!(
449 compare_versions("1.0.0+build7", "1.0.1"),
450 Some(Ordering::Less)
451 );
452 }
453
454 #[test]
455 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
456 assert_eq!(compare_versions("1.0", "1.0.0"), None);
457 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
458 assert_eq!(compare_versions("nightly", "1.0.0"), None);
459 }
460
461 #[test]
462 fn the_check_is_on_unless_the_user_turns_it_off() {
463 assert!(Registry::default().settings.update_check);
464 }
465
466 #[test]
467 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
468 let mut registry = Registry::default();
469 registry.settings.update_check = false;
470 assert!(!notify_if_outdated(&mut registry));
471 assert!(registry.last_update_check.is_none());
472 }
473
474 #[test]
475 fn each_channel_is_recognised_by_its_marker_directory() {
476 use std::path::Path;
477 let cases: &[(&str, Channel)] = &[
478 ("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
479 (
480 "/usr/lib/node_modules/dev-prune/bin/dev-prune",
481 Channel::Npm,
482 ),
483 (
484 "/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
485 Channel::UvTool,
486 ),
487 (
488 "/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
489 Channel::Pipx,
490 ),
491 ("/opt/somewhere/dev-prune", Channel::Unknown),
492 ];
493 for (path, expected) in cases {
494 assert_eq!(detect_channel(Path::new(path), None), *expected, "{path}");
495 }
496 }
497
498 #[test]
499 fn the_managed_copy_wins_over_every_path_heuristic() {
500 use std::path::Path;
501 let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
503 assert_eq!(detect_channel(managed, Some(managed)), Channel::Installer);
504 }
505
506 #[test]
507 fn auto_update_is_off_by_default_and_silent_when_off() {
508 let registry = Registry::default();
509 assert!(!registry.settings.auto_update);
510 maybe_auto_update(®istry);
512 }
513
514 #[test]
515 fn a_recent_check_is_not_repeated() {
516 let mut registry = Registry::default();
517 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
518 registry.last_update_check = Some(stamp);
519 assert!(!notify_if_outdated(&mut registry));
521 assert_eq!(registry.last_update_check, Some(stamp));
522 }
523}