返回 CodeWhale
launch.rs
根目录 / crates / release / src / launch.rs
1 //! Remembering which version last started, so the first launch after an
2 //! update can say what changed.
3 //!
4 //! An update is invisible from inside the TUI: the user runs `codewhale
5 //! update` in a shell, restarts, and lands in a session that looks exactly
6 //! like the one before it. The changelog exists (`/change` renders it, already
7 //! localized) but nothing points at it at the one moment it is relevant.
8 //!
9 //! This module supplies the missing edge. It keeps a single version string on
10 //! disk next to the update-check cache and compares it to the running binary
11 //! at startup.
12 //!
13 //! Three deliberate choices about when to stay quiet:
14 //!
15 //! * **A fresh install is not an update.** With no record on disk we write one
16 //! and say nothing: a first-run user has no previous version to have moved
17 //! from, and pointing them at a changelog for software they have never run
18 //! is noise.
19 //! * **Going backwards is not an update.** Bisecting a bug, or having two
20 //! installs on `PATH`, means older binaries run after newer ones. That is
21 //! not an event to celebrate, and a "what's new" pointer that appears while
22 //! you downgrade is actively confusing.
23 //! * **Unparseable versions are not compared.** A `-pre`/`-dev` suffix or a
24 //! locally patched version string means the comparison cannot be trusted;
25 //! an exact string change is still recorded, but only a real, parseable
26 //! increase produces a hint.
27 //!
28 //! Recording is best-effort: a read-only or full home directory costs the user
29 //! a hint, which is not worth an error dialog on startup. It is not, however,
30 //! silent -- [`record_launch`] hands the failure back in its result so the
31 //! caller can log it through whatever channel it already has. This crate is
32 //! reachable from the CLI updater before logging is initialized, which is why
33 //! it takes no `tracing` dependency of its own.
34
35 use std::path::{Path, PathBuf};
36
37 use anyhow::{Context, Result};
38 use semver::Version;
39 use serde::{Deserialize, Serialize};
40
41 /// Filename of the last-launch record, relative to the CodeWhale home
42 /// directory (`~/.codewhale/last-launch.json` by default).
43 pub const LAST_LAUNCH_FILE: &str = "last-launch.json";
44
45 /// What a startup recording found and whether it managed to persist.
46 #[derive(Debug)]
47 pub struct LaunchOutcome {
48 /// The upgrade the user just completed, if this launch was one.
49 pub change: Option<VersionChange>,
50 /// Why the record could not be written, if it could not be.
51 ///
52 /// The only consequence is a hint that will be offered again after the
53 /// next update; callers should log this, not surface it.
54 pub record_error: Option<anyhow::Error>,
55 }
56
57 /// The versions either side of an update the user has just completed.
58 #[derive(Debug, Clone, PartialEq, Eq)]
59 pub struct VersionChange {
60 /// The version recorded by the previous run.
61 pub previous: String,
62 /// The version running now.
63 pub current: String,
64 }
65
66 /// The version that last started CodeWhale on this machine.
67 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68 pub struct LastLaunch {
69 /// The `CARGO_PKG_VERSION` of the binary that last wrote this file.
70 pub version: String,
71 }
72
73 impl LastLaunch {
74 /// Read the record, returning `None` for "absent, unreadable, or corrupt".
75 ///
76 /// As with the update-check cache, a damaged file is indistinguishable
77 /// from no file: both mean "we do not know what ran last", and the safe
78 /// answer to that is silence plus a fresh record.
79 #[must_use]
80 pub fn load(path: &Path) -> Option<Self> {
81 let raw = std::fs::read_to_string(path).ok()?;
82 serde_json::from_str(&raw).ok()
83 }
84
85 /// Write the record atomically (temp file, then rename), creating the
86 /// parent directory if needed.
87 pub fn store(&self, path: &Path) -> Result<()> {
88 let parent = path.parent().filter(|dir| !dir.as_os_str().is_empty());
89 if let Some(dir) = parent {
90 std::fs::create_dir_all(dir)
91 .with_context(|| format!("failed to create {}", dir.display()))?;
92 }
93 let body = serde_json::to_vec_pretty(self).context("failed to serialize launch record")?;
94 let dir = parent.unwrap_or_else(|| Path::new("."));
95 let mut tmp = tempfile::NamedTempFile::new_in(dir).with_context(|| {
96 format!(
97 "failed to create launch-record temp file in {}",
98 dir.display()
99 )
100 })?;
101 use std::io::Write as _;
102 tmp.write_all(&body)
103 .with_context(|| format!("failed to write launch record for {}", path.display()))?;
104 tmp.flush()
105 .with_context(|| format!("failed to flush launch record for {}", path.display()))?;
106 // `persist` replaces an existing record on every platform
107 // (`MOVEFILE_REPLACE_EXISTING` on Windows) from a uniquely named temp
108 // file, so concurrent launches cannot clobber each other's partial
109 // writes and a failed write leaves no dangling `*.tmp` behind.
110 tmp.persist(path)
111 .map_err(|error| error.error)
112 .with_context(|| format!("failed to install {}", path.display()))?;
113 Ok(())
114 }
115 }
116
117 /// Resolve the record path inside a CodeWhale home directory.
118 #[must_use]
119 pub fn record_path_in(codewhale_home: &Path) -> PathBuf {
120 codewhale_home.join(LAST_LAUNCH_FILE)
121 }
122
123 /// Compare a stored version against the running one.
124 ///
125 /// Split out from [`record_launch`] so the decision is testable without
126 /// touching the filesystem.
127 #[must_use]
128 pub fn version_change(previous: Option<&str>, current: &str) -> Option<VersionChange> {
129 let previous = previous?;
130 if previous == current {
131 return None;
132 }
133 // Both sides must parse for the comparison to mean anything. A version we
134 // cannot order is a version we cannot claim moved forward.
135 let (Ok(before), Ok(now)) = (Version::parse(previous), Version::parse(current)) else {
136 return None;
137 };
138 if now <= before {
139 return None;
140 }
141 Some(VersionChange {
142 previous: previous.to_string(),
143 current: current.to_string(),
144 })
145 }
146
147 /// Record that `current` is running, and report whether that is an upgrade
148 /// over whatever ran last.
149 ///
150 /// The record is rewritten whenever it does not already name `current`,
151 /// including on downgrade and on the unparseable versions that never produce a
152 /// hint. Storing the version that actually ran — rather than the highest ever
153 /// seen — keeps the file a truthful answer to "what ran last", and stops a
154 /// single downgrade-then-upgrade cycle from re-announcing a version the user
155 /// has already been shown.
156 ///
157 /// A failed write is reported alongside the answer rather than replacing it:
158 /// the comparison has already been made by that point and is still true.
159 pub fn record_launch(codewhale_home: &Path, current: &str) -> LaunchOutcome {
160 let path = record_path_in(codewhale_home);
161 let previous = LastLaunch::load(&path).map(|record| record.version);
162 let change = version_change(previous.as_deref(), current);
163
164 let record_error = if previous.as_deref() == Some(current) {
165 None
166 } else {
167 LastLaunch {
168 version: current.to_string(),
169 }
170 .store(&path)
171 .err()
172 };
173
174 LaunchOutcome {
175 change,
176 record_error,
177 }
178 }
179
180 #[cfg(test)]
181 mod tests {
182 use super::*;
183
184 #[test]
185 fn a_forward_version_move_is_an_update() {
186 assert_eq!(
187 version_change(Some("0.9.10"), "0.9.11"),
188 Some(VersionChange {
189 previous: "0.9.10".to_string(),
190 current: "0.9.11".to_string(),
191 })
192 );
193 }
194
195 #[test]
196 fn the_same_version_is_not_an_update() {
197 assert_eq!(version_change(Some("0.9.11"), "0.9.11"), None);
198 }
199
200 #[test]
201 fn a_first_ever_launch_is_not_an_update() {
202 assert_eq!(version_change(None, "0.9.11"), None);
203 }
204
205 #[test]
206 fn a_downgrade_is_not_an_update() {
207 assert_eq!(version_change(Some("0.9.11"), "0.9.10"), None);
208 }
209
210 #[test]
211 fn a_major_or_minor_move_counts_as_much_as_a_patch() {
212 assert!(version_change(Some("0.9.11"), "0.10.0").is_some());
213 assert!(version_change(Some("0.9.11"), "1.0.0").is_some());
214 }
215
216 // Ordering by string would rank "0.9.9" above "0.9.10" and suppress the
217 // hint on the release where the patch number gains a digit.
218 #[test]
219 fn double_digit_patches_order_numerically_not_lexically() {
220 assert!(version_change(Some("0.9.9"), "0.9.10").is_some());
221 assert_eq!(version_change(Some("0.9.10"), "0.9.9"), None);
222 }
223
224 #[test]
225 fn a_prerelease_sorts_below_the_release_it_precedes() {
226 assert!(version_change(Some("0.9.11-pre"), "0.9.11").is_some());
227 assert_eq!(version_change(Some("0.9.11"), "0.9.11-pre"), None);
228 }
229
230 #[test]
231 fn an_unparseable_version_on_either_side_produces_no_hint() {
232 assert_eq!(version_change(Some("not-a-version"), "0.9.11"), None);
233 assert_eq!(version_change(Some("0.9.10"), "also-not-a-version"), None);
234 }
235
236 #[test]
237 fn a_first_launch_records_the_version_without_claiming_an_update() {
238 let home = tempfile::tempdir().expect("tempdir");
239 let outcome = record_launch(home.path(), "0.9.10");
240 assert_eq!(outcome.change, None);
241 assert!(outcome.record_error.is_none());
242 assert_eq!(
243 LastLaunch::load(&record_path_in(home.path())).map(|r| r.version),
244 Some("0.9.10".to_string())
245 );
246 }
247
248 #[test]
249 fn the_hint_fires_once_and_not_again_on_the_next_launch() {
250 let home = tempfile::tempdir().expect("tempdir");
251 record_launch(home.path(), "0.9.10");
252 assert!(record_launch(home.path(), "0.9.11").change.is_some());
253 assert_eq!(record_launch(home.path(), "0.9.11").change, None);
254 }
255
256 // The record answers "what ran last", so a downgrade must overwrite it.
257 // Keeping the high-water mark instead would swallow the hint when the user
258 // returns to the newer build.
259 #[test]
260 fn a_downgrade_rewrites_the_record_so_the_return_trip_still_hints() {
261 let home = tempfile::tempdir().expect("tempdir");
262 record_launch(home.path(), "0.9.11");
263 assert_eq!(record_launch(home.path(), "0.9.10").change, None);
264 assert_eq!(
265 LastLaunch::load(&record_path_in(home.path())).map(|r| r.version),
266 Some("0.9.10".to_string())
267 );
268 assert!(record_launch(home.path(), "0.9.11").change.is_some());
269 }
270
271 #[test]
272 fn a_corrupt_record_is_replaced_rather_than_reported() {
273 let home = tempfile::tempdir().expect("tempdir");
274 let path = record_path_in(home.path());
275 std::fs::write(&path, b"{ not json").expect("seed corrupt record");
276 assert_eq!(record_launch(home.path(), "0.9.11").change, None);
277 assert_eq!(
278 LastLaunch::load(&path).map(|r| r.version),
279 Some("0.9.11".to_string())
280 );
281 }
282
283 #[test]
284 fn store_replaces_an_existing_record() {
285 let home = tempfile::tempdir().expect("tempdir");
286 let path = record_path_in(home.path());
287 LastLaunch {
288 version: "0.9.10".to_string(),
289 }
290 .store(&path)
291 .expect("seed record");
292 LastLaunch {
293 version: "0.9.11".to_string(),
294 }
295 .store(&path)
296 .expect("replace record");
297 assert_eq!(
298 LastLaunch::load(&path).map(|record| record.version),
299 Some("0.9.11".to_string())
300 );
301 }
302
303 // A home that cannot be written costs the user a hint, not a startup
304 // failure -- the version comparison itself must still be answered.
305 #[cfg(unix)]
306 #[test]
307 fn an_unwritable_home_reports_the_failure_and_still_answers() {
308 use std::os::unix::fs::PermissionsExt;
309 let home = tempfile::tempdir().expect("tempdir");
310 let path = record_path_in(home.path());
311 LastLaunch {
312 version: "0.9.10".to_string(),
313 }
314 .store(&path)
315 .expect("seed record");
316 let mut perms = std::fs::metadata(home.path())
317 .expect("metadata")
318 .permissions();
319 perms.set_mode(0o500);
320 std::fs::set_permissions(home.path(), perms).expect("chmod");
321
322 let outcome = record_launch(home.path(), "0.9.11");
323
324 let mut perms = std::fs::metadata(home.path())
325 .expect("metadata")
326 .permissions();
327 perms.set_mode(0o700);
328 std::fs::set_permissions(home.path(), perms).expect("restore chmod");
329
330 assert!(outcome.change.is_some(), "the comparison still holds");
331 assert!(
332 outcome.record_error.is_some(),
333 "an unwritable home must be reported, not swallowed"
334 );
335 }
336 }
337
337 lines RUST