Skip to main content

dev_prune/commands/
update.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune update`, and the periodic release check behind it.
5//
6// The check is opt-*out*. An out-of-date cleanup tool is a tool whose safety fixes you do
7// not have, so `devp update` asks GitHub for the latest release by default, and `devp
8// run` / `devp status` repeat that quietly at most once a week. Both are switched off by
9// `devp config set update_check false`, and `devp update --offline` skips a single run.
10//
11// What leaves the machine is one unauthenticated GET to the public releases endpoint. It
12// carries no identifier, no configuration, no repository paths and no usage data — the
13// only thing the server learns is that some copy of dev-prune asked what the latest
14// version is. Nothing else in the binary opens a socket. See `docs/PRIVACY.md`.
15//
16// The command deliberately does not download or install anything. Replacing a running
17// binary is the package manager's job, and doing it ourselves would mean writing to a
18// PATH directory with whatever privileges the user happened to have.
19
20use std::cmp::Ordering;
21use std::time::Duration;
22
23use anyhow::{Context, Result};
24use chrono::Utc;
25
26use crate::config::Registry;
27use crate::constants;
28use crate::output;
29
30pub fn run(offline: bool) -> Result<()> {
31    output::print_header("dev-prune version & upgrade");
32
33    output::print_info(&format!("Installed version: v{}", constants::VERSION));
34
35    if offline {
36        output::print_info("Skipping the release check because `--offline` was passed.");
37    } else if let Ok(mut registry) = Registry::load() {
38        if registry.settings.update_check {
39            // An explicit `devp update` always asks, regardless of when the last
40            // automatic check ran — the user is standing there waiting for the answer.
41            match refresh_latest(&mut registry) {
42                Ok(latest) => report_comparison(&latest),
43                // A failed check is not a failed command. Someone offline, behind a
44                // proxy, or hitting a rate limit still wants the upgrade instructions.
45                Err(e) => output::print_warning(&format!(
46                    "Could not reach the release API ({e}). The upgrade commands below still apply."
47                )),
48            }
49            let _ = registry.save();
50        } else {
51            output::print_info(
52                "The release check is off (`devp config set update_check true` re-enables it).",
53            );
54        }
55    }
56
57    println!();
58    println!("  Latest releases:  {}", constants::RELEASES_URL);
59    println!();
60    print_upgrade_commands();
61
62    Ok(())
63}
64
65/// Ask GitHub right now — no interval — and say where the installed build stands.
66///
67/// For `devp init`, which is deliberate and infrequent enough to be worth a round trip:
68/// setting a machine up is exactly the moment to learn the binary is a version behind.
69/// `devp run` deliberately does not use this; it goes through [`notify_if_outdated`],
70/// which is interval-gated so everyday work never waits on the network.
71///
72/// Returns `true` when the registry changed and needs saving.
73pub fn check_now(registry: &mut Registry) -> bool {
74    if !registry.settings.update_check {
75        return false;
76    }
77
78    match refresh_latest(registry) {
79        Ok(latest) => {
80            report_comparison(&latest);
81            if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
82                print_upgrade_commands();
83            }
84        }
85        // Not being able to reach GitHub is not a failed `init`.
86        Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
87    }
88    true
89}
90
91/// Every install channel, in one place so they cannot drift apart.
92fn print_upgrade_commands() {
93    println!("  Upgrade with whichever channel you installed from:");
94    println!("    cargo binstall dev-prune --force");
95    println!("    cargo install dev-prune --force");
96    println!("    npm install -g dev-prune@latest");
97    println!("    curl -fsSL https://devprune.vkrishna04.me/install.sh | sh");
98    println!("    iwr -useb https://devprune.vkrishna04.me/install.ps1 | iex");
99}
100
101/// Quietly keep the release check current and print a one-line notice when the installed
102/// build is behind. Returns `true` when the registry changed and needs saving.
103///
104/// Called from `devp run` and `devp status`. Never returns an error: a background
105/// convenience must not be able to fail the command the user actually asked for.
106pub fn notify_if_outdated(registry: &mut Registry) -> bool {
107    if !registry.settings.update_check {
108        return false;
109    }
110
111    let interval = registry.settings.update_check_interval_days;
112    let due = registry
113        .last_update_check
114        .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
115
116    if due {
117        // The result is deliberately ignored: `refresh_latest` moves the timestamp even
118        // when the request fails, and retrying on every command while the machine is
119        // offline would put a five-second stall in front of everyday work.
120        let _ = refresh_latest(registry);
121    }
122
123    if let Some(latest) = registry.latest_known_version.as_deref() {
124        if compare_versions(constants::VERSION, latest) == Some(Ordering::Less) {
125            output::print_info(&format!(
126                "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
127                 `devp config set update_check false` silences this.",
128                constants::VERSION
129            ));
130        }
131    }
132
133    due
134}
135
136/// Ask GitHub for the latest release and record the answer on the registry.
137///
138/// The caller is responsible for saving; that keeps this usable from both the
139/// already-loaded-registry path and the standalone command.
140fn refresh_latest(registry: &mut Registry) -> Result<String> {
141    let result = latest_release(registry.settings.update_check_timeout_secs);
142    registry.last_update_check = Some(Utc::now());
143    let latest = result?;
144    registry.latest_known_version = Some(latest.clone());
145    Ok(latest)
146}
147
148/// Say whether the installed build is behind, current, or ahead of the latest release.
149fn report_comparison(latest: &str) {
150    let installed = constants::VERSION;
151    match compare_versions(installed, latest) {
152        Some(Ordering::Less) => {
153            output::print_warning(&format!(
154                "Latest release:    v{latest} — an upgrade is available."
155            ));
156        }
157        Some(Ordering::Equal) => {
158            output::print_success(&format!(
159                "Latest release:    v{latest} — you are up to date."
160            ));
161        }
162        Some(Ordering::Greater) => {
163            // Normal when running a local build between releases.
164            output::print_info(&format!(
165                "Latest release:    v{latest} — your build is newer than the last published one."
166            ));
167        }
168        None => {
169            output::print_info(&format!(
170                "Latest release:    v{latest} (could not compare it to v{installed})."
171            ));
172        }
173    }
174}
175
176/// Fetch the tag name of the most recent published release.
177///
178/// Returns the version without any leading `v`, so it can be compared to
179/// `CARGO_PKG_VERSION` directly.
180fn latest_release(timeout_secs: u64) -> Result<String> {
181    let body = ureq::get(constants::LATEST_RELEASE_API_URL)
182        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
183        .header("Accept", "application/vnd.github+json")
184        .config()
185        .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
186        .build()
187        .call()
188        .context("request failed")?
189        .body_mut()
190        .read_to_string()
191        .context("could not read the response")?;
192
193    let json: serde_json::Value =
194        serde_json::from_str(&body).context("the response was not JSON")?;
195    let tag = json
196        .get("tag_name")
197        .and_then(|v| v.as_str())
198        .context("the response carried no tag_name")?;
199
200    Ok(tag.trim_start_matches('v').to_string())
201}
202
203/// Compare two dotted numeric versions, ignoring any pre-release suffix.
204///
205/// Returns `None` when either side is not `major.minor.patch` — better to say "could not
206/// compare" than to claim an upgrade exists because `1.0.0` sorts before `1.0.0-rc.1`
207/// as a string.
208fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
209    let parse = |v: &str| -> Option<[u64; 3]> {
210        let core = v.split(['-', '+']).next()?;
211        let mut parts = core.split('.');
212        let out = [
213            parts.next()?.parse().ok()?,
214            parts.next()?.parse().ok()?,
215            parts.next()?.parse().ok()?,
216        ];
217        // A fourth component means this is not the scheme we release under.
218        if parts.next().is_some() {
219            return None;
220        }
221        Some(out)
222    };
223    Some(parse(a)?.cmp(&parse(b)?))
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use chrono::Duration as ChronoDuration;
230
231    #[test]
232    fn orders_by_component_not_lexically() {
233        // "1.10.0" < "1.9.0" as strings, which is the bug this function exists to avoid.
234        assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
235        assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
236        assert_eq!(
237            compare_versions("2.0.0", "1.99.99"),
238            Some(Ordering::Greater)
239        );
240    }
241
242    #[test]
243    fn pre_release_suffixes_compare_by_their_core() {
244        assert_eq!(
245            compare_versions("1.0.0", "1.0.0-rc.1"),
246            Some(Ordering::Equal)
247        );
248        assert_eq!(
249            compare_versions("1.0.0+build7", "1.0.1"),
250            Some(Ordering::Less)
251        );
252    }
253
254    #[test]
255    fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
256        assert_eq!(compare_versions("1.0", "1.0.0"), None);
257        assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
258        assert_eq!(compare_versions("nightly", "1.0.0"), None);
259    }
260
261    #[test]
262    fn the_check_is_on_unless_the_user_turns_it_off() {
263        assert!(Registry::default().settings.update_check);
264    }
265
266    #[test]
267    fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
268        let mut registry = Registry::default();
269        registry.settings.update_check = false;
270        assert!(!notify_if_outdated(&mut registry));
271        assert!(registry.last_update_check.is_none());
272    }
273
274    #[test]
275    fn a_recent_check_is_not_repeated() {
276        let mut registry = Registry::default();
277        let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
278        registry.last_update_check = Some(stamp);
279        // No network call, so the stamp survives untouched and nothing needs saving.
280        assert!(!notify_if_outdated(&mut registry));
281        assert_eq!(registry.last_update_check, Some(stamp));
282    }
283}