1use std::env;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17use std::time::Duration;
18
19use crate::store;
20
21const NO_CHECK: &str = "LORE_NO_UPDATE_CHECK";
23
24const CACHE: &str = ".lore-latest";
27
28const FRESH: Duration = Duration::from_secs(24 * 60 * 60);
30
31const REPOSITORY: &str = "https://github.com/alpcakin/lore";
33
34#[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 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 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
78pub fn status() -> String {
81 let current = Version::this_build();
82 let exe = env::current_exe().ok();
83 let latest = check().ok().flatten();
84 report(¤t, 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
108pub 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
118fn 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
130fn 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
157pub 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
203pub 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 fs::write(&cache, latest.unwrap_or_else(Version::this_build).label())?;
223 Ok(latest)
224}
225
226fn 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 #[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(¤t, &version("0.2.3"), exe).is_none());
260 assert!(advice(¤t, &version("0.2.99"), exe).is_none());
261 assert!(advice(¤t, &version("0.2.2"), exe).is_none());
262 assert!(advice(¤t, &version("0.1.9"), exe).is_none());
263
264 assert!(advice(¤t, &version("0.3.0"), exe).is_some());
265 assert!(advice(¤t, &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(¤t, &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(¤t, 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 let patch = report(¤t, 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(¤t, Some(current), Some(exe));
299 assert!(
300 current_release.contains("This is the newest release"),
301 "{current_release}"
302 );
303
304 let offline = report(¤t, 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}