diff --git a/Cargo.lock b/Cargo.lock index e8db079..4da46ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1068,7 +1068,6 @@ dependencies = [ "tempfile", "toml", "ureq", - "walkdir", "yansi", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 628c9bb..6594b14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/cache.rs b/src/cache.rs index fdd77da..c18f03f 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -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, } +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> { + 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 { + 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 { + 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> { + 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 { + 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::>>()?; + + // 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

(cache_dir: P, enable_styles: bool, tls_backend: TlsBackend) -> Self - where - P: Into, - { - 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 = 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 { - 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 { - 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 (.patch.md). If it exists, store it in a variable. - fn find_patch(patch_name: &str, custom_pages_dir: Option<&Path>) -> Option { - 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 { - 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 = languages.iter().map(Language::directory_name).collect(); - - // Look up custom page (.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 { - // 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 { - entry - .path() - .file_stem() - .and_then(OsStr::to_str) - .map(str::to_string) - }; - - let to_stem_custom = |entry: DirEntry| -> Option { - 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::>(); - - 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 { - 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\ - - `.page` → `.page.md`\n\ - - `.patch` → `.patch.md`", - custom_pages_dir.display() - ), - ); - } - } } -impl Cache { - fn build_client(tls_backend: TlsBackend) -> Result { +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> { - let response = client - .get(archive_url) - .call() - .with_context(|| format!("Could not download tldr pages from {archive_url}"))?; - let mut buf: Vec = 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>> { + 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 = 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."); - } } diff --git a/src/main.rs b/src/main.rs index 2113de0..3797f0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { 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 { 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\ + - `.page` → `.page.md`\n\ + - `.patch` → `.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, diff --git a/tests/cache/pages/common/git-checkout.md b/tests/cache/pages.en/common/git-checkout.md similarity index 100% rename from tests/cache/pages/common/git-checkout.md rename to tests/cache/pages.en/common/git-checkout.md diff --git a/tests/cache/pages/common/inkscape-v1.md b/tests/cache/pages.en/common/inkscape-v1.md similarity index 100% rename from tests/cache/pages/common/inkscape-v1.md rename to tests/cache/pages.en/common/inkscape-v1.md diff --git a/tests/cache/pages/common/inkscape-v2.md b/tests/cache/pages.en/common/inkscape-v2.md similarity index 100% rename from tests/cache/pages/common/inkscape-v2.md rename to tests/cache/pages.en/common/inkscape-v2.md diff --git a/tests/cache/pages/common/which.md b/tests/cache/pages.en/common/which.md similarity index 100% rename from tests/cache/pages/common/which.md rename to tests/cache/pages.en/common/which.md diff --git a/tests/lib.rs b/tests/lib.rs index 172c1c5..2a1665c 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -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) {