use std::{
env,
ffi::OsStr,
fs::{self, File},
io::{BufReader, Cursor, Read},
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use anyhow::{ensure, Context, Result};
use log::debug;
use reqwest::{blocking::Client, Proxy};
use walkdir::{DirEntry, WalkDir};
use zip::ZipArchive;
use crate::types::PlatformType;
pub static TLDR_PAGES_DIR: &str = "tldr-pages";
static TLDR_OLD_PAGES_DIR: &str = "tldr-master";
#[derive(Debug)]
pub struct Cache {
platform: PlatformType,
cache_dir: PathBuf,
}
#[derive(Debug)]
pub struct PageLookupResult {
pub page_path: PathBuf,
pub patch_path: Option<PathBuf>,
}
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<BufReader<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(BufReader::new(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>
}))
}
}
pub enum CacheFreshness {
Fresh,
Stale(Duration),
Missing,
}
impl Cache {
pub fn new<P>(platform: PlatformType, cache_dir: P) -> Self
where
P: Into<PathBuf>,
{
Self {
platform,
cache_dir: cache_dir.into(),
}
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn ensure_cache_dir_exists(&self) -> Result<()> {
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 {
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)
}
fn download(archive_url: &str) -> Result<Vec<u8>> {
let mut builder = Client::builder();
if let Ok(ref host) = env::var("HTTP_PROXY") {
if let Ok(proxy) = Proxy::http(host) {
builder = builder.proxy(proxy);
}
}
if let Ok(ref host) = env::var("HTTPS_PROXY") {
if let Ok(proxy) = Proxy::https(host) {
builder = builder.proxy(proxy);
}
}
let client = builder
.build()
.context("Could not instantiate HTTP client")?;
let mut resp = client
.get(archive_url)
.send()?
.error_for_status()
.with_context(|| format!("Could not download tldr pages from {}", archive_url))?;
let mut buf: Vec<u8> = vec![];
let bytes_downloaded = resp.copy_to(&mut buf)?;
debug!("{} bytes downloaded", bytes_downloaded);
Ok(buf)
}
pub fn update(&self, archive_url: &str) -> Result<()> {
self.ensure_cache_dir_exists()?;
let bytes: Vec<u8> = Self::download(archive_url)?;
let mut archive = ZipArchive::new(Cursor::new(bytes))
.context("Could not decompress downloaded ZIP archive")?;
self.clear()
.context("Could not clear the cache directory")?;
archive
.extract(&self.pages_dir())
.context("Could not unpack compressed data")?;
Ok(())
}
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
}
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,
}
}
fn get_platform_dir(&self) -> &'static str {
match self.platform {
PlatformType::Linux => "linux",
PlatformType::OsX => "osx",
PlatformType::SunOs => "sunos",
PlatformType::Windows => "windows",
PlatformType::Android => "android",
}
}
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())
}
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())
}
pub fn find_page(
&self,
name: &str,
languages: &[String],
custom_pages_dir: Option<&Path>,
) -> Option<PageLookupResult> {
let page_filename = format!("{}.md", name);
let patch_filename = format!("{}.patch", name);
let custom_filename = format!("{}.page", name);
let pages_dir = self.pages_dir();
let lang_dirs: Vec<String> = languages
.iter()
.map(|lang| {
if lang == "en" {
String::from("pages")
} else {
format!("pages.{}", lang)
}
})
.collect();
if let Some(config_dir) = custom_pages_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);
let platform_dir = self.get_platform_dir();
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));
}
Self::find_page_for_platform(&page_filename, &pages_dir, "common", &lang_dirs)
.map(|page| PageLookupResult::with_page(page).with_optional_patch(patch_path))
}
pub fn list_pages(&self, custom_pages_dir: Option<&Path>) -> Vec<String> {
let platforms_dir = self.pages_dir().join("pages");
let platform_dir = self.get_platform_dir();
let should_walk = |entry: &DirEntry| -> bool {
let file_type = entry.file_type();
let file_name = match entry.file_name().to_str() {
Some(name) => name,
None => return false,
};
if file_type.is_dir() {
return file_name == "common" || file_name == platform_dir;
} 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 mut pages = WalkDir::new(platforms_dir)
.min_depth(1) .into_iter()
.filter_entry(should_walk) .filter_map(Result::ok) .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 {
let extension = entry.path().extension().unwrap_or_default();
entry.file_type().is_file() && extension == "page"
};
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);
pages.extend(custom_pages);
}
pages.sort();
pages.dedup();
pages
}
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(),
);
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)
}
}
#[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");
let patch_path = dir.path().join("test.patch");
{
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");
{
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");
}
}