Skip to content

Commit 7ee4557

Browse files
committed
wip: refactor stuff to have less global mutability, move optimistic cache logic to cache Drop impl
1 parent 70b2b18 commit 7ee4557

File tree

4 files changed

+202
-147
lines changed

4 files changed

+202
-147
lines changed

src/cache.rs

Lines changed: 155 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
use std::collections::{BTreeMap, HashMap};
22
use std::ffi::OsString;
33
use std::fs::{self, File};
4-
use std::io::{self, BufWriter, Cursor, Write};
4+
use std::io::{self, BufWriter, Cursor, Read, Write};
55
use std::path::{Path, PathBuf};
6-
use std::time::Duration;
6+
use std::time::{Duration, SystemTime};
77

88
use once_cell::unsync::OnceCell;
99
use ureq::tls::{RootCerts, TlsConfig};
1010
use yansi::Paint;
1111
use zip::ZipArchive;
1212

13-
use crate::config::Config;
13+
use crate::config::CacheConfig;
1414
use crate::error::{Error, Result};
1515
use crate::util::{self, info_end, info_start, infoln, warnln, Dedup};
1616

@@ -19,18 +19,43 @@ const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), '/', env!("CARGO_PKG_VE
1919

2020
type PagesArchive = ZipArchive<Cursor<Vec<u8>>>;
2121

22-
pub struct Cache<'a> {
23-
dir: &'a Path,
22+
pub struct Cache {
2423
platforms: OnceCell<Vec<OsString>>,
25-
age: OnceCell<Duration>,
24+
cfg: CacheConfig,
25+
update_cache_on_drop: bool,
26+
}
27+
impl Drop for Cache {
28+
fn drop(&mut self) {
29+
if self.update_cache_on_drop {
30+
let _ = (|| -> Result<()> {
31+
// if cache is no longer stale
32+
if !self.is_stale()? {
33+
return Ok(());
34+
}
35+
infoln!("cache is stale, updating...");
36+
if let Err(e) = self
37+
.update()
38+
.map_err(|e| e.describe(Error::DESC_AUTO_UPDATE_ERR))
39+
{
40+
// use this only for printing
41+
e.exit_code();
42+
}
43+
Ok(())
44+
})();
45+
}
46+
}
2647
}
2748

28-
impl<'a> Cache<'a> {
29-
pub fn new(dir: &'a Path) -> Self {
49+
impl Cache {
50+
pub fn new(mut cfg: CacheConfig) -> Self {
51+
// Sort to always download archives in alphabetical order.
52+
cfg.languages.sort_unstable();
53+
// The user can put duplicates in the config file.
54+
cfg.languages.dedup();
3055
Self {
31-
dir,
3256
platforms: OnceCell::new(),
33-
age: OnceCell::new(),
57+
cfg,
58+
update_cache_on_drop: false,
3459
}
3560
}
3661

@@ -41,7 +66,7 @@ impl<'a> Cache<'a> {
4166

4267
/// Return `true` if the specified subdirectory exists in the cache.
4368
pub fn subdir_exists(&self, sd: &str) -> bool {
44-
self.dir.join(sd).is_dir()
69+
self.cfg.dir.join(sd).is_dir()
4570
}
4671

4772
/// Send a GET request with the provided agent and return the response body.
@@ -76,11 +101,7 @@ impl<'a> Cache<'a> {
76101
}
77102

78103
/// Download tldr pages archives for directories that are out of date and update the checksum file.
79-
fn download_and_verify(
80-
&self,
81-
mirror: &str,
82-
languages: &[String],
83-
) -> Result<BTreeMap<String, PagesArchive>> {
104+
fn download_and_verify(&self) -> Result<BTreeMap<String, PagesArchive>> {
84105
let agent = ureq::Agent::config_builder()
85106
.user_agent(USER_AGENT)
86107
.timeout_global(Some(Duration::from_secs(30)))
@@ -92,17 +113,25 @@ impl<'a> Cache<'a> {
92113
.build()
93114
.into();
94115

95-
let sums = Self::get_asset(&agent, &format!("{mirror}/tldr.sha256sums"))?;
116+
let sums = Self::get_asset(&agent, &format!("{}/tldr.sha256sums", self.cfg.mirror))?;
96117
let sums_str = String::from_utf8_lossy(&sums);
97118
let sum_map = Self::parse_sumfile(&sums_str)?;
98119

99-
let old_sumfile_path = self.dir.join("tldr.sha256sums");
100-
let old_sums = fs::read_to_string(&old_sumfile_path).unwrap_or_default();
120+
let old_sumfile_path = self.cfg.dir.join("tldr.sha256sums");
121+
let mut old_sumfile = fs::File::open(&old_sumfile_path);
122+
let old_sums = old_sumfile
123+
.as_mut()
124+
.map(|f| {
125+
let mut s = String::new();
126+
let _ = f.read_to_string(&mut s);
127+
s
128+
})
129+
.unwrap_or_default();
101130
let old_sum_map = Self::parse_sumfile(&old_sums).unwrap_or_default();
102131

103132
let mut langdir_archive_map = BTreeMap::new();
104133

105-
for lang in languages {
134+
for lang in &self.cfg.languages {
106135
let lang = &**lang;
107136
let Some(sum) = sum_map.get(lang) else {
108137
// Skip nonexistent languages.
@@ -112,10 +141,17 @@ impl<'a> Cache<'a> {
112141
let lang_dir = format!("pages.{lang}");
113142
if Some(sum) == old_sum_map.get(lang) && self.subdir_exists(&lang_dir) {
114143
infoln!("'pages.{lang}' is up to date");
144+
// update modified to current timestamp to refresh cache age
145+
if let Ok(ref mut file) = old_sumfile {
146+
file.set_modified(SystemTime::now())?;
147+
}
115148
continue;
116149
}
117150

118-
let archive = Self::get_asset(&agent, &format!("{mirror}/tldr-pages.{lang}.zip"))?;
151+
let archive = Self::get_asset(
152+
&agent,
153+
&format!("{}/tldr-pages.{}.zip", self.cfg.mirror, lang),
154+
)?;
119155
info_start!("validating sha256sums... ");
120156
let actual_sum = util::sha256_hexdigest(&archive);
121157

@@ -133,7 +169,7 @@ impl<'a> Cache<'a> {
133169
langdir_archive_map.insert(lang_dir, ZipArchive::new(Cursor::new(archive))?);
134170
}
135171

136-
fs::create_dir_all(self.dir)?;
172+
fs::create_dir_all(&self.cfg.dir)?;
137173
File::create(&old_sumfile_path)?.write_all(&sums)?;
138174

139175
Ok(langdir_archive_map)
@@ -199,7 +235,7 @@ impl<'a> Cache<'a> {
199235
continue;
200236
}
201237

202-
let path = self.dir.join(lang_dir).join(&fname);
238+
let path = self.cfg.dir.join(lang_dir).join(&fname);
203239

204240
if zipfile.is_dir() {
205241
fs::create_dir_all(&path)?;
@@ -226,13 +262,8 @@ impl<'a> Cache<'a> {
226262
}
227263

228264
/// Delete the old cache and replace it with a fresh copy.
229-
pub fn update(&self, mirror: &str, languages: &mut Vec<String>) -> Result<()> {
230-
// Sort to always download archives in alphabetical order.
231-
languages.sort_unstable();
232-
// The user can put duplicates in the config file.
233-
languages.dedup();
234-
235-
let archives = self.download_and_verify(mirror, languages)?;
265+
pub fn update(&self) -> Result<()> {
266+
let archives = self.download_and_verify()?;
236267

237268
if archives.is_empty() {
238269
infoln!(
@@ -249,7 +280,7 @@ impl<'a> Cache<'a> {
249280
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
250281
let n_existing = self.list_all_vec(&lang_dir).map(|v| v.len()).unwrap_or(0) as i32;
251282

252-
let lang_dir_full = self.dir.join(&lang_dir);
283+
let lang_dir_full = self.cfg.dir.join(&lang_dir);
253284
if lang_dir_full.is_dir() {
254285
fs::remove_dir_all(&lang_dir_full)?;
255286
}
@@ -277,15 +308,15 @@ impl<'a> Cache<'a> {
277308

278309
/// Delete the cache directory.
279310
pub fn clean(&self) -> Result<()> {
280-
if !self.dir.is_dir() {
311+
if !self.cfg.dir.is_dir() {
281312
infoln!("cache does not exist, not cleaning.");
282-
fs::create_dir_all(self.dir)?;
313+
fs::create_dir_all(&self.cfg.dir)?;
283314
return Ok(());
284315
}
285316

286317
infoln!("cleaning the cache directory...");
287-
fs::remove_dir_all(self.dir)?;
288-
fs::create_dir_all(self.dir)?;
318+
fs::remove_dir_all(&self.cfg.dir)?;
319+
fs::create_dir_all(&self.cfg.dir)?;
289320

290321
Ok(())
291322
}
@@ -296,7 +327,7 @@ impl<'a> Cache<'a> {
296327
.get_or_try_init(|| {
297328
let mut result = vec![];
298329

299-
for entry in fs::read_dir(self.dir.join(ENGLISH_DIR))? {
330+
for entry in fs::read_dir(self.cfg.dir.join(ENGLISH_DIR))? {
300331
let entry = entry?;
301332
let path = entry.path();
302333
let platform = path.file_name().unwrap();
@@ -339,7 +370,7 @@ impl<'a> Cache<'a> {
339370
P: AsRef<Path>,
340371
{
341372
for lang_dir in lang_dirs {
342-
let path = self.dir.join(lang_dir).join(&platform).join(fname);
373+
let path = self.cfg.dir.join(lang_dir).join(&platform).join(fname);
343374

344375
if path.is_file() {
345376
return Some(path);
@@ -350,11 +381,38 @@ impl<'a> Cache<'a> {
350381
}
351382

352383
/// Find all pages with the given name.
353-
pub fn find(&self, name: &str, languages: &[String], platform: &str) -> Result<Vec<PathBuf>> {
384+
pub fn find(
385+
&self,
386+
name: &str,
387+
languages_override: Option<&[String]>,
388+
platform: &str,
389+
) -> Result<Vec<PathBuf>> {
390+
let res = self.find_paths(name, languages_override, platform);
391+
// if no paths were found and we are using optimistic cache, let's run update and search
392+
// again
393+
if res.as_ref().is_ok_and(Vec::is_empty)
394+
&& self.is_stale().unwrap_or_default()
395+
&& self.cfg.optimistic_cache
396+
{
397+
warnln!("Page not found, updating cache");
398+
self.update()?;
399+
self.find_paths(name, languages_override, platform)
400+
} else {
401+
res
402+
}
403+
}
404+
405+
fn find_paths(
406+
&self,
407+
name: &str,
408+
languages_override: Option<&[String]>,
409+
platform: &str,
410+
) -> Result<Vec<PathBuf>> {
354411
// https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#page-resolution
355412

356413
let platforms = self.get_platforms_and_check(platform)?;
357414
let file = format!("{name}.md");
415+
let languages = languages_override.unwrap_or(&self.cfg.languages);
358416

359417
let mut result = vec![];
360418
let mut lang_dirs: Vec<String> = languages.iter().map(|x| format!("pages.{x}")).collect();
@@ -411,7 +469,7 @@ impl<'a> Cache<'a> {
411469
P: AsRef<Path>,
412470
Q: AsRef<Path>,
413471
{
414-
match fs::read_dir(self.dir.join(lang_dir.as_ref()).join(platform)) {
472+
match fs::read_dir(self.cfg.dir.join(lang_dir.as_ref()).join(platform)) {
415473
Ok(entries) => {
416474
let entries = entries.map(|res| res.map(|ent| ent.file_name()));
417475
Ok(entries.collect::<io::Result<Vec<OsString>>>()?)
@@ -493,7 +551,7 @@ impl<'a> Cache<'a> {
493551

494552
/// List languages (used in shell completions).
495553
pub fn list_languages(&self) -> Result<()> {
496-
let languages = fs::read_dir(self.dir)?
554+
let languages = fs::read_dir(&self.cfg.dir)?
497555
.filter(|res| res.is_ok() && res.as_ref().unwrap().path().is_dir())
498556
.map(|res| res.unwrap().file_name());
499557
let mut stdout = io::stdout().lock();
@@ -509,11 +567,11 @@ impl<'a> Cache<'a> {
509567
}
510568

511569
/// Show cache information.
512-
pub fn info(&self, cfg: &Config) -> Result<()> {
570+
pub fn info(&self) -> Result<()> {
513571
let mut n_map = BTreeMap::new();
514572
let mut n_total = 0;
515573

516-
for lang_dir in fs::read_dir(self.dir)? {
574+
for lang_dir in fs::read_dir(&self.cfg.dir)? {
517575
let lang_dir = lang_dir?;
518576
if !lang_dir.path().is_dir() {
519577
continue;
@@ -534,12 +592,12 @@ impl<'a> Cache<'a> {
534592
writeln!(
535593
stdout,
536594
"Cache: {} (last update: {} ago)",
537-
self.dir.display().red(),
595+
self.cfg.dir.display().red(),
538596
util::duration_fmt(age).green().bold()
539597
)?;
540598

541-
if cfg.cache.auto_update {
542-
let max_age = cfg.cache_max_age().as_secs();
599+
if self.cfg.auto_update {
600+
let max_age = self.max_age().as_secs();
543601
if max_age > age {
544602
let age_diff = max_age - age;
545603

@@ -568,27 +626,62 @@ impl<'a> Cache<'a> {
568626

569627
Ok(())
570628
}
629+
/// Checks cache status and downloads it stale or empty.
630+
pub fn load(&mut self, is_offline: bool) -> Result<()> {
631+
if !self.subdir_exists(ENGLISH_DIR) {
632+
if is_offline {
633+
return Err(Error::offline_no_cache());
634+
}
635+
infoln!("cache is empty, downloading...");
636+
self.update()?;
637+
} else if self.cfg.auto_update && self.age()? > self.max_age() {
638+
let age = util::duration_fmt(self.age()?.as_secs());
639+
let age = age.green().bold();
640+
641+
if is_offline {
642+
warnln!(
643+
"cache is stale (last update: {age} ago). Run tldr without --offline to update."
644+
);
645+
} else if self.cfg.optimistic_cache {
646+
self.update_cache_on_drop = true;
647+
// For optimistic cache, we'll notify the user but defer the update until after displaying the page
648+
warnln!(
649+
"cache is stale (last update: {age} ago), will defer update after cache lookup. Run without --optimistic-cache to update before lookup"
650+
);
651+
} else {
652+
infoln!("cache is stale (last update: {age} ago), updating...");
653+
self.update()
654+
.map_err(|e| e.describe(Error::DESC_AUTO_UPDATE_ERR))?;
655+
}
656+
}
657+
Ok(())
658+
}
659+
660+
pub fn is_stale(&self) -> Result<bool> {
661+
self.age().map(|age| age > self.max_age())
662+
}
571663

572664
/// Get the age of the cache.
573-
pub fn age(&self) -> Result<Duration> {
574-
self.age
575-
.get_or_try_init(|| {
576-
let sumfile = self.dir.join("tldr.sha256sums");
577-
let metadata = if sumfile.is_file() {
578-
fs::metadata(&sumfile)
579-
} else {
580-
// The sumfile is not available, fall back to the base directory.
581-
fs::metadata(self.dir)
582-
}?;
665+
fn age(&self) -> Result<Duration> {
666+
let sumfile = self.cfg.dir.join("tldr.sha256sums");
667+
let metadata = if sumfile.is_file() {
668+
fs::metadata(&sumfile)
669+
} else {
670+
// The sumfile is not available, fall back to the base directory.
671+
fs::metadata(&self.cfg.dir)
672+
}?;
583673

584-
metadata.modified()?.elapsed().map_err(|_| {
585-
Error::new(
586-
"the system clock is not functioning correctly.\n\
674+
metadata.modified()?.elapsed().map_err(|_| {
675+
Error::new(
676+
"the system clock is not functioning correctly.\n\
587677
Modification time of the cache is later than the current system time.\n\
588678
Please fix your system clock.",
589-
)
590-
})
591-
})
592-
.copied()
679+
)
680+
})
681+
}
682+
/// Convert the number of hours from config to a `Duration`.
683+
pub const fn max_age(&self) -> Duration {
684+
return Duration::from_secs(5);
685+
Duration::from_secs(self.cfg.max_age * 60 * 60)
593686
}
594687
}

0 commit comments

Comments
 (0)