Skip to main content

abbaye/
updater.rs

1//! Self-update logic for the `abbaye self-update` command.
2//!
3//! Fetches the project's Atom feed, finds the latest release version,
4//! compares it against the running binary's version, and – unless `--check`
5//! was passed – downloads the appropriate pre-built binary and atomically
6//! replaces the current executable.
7
8use miette::{IntoDiagnostic, Result, miette};
9use semver::Version;
10use tracing::info;
11
12/// Compile-time target triple (e.g. `x86_64-unknown-linux-musl`).
13const TARGET: &str = env!("TARGET");
14/// Version baked in at compile time (e.g. `0.5.1`).
15const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
16/// Root URL of the release site.
17const BASE_URL: &str = "https://vit.am/~ololduck/abbaye";
18/// Atom feed URL.
19const FEED_URL: &str = "https://vit.am/~ololduck/abbaye/releases.atom";
20
21/// Extract all semver-parseable versions from the `<title>` elements inside
22/// `<entry>` blocks of an Atom feed.
23///
24/// The feed is generated by abbaye itself and has a predictable structure:
25/// each entry's title appears on its own line as `    <title>X.Y.Z</title>`.
26/// The feed-level `<title>Abbaye Releases</title>` will not parse as semver
27/// and is silently ignored.
28fn parse_versions_from_feed(xml: &str) -> Vec<Version> {
29    let mut versions = Vec::new();
30    let mut in_entry = false;
31
32    for line in xml.lines() {
33        let trimmed = line.trim();
34        if trimmed.starts_with("<entry>") {
35            in_entry = true;
36        } else if trimmed == "</entry>" {
37            in_entry = false;
38        } else if in_entry {
39            if let Some(rest) = trimmed.strip_prefix("<title>") {
40                if let Some(title) = rest.strip_suffix("</title>") {
41                    // Strip a leading 'v' in case the title ever includes it.
42                    let v = title.strip_prefix('v').unwrap_or(title);
43                    if let Ok(version) = Version::parse(v) {
44                        versions.push(version);
45                    }
46                }
47            }
48        }
49    }
50
51    versions
52}
53
54/// Check for a newer version and – unless `check_only` is `true` – download
55/// and atomically replace the running binary.
56///
57/// # Errors
58///
59/// Returns an error if the feed cannot be fetched or parsed, the download
60/// fails, or the binary cannot be replaced (e.g. insufficient permissions).
61pub async fn self_update(check_only: bool) -> Result<()> {
62    let current = Version::parse(CURRENT_VERSION).into_diagnostic()?;
63
64    info!("Checking for updates (current: v{current})…");
65    println!("Checking for updates (current: v{current})…");
66
67    let client = reqwest::Client::builder()
68        .user_agent(format!("abbaye/{CURRENT_VERSION}"))
69        .build()
70        .into_diagnostic()?;
71
72    let response = client.get(FEED_URL).send().await.into_diagnostic()?;
73
74    if !response.status().is_success() {
75        return Err(miette!(
76            "Failed to fetch release feed (HTTP {}): {FEED_URL}",
77            response.status()
78        ));
79    }
80
81    let feed_xml = response.text().await.into_diagnostic()?;
82    let mut versions = parse_versions_from_feed(&feed_xml);
83
84    if versions.is_empty() {
85        return Err(miette!(
86            "No versions found in the release feed at {FEED_URL}"
87        ));
88    }
89
90    versions.sort();
91    let latest = versions.into_iter().max().unwrap();
92
93    if latest <= current {
94        println!("Already up to date (v{current}).");
95        return Ok(());
96    }
97
98    println!("Update available: v{current} → v{latest}");
99
100    if check_only {
101        println!("Run `abbaye self-update` without --check to apply the update.");
102        return Ok(());
103    }
104
105    let exe_suffix = std::env::consts::EXE_SUFFIX; // ".exe" on Windows, "" elsewhere
106    let artifact_name = format!("abbaye-{latest}-{TARGET}{exe_suffix}");
107    let download_url = format!("{BASE_URL}/{latest}/dist/{artifact_name}");
108
109    println!("Downloading {artifact_name}…");
110    info!("Downloading from {download_url}");
111
112    let dl_response = client.get(&download_url).send().await.into_diagnostic()?;
113
114    if !dl_response.status().is_success() {
115        return Err(miette!(
116            "Download failed (HTTP {}): {download_url}",
117            dl_response.status()
118        ));
119    }
120
121    let bytes = dl_response.bytes().await.into_diagnostic()?;
122
123    // Using the same directory as the current exe guarantees we stay on the
124    // same filesystem, making the subsequent rename(2) atomic.
125    let current_exe = std::env::current_exe()
126        .into_diagnostic()?
127        .canonicalize()
128        .into_diagnostic()?;
129
130    let exe_dir = current_exe
131        .parent()
132        .ok_or_else(|| miette!("Could not determine the directory of the current executable"))?;
133
134    let tmp_path = exe_dir.join(format!("abbaye.new{exe_suffix}"));
135
136    std::fs::write(&tmp_path, &bytes).into_diagnostic()?;
137
138    // Make the new binary executable on Unix.
139    #[cfg(unix)]
140    {
141        use std::os::unix::fs::PermissionsExt;
142        let perms = std::fs::Permissions::from_mode(0o755);
143        std::fs::set_permissions(&tmp_path, perms).into_diagnostic()?;
144    }
145
146    // Atomically replace the running binary.
147    std::fs::rename(&tmp_path, &current_exe).into_diagnostic()?;
148
149    println!("Successfully updated to v{latest}!");
150    info!("Updated to v{latest}");
151
152    Ok(())
153}