1use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14use serde::{Deserialize, Serialize};
15
16use super::discover::{self, Discovery};
17
18pub const SUCCESS_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
20
21pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 60);
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Cache {
27 pub attempted: Option<String>,
29 pub succeeded: Option<String>,
31 pub latest: Option<String>,
33 pub error: Option<String>,
35}
36
37#[derive(Debug, Clone)]
39pub struct Paths {
40 pub cache: PathBuf,
42 pub lock: PathBuf,
44}
45
46impl Paths {
47 #[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#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Outcome {
61 Cached,
63 Refreshed,
65 Locked,
67 Unwritable(String),
69}
70
71impl Outcome {
72 #[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
84fn time(text: Option<&str>) -> Option<SystemTime> {
86 humantime::parse_rfc3339(text?).ok()
87}
88
89#[must_use]
91pub fn stamp(at: SystemTime) -> String {
92 humantime::format_rfc3339_seconds(at).to_string()
93}
94
95fn age(now: SystemTime, then: SystemTime) -> Duration {
97 now.duration_since(then).unwrap_or_default()
98}
99
100#[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#[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#[must_use]
135pub fn read(path: &Path) -> Option<Cache> {
136 serde_json::from_slice(&fs::read(path).ok()?).ok()
137}
138
139fn 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#[derive(Debug, Clone)]
148pub struct Run {
149 pub outcome: Outcome,
151 pub cache: Option<Cache>,
153 pub fetched: bool,
155}
156
157#[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
169fn 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 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 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 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#[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}