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!("    curl -fsSL https://devprune.vkrishna04.me/install.sh | sh");
97    println!("    iwr -useb https://devprune.vkrishna04.me/install.ps1 | iex");
98}
99
100/// Quietly keep the release check current and print a one-line notice when the installed
101/// build is behind. Returns `true` when the registry changed and needs saving.
102///
103/// Called from `devp run` and `devp status`. Never returns an error: a background
104/// convenience must not be able to fail the command the user actually asked for.
105pub fn notify_if_outdated(registry: &mut Registry) -> bool {
106    if !registry.settings.update_check {
107        return false;
108    }
109
110    let interval = registry.settings.update_check_interval_days;
111    let due = registry
112        .last_update_check
113        .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
114
115    if due {
116        // The result is deliberately ignored: `refresh_latest` moves the timestamp even
117        // when the request fails, and retrying on every command while the machine is
118        // offline would put a five-second stall in front of everyday work.
119        let _ = refresh_latest(registry);
120    }
121
122    if let Some(latest) = registry.latest_known_version.as_deref() {
123        if compare_versions(constants::VERSION, latest) == Some(Ordering::Less) {
124            output::print_info(&format!(
125                "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
126                 `devp config set update_check false` silences this.",
127                constants::VERSION
128            ));
129        }
130    }
131
132    due
133}
134
135/// Ask GitHub for the latest release and record the answer on the registry.
136///
137/// The caller is responsible for saving; that keeps this usable from both the
138/// already-loaded-registry path and the standalone command.
139fn refresh_latest(registry: &mut Registry) -> Result<String> {
140    let result = latest_release(registry.settings.update_check_timeout_secs);
141    registry.last_update_check = Some(Utc::now());
142    let latest = result?;
143    registry.latest_known_version = Some(latest.clone());
144    Ok(latest)
145}
146
147/// Say whether the installed build is behind, current, or ahead of the latest release.
148fn report_comparison(latest: &str) {
149    let installed = constants::VERSION;
150    match compare_versions(installed, latest) {
151        Some(Ordering::Less) => {
152            output::print_warning(&format!(
153                "Latest release:    v{latest} — an upgrade is available."
154            ));
155        }
156        Some(Ordering::Equal) => {
157            output::print_success(&format!(
158                "Latest release:    v{latest} — you are up to date."
159            ));
160        }
161        Some(Ordering::Greater) => {
162            // Normal when running a local build between releases.
163            output::print_info(&format!(
164                "Latest release:    v{latest} — your build is newer than the last published one."
165            ));
166        }
167        None => {
168            output::print_info(&format!(
169                "Latest release:    v{latest} (could not compare it to v{installed})."
170            ));
171        }
172    }
173}
174
175/// Fetch the tag name of the most recent published release.
176///
177/// Returns the version without any leading `v`, so it can be compared to
178/// `CARGO_PKG_VERSION` directly.
179fn latest_release(timeout_secs: u64) -> Result<String> {
180    let body = ureq::get(constants::LATEST_RELEASE_API_URL)
181        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
182        .header("Accept", "application/vnd.github+json")
183        .config()
184        .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
185        .build()
186        .call()
187        .context("request failed")?
188        .body_mut()
189        .read_to_string()
190        .context("could not read the response")?;
191
192    let json: serde_json::Value =
193        serde_json::from_str(&body).context("the response was not JSON")?;
194    let tag = json
195        .get("tag_name")
196        .and_then(|v| v.as_str())
197        .context("the response carried no tag_name")?;
198
199    Ok(tag.trim_start_matches('v').to_string())
200}
201
202/// Compare two dotted numeric versions, ignoring any pre-release suffix.
203///
204/// Returns `None` when either side is not `major.minor.patch` — better to say "could not
205/// compare" than to claim an upgrade exists because `1.0.0` sorts before `1.0.0-rc.1`
206/// as a string.
207fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
208    let parse = |v: &str| -> Option<[u64; 3]> {
209        let core = v.split(['-', '+']).next()?;
210        let mut parts = core.split('.');
211        let out = [
212            parts.next()?.parse().ok()?,
213            parts.next()?.parse().ok()?,
214            parts.next()?.parse().ok()?,
215        ];
216        // A fourth component means this is not the scheme we release under.
217        if parts.next().is_some() {
218            return None;
219        }
220        Some(out)
221    };
222    Some(parse(a)?.cmp(&parse(b)?))
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use chrono::Duration as ChronoDuration;
229
230    #[test]
231    fn orders_by_component_not_lexically() {
232        // "1.10.0" < "1.9.0" as strings, which is the bug this function exists to avoid.
233        assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
234        assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
235        assert_eq!(
236            compare_versions("2.0.0", "1.99.99"),
237            Some(Ordering::Greater)
238        );
239    }
240
241    #[test]
242    fn pre_release_suffixes_compare_by_their_core() {
243        assert_eq!(
244            compare_versions("1.0.0", "1.0.0-rc.1"),
245            Some(Ordering::Equal)
246        );
247        assert_eq!(
248            compare_versions("1.0.0+build7", "1.0.1"),
249            Some(Ordering::Less)
250        );
251    }
252
253    #[test]
254    fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
255        assert_eq!(compare_versions("1.0", "1.0.0"), None);
256        assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
257        assert_eq!(compare_versions("nightly", "1.0.0"), None);
258    }
259
260    #[test]
261    fn the_check_is_on_unless_the_user_turns_it_off() {
262        assert!(Registry::default().settings.update_check);
263    }
264
265    #[test]
266    fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
267        let mut registry = Registry::default();
268        registry.settings.update_check = false;
269        assert!(!notify_if_outdated(&mut registry));
270        assert!(registry.last_update_check.is_none());
271    }
272
273    #[test]
274    fn a_recent_check_is_not_repeated() {
275        let mut registry = Registry::default();
276        let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
277        registry.last_update_check = Some(stamp);
278        // No network call, so the stamp survives untouched and nothing needs saving.
279        assert!(!notify_if_outdated(&mut registry));
280        assert_eq!(registry.last_update_check, Some(stamp));
281    }
282}