Skip to main content

mant_core/tldr/
cache.rs

1//! Resolves installed-client caches and reads tldr pages without network I/O.
2
3use std::{
4    collections::{BTreeMap, HashSet},
5    env,
6    error::Error,
7    ffi::OsStr,
8    fmt, fs, io,
9    os::unix::fs::PermissionsExt,
10    path::{Path, PathBuf},
11};
12
13use mant_ast::TldrDocument;
14
15use super::parser::{TldrPageLocation, TldrParseError, parse_tldr_page};
16
17const ALL_PLATFORMS: &[&str] = &[
18    "common",
19    "linux",
20    "osx",
21    "macos",
22    "windows",
23    "android",
24    "freebsd",
25    "openbsd",
26    "netbsd",
27    "sunos",
28    "cisco-ios",
29    "dos",
30];
31
32/// Native host families supported by `ManT` distributions.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HostPlatform {
35    Linux,
36    Macos,
37}
38
39impl HostPlatform {
40    /// Identify the current build target.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`TldrCacheError::UnsupportedPlatform`] outside Linux and macOS.
45    pub fn current() -> Result<Self, TldrCacheError> {
46        if cfg!(target_os = "linux") {
47            Ok(Self::Linux)
48        } else if cfg!(target_os = "macos") {
49            Ok(Self::Macos)
50        } else {
51            Err(TldrCacheError::UnsupportedPlatform)
52        }
53    }
54}
55
56/// Offline cache discovery or page-read failure.
57#[derive(Debug)]
58pub enum TldrCacheError {
59    UnsupportedPlatform,
60    MissingHomeDirectory,
61    Read {
62        path: PathBuf,
63        source: io::Error,
64    },
65    Parse {
66        path: PathBuf,
67        source: TldrParseError,
68    },
69}
70
71impl fmt::Display for TldrCacheError {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::UnsupportedPlatform => {
75                formatter.write_str("tldr cache lookup is supported only on Linux and macOS")
76            }
77            Self::MissingHomeDirectory => {
78                formatter.write_str("cannot locate a tldr cache without HOME")
79            }
80            Self::Read { path, source } => {
81                write!(
82                    formatter,
83                    "cannot read cached tldr page {}: {source}",
84                    path.display()
85                )
86            }
87            Self::Parse { path, source } => {
88                write!(
89                    formatter,
90                    "cannot parse cached tldr page {}: {source}",
91                    path.display()
92                )
93            }
94        }
95    }
96}
97
98impl Error for TldrCacheError {
99    fn source(&self) -> Option<&(dyn Error + 'static)> {
100        match self {
101            Self::Read { source, .. } => Some(source),
102            Self::Parse { source, .. } => Some(source),
103            Self::UnsupportedPlatform | Self::MissingHomeDirectory => None,
104        }
105    }
106}
107
108/// Resolve the `ManT`-owned fallback checkout for an explicit environment.
109///
110/// # Errors
111///
112/// Returns [`TldrCacheError::MissingHomeDirectory`] when neither an explicit
113/// override nor `HOME` is available.
114pub fn get_tldr_cache_dir(
115    environment: &BTreeMap<String, String>,
116    platform: HostPlatform,
117) -> Result<PathBuf, TldrCacheError> {
118    if let Some(path) = environment.get("MANT_TLDR_DIR") {
119        return Ok(PathBuf::from(path));
120    }
121    let home = home_dir(environment)?;
122    Ok(match platform {
123        HostPlatform::Linux => environment.get("XDG_CACHE_HOME").map_or_else(
124            || home.join(".cache").join("mant").join("tldr-pages"),
125            |cache| PathBuf::from(cache).join("mant").join("tldr-pages"),
126        ),
127        HostPlatform::Macos => home
128            .join("Library")
129            .join("Caches")
130            .join("mant")
131            .join("tldr-pages"),
132    })
133}
134
135/// Return known installed-client cache roots in priority order.
136///
137/// # Errors
138///
139/// Returns [`TldrCacheError::MissingHomeDirectory`] when `HOME` is absent.
140pub fn get_system_tldr_cache_dirs(
141    environment: &BTreeMap<String, String>,
142    platform: HostPlatform,
143) -> Result<Vec<PathBuf>, TldrCacheError> {
144    let home = home_dir(environment)?;
145    let portable_cache = environment
146        .get("XDG_CACHE_HOME")
147        .map_or_else(|| home.join(".cache"), PathBuf::from);
148    let native_cache = match platform {
149        HostPlatform::Linux => portable_cache.clone(),
150        HostPlatform::Macos => home.join("Library").join("Caches"),
151    };
152    let mut candidates = vec![
153        portable_cache.join("tldr"),
154        native_cache.join("tlrc"),
155        portable_cache.join("tlrc"),
156        native_cache.join("tealdeer").join("tldr-pages"),
157        portable_cache.join("tealdeer").join("tldr-pages"),
158        // Homebrew's `tldr` formula installs tldr-c-client, which extracts
159        // the upstream repository below this root on every supported host.
160        home.join(".tldrc").join("tldr"),
161        // The official Node client adds one private `cache` layer beneath its
162        // configured root (which defaults to ~/.tldr).
163        home.join(".tldr").join("cache"),
164        home.join(".tldr"),
165    ];
166
167    if let Some(value) = environment.get("XDG_DATA_DIRS") {
168        candidates.extend(
169            env::split_paths(OsStr::new(value))
170                .filter(|path| !path.as_os_str().is_empty())
171                .map(|path| path.join("tldr")),
172        );
173    } else {
174        candidates.extend(
175            ["/usr/local/share", "/usr/share"]
176                .into_iter()
177                .map(|path| PathBuf::from(path).join("tldr")),
178        );
179    }
180    Ok(deduplicate_paths(candidates))
181}
182
183/// Select installed-client caches or `ManT`'s private fallback checkout.
184///
185/// # Errors
186///
187/// Propagates cache path resolution failures.
188pub fn get_tldr_read_cache_dirs(
189    environment: &BTreeMap<String, String>,
190    platform: HostPlatform,
191    tldr_installed: bool,
192) -> Result<Vec<PathBuf>, TldrCacheError> {
193    if environment.contains_key("MANT_TLDR_DIR") {
194        return get_tldr_cache_dir(environment, platform).map(|path| vec![path]);
195    }
196    if tldr_installed {
197        get_system_tldr_cache_dirs(environment, platform)
198    } else {
199        get_tldr_cache_dir(environment, platform).map(|path| vec![path])
200    }
201}
202
203/// Resolve locale candidates, retaining first occurrence priority.
204#[must_use]
205pub fn get_tldr_languages(environment: &BTreeMap<String, String>) -> Vec<String> {
206    let mut languages = Vec::new();
207    if environment
208        .get("LANG")
209        .is_some_and(|lang| !matches!(lang.as_str(), "C" | "POSIX"))
210    {
211        if let Some(language) = environment.get("LANGUAGE") {
212            for locale in language.split(':') {
213                languages.extend(normalize_locale(locale));
214            }
215        }
216        if let Some(locale) = environment.get("LANG") {
217            languages.extend(normalize_locale(locale));
218        }
219    }
220    languages.push("en".to_owned());
221    deduplicate_strings(languages)
222}
223
224/// Resolve host, common, then cross-platform fallback page directories.
225#[must_use]
226pub fn get_tldr_platforms(platform: HostPlatform) -> Vec<String> {
227    let mut platforms = match platform {
228        HostPlatform::Linux => vec!["linux".to_owned()],
229        HostPlatform::Macos => vec!["osx".to_owned(), "macos".to_owned()],
230    };
231    platforms.extend(ALL_PLATFORMS.iter().map(ToString::to_string));
232    deduplicate_strings(platforms)
233}
234
235/// Convert a multi-word query to the tldr filename convention.
236#[must_use]
237pub fn normalize_tldr_topic(topic: &str) -> String {
238    topic
239        .trim()
240        .to_lowercase()
241        .split_whitespace()
242        .collect::<Vec<_>>()
243        .join("-")
244}
245
246/// Reject a normalized topic that would escape the platform page directory.
247///
248/// The topic becomes a single `<page>.md` filename joined onto a cache root,
249/// so it must be exactly one ordinary path component. Anything containing a
250/// path separator, a `.`/`..` segment, or an absolute or prefix component is
251/// refused before it reaches the filesystem, which prevents an untrusted topic
252/// (for example one supplied over MCP) from reading files outside the cache.
253fn is_safe_page_name(page_name: &str) -> bool {
254    let mut components = Path::new(page_name).components();
255    matches!(
256        (components.next(), components.next()),
257        (Some(std::path::Component::Normal(_)), None)
258    )
259}
260
261/// Read one cached tldr page using current host conventions; never updates it.
262///
263/// # Errors
264///
265/// Returns a cache path, I/O, or parser error. A missing page is `Ok(None)`.
266pub fn read_cached_tldr_page(topic: &str) -> Result<Option<TldrDocument>, TldrCacheError> {
267    let environment = env::vars().collect::<BTreeMap<_, _>>();
268    let platform = HostPlatform::current()?;
269    let cache_dirs = get_tldr_read_cache_dirs(
270        &environment,
271        platform,
272        find_executable("tldr", &environment).is_some(),
273    )?;
274    read_cached_tldr_page_with(
275        topic,
276        &cache_dirs,
277        &get_tldr_languages(&environment),
278        &get_tldr_platforms(platform),
279        &SystemFileReader,
280    )
281}
282
283trait TldrFileReader {
284    fn is_file(&self, path: &Path) -> bool;
285    fn read_to_string(&self, path: &Path) -> io::Result<String>;
286}
287
288struct SystemFileReader;
289
290impl TldrFileReader for SystemFileReader {
291    fn is_file(&self, path: &Path) -> bool {
292        path.is_file()
293    }
294
295    fn read_to_string(&self, path: &Path) -> io::Result<String> {
296        fs::read_to_string(path)
297    }
298}
299
300fn read_cached_tldr_page_with(
301    topic: &str,
302    cache_dirs: &[PathBuf],
303    languages: &[String],
304    platforms: &[String],
305    files: &dyn TldrFileReader,
306) -> Result<Option<TldrDocument>, TldrCacheError> {
307    let page_name = normalize_tldr_topic(topic);
308    if page_name.is_empty() || !is_safe_page_name(&page_name) {
309        return Ok(None);
310    }
311
312    // The client specification gives host platform precedence over language.
313    for platform in platforms {
314        for language in languages {
315            let page_directories = if language == "en" {
316                vec!["pages".to_owned(), "pages.en".to_owned()]
317            } else {
318                vec![format!("pages.{language}")]
319            };
320            for cache_dir in cache_dirs {
321                for pages in &page_directories {
322                    let source_path = cache_dir
323                        .join(pages)
324                        .join(platform)
325                        .join(format!("{page_name}.md"));
326                    if !files.is_file(&source_path) {
327                        continue;
328                    }
329                    let markdown = files.read_to_string(&source_path).map_err(|source| {
330                        TldrCacheError::Read {
331                            path: source_path.clone(),
332                            source,
333                        }
334                    })?;
335                    let page = parse_tldr_page(
336                        &markdown,
337                        TldrPageLocation {
338                            platform: platform.clone(),
339                            language: language.clone(),
340                            source_path: source_path.to_string_lossy().into_owned(),
341                        },
342                    )
343                    .map_err(|source| TldrCacheError::Parse {
344                        path: source_path,
345                        source,
346                    })?;
347                    return Ok(Some(page));
348                }
349            }
350        }
351    }
352    Ok(None)
353}
354
355fn home_dir(environment: &BTreeMap<String, String>) -> Result<PathBuf, TldrCacheError> {
356    environment
357        .get("HOME")
358        .filter(|home| !home.is_empty())
359        .map(PathBuf::from)
360        .ok_or(TldrCacheError::MissingHomeDirectory)
361}
362
363fn normalize_locale(locale: &str) -> Vec<String> {
364    let normalized = locale
365        .split('.')
366        .next()
367        .unwrap_or_default()
368        .replace('-', "_");
369    if normalized.is_empty() || matches!(normalized.as_str(), "C" | "POSIX") {
370        return Vec::new();
371    }
372    let language = normalized.split('_').next().unwrap_or_default().to_owned();
373    if normalized == language {
374        vec![language]
375    } else {
376        vec![normalized, language]
377    }
378}
379
380fn find_executable(name: &str, environment: &BTreeMap<String, String>) -> Option<PathBuf> {
381    let path = environment.get("PATH")?;
382    env::split_paths(OsStr::new(path))
383        .map(|directory| directory.join(name))
384        .find(|candidate| {
385            candidate.metadata().is_ok_and(|metadata| {
386                metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
387            })
388        })
389}
390
391fn deduplicate_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
392    let mut seen = HashSet::new();
393    paths
394        .into_iter()
395        .filter(|path| seen.insert(path.clone()))
396        .collect()
397}
398
399fn deduplicate_strings(values: Vec<String>) -> Vec<String> {
400    let mut seen = HashSet::new();
401    values
402        .into_iter()
403        .filter(|value| seen.insert(value.clone()))
404        .collect()
405}
406
407#[cfg(test)]
408mod tests {
409    use std::{
410        collections::{BTreeMap, HashMap},
411        io,
412        path::{Path, PathBuf},
413    };
414
415    use super::{
416        HostPlatform, TldrFileReader, get_system_tldr_cache_dirs, get_tldr_cache_dir,
417        get_tldr_languages, get_tldr_platforms, get_tldr_read_cache_dirs, normalize_tldr_topic,
418        read_cached_tldr_page_with,
419    };
420
421    const PAGE: &str = "# tar\n\n> Archiving utility.\n\n- List: `tar --list`\n";
422
423    #[derive(Default)]
424    struct MemoryFiles {
425        files: HashMap<PathBuf, String>,
426    }
427
428    impl TldrFileReader for MemoryFiles {
429        fn is_file(&self, path: &Path) -> bool {
430            self.files.contains_key(path)
431        }
432
433        fn read_to_string(&self, path: &Path) -> io::Result<String> {
434            self.files
435                .get(path)
436                .cloned()
437                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "memory fixture is missing"))
438        }
439    }
440
441    fn env(values: &[(&str, &str)]) -> BTreeMap<String, String> {
442        values
443            .iter()
444            .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
445            .collect()
446    }
447
448    #[test]
449    fn resolves_mant_and_installed_client_cache_conventions() {
450        let environment = env(&[("HOME", "/home/test"), ("XDG_CACHE_HOME", "/cache")]);
451        assert_eq!(
452            get_tldr_cache_dir(&environment, HostPlatform::Linux).expect("cache dir"),
453            PathBuf::from("/cache/mant/tldr-pages")
454        );
455        assert_eq!(
456            get_tldr_cache_dir(&environment, HostPlatform::Macos).expect("cache dir"),
457            PathBuf::from("/home/test/Library/Caches/mant/tldr-pages")
458        );
459        assert_eq!(
460            get_system_tldr_cache_dirs(&environment, HostPlatform::Linux).expect("system caches"),
461            [
462                "/cache/tldr",
463                "/cache/tlrc",
464                "/cache/tealdeer/tldr-pages",
465                "/home/test/.tldrc/tldr",
466                "/home/test/.tldr/cache",
467                "/home/test/.tldr",
468                "/usr/local/share/tldr",
469                "/usr/share/tldr",
470            ]
471            .map(PathBuf::from)
472        );
473        assert_eq!(
474            get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, false)
475                .expect("fallback cache"),
476            [PathBuf::from("/cache/mant/tldr-pages")]
477        );
478    }
479
480    #[test]
481    fn reads_homebrew_c_client_and_node_client_cache_layouts_on_macos() {
482        let environment = env(&[("HOME", "/Users/test")]);
483        let cache_dirs = get_system_tldr_cache_dirs(&environment, HostPlatform::Macos)
484            .expect("macOS client caches");
485
486        for source in [
487            PathBuf::from("/Users/test/.tldrc/tldr/pages/common/tar.md"),
488            PathBuf::from("/Users/test/.tldr/cache/pages/common/tar.md"),
489        ] {
490            let files = MemoryFiles {
491                files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
492            };
493            let page = read_cached_tldr_page_with(
494                "tar",
495                &cache_dirs,
496                &["en".to_owned()],
497                &["osx".to_owned(), "common".to_owned()],
498                &files,
499            )
500            .expect("cache read")
501            .expect("page");
502
503            assert_eq!(page.source_path, source.to_string_lossy());
504        }
505    }
506
507    #[test]
508    fn explicit_cache_is_independent_from_an_installed_client() {
509        let environment = env(&[("HOME", "/home/test"), ("MANT_TLDR_DIR", "/custom/tldr")]);
510        assert_eq!(
511            get_tldr_read_cache_dirs(&environment, HostPlatform::Linux, true)
512                .expect("explicit cache"),
513            [PathBuf::from("/custom/tldr")]
514        );
515    }
516
517    #[test]
518    fn normalizes_topic_locale_and_platform_priority() {
519        let environment = env(&[("LANG", "pt_BR.UTF-8"), ("LANGUAGE", "zh_TW:pt_BR")]);
520        assert_eq!(
521            get_tldr_languages(&environment),
522            ["zh_TW", "zh", "pt_BR", "pt", "en"]
523        );
524        assert_eq!(
525            &get_tldr_platforms(HostPlatform::Linux)[..3],
526            ["linux", "common", "osx"]
527        );
528        assert_eq!(normalize_tldr_topic(" Git Commit "), "git-commit");
529    }
530
531    #[test]
532    fn host_platform_precedes_a_translated_common_page() {
533        let root = PathBuf::from("/cache");
534        let english_linux = root.join("pages/linux/tar.md");
535        let translated_common = root.join("pages.zh/common/tar.md");
536        let files = MemoryFiles {
537            files: [
538                (english_linux.clone(), PAGE.to_owned()),
539                (translated_common, PAGE.to_owned()),
540            ]
541            .into_iter()
542            .collect(),
543        };
544        let page = read_cached_tldr_page_with(
545            "tar",
546            &[root],
547            &["zh".to_owned(), "en".to_owned()],
548            &["linux".to_owned(), "common".to_owned()],
549            &files,
550        )
551        .expect("cache read")
552        .expect("page");
553        assert_eq!(page.source_path, english_linux.to_string_lossy());
554        assert_eq!(page.language, "en");
555        assert_eq!(page.platform, "linux");
556    }
557
558    #[test]
559    fn reads_pages_dot_en_layout_after_repository_layout() {
560        let root = PathBuf::from("/cache/tlrc");
561        let source = root.join("pages.en/linux/tar.md");
562        let files = MemoryFiles {
563            files: [(source.clone(), PAGE.to_owned())].into_iter().collect(),
564        };
565        let page = read_cached_tldr_page_with(
566            "tar",
567            &[root],
568            &["en".to_owned()],
569            &["linux".to_owned()],
570            &files,
571        )
572        .expect("cache read")
573        .expect("page");
574        assert_eq!(page.source_path, source.to_string_lossy());
575    }
576
577    #[test]
578    fn refuses_topics_that_escape_the_platform_page_directory() {
579        let root = PathBuf::from("/cache");
580        // A page planted where a naive join of a traversal topic would land.
581        let escaped = PathBuf::from("/etc/hostname.md");
582        let files = MemoryFiles {
583            files: [(escaped, PAGE.to_owned())].into_iter().collect(),
584        };
585
586        for topic in ["../../../../etc/hostname", "/etc/hostname", "..", "a/b"] {
587            let result = read_cached_tldr_page_with(
588                topic,
589                std::slice::from_ref(&root),
590                &["en".to_owned()],
591                &["linux".to_owned()],
592                &files,
593            )
594            .expect("cache read must not error");
595            assert!(
596                result.is_none(),
597                "traversal topic {topic:?} must not resolve a page"
598            );
599        }
600    }
601
602    #[test]
603    fn only_single_ordinary_components_are_safe_page_names() {
604        assert!(super::is_safe_page_name("tar"));
605        assert!(super::is_safe_page_name("git-commit"));
606        assert!(!super::is_safe_page_name("../etc/passwd"));
607        assert!(!super::is_safe_page_name("/etc/passwd"));
608        assert!(!super::is_safe_page_name(".."));
609        assert!(!super::is_safe_page_name("a/b"));
610    }
611}