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