Skip to main content

lore/
update.rs

1//! Telling people a newer lore exists, without ever installing one.
2//!
3//! lore never replaces its own binary. A tool that quietly downloads and runs
4//! new code is a way in for anyone who takes over the project's releases, it
5//! fights whichever package manager installed it, and a keystroke that has to
6//! open a panel is no place to wait on a download. So this only ever prints a
7//! line naming the command the user would run themselves.
8//!
9//! Only a new first or second number is worth saying anything about. Patch
10//! releases are frequent and each one would be another line in front of
11//! somebody who did not ask.
12
13use std::env;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17use std::time::Duration;
18
19use crate::store;
20
21/// Set to anything to never look for a newer version.
22const NO_CHECK: &str = "LORE_NO_UPDATE_CHECK";
23
24/// Where the last answer is kept, so the picker reads a file rather than the
25/// network.
26const CACHE: &str = ".lore-latest";
27
28/// How long an answer is trusted before it is worth asking again.
29const FRESH: Duration = Duration::from_secs(24 * 60 * 60);
30
31/// The project's own repository, which is where releases are announced.
32const REPOSITORY: &str = "https://github.com/alpcakin/lore";
33
34/// A released version, compared by what the release numbers mean.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub struct Version {
37    major: u32,
38    minor: u32,
39    patch: u32,
40}
41
42impl std::fmt::Display for Version {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "{}", self.label())
45    }
46}
47
48impl Version {
49    /// Parses `1.2.3`, with or without a leading `v`. Anything else, such as
50    /// a release candidate, is not a version this offers to anyone.
51    pub fn parse(text: &str) -> Option<Self> {
52        let mut numbers = text.trim().trim_start_matches('v').split('.');
53        let mut next = || numbers.next()?.parse::<u32>().ok();
54
55        let version = Self {
56            major: next()?,
57            minor: next()?,
58            patch: next()?,
59        };
60        numbers.next().is_none().then_some(version)
61    }
62
63    pub fn this_build() -> Self {
64        Self::parse(env!("CARGO_PKG_VERSION")).expect("the crate's own version should parse")
65    }
66
67    /// Whether `self` is a release worth interrupting someone about: a new
68    /// first or second number, never a third.
69    fn worth_announcing_over(&self, current: &Self) -> bool {
70        (self.major, self.minor) > (current.major, current.minor)
71    }
72
73    fn label(&self) -> String {
74        format!("{}.{}.{}", self.major, self.minor, self.patch)
75    }
76}
77
78/// What `lore version` prints: this version, the newest release, and what to
79/// do about the difference.
80pub fn status() -> String {
81    let current = Version::this_build();
82    let exe = env::current_exe().ok();
83    let latest = check().ok().flatten();
84    report(&current, latest, exe.as_deref())
85}
86
87fn report(current: &Version, latest: Option<Version>, exe: Option<&Path>) -> String {
88    let mut lines = vec![format!("lore {current}")];
89
90    match latest {
91        None => {
92            lines.push("Could not check for a newer release".to_string());
93            lines.push(format!("Releases are listed at {REPOSITORY}/releases"));
94        }
95        Some(latest) if latest > *current => {
96            lines.push(format!("The newest release is {latest}"));
97            lines.push(match exe {
98                Some(exe) => upgrade_command(exe),
99                None => format!("Upgrade from {REPOSITORY}/releases"),
100            });
101        }
102        Some(_) => lines.push("This is the newest release".to_string()),
103    }
104
105    lines.join("\n")
106}
107
108/// The line to show, or nothing at all.
109pub fn notice() -> Option<String> {
110    if turned_off() {
111        return None;
112    }
113
114    let latest = Version::parse(&fs::read_to_string(cache().ok()?).ok()?)?;
115    advice(&Version::this_build(), &latest, &env::current_exe().ok()?)
116}
117
118/// What to tell someone running `current` when `latest` is out.
119fn advice(current: &Version, latest: &Version, exe: &Path) -> Option<String> {
120    if !latest.worth_announcing_over(current) {
121        return None;
122    }
123    Some(format!(
124        "lore {} is out. {}",
125        latest.label(),
126        upgrade_command(exe)
127    ))
128}
129
130/// How to upgrade an install that lives at `exe`.
131///
132/// Whoever installed lore through a package manager has to upgrade it through
133/// the same one, or the package manager is left believing something that is no
134/// longer true.
135fn upgrade_command(exe: &Path) -> String {
136    let path = exe.to_string_lossy().replace('\\', "/");
137
138    if path.contains("/Cellar/") || path.contains("/homebrew/") || path.contains("/linuxbrew/") {
139        "Run: brew upgrade lore".to_string()
140    } else if path.contains("/scoop/") {
141        "Run: scoop update lore".to_string()
142    } else if path.contains("/.cargo/") {
143        "Run: cargo install cmdlore --force".to_string()
144    } else {
145        format!("Upgrade from {REPOSITORY}/releases")
146    }
147}
148
149fn turned_off() -> bool {
150    env::var_os(NO_CHECK).is_some_and(|value| !value.is_empty())
151}
152
153fn cache() -> anyhow::Result<PathBuf> {
154    Ok(store::sync_dir()?.with_file_name(CACHE))
155}
156
157/// Looks for a newer release in the background, at most once a day.
158///
159/// Through `git ls-remote`, which reads the public tags of the repository
160/// without a token and without an API to be rate limited by. Where there is no
161/// git there is no check, which is the same as saying nothing.
162pub fn refresh_in_background() {
163    if turned_off() {
164        return;
165    }
166    let Ok(cache) = cache() else {
167        return;
168    };
169
170    let fresh = fs::metadata(&cache)
171        .and_then(|meta| meta.modified())
172        .is_ok_and(|at| at.elapsed().is_ok_and(|age| age < FRESH));
173    if fresh {
174        return;
175    }
176
177    let Ok(exe) = env::current_exe() else {
178        return;
179    };
180    let mut command = Command::new(exe);
181    command
182        .args(["check-update", "--background"])
183        .stdin(Stdio::null())
184        .stdout(Stdio::null())
185        .stderr(Stdio::null());
186
187    #[cfg(unix)]
188    {
189        use std::os::unix::process::CommandExt;
190        command.process_group(0);
191    }
192    #[cfg(windows)]
193    {
194        use std::os::windows::process::CommandExt;
195        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
196        const DETACHED_PROCESS: u32 = 0x0000_0008;
197        command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
198    }
199
200    let _ = command.spawn();
201}
202
203/// Asks the repository for its newest release and records the answer.
204pub fn check() -> anyhow::Result<Option<Version>> {
205    let cache = cache()?;
206    if let Some(parent) = cache.parent() {
207        fs::create_dir_all(parent)?;
208    }
209
210    let output = Command::new("git")
211        .args(["ls-remote", "--tags", "--refs", REPOSITORY, "v*"])
212        .env("GIT_TERMINAL_PROMPT", "0")
213        .stdin(Stdio::null())
214        .output()?;
215    if !output.status.success() {
216        return Ok(None);
217    }
218
219    let latest = newest(&String::from_utf8_lossy(&output.stdout));
220    // Written either way: an answer of "nothing newer" is still an answer, and
221    // recording it keeps the check to once a day rather than every time.
222    fs::write(&cache, latest.unwrap_or_else(Version::this_build).label())?;
223    Ok(latest)
224}
225
226/// The highest release in the output of `git ls-remote --tags`.
227fn newest(refs: &str) -> Option<Version> {
228    refs.lines()
229        .filter_map(|line| line.rsplit("refs/tags/").next())
230        .filter_map(Version::parse)
231        .max()
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn version(text: &str) -> Version {
239        Version::parse(text).expect("should parse")
240    }
241
242    #[test]
243    fn versions_are_read_and_ordered_by_what_they_mean() {
244        assert_eq!(version("v0.2.2"), version("0.2.2"));
245        assert!(version("0.10.0") > version("0.9.9"));
246        assert!(version("1.0.0") > version("0.99.99"));
247        assert!(Version::parse("0.2").is_none());
248        assert!(Version::parse("0.3.0-rc1").is_none());
249        assert!(Version::parse("nightly").is_none());
250    }
251
252    /// Patch releases go out often. Every one of them saying so in front of
253    /// somebody trying to find a command would be noise.
254    #[test]
255    fn only_a_new_first_or_second_number_is_worth_saying() {
256        let current = version("0.2.2");
257        let exe = Path::new("/opt/homebrew/Cellar/lore/0.2.2/bin/lore");
258
259        assert!(advice(&current, &version("0.2.3"), exe).is_none());
260        assert!(advice(&current, &version("0.2.99"), exe).is_none());
261        assert!(advice(&current, &version("0.2.2"), exe).is_none());
262        assert!(advice(&current, &version("0.1.9"), exe).is_none());
263
264        assert!(advice(&current, &version("0.3.0"), exe).is_some());
265        assert!(advice(&current, &version("1.0.0"), exe).is_some());
266    }
267
268    #[test]
269    fn the_advice_names_the_version_and_the_right_command() {
270        let current = version("0.2.2");
271        let said = |exe: &str| advice(&current, &version("0.3.0"), Path::new(exe)).unwrap();
272
273        assert!(said("/opt/homebrew/Cellar/lore/0.2.2/bin/lore").contains("lore 0.3.0 is out"));
274        assert!(said("/opt/homebrew/Cellar/lore/0.2.2/bin/lore").contains("brew upgrade lore"));
275        assert!(said("/home/alp/.cargo/bin/lore").contains("cargo install cmdlore --force"));
276        assert!(
277            said(r"C:\Users\alp\scoop\apps\lore\current\lore.exe").contains("scoop update lore")
278        );
279        assert!(said("/home/alp/.local/bin/lore").contains("releases"));
280    }
281
282    #[test]
283    fn the_version_command_says_where_you_stand() {
284        let current = version("0.2.2");
285        let exe = Path::new("/opt/homebrew/Cellar/lore/0.2.2/bin/lore");
286
287        let behind = report(&current, Some(version("0.3.0")), Some(exe));
288        assert!(behind.starts_with("lore 0.2.2\n"), "{behind}");
289        assert!(behind.contains("newest release is 0.3.0"), "{behind}");
290        assert!(behind.contains("brew upgrade lore"), "{behind}");
291
292        // A patch release says nothing in the picker, but somebody who asked
293        // outright is told about it.
294        let patch = report(&current, Some(version("0.2.3")), Some(exe));
295        assert!(patch.contains("newest release is 0.2.3"), "{patch}");
296        assert!(patch.contains("brew upgrade lore"), "{patch}");
297
298        let current_release = report(&current, Some(current), Some(exe));
299        assert!(
300            current_release.contains("This is the newest release"),
301            "{current_release}"
302        );
303
304        let offline = report(&current, None, Some(exe));
305        assert!(offline.contains("Could not check"), "{offline}");
306        assert!(offline.contains("/releases"), "{offline}");
307    }
308
309    #[test]
310    fn the_newest_tag_wins_whatever_order_they_arrive_in() {
311        let refs = "a1\trefs/tags/v0.1.0\nb2\trefs/tags/v0.10.1\nc3\trefs/tags/v0.9.0\nd4\trefs/tags/broken\n";
312        assert_eq!(newest(refs), Version::parse("0.10.1"));
313        assert!(newest("").is_none());
314    }
315}