use std::{
fs::{self, File},
io::{Cursor, ErrorKind, Read},
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use anyhow::{Context, Result, anyhow, bail, ensure};
use log::{debug, info};
use ureq::{
Agent,
http::StatusCode,
tls::{RootCerts, TlsConfig, TlsProvider},
};
use zip::ZipArchive;
use crate::{
config::{Language, TlsBackend},
types::PlatformType,
};
pub static TLDR_PAGES_DIR: &str = "tldr-pages";
#[derive(Clone)]
pub struct CacheConfig<'a> {
pub pages_directory: &'a Path,
pub custom_pages_directory: Option<&'a Path>,
pub platforms: &'a [PlatformType],
pub search_languages: &'a [Language<'a>],
pub download_languages: &'a [Language<'a>],
}
pub struct Cache<'a> {
config: CacheConfig<'a>,
}
#[derive(Debug)]
pub struct PageLookupResult {
pub page_path: PathBuf,
pub patch_path: Option<PathBuf>,
}
impl<'a> Cache<'a> {
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()
))),
}
}
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.search_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> + use<>> {
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.search_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()
&& (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<impl IntoIterator<Item = Language<'_>> + use<'_>> {
let client = Self::build_client(tls_backend);
let mut archives = self
.config
.download_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<_>>>()?;
fs::remove_dir_all(self.config.pages_directory)?;
fs::create_dir(self.config.pages_directory)?;
for (lang, archive) in &mut archives {
if let Some(archive) = archive {
info!("Extracting archive for {lang:?}");
archive.extract(self.config.pages_directory.join(lang.directory_name()))?;
} else {
info!("No archive found for {lang:?}");
}
}
Ok(archives
.into_iter()
.filter_map(|(lang, archive)| archive.is_some().then_some(lang)))
}
pub fn config(&self) -> &CacheConfig<'a> {
&self.config
}
}
impl PageLookupResult {
pub fn with_page(page_path: PathBuf) -> Self {
Self {
page_path,
patch_path: None,
}
}
pub fn with_optional_patch(mut self, patch_path: Option<PathBuf>) -> Self {
self.patch_path = patch_path;
self
}
pub fn reader(&self) -> Result<Box<dyn Read>> {
let page_file = File::open(&self.page_path)
.with_context(|| format!("Could not open page file at {}", self.page_path.display()))?;
let patch_file_opt = match &self.patch_path {
Some(path) => Some(
File::open(path)
.with_context(|| format!("Could not open patch file at {}", path.display()))?,
),
None => None,
};
Ok(if let Some(patch_file) = patch_file_opt {
Box::new(page_file.chain(&b"\n"[..]).chain(patch_file)) as Box<dyn Read>
} else {
Box::new(page_file) as Box<dyn Read>
})
}
}
impl Language<'_> {
fn directory_name(&self) -> String {
format!("pages.{}", self.0)
}
}
impl PlatformType {
fn directory_name(self) -> &'static str {
match self {
PlatformType::Linux => "linux",
PlatformType::OsX => "osx",
PlatformType::SunOs => "sunos",
PlatformType::Windows => "windows",
PlatformType::Android => "android",
PlatformType::FreeBsd => "freebsd",
PlatformType::NetBsd => "netbsd",
PlatformType::OpenBsd => "openbsd",
PlatformType::Common => "common",
}
}
}
impl Cache<'_> {
fn build_client(tls_backend: TlsBackend) -> Agent {
let tls_builder = match tls_backend {
#[cfg(feature = "native-tls")]
TlsBackend::NativeTls => TlsConfig::builder()
.provider(TlsProvider::NativeTls)
.root_certs(RootCerts::PlatformVerifier),
#[cfg(feature = "rustls-with-webpki-roots")]
TlsBackend::RustlsWithWebpkiRoots => TlsConfig::builder()
.provider(TlsProvider::Rustls)
.root_certs(RootCerts::WebPki),
#[cfg(feature = "rustls-with-native-roots")]
TlsBackend::RustlsWithNativeRoots => TlsConfig::builder()
.provider(TlsProvider::Rustls)
.root_certs(RootCerts::PlatformVerifier),
};
let config = Agent::config_builder()
.http_status_as_error(false) .tls_config(tls_builder.build())
.build();
config.into()
}
fn download(client: &Agent, archive_url: &str) -> Result<Option<Vec<u8>>> {
info!("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:?}")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs::File,
io::{Read, Write},
};
#[test]
fn test_reader_with_patch() {
let dir = tempfile::tempdir().unwrap();
let page_path = dir.path().join("test.page.md");
let patch_path = dir.path().join("test.patch.md");
{
let mut f1 = File::create(&page_path).unwrap();
f1.write_all(b"Hello\n").unwrap();
let mut f2 = File::create(&patch_path).unwrap();
f2.write_all(b"World").unwrap();
}
let lr = PageLookupResult::with_page(page_path).with_optional_patch(Some(patch_path));
let mut reader = lr.reader().unwrap();
let mut buf = Vec::new();
reader.read_to_end(&mut buf).unwrap();
assert_eq!(&buf, b"Hello\n\nWorld");
}
#[test]
fn test_reader_without_patch() {
let dir = tempfile::tempdir().unwrap();
let page_path = dir.path().join("test.page.md");
{
let mut f = File::create(&page_path).unwrap();
f.write_all(b"Hello\n").unwrap();
}
let lr = PageLookupResult::with_page(page_path);
let mut reader = lr.reader().unwrap();
let mut buf = Vec::new();
reader.read_to_end(&mut buf).unwrap();
assert_eq!(&buf, b"Hello\n");
}
}