Cache rewrite (#416)

This commit is contained in:
Niklas Mohrin 2025-08-01 16:03:01 +02:00 committed by GitHub
commit 1e87db7ab7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 463 additions and 508 deletions

1
Cargo.lock generated
View file

@ -1068,7 +1068,6 @@ dependencies = [
"tempfile",
"toml",
"ureq",
"walkdir",
"yansi",
"zip",
]

View file

@ -29,7 +29,6 @@ serde = "1.0.21"
serde_derive = "1.0.21"
ureq = { version = "3.0.8", default-features = false, features = ["gzip"] }
toml = "0.8.19"
walkdir = "2.0.1"
yansi = "1"
zip = { version = "2.3.0", default-features = false, features = ["deflate"] }

View file

@ -1,41 +1,38 @@
use std::{
ffi::OsStr,
fs::{self, File},
io::{BufReader, Cursor, Read},
io::{BufReader, Cursor, ErrorKind, Read},
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use anyhow::{ensure, Context, Result};
use anyhow::{anyhow, bail, ensure, Context, Result};
use log::debug;
use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
use ureq::Agent;
use walkdir::{DirEntry, WalkDir};
use ureq::{
http::StatusCode,
tls::{RootCerts, TlsConfig, TlsProvider},
Agent,
};
use zip::ZipArchive;
use crate::{config::TlsBackend, types::PlatformType, utils::print_warning};
use crate::{config::TlsBackend, types::PlatformType};
pub static TLDR_PAGES_DIR: &str = "tldr-pages";
static TLDR_OLD_PAGES_DIR: &str = "tldr-master";
pub static TLDR_OLD_PAGES_DIR: &str = "tldr-master";
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct Language<'a>(pub &'a str);
impl Language<'_> {
fn directory_name(&self) -> String {
if *self == Language("en") {
String::from("pages")
} else {
format!("pages.{}", self.0)
}
}
#[derive(Clone)]
pub struct CacheConfig<'a> {
pub pages_directory: &'a Path,
pub custom_pages_directory: Option<&'a Path>,
pub platforms: &'a [PlatformType],
pub languages: &'a [Language<'a>],
}
#[derive(Debug)]
pub struct Cache {
cache_dir: PathBuf,
enable_styles: bool,
tls_backend: TlsBackend,
/// The directory backing this cache is checked to be populated at construction.
pub struct Cache<'a> {
config: CacheConfig<'a>,
}
#[derive(Debug)]
@ -44,6 +41,218 @@ pub struct PageLookupResult {
pub patch_path: Option<PathBuf>,
}
impl<'a> Cache<'a> {
/// Try opening a cache at the location given by `config.pages_directory`. If no directory
/// exists at this location, `Ok(None)` is returned.
pub fn open(config: CacheConfig<'a>) -> Result<Option<Self>> {
match config.pages_directory.metadata() {
Ok(md) => {
ensure!(
md.is_dir(),
"Cache directory `{}` exists, but is not a directory.",
config.pages_directory.display(),
);
Ok(Some(Cache { config }))
}
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(anyhow!(err).context(format!(
"Error getting metdata of cache directory {}",
config.pages_directory.display()
))),
}
}
/// Open an existing cache at `config.pages_directory` or create one if no cache resides at
/// this location. In case of success, the return value is a tuple with the `Cache` and a
/// boolean indicating whether the cache was newly created.
pub fn open_or_create(config: CacheConfig<'a>) -> Result<(Self, bool)> {
if let Some(cache) = Self::open(config.clone())? {
return Ok((cache, false));
}
fs::create_dir_all(config.pages_directory).with_context(|| {
format!(
"Cache directory `{}` cannot be created",
config.pages_directory.display(),
)
})?;
eprintln!(
"Successfully created cache directory `{}`.",
config.pages_directory.display(),
);
Ok((Cache { config }, true))
}
pub fn age(&self) -> Result<Duration> {
let mtime = self.config.pages_directory.metadata()?.modified()?;
SystemTime::now()
.duration_since(mtime)
.context("Error comparing cache mtime with current time")
}
pub fn find_page(&self, command: &str) -> Option<PageLookupResult> {
let page_filename = format!("{command}.md");
let patch_filename = format!("{command}.patch.md");
let custom_filename = format!("{command}.page.md");
if let Some(custom_pages_dir) = self.config.custom_pages_directory {
let custom_page = custom_pages_dir.join(custom_filename);
if custom_page.is_file() {
return Some(PageLookupResult::with_page(custom_page));
}
}
let patch_path = self
.config
.custom_pages_directory
.map(|dir| dir.join(&patch_filename))
.filter(|path| path.is_file());
for &platform in self.config.platforms {
for language in self.config.languages {
let mut search_path = self.config.pages_directory.to_path_buf();
search_path.push(language.directory_name());
search_path.push(platform.directory_name());
search_path.push(&page_filename);
if search_path.is_file() {
return Some(
PageLookupResult::with_page(search_path).with_optional_patch(patch_path),
);
}
}
}
None
}
pub fn list_pages(&self) -> Result<impl IntoIterator<Item = String>> {
let mut pages = Vec::new();
let mut append_all = |directory: &Path, suffix: &str| -> Result<()> {
let Ok(file_iter) = fs::read_dir(directory) else {
return Ok(());
};
for entry in file_iter {
let entry = entry?;
if entry.file_type()?.is_file() {
let mut page_path = entry
.file_name()
.into_string()
.map_err(|_| anyhow!("Found invalid filename: {:?}", entry.path()))?;
if page_path.ends_with(suffix) {
page_path.truncate(page_path.len() - suffix.len());
pages.push(page_path);
} else {
debug!(
"Skipping page entry not ending in \".md\": {:?}",
entry.path(),
);
}
}
}
Ok(())
};
let mut search_path = self.config.pages_directory.to_path_buf();
for language in self.config.languages {
search_path.push(language.directory_name());
for platform in self.config.platforms {
search_path.push(platform.directory_name());
append_all(&search_path, ".md")?;
search_path.pop();
}
search_path.pop();
}
if let Some(custom_pages_dir) = self.config.custom_pages_directory {
append_all(custom_pages_dir, ".page.md")?;
}
pages.sort_unstable();
pages.dedup();
Ok(pages)
}
pub fn old_custom_pages_exist(&self) -> Result<bool> {
let Some(directory) = self.config.custom_pages_directory else {
return Ok(false);
};
let Ok(file_iter) = fs::read_dir(directory) else {
return Ok(false);
};
for entry in file_iter {
if let Some(extension) = entry?.path().extension() {
if extension == "page" || extension == "patch" {
return Ok(true);
}
}
}
Ok(false)
}
pub fn clear(self) -> Result<()> {
fs::remove_dir_all(self.config.pages_directory).with_context(|| {
format!(
"Could not remove pages directory at {}",
self.config.pages_directory.display(),
)
})
}
pub fn update(&mut self, archive_url: &str, tls_backend: TlsBackend) -> Result<()> {
let client = Self::build_client(tls_backend);
// Download everything before deleting anything
let archives = self
.config
.languages
.iter()
.map(|lang| {
Ok((
lang,
Self::download(
&client,
&format!("{archive_url}/tldr-{}.zip", lang.directory_name()),
)?
.map(|bytes| ZipArchive::new(Cursor::new(bytes)))
.transpose()?,
))
})
.collect::<Result<Vec<_>>>()?;
// Clear cache directory
// Note: This is not the best solution. Ideally we would download the
// archive to a temporary directory and then swap the two directories.
// But renaming a directory doesn't work across filesystems and Rust
// does not yet offer a recursive directory copying function. So for
// now, we'll use this approach.
fs::remove_dir_all(self.config.pages_directory)?;
fs::create_dir(self.config.pages_directory)?;
for (lang, archive) in archives {
if let Some(mut archive) = archive {
debug!("Extracting archive for {lang:?}");
archive.extract(self.config.pages_directory.join(lang.directory_name()))?;
} else {
debug!("No archive found for {lang:?}");
}
}
Ok(())
}
pub fn config(&self) -> &CacheConfig<'a> {
&self.config
}
}
impl PageLookupResult {
pub fn with_page(page_path: PathBuf) -> Self {
Self {
@ -90,120 +299,15 @@ impl PageLookupResult {
}
}
pub enum CacheFreshness {
/// The cache is still fresh (less than `MAX_CACHE_AGE` old)
Fresh,
/// The cache is stale and should be updated
Stale(Duration),
/// The cache is missing
Missing,
impl Language<'_> {
fn directory_name(&self) -> String {
format!("pages.{}", self.0)
}
}
impl Cache {
pub fn new<P>(cache_dir: P, enable_styles: bool, tls_backend: TlsBackend) -> Self
where
P: Into<PathBuf>,
{
Self {
cache_dir: cache_dir.into(),
enable_styles,
tls_backend,
}
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
/// Make sure that the cache directory exists and is a directory.
/// If necessary, create the directory.
fn ensure_cache_dir_exists(&self) -> Result<()> {
// Check whether `cache_dir` exists and is a directory
let (cache_dir_exists, cache_dir_is_dir) = self
.cache_dir
.metadata()
.map_or((false, false), |md| (true, md.is_dir()));
ensure!(
!cache_dir_exists || cache_dir_is_dir,
"Cache directory path `{}` is not a directory",
self.cache_dir.display(),
);
if !cache_dir_exists {
// If missing, try to create the complete directory path
fs::create_dir_all(&self.cache_dir).with_context(|| {
format!(
"Cache directory path `{}` cannot be created",
self.cache_dir.display(),
)
})?;
eprintln!(
"Successfully created cache directory path `{}`.",
self.cache_dir.display(),
);
}
Ok(())
}
fn pages_dir(&self) -> PathBuf {
self.cache_dir.join(TLDR_PAGES_DIR)
}
/// Update the pages cache from the specified URL.
pub fn update(&self, archive_source: &str) -> Result<()> {
self.ensure_cache_dir_exists()?;
let archive_url = format!("{archive_source}/tldr.zip");
let client = Self::build_client(self.tls_backend)?;
// First, download the compressed data
let bytes: Vec<u8> = Self::download(&client, &archive_url)?;
// Decompress the response body into an `Archive`
let mut archive = ZipArchive::new(Cursor::new(bytes))
.context("Could not decompress downloaded ZIP archive")?;
// Clear cache directory
// Note: This is not the best solution. Ideally we would download the
// archive to a temporary directory and then swap the two directories.
// But renaming a directory doesn't work across filesystems and Rust
// does not yet offer a recursive directory copying function. So for
// now, we'll use this approach.
self.clear()
.context("Could not clear the cache directory")?;
// Extract archive into pages dir
archive
.extract(self.pages_dir())
.context("Could not unpack compressed data")?;
Ok(())
}
/// Return the duration since the cache directory was last modified.
pub fn last_update(&self) -> Option<Duration> {
if let Ok(metadata) = fs::metadata(self.pages_dir()) {
if let Ok(mtime) = metadata.modified() {
let now = SystemTime::now();
return now.duration_since(mtime).ok();
}
}
None
}
/// Return the freshness of the cache (fresh, stale or missing).
pub fn freshness(&self) -> CacheFreshness {
match self.last_update() {
Some(ago) if ago > crate::config::MAX_CACHE_AGE => CacheFreshness::Stale(ago),
Some(_) => CacheFreshness::Fresh,
None => CacheFreshness::Missing,
}
}
/// Return the platform directory.
fn get_platform_dir(platform: PlatformType) -> &'static str {
match platform {
impl PlatformType {
fn directory_name(self) -> &'static str {
match self {
PlatformType::Linux => "linux",
PlatformType::OsX => "osx",
PlatformType::SunOs => "sunos",
@ -215,224 +319,10 @@ impl Cache {
PlatformType::Common => "common",
}
}
/// Check for pages for a given platform in one of the given languages.
fn find_page_for_platform(
page_name: &str,
pages_dir: &Path,
platform: &str,
language_dirs: &[String],
) -> Option<PathBuf> {
language_dirs
.iter()
.map(|lang_dir| pages_dir.join(lang_dir).join(platform).join(page_name))
.find(|path| path.exists() && path.is_file())
}
/// Look up custom patch (<name>.patch.md). If it exists, store it in a variable.
fn find_patch(patch_name: &str, custom_pages_dir: Option<&Path>) -> Option<PathBuf> {
custom_pages_dir
.map(|custom_dir| custom_dir.join(patch_name))
.filter(|path| path.exists() && path.is_file())
}
/// Search for a page and return the path to it.
pub fn find_page(
&self,
name: &str,
languages: &[Language<'_>],
custom_pages_dir: Option<&Path>,
platforms: &[PlatformType],
) -> Option<PageLookupResult> {
let page_filename = format!("{name}.md");
let patch_filename = format!("{name}.patch.md");
let custom_filename = format!("{name}.page.md");
// Determine directory paths
let pages_dir = self.pages_dir();
let lang_dirs: Vec<String> = languages.iter().map(Language::directory_name).collect();
// Look up custom page (<name>.page.md). If it exists, return it directly
if let Some(config_dir) = custom_pages_dir {
// TODO: Remove this check 1 year after version 1.7.0 was released
self.check_for_old_custom_pages(config_dir);
let custom_page = config_dir.join(custom_filename);
if custom_page.exists() && custom_page.is_file() {
return Some(PageLookupResult::with_page(custom_page));
}
}
let patch_path = Self::find_patch(&patch_filename, custom_pages_dir);
// Try to find a platform specific path next, in the order supplied by the user, and append custom patch to it.
for &platform in platforms {
let platform_dir = Cache::get_platform_dir(platform);
if let Some(page) =
Self::find_page_for_platform(&page_filename, &pages_dir, platform_dir, &lang_dirs)
{
return Some(PageLookupResult::with_page(page).with_optional_patch(patch_path));
}
}
None
}
/// Return the available pages.
pub fn list_pages(
&self,
custom_pages_dir: Option<&Path>,
platforms: &[PlatformType],
) -> Vec<String> {
// Determine platforms directory and platform
let platforms_dir = self.pages_dir().join("pages");
let platform_dirs: Vec<&'static str> = platforms
.iter()
.map(|&p| Self::get_platform_dir(p))
.collect();
// Closure that allows the WalkDir instance to traverse platform
// relevant page directories, but not others.
let should_walk = |entry: &DirEntry| -> bool {
let file_type = entry.file_type();
let Some(file_name) = entry.file_name().to_str() else {
return false;
};
if file_type.is_dir() {
return platform_dirs.contains(&file_name);
} else if file_type.is_file() {
return true;
}
false
};
let to_stem = |entry: DirEntry| -> Option<String> {
entry
.path()
.file_stem()
.and_then(OsStr::to_str)
.map(str::to_string)
};
let to_stem_custom = |entry: DirEntry| -> Option<String> {
entry
.path()
.file_name()
.and_then(OsStr::to_str)
.and_then(|s| s.strip_suffix(".page.md"))
.map(str::to_string)
};
// Recursively walk through platform specific directory
let mut pages = WalkDir::new(platforms_dir)
.min_depth(1) // Skip root directory
.into_iter()
.filter_entry(should_walk) // Filter out pages for other architectures
.filter_map(Result::ok) // Convert results to options, filter out errors
.filter_map(|e| {
let extension = e.path().extension().unwrap_or_default();
if e.file_type().is_file() && extension == "md" {
to_stem(e)
} else {
None
}
})
.collect::<Vec<String>>();
if let Some(custom_pages_dir) = custom_pages_dir {
let is_page = |entry: &DirEntry| -> bool {
entry.file_type().is_file()
&& entry
.path()
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|file_name| file_name.ends_with(".page.md"))
};
let custom_pages = WalkDir::new(custom_pages_dir)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_entry(is_page)
.filter_map(Result::ok)
.filter_map(to_stem_custom);
pages.extend(custom_pages);
}
pages.sort();
pages.dedup();
pages
}
/// Delete the cache directory
///
/// Returns true if the cache was deleted and false if the cache dir did
/// not exist.
pub fn clear(&self) -> Result<bool> {
if !self.cache_dir.exists() {
return Ok(false);
}
ensure!(
self.cache_dir.is_dir(),
"Cache path ({}) is not a directory.",
self.cache_dir.display(),
);
// Delete old tldr-pages cache location as well if present
// TODO: To be removed in the future
for pages_dir_name in [TLDR_PAGES_DIR, TLDR_OLD_PAGES_DIR] {
let pages_dir = self.cache_dir.join(pages_dir_name);
if pages_dir.exists() {
fs::remove_dir_all(&pages_dir).with_context(|| {
format!(
"Could not remove the cache directory at {}",
pages_dir.display()
)
})?;
}
}
Ok(true)
}
/// Check for old custom pages (without .md suffix) and print a warning.
fn check_for_old_custom_pages(&self, custom_pages_dir: &Path) {
let old_custom_pages_exist = WalkDir::new(custom_pages_dir)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_entry(|entry| entry.file_type().is_file())
.any(|entry| {
if let Ok(entry) = entry {
let extension = entry.path().extension();
if let Some(extension) = extension {
extension == "page" || extension == "patch"
} else {
false
}
} else {
false
}
});
if old_custom_pages_exist {
print_warning(
self.enable_styles,
&format!(
"Custom pages using the old naming convention were found in {}.\n\
Please rename them to follow the new convention:\n\
- `<name>.page` `<name>.page.md`\n\
- `<name>.patch` `<name>.patch.md`",
custom_pages_dir.display()
),
);
}
}
}
impl Cache {
fn build_client(tls_backend: TlsBackend) -> Result<Agent> {
impl Cache<'_> {
fn build_client(tls_backend: TlsBackend) -> Agent {
let tls_builder = match tls_backend {
#[cfg(feature = "native-tls")]
TlsBackend::NativeTls => TlsConfig::builder()
@ -448,22 +338,32 @@ impl Cache {
.root_certs(RootCerts::PlatformVerifier),
};
let config = Agent::config_builder()
.http_status_as_error(false) // because we want to handle them
.tls_config(tls_builder.build())
.build();
Ok(config.into())
config.into()
}
/// Download the archive from the specified URL.
fn download(client: &Agent, archive_url: &str) -> Result<Vec<u8>> {
let response = client
.get(archive_url)
.call()
.with_context(|| format!("Could not download tldr pages from {archive_url}"))?;
let mut buf: Vec<u8> = Vec::new();
response.into_body().into_reader().read_to_end(&mut buf)?;
debug!("{} bytes downloaded", buf.len());
Ok(buf)
fn download(client: &Agent, archive_url: &str) -> Result<Option<Vec<u8>>> {
debug!("Downloading archive from {archive_url}");
let response = client.get(archive_url).call();
match response {
Ok(response) if response.status().is_success() => {
let mut buf: Vec<u8> = Vec::new();
response.into_body().into_reader().read_to_end(&mut buf)?;
debug!("{} bytes downloaded", buf.len());
Ok(Some(buf))
}
Ok(response) if response.status() == StatusCode::NOT_FOUND => Ok(None),
_ => {
bail!(
"Could not download tldr pages from {archive_url}: {:?}",
response,
)
}
}
}
}
@ -521,22 +421,4 @@ mod tests {
assert_eq!(&buf, b"Hello\n");
}
#[test]
#[cfg(feature = "native-tls")]
fn test_create_https_client_with_native_tls() {
Cache::build_client(TlsBackend::NativeTls).expect("fails to build a client.");
}
#[test]
#[cfg(feature = "rustls-with-webpki-roots")]
fn test_create_https_client_with_rustls() {
Cache::build_client(TlsBackend::RustlsWithWebpkiRoots).expect("fails to build a client.");
}
#[test]
#[cfg(feature = "rustls-with-native-roots")]
fn test_create_https_client_with_rustls_with_native_roots() {
Cache::build_client(TlsBackend::RustlsWithNativeRoots).expect("fails to build a client.");
}
}

View file

@ -36,9 +36,9 @@ use std::{
use anyhow::{anyhow, Context, Result};
use app_dirs::AppInfo;
use cache::Language;
use cache::{CacheConfig, Language, TLDR_OLD_PAGES_DIR};
use clap::Parser;
use config::StyleConfig;
use config::{StyleConfig, TlsBackend};
use log::debug;
mod cache;
@ -52,7 +52,7 @@ mod types;
mod utils;
use crate::{
cache::{Cache, CacheFreshness, PageLookupResult, TLDR_PAGES_DIR},
cache::{Cache, PageLookupResult, TLDR_PAGES_DIR},
cli::Cli,
config::{get_config_dir, make_default_config, Config, PathWithSource},
extensions::Dedup,
@ -67,77 +67,25 @@ const APP_INFO: AppInfo = AppInfo {
author: NAME,
};
/// The cache should be updated if it was explicitly requested,
/// or if an automatic update is due and allowed.
fn should_update_cache(cache: &Cache, args: &Cli, config: &Config) -> bool {
args.update
|| (!args.no_auto_update
&& config.updates.auto_update
&& cache
.last_update()
.map_or(true, |ago| ago >= config.updates.auto_update_interval))
}
#[derive(PartialEq)]
enum CheckCacheResult {
CacheFound,
CacheMissing,
}
/// Check the cache for freshness. If it's stale or missing, show a warning.
fn check_cache(cache: &Cache, args: &Cli, enable_styles: bool) -> CheckCacheResult {
match cache.freshness() {
CacheFreshness::Fresh => CheckCacheResult::CacheFound,
CacheFreshness::Stale(_) if args.quiet => CheckCacheResult::CacheFound,
CacheFreshness::Stale(age) => {
print_warning(
enable_styles,
&format!(
"The cache hasn't been updated for {} days.\n\
You should probably run `tldr --update` soon.",
age.as_secs() / 24 / 3600
),
);
CheckCacheResult::CacheFound
}
CacheFreshness::Missing => {
print_error(
enable_styles,
&anyhow::anyhow!(
"Page cache not found. Please run `tldr --update` to download the cache."
),
);
println!("\nNote: You can optionally enable automatic cache updates by adding the");
println!("following config to your config file:\n");
println!(" [updates]");
println!(" auto_update = true\n");
println!("The path to your config file can be looked up with `tldr --show-paths`.");
println!("To create an initial config file, use `tldr --seed-config`.\n");
println!("You can find more tips and tricks in our docs:\n");
println!(" https://tealdeer-rs.github.io/tealdeer/config_updates.html");
CheckCacheResult::CacheMissing
}
}
}
/// Clear the cache
fn clear_cache(cache: &Cache, quietly: bool) -> Result<()> {
let cache_dir_found = cache.clear().context("Could not clear cache")?;
fn clear_cache(cache: Cache, quietly: bool) -> Result<()> {
let cache_dir = cache.config().pages_directory.display();
cache.clear().context("Could not clear cache")?;
if !quietly {
let cache_dir = cache.cache_dir().display();
if cache_dir_found {
eprintln!("Successfully cleared cache at `{cache_dir}`.");
} else {
eprintln!("Cache directory not found at `{cache_dir}`, nothing to do.");
}
eprintln!("Successfully cleared cache at `{cache_dir}`.");
}
Ok(())
}
/// Update the cache
fn update_cache(cache: &Cache, archive_source: &str, quietly: bool) -> Result<()> {
fn update_cache(
cache: &mut Cache,
archive_source: &str,
tls_backend: TlsBackend,
quietly: bool,
) -> Result<()> {
cache
.update(archive_source)
.update(archive_source, tls_backend)
.context("Could not update cache")?;
if !quietly {
eprintln!("Successfully updated cache.");
@ -333,8 +281,6 @@ fn try_main(args: Cli, enable_styles: bool) -> Result<ExitCode> {
return Ok(ExitCode::SUCCESS);
}
let platforms = compute_platforms(args.platforms.as_ref());
// If a local file was passed in, render it and exit
if let Some(file) = args.render {
let path = PageLookupResult::with_page(file);
@ -342,56 +288,120 @@ fn try_main(args: Cli, enable_styles: bool) -> Result<ExitCode> {
return Ok(ExitCode::SUCCESS);
}
// Instantiate cache. This will not yet create the cache directory!
let cache = Cache::new(
&config.directories.cache_dir.path,
enable_styles,
config.updates.tls_backend,
);
let platforms = compute_platforms(args.platforms.as_ref());
let languages = args
.language
.as_deref()
.map_or_else(get_languages_from_env, |lang| vec![Language(lang)]);
let cache_config = CacheConfig {
pages_directory: &config.directories.cache_dir.path().join(TLDR_PAGES_DIR),
custom_pages_directory: config
.directories
.custom_pages_dir
.as_ref()
.map(PathWithSource::path),
platforms: &platforms,
languages: &languages,
};
// TODO: remove in tealdeer 1.9
let old_config = CacheConfig {
pages_directory: &config.directories.cache_dir.path().join(TLDR_OLD_PAGES_DIR),
..cache_config
};
if let Ok(Some(old_cache)) = Cache::open(old_config) {
old_cache.clear()?;
eprintln!("Cleared pages from old cache location.");
}
// Clear cache, pass through
if args.clear_cache {
clear_cache(&cache, args.quiet)?;
if let Some(cache) = Cache::open(cache_config)? {
clear_cache(cache, args.quiet)?;
}
return Ok(ExitCode::SUCCESS);
}
if should_update_cache(&cache, &args, &config) {
update_cache(&cache, &config.updates.archive_source, args.quiet)?;
} else if (args.list || !args.command.is_empty())
&& check_cache(&cache, &args, enable_styles) == CheckCacheResult::CacheMissing
{
// Cache is needed, but missing
return Ok(ExitCode::FAILURE);
}
let cache = if args.update || config.updates.auto_update && !args.no_auto_update {
let (mut cache, was_created) = Cache::open_or_create(cache_config)?;
if was_created || args.update || cache.age()? >= config.updates.auto_update_interval {
update_cache(
&mut cache,
&config.updates.archive_source,
config.updates.tls_backend,
args.quiet,
)?;
}
cache
} else if args.list || !command.is_empty() {
// Cache is needed for these commands to work
let Some(cache) = Cache::open(cache_config)? else {
print_error(
enable_styles,
&anyhow::anyhow!(
"Page cache not found. Please run `tldr --update` to download the cache."
),
);
println!("\nNote: You can optionally enable automatic cache updates by adding the");
println!("following config to your config file:\n");
println!(" [updates]");
println!(" auto_update = true\n");
println!("The path to your config file can be looked up with `tldr --show-paths`.");
println!("To create an initial config file, use `tldr --seed-config`.\n");
println!("You can find more tips and tricks in our docs:\n");
println!(" https://tealdeer-rs.github.io/tealdeer/config_updates.html");
return Ok(ExitCode::FAILURE);
};
let age = cache.age()?;
if age > config::MAX_CACHE_AGE && !args.quiet {
print_warning(
enable_styles,
&format!(
"The cache hasn't been updated for {} days.\n\
You should probably run `tldr --update` soon.",
age.as_secs() / 24 / 3600
),
);
}
cache
} else {
// There is nothing left to do
return Ok(ExitCode::SUCCESS);
};
// List cached commands and exit
if args.list {
println!(
"{}",
cache.list_pages(custom_pages_dir, &platforms).join("\n")
);
for page in cache.list_pages()? {
println!("{page}");
}
return Ok(ExitCode::SUCCESS);
}
// Show command from cache
if !command.is_empty() {
// Collect languages
let languages = args
.language
.as_deref()
.map_or_else(get_languages_from_env, |lang| vec![Language(lang)]);
// TODO: Remove this check 1 year after version 1.7.0 was released
if cache.old_custom_pages_exist()? {
print_warning(
enable_styles,
&format!(
"Custom pages using the old naming convention were found in {}.\n\
Please rename them to follow the new convention:\n\
- `<name>.page` `<name>.page.md`\n\
- `<name>.patch` `<name>.patch.md`",
cache
.config()
.custom_pages_directory
.expect("Old custom pages can only exist in custom pages directory")
.display(),
),
);
}
// Search for command in cache
let Some(lookup_result) = cache.find_page(
&command,
&languages,
config
.directories
.custom_pages_dir
.as_ref()
.map(PathWithSource::path),
&platforms,
) else {
let Some(lookup_result) = cache.find_page(&command) else {
if !args.quiet {
print_warning(
enable_styles,

View file

@ -16,6 +16,7 @@ use predicates::{
use tempfile::{Builder as TempfileBuilder, TempDir};
pub static TLDR_PAGES_DIR: &str = "tldr-pages";
pub static TLDR_OLD_PAGES_DIR: &str = "tldr-master";
struct TestEnv {
_test_dir: TempDir,
@ -36,9 +37,9 @@ impl TestEnv {
features: vec![],
};
create_dir_all(&this.cache_dir()).unwrap();
create_dir_all(&this.config_dir()).unwrap();
create_dir_all(&this.custom_pages_dir()).unwrap();
create_dir_all(this.cache_dir()).unwrap();
create_dir_all(this.config_dir()).unwrap();
create_dir_all(this.custom_pages_dir()).unwrap();
this.append_to_config(format!(
"directories.cache_dir = '{}'\n",
@ -100,7 +101,11 @@ impl TestEnv {
/// Add entry for that environment to an OS-specific subfolder.
fn add_os_entry(&self, os: &str, name: &str, contents: &str) {
let dir = self.cache_dir().join(TLDR_PAGES_DIR).join("pages").join(os);
let dir = self
.cache_dir()
.join(TLDR_PAGES_DIR)
.join("pages.en")
.join(os);
create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{name}.md")), contents.as_bytes()).unwrap();
@ -355,6 +360,45 @@ fn test_quiet_cache() {
.stdout(is_empty());
}
#[test]
fn test_clear_only_pages_directory() {
let testenv = TestEnv::new().install_default_cache();
testenv
.command()
.args(["--clear-cache"])
.assert()
.success()
.stderr(contains(format!(
"Successfully cleared cache at `{}`.",
testenv.cache_dir().join(TLDR_PAGES_DIR).to_str().unwrap(),
)));
assert!(testenv.cache_dir().is_dir());
assert!(!testenv.cache_dir().join(TLDR_PAGES_DIR).exists());
}
#[test]
fn test_always_delete_old_pages_directory() {
let testenv = TestEnv::new().install_default_cache();
fs::rename(
testenv.cache_dir().join(TLDR_PAGES_DIR),
testenv.cache_dir().join(TLDR_OLD_PAGES_DIR),
)
.unwrap();
testenv
.command()
.arg("--list")
.assert()
.failure()
.stderr(contains("Cleared pages from old cache location."))
.stderr(contains("Page cache not found."));
assert!(testenv.cache_dir().is_dir());
assert!(!testenv.cache_dir().join(TLDR_PAGES_DIR).exists());
assert!(!testenv.cache_dir().join(TLDR_OLD_PAGES_DIR).exists());
}
#[test]
fn test_warn_invalid_tls_backend() {
let testenv = TestEnv::new()
@ -429,38 +473,59 @@ fn test_create_cache_directory_path() {
.assert()
.success()
.stderr(contains(format!(
"Successfully created cache directory path `{}`.",
internal_cache_dir.to_str().unwrap()
"Successfully created cache directory `{}`.",
internal_cache_dir.join(TLDR_PAGES_DIR).to_str().unwrap()
)))
.stderr(contains("Successfully updated cache."));
assert!(internal_cache_dir.is_dir());
}
#[cfg_attr(feature = "ignore-online-tests", ignore = "online test")]
#[test]
fn test_cache_location_not_a_directory() {
let testenv = TestEnv::new().remove_initial_config();
let testenv = TestEnv::new();
let cache_dir = &testenv.cache_dir();
let internal_file = cache_dir.join("internal");
File::create(&internal_file).unwrap();
testenv.append_to_config(format!(
"directories.cache_dir = '{}'\n",
internal_file.to_str().unwrap()
));
File::create(cache_dir.join(TLDR_PAGES_DIR)).unwrap();
testenv
.command()
.arg("--update")
.arg("--list")
.assert()
.failure()
.stderr(contains(format!(
"Cache directory path `{}` is not a directory",
internal_file.display(),
"Cache directory `{}` exists, but is not a directory.",
cache_dir.join(TLDR_PAGES_DIR).display(),
)));
}
#[cfg(unix)]
#[test]
fn test_cache_location_permission_denied() {
use std::os::unix::fs::PermissionsExt;
let testenv = TestEnv::new().install_default_cache();
testenv
.command()
.arg("--list")
.assert()
.success()
.stderr(contains("Permission denied").not());
// Make cache directory unreadable
let cache_dir = testenv.cache_dir();
let mut permissions = cache_dir.metadata().unwrap().permissions();
permissions.set_mode(0);
fs::set_permissions(cache_dir, permissions).unwrap();
testenv
.command()
.arg("--list")
.assert()
.failure()
.stderr(contains("Permission denied"));
}
#[test]
fn test_cache_location_source() {
let testenv = TestEnv::new().remove_initial_config();
@ -624,7 +689,7 @@ fn test_os_specific_page() {
fn test_markdown_rendering() {
let testenv = TestEnv::new().install_default_cache();
let expected = include_str!("cache/pages/common/which.md");
let expected = include_str!("cache/pages.en/common/which.md");
testenv
.command()
.args(["--raw", "which"])
@ -1008,7 +1073,7 @@ fn test_custom_page_overwrites() {
// Add .page.md file to custom_pages_dir
testenv.add_page_entry(
"inkscape-v2",
include_str!("cache/pages/common/inkscape-v2.md"),
include_str!("cache/pages.en/common/inkscape-v2.md"),
);
// Load expected output
@ -1051,7 +1116,7 @@ fn test_custom_patch_does_not_append_to_custom() {
// In addition to the page in the cache, add the same page as a custom page.
testenv.add_page_entry(
"inkscape-v2",
include_str!("cache/pages/common/inkscape-v2.md"),
include_str!("cache/pages.en/common/inkscape-v2.md"),
);
// Load expected output
@ -1114,7 +1179,7 @@ fn test_raw_render_file() {
let path = testenv
.cache_dir()
.join(TLDR_PAGES_DIR)
.join("pages/common/inkscape-v1.md");
.join("pages.en/common/inkscape-v1.md");
let mut args = vec!["--color", "never", "-f", &path.to_str().unwrap()];
// Default render
@ -1134,7 +1199,7 @@ fn test_raw_render_file() {
.args(&args)
.assert()
.success()
.stdout(diff(include_str!("cache/pages/common/inkscape-v1.md")));
.stdout(diff(include_str!("cache/pages.en/common/inkscape-v1.md")));
}
fn touch_custom_page(testenv: &TestEnv) {