mirror of
https://github.com/tealdeer-rs/tealdeer.git
synced 2026-08-09 09:49:10 +02:00
Cache rewrite (#416)
This commit is contained in:
parent
d1be7d6bb9
commit
1e87db7ab7
9 changed files with 463 additions and 508 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1068,7 +1068,6 @@ dependencies = [
|
|||
"tempfile",
|
||||
"toml",
|
||||
"ureq",
|
||||
"walkdir",
|
||||
"yansi",
|
||||
"zip",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
636
src/cache.rs
636
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<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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
220
src/main.rs
220
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<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,
|
||||
|
|
|
|||
111
tests/lib.rs
111
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue