Skip to main content

release_kit/self_depend/
check.rs

1//! The version check the `.envrc` line runs on every directory entry.
2//!
3//! It reads one cache file per checkout and reads the network only when a
4//! refresh is due: seven days after the last success, or one hour after a
5//! failed attempt. A due refresh takes an OS file lock beside the cache
6//! first; a check that finds the lock held reads the cache it has, because
7//! another process is refreshing. It writes no file but its cache and the
8//! lock beside it, and it never moves a pin.
9
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14use serde::{Deserialize, Serialize};
15
16use super::discover::{self, Discovery};
17
18/// How long a success stays fresh.
19pub const SUCCESS_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
20
21/// How long a failed attempt waits before the next one.
22pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 60);
23
24/// One checkout's cached answer.
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Cache {
27    /// When the last attempt started, RFC 3339 UTC.
28    pub attempted: Option<String>,
29    /// When the last successful attempt finished, RFC 3339 UTC.
30    pub succeeded: Option<String>,
31    /// The latest release that success found.
32    pub latest: Option<String>,
33    /// Why the last attempt failed, where it failed.
34    pub error: Option<String>,
35}
36
37/// The cache and its lock for one checkout.
38#[derive(Debug, Clone)]
39pub struct Paths {
40    /// The cache file.
41    pub cache: PathBuf,
42    /// The lock file beside it.
43    pub lock: PathBuf,
44}
45
46impl Paths {
47    /// The paths for the checkout `key`, where a cache root resolves.
48    #[must_use]
49    pub fn for_key(key: &str) -> Option<Self> {
50        let dir = super::check_cache_dir()?;
51        Some(Self {
52            cache: dir.join(format!("{key}.json")),
53            lock: dir.join(format!("{key}.lock")),
54        })
55    }
56}
57
58/// What one check did.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Outcome {
61    /// The cache answered; no refresh was due.
62    Cached,
63    /// A refresh read the network and recorded its answer.
64    Refreshed,
65    /// A refresh was due, and another process held the lock.
66    Locked,
67    /// The cache cannot be written, so nothing was read.
68    Unwritable(String),
69}
70
71impl Outcome {
72    /// The outcome's word in the machine report.
73    #[must_use]
74    pub const fn word(&self) -> &'static str {
75        match self {
76            Self::Cached => "cached",
77            Self::Refreshed => "refreshed",
78            Self::Locked => "locked",
79            Self::Unwritable(_) => "unwritable",
80        }
81    }
82}
83
84/// A parsed time, or `None` for an absent or unreadable one.
85fn time(text: Option<&str>) -> Option<SystemTime> {
86    humantime::parse_rfc3339(text?).ok()
87}
88
89/// RFC 3339 UTC to the second.
90#[must_use]
91pub fn stamp(at: SystemTime) -> String {
92    humantime::format_rfc3339_seconds(at).to_string()
93}
94
95/// How long ago `then` was, zero for a time in the future.
96fn age(now: SystemTime, then: SystemTime) -> Duration {
97    now.duration_since(then).unwrap_or_default()
98}
99
100/// Whether the cache asks for a network read at `now`.
101///
102/// After an attempt that failed, or that never finished, a read is due
103/// only one hour after that attempt, whatever an older success says. An
104/// attempt never finished where it is later than the last success, or
105/// where no success exists, and no error was recorded. Otherwise a read is
106/// due where the cache is missing, holds no success, or holds a success
107/// seven days old or more. Times compare as times, and an unreadable time
108/// counts as missing.
109#[must_use]
110pub fn due(cache: Option<&Cache>, now: SystemTime) -> bool {
111    let Some(cache) = cache else {
112        return true;
113    };
114    let attempted = time(cache.attempted.as_deref());
115    let succeeded = time(cache.succeeded.as_deref());
116    let unfinished = match (attempted, succeeded) {
117        (Some(attempted), Some(succeeded)) => attempted > succeeded,
118        (Some(_), None) => true,
119        (None, _) => false,
120    };
121    if cache.error.is_some() || unfinished {
122        return attempted.is_none_or(|attempted| age(now, attempted) >= RETRY_INTERVAL);
123    }
124    succeeded.is_none_or(|succeeded| age(now, succeeded) >= SUCCESS_INTERVAL)
125}
126
127/// When the next attempt after a failure may read the network.
128#[must_use]
129pub fn next_try(cache: &Cache) -> Option<String> {
130    time(cache.attempted.as_deref()).map(|attempted| stamp(attempted + RETRY_INTERVAL))
131}
132
133/// The cache at `path`, or `None` where it is absent or unreadable.
134#[must_use]
135pub fn read(path: &Path) -> Option<Cache> {
136    serde_json::from_slice(&fs::read(path).ok()?).ok()
137}
138
139/// Write the cache atomically.
140fn write(path: &Path, cache: &Cache) -> std::io::Result<()> {
141    let mut bytes = serde_json::to_vec_pretty(cache).map_err(std::io::Error::other)?;
142    bytes.push(b'\n');
143    crate::atomic::write(path, &bytes)
144}
145
146/// What one run of the check found.
147#[derive(Debug, Clone)]
148pub struct Run {
149    /// What the run did.
150    pub outcome: Outcome,
151    /// The cache the warnings read from.
152    pub cache: Option<Cache>,
153    /// Whether this run read the network, whatever it could record.
154    pub fetched: bool,
155}
156
157/// Run the check for one checkout: read the cache, and refresh it where a
158/// refresh is due and the lock is free.
159#[must_use]
160pub fn run(paths: &Paths, now: SystemTime) -> Run {
161    let (outcome, cache, fetched) = refresh(paths, now);
162    Run {
163        outcome,
164        cache,
165        fetched,
166    }
167}
168
169/// The refresh behind [`run`], as the outcome, the cache, and whether the
170/// network was read.
171fn refresh(paths: &Paths, now: SystemTime) -> (Outcome, Option<Cache>, bool) {
172    let cached = read(&paths.cache);
173    if !due(cached.as_ref(), now) {
174        return (Outcome::Cached, cached, false);
175    }
176    if let Some(parent) = paths.cache.parent()
177        && let Err(source) = fs::create_dir_all(parent)
178    {
179        return (Outcome::Unwritable(source.to_string()), cached, false);
180    }
181    let lock = match fs::OpenOptions::new()
182        .create(true)
183        .truncate(false)
184        .write(true)
185        .open(&paths.lock)
186    {
187        Ok(lock) => lock,
188        Err(source) => return (Outcome::Unwritable(source.to_string()), cached, false),
189    };
190    // An OS lock, released when the file closes, so a process that dies
191    // holding it frees it: no age, no owner file, and no takeover.
192    match lock.try_lock() {
193        Ok(()) => {}
194        Err(fs::TryLockError::WouldBlock) => return (Outcome::Locked, cached, false),
195        Err(fs::TryLockError::Error(source)) => {
196            return (Outcome::Unwritable(source.to_string()), cached, false);
197        }
198    }
199    // Another process may have refreshed between the read and the lock.
200    let cached = read(&paths.cache);
201    if !due(cached.as_ref(), now) {
202        return (Outcome::Cached, cached, false);
203    }
204    let mut cache = cached.unwrap_or_default();
205    cache.attempted = Some(stamp(now));
206    // The attempt is recorded before the read, so a read that hangs or
207    // dies still postpones the next one. A cache that cannot be written
208    // reads nothing.
209    if let Err(source) = write(&paths.cache, &cache) {
210        return (
211            Outcome::Unwritable(source.to_string()),
212            read(&paths.cache),
213            false,
214        );
215    }
216    match discover::latest_tag() {
217        Discovery::Tag(tag) => {
218            cache.succeeded = Some(stamp(SystemTime::now()));
219            cache.latest = Some(tag);
220            cache.error = None;
221        }
222        Discovery::Unreachable(reason) => cache.error = Some(reason),
223        Discovery::Unparsable(answer) => {
224            cache.error = Some(format!("the release page answered no tag: {answer}"));
225        }
226    }
227    if let Err(source) = write(&paths.cache, &cache) {
228        return (Outcome::Unwritable(source.to_string()), Some(cache), true);
229    }
230    drop(lock);
231    (Outcome::Refreshed, Some(cache), true)
232}
233
234/// The warnings one check prints, from the cache it read, for the pin
235/// `pin`.
236#[must_use]
237pub fn warnings(
238    pin: Option<&str>,
239    cache: Option<&Cache>,
240    outcome: &Outcome,
241    path: &Path,
242) -> Vec<String> {
243    let mut lines = Vec::new();
244    if let Outcome::Unwritable(reason) = outcome {
245        lines.push(format!(
246            "rk cannot record its version check at {}: {reason}.",
247            path.display()
248        ));
249    }
250    let Some(cache) = cache else {
251        return lines;
252    };
253    if let (Some(pin), Some(latest)) = (pin, cache.latest.as_deref())
254        && discover::version_order(latest, pin).is_gt()
255    {
256        lines.push(format!(
257            "rk {} is out, this project pins {}. To move: rk self-depend sync --apply",
258            latest.trim_start_matches('v'),
259            pin.trim_start_matches('v')
260        ));
261    }
262    if let Some(error) = &cache.error {
263        lines.push(format!(
264            "rk could not check for a newer release: {error}. Next try after {}.",
265            next_try(cache).unwrap_or_else(|| "the next entry".to_owned())
266        ));
267    }
268    lines
269}
270
271#[cfg(test)]
272mod tests {
273    use std::time::{Duration, SystemTime};
274
275    use super::{Cache, due, stamp};
276
277    fn ago(secs: u64) -> String {
278        stamp(SystemTime::now() - Duration::from_secs(secs))
279    }
280
281    #[test]
282    fn a_success_is_fresh_for_seven_days() {
283        let now = SystemTime::now();
284        let cache = |secs| Cache {
285            attempted: Some(ago(secs)),
286            succeeded: Some(ago(secs)),
287            latest: Some("v0.9.15".to_owned()),
288            error: None,
289        };
290        assert!(!due(Some(&cache(6 * 86_400)), now));
291        assert!(due(Some(&cache(7 * 86_400)), now));
292        assert!(due(None, now));
293    }
294
295    #[test]
296    fn a_failure_waits_one_hour_whatever_the_success() {
297        let now = SystemTime::now();
298        let cache = |failed| Cache {
299            attempted: Some(ago(failed)),
300            succeeded: Some(ago(8 * 86_400)),
301            latest: Some("v0.9.15".to_owned()),
302            error: Some("no answer".to_owned()),
303        };
304        assert!(!due(Some(&cache(60)), now));
305        assert!(!due(Some(&cache(59 * 60)), now));
306        assert!(due(Some(&cache(60 * 60)), now));
307    }
308
309    #[test]
310    fn an_unfinished_attempt_waits_one_hour() {
311        let now = SystemTime::now();
312        let first = |secs| Cache {
313            attempted: Some(ago(secs)),
314            ..Cache::default()
315        };
316        assert!(!due(Some(&first(60)), now));
317        assert!(due(Some(&first(60 * 60)), now));
318        let overdue = |secs| Cache {
319            attempted: Some(ago(secs)),
320            succeeded: Some(ago(8 * 86_400)),
321            latest: Some("v0.9.15".to_owned()),
322            error: None,
323        };
324        assert!(!due(Some(&overdue(60)), now));
325        assert!(due(Some(&overdue(60 * 60)), now));
326    }
327
328    #[test]
329    fn an_unreadable_time_counts_as_missing() {
330        let cache = Cache {
331            attempted: Some("yesterday".to_owned()),
332            succeeded: Some("last week".to_owned()),
333            latest: None,
334            error: None,
335        };
336        assert!(due(Some(&cache), SystemTime::now()));
337    }
338}