1use std::{
4 collections::{BTreeMap, HashSet},
5 env,
6 error::Error,
7 ffi::OsStr,
8 fmt, fs, io,
9 path::{Path, PathBuf},
10};
11
12use mant_ir::TldrDocument;
13
14use crate::executable::{environment_value, find_executable};
15
16use super::parser::{TldrPageLocation, TldrParseError, parse_tldr_page};
17
18const ALL_PLATFORMS: &[&str] = &[
19 "common",
20 "linux",
21 "osx",
22 "macos",
23 "windows",
24 "android",
25 "freebsd",
26 "openbsd",
27 "netbsd",
28 "sunos",
29 "cisco-ios",
30 "dos",
31];
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum HostPlatform {
36 Linux,
38 Macos,
40 Windows,
42}
43
44impl HostPlatform {
45 pub fn current() -> Result<Self, TldrCacheError> {
51 if cfg!(target_os = "linux") {
52 Ok(Self::Linux)
53 } else if cfg!(target_os = "macos") {
54 Ok(Self::Macos)
55 } else if cfg!(windows) {
56 Ok(Self::Windows)
57 } else {
58 Err(TldrCacheError::UnsupportedPlatform)
59 }
60 }
61}
62
63#[derive(Debug)]
65pub enum TldrCacheError {
66 UnsupportedPlatform,
68 MissingHomeDirectory,
70 MissingLocalAppData,
72 Read {
74 path: PathBuf,
76 source: io::Error,
78 },
79 Parse {
81 path: PathBuf,
83 source: TldrParseError,
85 },
86}
87
88impl fmt::Display for TldrCacheError {
89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90 match self {
91 Self::UnsupportedPlatform => {
92 formatter.write_str("tldr cache lookup is unsupported on this platform")
93 }
94 Self::MissingHomeDirectory => {
95 formatter.write_str("cannot locate a tldr cache without HOME")
96 }
97 Self::MissingLocalAppData => {
98 formatter.write_str("cannot locate a tldr cache without LOCALAPPDATA")
99 }
100 Self::Read { path, source } => {
101 write!(
102 formatter,
103 "cannot read cached tldr page {}: {source}",
104 path.display()
105 )
106 }
107 Self::Parse { path, source } => {
108 write!(
109 formatter,
110 "cannot parse cached tldr page {}: {source}",
111 path.display()
112 )
113 }
114 }
115 }
116}
117
118impl Error for TldrCacheError {
119 fn source(&self) -> Option<&(dyn Error + 'static)> {
120 match self {
121 Self::Read { source, .. } => Some(source),
122 Self::Parse { source, .. } => Some(source),
123 Self::UnsupportedPlatform | Self::MissingHomeDirectory | Self::MissingLocalAppData => {
124 None
125 }
126 }
127 }
128}
129
130pub fn get_tldr_cache_dir(
137 environment: &BTreeMap<String, String>,
138 platform: HostPlatform,
139) -> Result<PathBuf, TldrCacheError> {
140 if let Some(path) = environment_value(environment, "MANT_TLDR_DIR") {
141 return Ok(PathBuf::from(path));
142 }
143 match platform {
144 HostPlatform::Linux => {
145 let home = home_dir(environment)?;
146 Ok(
147 environment_value(environment, "XDG_CACHE_HOME").map_or_else(
148 || home.join(".cache").join("mant").join("tldr-pages"),
149 |cache| PathBuf::from(cache).join("mant").join("tldr-pages"),
150 ),
151 )
152 }
153 HostPlatform::Macos => Ok(home_dir(environment)?
154 .join("Library")
155 .join("Caches")
156 .join("ManT")
157 .join("tldr-pages")),
158 HostPlatform::Windows => Ok(local_app_data(environment)?
159 .join("ManT")
160 .join("cache")
161 .join("tldr-pages")),
162 }
163}
164
165pub fn get_system_tldr_cache_dirs(
171 environment: &BTreeMap<String, String>,
172 platform: HostPlatform,
173) -> Result<Vec<PathBuf>, TldrCacheError> {
174 if platform == HostPlatform::Windows {
175 let local = local_app_data(environment)?;
176 let roaming = environment_value(environment, "APPDATA").map(PathBuf::from);
177 let mut candidates = vec![
178 local.join("tldr"),
179 local.join("tlrc"),
180 local.join("tealdeer").join("tldr-pages"),
181 ];
182 if let Some(roaming) = roaming {
183 candidates.push(roaming.join("tldr"));
184 candidates.push(roaming.join("tlrc"));
185 }
186 if let Some(home) = optional_home_dir(environment) {
187 candidates.push(home.join(".tldrc").join("tldr"));
188 candidates.push(home.join(".tldr").join("cache"));
189 candidates.push(home.join(".tldr"));
190 }
191 return Ok(deduplicate_paths(candidates));
192 }
193
194 let home = home_dir(environment)?;
195 let portable_cache = environment_value(environment, "XDG_CACHE_HOME")
196 .map_or_else(|| home.join(".cache"), PathBuf::from);
197 let native_cache = match platform {
198 HostPlatform::Linux => portable_cache.clone(),
199 HostPlatform::Macos => home.join("Library").join("Caches"),
200 HostPlatform::Windows => unreachable!("Windows returned above"),
201 };
202 let mut candidates = vec![
203 portable_cache.join("tldr"),
204 native_cache.join("tlrc"),
205 portable_cache.join("tlrc"),
206 native_cache.join("tealdeer").join("tldr-pages"),
207 portable_cache.join("tealdeer").join("tldr-pages"),
208 home.join(".tldrc").join("tldr"),
211 home.join(".tldr").join("cache"),
214 home.join(".tldr"),
215 ];
216
217 if let Some(value) = environment_value(environment, "XDG_DATA_DIRS") {
218 candidates.extend(
219 env::split_paths(OsStr::new(value))
220 .filter(|path| !path.as_os_str().is_empty())
221 .map(|path| path.join("tldr")),
222 );
223 } else {
224 candidates.extend(
225 ["/usr/local/share", "/usr/share"]
226 .into_iter()
227 .map(|path| PathBuf::from(path).join("tldr")),
228 );
229 }
230 Ok(deduplicate_paths(candidates))
231}
232
233pub fn get_tldr_read_cache_dirs(
239 environment: &BTreeMap<String, String>,
240 platform: HostPlatform,
241 tldr_installed: bool,
242) -> Result<Vec<PathBuf>, TldrCacheError> {
243 if environment_value(environment, "MANT_TLDR_DIR").is_some() {
244 return get_tldr_cache_dir(environment, platform).map(|path| vec![path]);
245 }
246 let private_cache = get_tldr_cache_dir(environment, platform)?;
247 if !tldr_installed {
248 return Ok(vec![private_cache]);
249 }
250 let mut caches = get_system_tldr_cache_dirs(environment, platform)?;
251 caches.push(private_cache);
252 Ok(deduplicate_paths(caches))
253}
254
255#[must_use]
257pub fn get_tldr_languages(environment: &BTreeMap<String, String>) -> Vec<String> {
258 let mut languages = Vec::new();
259 if environment
260 .get("LANG")
261 .is_some_and(|lang| !matches!(lang.as_str(), "C" | "POSIX"))
262 {
263 if let Some(language) = environment.get("LANGUAGE") {
264 for locale in language.split(':') {
265 languages.extend(normalize_locale(locale));
266 }
267 }
268 if let Some(locale) = environment.get("LANG") {
269 languages.extend(normalize_locale(locale));
270 }
271 }
272 languages.push("en".to_owned());
273 deduplicate_strings(languages)
274}
275
276#[must_use]
278pub fn get_tldr_platforms(platform: HostPlatform) -> Vec<String> {
279 let mut platforms = match platform {
280 HostPlatform::Linux => vec!["linux".to_owned()],
281 HostPlatform::Macos => vec!["osx".to_owned(), "macos".to_owned()],
282 HostPlatform::Windows => vec!["windows".to_owned()],
283 };
284 platforms.extend(ALL_PLATFORMS.iter().map(ToString::to_string));
285 deduplicate_strings(platforms)
286}
287
288#[must_use]
290pub fn normalize_tldr_topic(topic: &str) -> String {
291 topic
292 .trim()
293 .to_lowercase()
294 .split_whitespace()
295 .collect::<Vec<_>>()
296 .join("-")
297}
298
299fn is_safe_page_name(page_name: &str) -> bool {
307 let mut components = Path::new(page_name).components();
308 matches!(
309 (components.next(), components.next()),
310 (Some(std::path::Component::Normal(_)), None)
311 )
312}
313
314pub fn read_cached_tldr_page(topic: &str) -> Result<Option<TldrDocument>, TldrCacheError> {
320 let environment = env::vars().collect::<BTreeMap<_, _>>();
321 let platform = HostPlatform::current()?;
322 let cache_dirs = get_tldr_read_cache_dirs(
323 &environment,
324 platform,
325 find_executable("tldr", &environment).is_some(),
326 )?;
327 read_cached_tldr_page_with(
328 topic,
329 &cache_dirs,
330 &get_tldr_languages(&environment),
331 &get_tldr_platforms(platform),
332 &SystemFileReader,
333 )
334}
335
336trait TldrFileReader {
337 fn is_file(&self, path: &Path) -> bool;
338 fn read_to_string(&self, path: &Path) -> io::Result<String>;
339}
340
341struct SystemFileReader;
342
343impl TldrFileReader for SystemFileReader {
344 fn is_file(&self, path: &Path) -> bool {
345 path.is_file()
346 }
347
348 fn read_to_string(&self, path: &Path) -> io::Result<String> {
349 let file = fs::File::open(path)?;
353 crate::bounded::read_utf8(file, crate::query::MAX_MARKDOWN_BYTES, "Markdown document")
354 }
355}
356
357fn read_cached_tldr_page_with(
358 topic: &str,
359 cache_dirs: &[PathBuf],
360 languages: &[String],
361 platforms: &[String],
362 files: &dyn TldrFileReader,
363) -> Result<Option<TldrDocument>, TldrCacheError> {
364 let page_name = normalize_tldr_topic(topic);
365 if page_name.is_empty() || !is_safe_page_name(&page_name) {
366 return Ok(None);
367 }
368
369 for platform in platforms {
371 for language in languages {
372 let page_directories = if language == "en" {
373 vec!["pages".to_owned(), "pages.en".to_owned()]
374 } else {
375 vec![format!("pages.{language}")]
376 };
377 for cache_dir in cache_dirs {
378 for pages in &page_directories {
379 let source_path = cache_dir
380 .join(pages)
381 .join(platform)
382 .join(format!("{page_name}.md"));
383 if !files.is_file(&source_path) {
384 continue;
385 }
386 let markdown = files.read_to_string(&source_path).map_err(|source| {
387 TldrCacheError::Read {
388 path: source_path.clone(),
389 source,
390 }
391 })?;
392 let page = parse_tldr_page(
393 &markdown,
394 TldrPageLocation {
395 platform: platform.clone(),
396 language: language.clone(),
397 source_path: source_path.to_string_lossy().into_owned(),
398 },
399 )
400 .map_err(|source| TldrCacheError::Parse {
401 path: source_path,
402 source,
403 })?;
404 return Ok(Some(page));
405 }
406 }
407 }
408 }
409 Ok(None)
410}
411
412fn home_dir(environment: &BTreeMap<String, String>) -> Result<PathBuf, TldrCacheError> {
413 optional_home_dir(environment).ok_or(TldrCacheError::MissingHomeDirectory)
414}
415
416fn optional_home_dir(environment: &BTreeMap<String, String>) -> Option<PathBuf> {
417 environment_value(environment, "HOME")
418 .or_else(|| environment_value(environment, "USERPROFILE"))
419 .filter(|home| !home.is_empty())
420 .map(PathBuf::from)
421}
422
423fn local_app_data(environment: &BTreeMap<String, String>) -> Result<PathBuf, TldrCacheError> {
424 environment_value(environment, "LOCALAPPDATA")
425 .filter(|path| !path.is_empty())
426 .map(PathBuf::from)
427 .ok_or(TldrCacheError::MissingLocalAppData)
428}
429
430fn normalize_locale(locale: &str) -> Vec<String> {
431 let normalized = locale
432 .split('.')
433 .next()
434 .unwrap_or_default()
435 .replace('-', "_");
436 if normalized.is_empty() || matches!(normalized.as_str(), "C" | "POSIX") {
437 return Vec::new();
438 }
439 let language = normalized.split('_').next().unwrap_or_default().to_owned();
440 if normalized == language {
441 vec![language]
442 } else {
443 vec![normalized, language]
444 }
445}
446
447fn deduplicate_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
448 let mut seen = HashSet::new();
449 paths
450 .into_iter()
451 .filter(|path| seen.insert(path.clone()))
452 .collect()
453}
454
455fn deduplicate_strings(values: Vec<String>) -> Vec<String> {
456 let mut seen = HashSet::new();
457 values
458 .into_iter()
459 .filter(|value| seen.insert(value.clone()))
460 .collect()
461}
462
463#[cfg(test)]
464mod tests {
465 use std::{
466 collections::{BTreeMap, HashMap},
467 fs, io,
468 path::{Path, PathBuf},
469 };
470
471 use super::{
472 HostPlatform, SystemFileReader, TldrFileReader, get_system_tldr_cache_dirs,
473 get_tldr_cache_dir, get_tldr_languages, get_tldr_platforms, get_tldr_read_cache_dirs,
474 normalize_tldr_topic, read_cached_tldr_page_with,
475 };
476 use crate::query::MAX_MARKDOWN_BYTES;
477
478 const PAGE: &str = "# tar\n\n> Archiving utility.\n\n- List: `tar --list`\n";
479
480 #[derive(Default)]
481 struct MemoryFiles {
482 files: HashMap<PathBuf, String>,
483 }
484
485 impl TldrFileReader for MemoryFiles {
486 fn is_file(&self, path: &Path) -> bool {
487 self.files.contains_key(path)
488 }
489
490 fn read_to_string(&self, path: &Path) -> io::Result<String> {
491 self.files
492 .get(path)
493 .cloned()
494 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "memory fixture is missing"))
495 }
496 }
497
498 fn env(values: &[(&str, &str)]) -> BTreeMap<String, String> {
499 values
500 .iter()
501 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
502 .collect()
503 }
504
505 #[test]
506 fn resolves_mant_and_installed_client_cache_conventions() {
507 let environment = env(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]);
508 assert_eq!(
509 get_tldr_cache_dir(&environment, HostPlatform::Linux).expect("cache dir"),
510 PathBuf::from("/cache/mant/tldr-pages")
511 );
512 assert_eq!(
513 get_tldr_cache_dir(&environment, HostPlatform::Macos).expect("cache dir"),
514 PathBuf::from("/home/test/Library/Caches/ManT/tldr-pages")
515 );
516 assert_eq!(
517 get_system_tldr_cache_dirs(&environment, HostPlatform::Linux).expect("system caches"),
518 [
519 "/cache/tldr",
520 "/cache/tlrc",
521 "/cache/tealdeer/tldr-pages",
522 "/home/test/.tldrc/tldr",
523 "/home/test/.tldr/cache",
524 "/home/test/.tldr",
525 "/usr/local/share/tldr",
526 "/usr/share/tldr",
527 ]
528 .map(PathBuf::from)
529 );
530 assert_eq!(
531 get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, false)
532 .expect("fallback cache"),
533 [PathBuf::from("/cache/mant/tldr-pages")]
534 );
535 assert_eq!(
536 get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
537 .expect("client caches and fallback"),
538 [
539 "/cache/tldr",
540 "/cache/tlrc",
541 "/cache/tealdeer/tldr-pages",
542 "/home/test/.tldrc/tldr",
543 "/home/test/.tldr/cache",
544 "/home/test/.tldr",
545 "/usr/local/share/tldr",
546 "/usr/share/tldr",
547 "/cache/mant/tldr-pages",
548 ]
549 .map(PathBuf::from)
550 );
551 }
552
553 #[test]
554 fn windows_uses_local_application_data_for_private_and_client_caches() {
555 let environment = env(&[
556 ("LOCALAPPDATA", r"C:\Users\test\AppData\Local"),
557 ("APPDATA", r"C:\Users\test\AppData\Roaming"),
558 ("USERPROFILE", r"C:\Users\test"),
559 ]);
560 assert_eq!(
561 get_tldr_cache_dir(&environment, HostPlatform::Windows).expect("private cache"),
562 PathBuf::from(r"C:\Users\test\AppData\Local").join("ManT/cache/tldr-pages")
563 );
564 assert_eq!(
565 get_system_tldr_cache_dirs(&environment, HostPlatform::Windows).expect("client caches"),
566 [
567 PathBuf::from(r"C:\Users\test\AppData\Local").join("tldr"),
568 PathBuf::from(r"C:\Users\test\AppData\Local").join("tlrc"),
569 PathBuf::from(r"C:\Users\test\AppData\Local").join("tealdeer/tldr-pages"),
570 PathBuf::from(r"C:\Users\test\AppData\Roaming").join("tldr"),
571 PathBuf::from(r"C:\Users\test\AppData\Roaming").join("tlrc"),
572 PathBuf::from(r"C:\Users\test").join(".tldrc/tldr"),
573 PathBuf::from(r"C:\Users\test").join(".tldr/cache"),
574 PathBuf::from(r"C:\Users\test").join(".tldr"),
575 ]
576 );
577 assert_eq!(
578 &get_tldr_platforms(HostPlatform::Windows)[..2],
579 ["windows", "common"]
580 );
581 }
582
583 #[test]
584 fn reads_homebrew_c_client_and_node_client_cache_layouts_on_macos() {
585 let environment = env(&[("HOME", "/Users/test")]);
586 let cache_dirs = get_system_tldr_cache_dirs(&environment, HostPlatform::Macos)
587 .expect("macOS client caches");
588
589 for source in [
590 PathBuf::from("/Users/test/.tldrc/tldr/pages/common/tar.md"),
591 PathBuf::from("/Users/test/.tldr/cache/pages/common/tar.md"),
592 ] {
593 let files = MemoryFiles {
594 files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
595 };
596 let page = read_cached_tldr_page_with(
597 "tar",
598 &cache_dirs,
599 &["en".to_owned()],
600 &["osx".to_owned(), "common".to_owned()],
601 &files,
602 )
603 .expect("cache read")
604 .expect("page");
605
606 assert_eq!(Path::new(&page.source_path), source);
607 }
608 }
609
610 #[test]
611 fn installed_client_miss_falls_back_to_mant_private_cache() {
612 let environment = env(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]);
613 let cache_dirs = get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
614 .expect("client and private caches");
615 let private_page = PathBuf::from("/cache/mant/tldr-pages/pages/common/tar.md");
616 let files = MemoryFiles {
617 files: [(private_page.clone(), PAGE.to_owned())]
618 .into_iter()
619 .collect(),
620 };
621
622 let page = read_cached_tldr_page_with(
623 "tar",
624 &cache_dirs,
625 &["en".to_owned()],
626 &["linux".to_owned(), "common".to_owned()],
627 &files,
628 )
629 .expect("cache read")
630 .expect("private fallback page");
631
632 assert_eq!(Path::new(&page.source_path), private_page);
633 }
634
635 #[test]
636 fn explicit_cache_is_independent_from_an_installed_client() {
637 let environment = env(&[("HOME", "/home/test"), ("MANT_TLDR_DIR", "/custom/tldr")]);
638 assert_eq!(
639 get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
640 .expect("explicit cache"),
641 [PathBuf::from("/custom/tldr")]
642 );
643 }
644
645 #[test]
646 fn normalizes_topic_locale_and_platform_priority() {
647 let environment = env(&[("LANG", "pt_BR.UTF-8"), ("LANGUAGE", "zh_TW:pt_BR")]);
648 assert_eq!(
649 get_tldr_languages(&environment),
650 ["zh_TW", "zh", "pt_BR", "pt", "en"]
651 );
652 assert_eq!(
653 &get_tldr_platforms(HostPlatform::Linux)[..3],
654 ["linux", "common", "osx"]
655 );
656 assert_eq!(normalize_tldr_topic(" Git Commit "), "git-commit");
657 }
658
659 #[test]
660 fn host_platform_precedes_a_translated_common_page() {
661 let root = PathBuf::from("/cache");
662 let english_linux = root.join("pages/linux/tar.md");
663 let translated_common = root.join("pages.zh/common/tar.md");
664 let files = MemoryFiles {
665 files: [
666 (english_linux.clone(), PAGE.to_owned()),
667 (translated_common, PAGE.to_owned()),
668 ]
669 .into_iter()
670 .collect(),
671 };
672 let page = read_cached_tldr_page_with(
673 "tar",
674 &[root],
675 &["zh".to_owned(), "en".to_owned()],
676 &["linux".to_owned(), "common".to_owned()],
677 &files,
678 )
679 .expect("cache read")
680 .expect("page");
681 assert_eq!(Path::new(&page.source_path), english_linux);
682 assert_eq!(page.language, "en");
683 assert_eq!(page.platform, "linux");
684 }
685
686 #[test]
687 fn reads_pages_dot_en_layout_after_repository_layout() {
688 let root = PathBuf::from("/cache/tlrc");
689 let source = root.join("pages.en/linux/tar.md");
690 let files = MemoryFiles {
691 files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
692 };
693 let page = read_cached_tldr_page_with(
694 "tar",
695 &[root],
696 &["en".to_owned()],
697 &["linux".to_owned()],
698 &files,
699 )
700 .expect("cache read")
701 .expect("page");
702 assert_eq!(Path::new(&page.source_path), source);
703 }
704
705 #[test]
706 fn refuses_topics_that_escape_the_platform_page_directory() {
707 let root = PathBuf::from("/cache");
708 let escaped = PathBuf::from("/etc/hostname.md");
710 let files = MemoryFiles {
711 files: [(escaped, PAGE.to_owned())].into_iter().collect(),
712 };
713
714 for topic in ["../../../../etc/hostname", "/etc/hostname", "..", "a/b"] {
715 let result = read_cached_tldr_page_with(
716 topic,
717 std::slice::from_ref(&root),
718 &["en".to_owned()],
719 &["linux".to_owned()],
720 &files,
721 )
722 .expect("cache read must not error");
723 assert!(
724 result.is_none(),
725 "traversal topic {topic:?} must not resolve a page"
726 );
727 }
728 }
729
730 #[test]
731 fn only_single_ordinary_components_are_safe_page_names() {
732 assert!(super::is_safe_page_name("tar"));
733 assert!(super::is_safe_page_name("git-commit"));
734 assert!(!super::is_safe_page_name("../etc/passwd"));
735 assert!(!super::is_safe_page_name("/etc/passwd"));
736 assert!(!super::is_safe_page_name(".."));
737 assert!(!super::is_safe_page_name("a/b"));
738 }
739
740 fn temporary_page(label: &str) -> PathBuf {
741 std::env::temp_dir().join(format!(
742 "mant-tldr-cap-{label}-{}-{:?}.md",
743 std::process::id(),
744 std::thread::current().id()
745 ))
746 }
747
748 #[test]
749 fn system_reader_reads_ordinary_pages_and_rejects_oversized_ones() {
750 let ordinary = temporary_page("ordinary");
754 fs::write(&ordinary, PAGE).expect("write ordinary page");
755 assert_eq!(
756 SystemFileReader
757 .read_to_string(&ordinary)
758 .expect("ordinary page reads"),
759 PAGE
760 );
761 fs::remove_file(&ordinary).expect("remove ordinary fixture");
762
763 let oversized = temporary_page("oversized");
764 let bytes = usize::try_from(MAX_MARKDOWN_BYTES).expect("limit fits usize") + 1;
765 fs::write(&oversized, vec![b'a'; bytes]).expect("write oversized page");
766 let error = SystemFileReader
767 .read_to_string(&oversized)
768 .expect_err("oversized page is refused");
769 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
770 fs::remove_file(&oversized).expect("remove oversized fixture");
771 }
772}