Skip to main content

mant_core/
source.rs

1//! Discovers and resolves local manual sources without invoking a man program.
2
3use std::{
4    collections::{BTreeMap, HashMap, HashSet},
5    env,
6    ffi::{OsStr, OsString},
7    fmt, fs,
8    path::{Path, PathBuf},
9};
10
11#[cfg(unix)]
12const DEFAULT_MANUAL_ROOTS: [&str; 4] = [
13    "/usr/local/share/man",
14    "/usr/local/man",
15    "/usr/share/man",
16    "/usr/man",
17];
18const SUPPORTED_COMPRESSION_SUFFIXES: [&str; 2] = [".gz", ".zst"];
19
20/// One validated manual lookup independent from CLI token syntax.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct ManualRequest {
23    pub name: String,
24    pub section: Option<String>,
25}
26
27impl ManualRequest {
28    #[must_use]
29    pub fn new(name: impl Into<String>, section: Option<String>) -> Self {
30        Self {
31            name: name.into(),
32            section,
33        }
34    }
35}
36
37/// One effective local manual page after path and locale precedence.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct ManualPage {
40    pub name: String,
41    pub section: String,
42    pub path: PathBuf,
43    /// Approved hierarchy root used to resolve this page's `.so` redirects.
44    pub manual_root: PathBuf,
45}
46
47/// Immutable index shared by discovery and exact manual lookup.
48#[derive(Clone, Debug, Default, Eq, PartialEq)]
49pub struct ManualIndex {
50    roots: Vec<PathBuf>,
51    pages: Vec<ManualPage>,
52}
53
54impl ManualIndex {
55    /// Scan explicit roots in precedence order.
56    #[must_use]
57    pub fn from_roots(roots: Vec<PathBuf>) -> Self {
58        let locale = current_locale();
59        Self::from_roots_with_locale(roots, locale.as_deref())
60    }
61
62    fn from_roots_with_locale(roots: Vec<PathBuf>, locale: Option<&str>) -> Self {
63        let roots = deduplicate_paths(roots);
64        let mut effective = BTreeMap::<(String, String), ManualPage>::new();
65        for root in &roots {
66            for page in scan_manual_root(root, locale) {
67                effective
68                    .entry((manual_name_key(&page.name), page.section.clone()))
69                    .or_insert(page);
70            }
71        }
72        Self {
73            roots,
74            pages: effective.into_values().collect(),
75        }
76    }
77
78    /// Roots searched by this index, in precedence order.
79    #[must_use]
80    pub fn roots(&self) -> &[PathBuf] {
81        &self.roots
82    }
83
84    /// Effective pages sorted by name and section.
85    #[must_use]
86    pub fn pages(&self) -> &[ManualPage] {
87        &self.pages
88    }
89
90    /// Resolve one page using an optional exact section selector.
91    #[must_use]
92    pub fn find(&self, name: &str, section: Option<&str>) -> Option<&ManualPage> {
93        let name = name.trim();
94        let section = section.map(str::trim);
95        self.pages.iter().find(|page| {
96            manual_names_equal(&page.name, name)
97                && section.is_none_or(|section| page.section == section)
98        })
99    }
100}
101
102fn manual_names_equal(left: &str, right: &str) -> bool {
103    #[cfg(windows)]
104    {
105        left.eq_ignore_ascii_case(right)
106    }
107    #[cfg(not(windows))]
108    {
109        left == right
110    }
111}
112
113fn manual_name_key(name: &str) -> String {
114    #[cfg(windows)]
115    {
116        name.to_ascii_lowercase()
117    }
118    #[cfg(not(windows))]
119    {
120        name.to_owned()
121    }
122}
123
124/// Minimal subprocess result shared by external data update operations.
125#[derive(Clone, Debug, Default, Eq, PartialEq)]
126pub(crate) struct CommandOutput {
127    pub stdout: Vec<u8>,
128    pub stderr: Vec<u8>,
129    pub exit_code: i32,
130}
131
132/// Expected source-discovery failures suitable for a user-facing CLI error.
133#[derive(Debug, Clone, Eq, PartialEq)]
134pub enum LocateError {
135    EmptyName,
136    InvalidSection,
137    NotFound { name: String },
138}
139
140impl fmt::Display for LocateError {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        match self {
143            Self::EmptyName => formatter.write_str("manual page name must not be empty"),
144            Self::InvalidSection => formatter.write_str("manual section must not be empty"),
145            Self::NotFound { name } => {
146                write!(formatter, "no local manual source was found for '{name}'")
147            }
148        }
149    }
150}
151
152impl std::error::Error for LocateError {}
153
154/// Discover manual roots from explicit variables and platform conventions.
155#[must_use]
156pub fn discover_manual_roots() -> Vec<PathBuf> {
157    let environment = env::vars_os().collect::<HashMap<_, _>>();
158    discover_manual_roots_with(&environment)
159}
160
161/// Locate a manual in an explicit immutable index.
162///
163/// # Errors
164///
165/// Returns [`LocateError`] for invalid requests and missing local sources.
166pub fn locate_manual_source_in(
167    request: &ManualRequest,
168    index: &ManualIndex,
169) -> Result<ManualPage, LocateError> {
170    let name = request.name.trim();
171    if name.is_empty() {
172        return Err(LocateError::EmptyName);
173    }
174    let section = request.section.as_deref().map(str::trim);
175    if section.is_some_and(str::is_empty) {
176        return Err(LocateError::InvalidSection);
177    }
178    index
179        .find(name, section)
180        .cloned()
181        .ok_or_else(|| LocateError::NotFound {
182            name: name.to_owned(),
183        })
184}
185
186fn discover_manual_roots_with(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
187    if let Some(explicit) = environment.get(OsStr::new("MANT_MANPATH")) {
188        return deduplicate_paths(
189            env::split_paths(explicit).filter(|path| !path.as_os_str().is_empty()),
190        );
191    }
192
193    let defaults = conventional_manual_roots(environment);
194    if let Some(manpath) = environment.get(OsStr::new("MANPATH")) {
195        let mut roots = Vec::new();
196        for path in env::split_paths(manpath) {
197            if path.as_os_str().is_empty() {
198                roots.extend(defaults.iter().cloned());
199            } else {
200                roots.push(path);
201            }
202        }
203        return deduplicate_paths(roots);
204    }
205    defaults
206}
207
208fn conventional_manual_roots(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
209    let mut roots = Vec::new();
210    #[cfg(windows)]
211    if let Some(profile) = environment
212        .get(OsStr::new("USERPROFILE"))
213        .map(PathBuf::from)
214    {
215        roots.push(profile.join(".local/share/man"));
216    }
217    #[cfg(unix)]
218    if let Some(home) = environment.get(OsStr::new("HOME")).map(PathBuf::from) {
219        roots.push(home.join(".local/share/man"));
220        roots.push(home.join(".local/man"));
221        roots.push(home.join("man"));
222    }
223    #[cfg(unix)]
224    if let Some(data_home) = environment
225        .get(OsStr::new("XDG_DATA_HOME"))
226        .map(PathBuf::from)
227    {
228        roots.push(data_home.join("man"));
229    }
230    #[cfg(unix)]
231    if let Some(data_dirs) = environment.get(OsStr::new("XDG_DATA_DIRS")) {
232        roots.extend(env::split_paths(data_dirs).map(|root| root.join("man")));
233    }
234    #[cfg(unix)]
235    if let Some(path) = environment.get(OsStr::new("PATH")) {
236        for binary_dir in env::split_paths(path) {
237            if let Some(prefix) = binary_dir.parent() {
238                roots.push(prefix.join("share/man"));
239                roots.push(prefix.join("man"));
240            }
241        }
242    }
243    #[cfg(unix)]
244    roots.extend(DEFAULT_MANUAL_ROOTS.map(PathBuf::from));
245    deduplicate_paths(roots)
246}
247
248fn deduplicate_paths(paths: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
249    let current_directory = env::current_dir().ok();
250    let mut seen = HashSet::new();
251    paths
252        .into_iter()
253        .filter(|path| !path.as_os_str().is_empty())
254        .filter_map(|path| {
255            if path.is_absolute() {
256                Some(path)
257            } else {
258                current_directory.as_ref().map(|current| current.join(path))
259            }
260        })
261        .filter(|path| seen.insert(path.clone()))
262        .collect()
263}
264
265fn current_locale() -> Option<String> {
266    ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]
267        .into_iter()
268        .find_map(|name| env::var(name).ok().filter(|value| !value.is_empty()))
269        .and_then(|value| normalize_locale(&value))
270}
271
272fn normalize_locale(locale: &str) -> Option<String> {
273    let locale = locale.split(['.', '@', ':']).next()?.trim();
274    (!locale.is_empty() && locale != "C" && locale != "POSIX").then(|| locale.to_owned())
275}
276
277fn scan_manual_root(root: &Path, locale: Option<&str>) -> Vec<ManualPage> {
278    let mut candidates = BTreeMap::<(String, String), (u8, PathBuf)>::new();
279    let canonical_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
280    scan_directory(root, root, &canonical_root, locale, &mut candidates);
281    candidates
282        .into_iter()
283        .map(|((name, section), (_, path))| ManualPage {
284            name,
285            section,
286            path,
287            manual_root: root.to_path_buf(),
288        })
289        .collect()
290}
291
292fn scan_directory(
293    root: &Path,
294    directory: &Path,
295    canonical_root: &Path,
296    locale: Option<&str>,
297    candidates: &mut BTreeMap<(String, String), (u8, PathBuf)>,
298) {
299    let Ok(entries) = fs::read_dir(directory) else {
300        return;
301    };
302    let mut entries = entries.flatten().collect::<Vec<_>>();
303    entries.sort_unstable_by_key(fs::DirEntry::file_name);
304    for entry in entries {
305        let path = entry.path();
306        let Ok(file_type) = entry.file_type() else {
307            continue;
308        };
309        if file_type.is_dir() {
310            scan_directory(root, &path, canonical_root, locale, candidates);
311            continue;
312        }
313        if !file_type.is_file() && !file_type.is_symlink() {
314            continue;
315        }
316        let Some((name, section)) = manual_identity(root, &path) else {
317            continue;
318        };
319        if !fs::canonicalize(&path).is_ok_and(|path| path.starts_with(canonical_root)) {
320            continue;
321        }
322        let priority = locale_priority(root, &path, locale);
323        let key = (name, section);
324        match candidates.get(&key) {
325            Some((current_priority, current_path))
326                if (*current_priority, current_path) <= (priority, &path) => {}
327            _ => {
328                candidates.insert(key, (priority, path));
329            }
330        }
331    }
332}
333
334fn manual_identity(root: &Path, path: &Path) -> Option<(String, String)> {
335    let relative = path.strip_prefix(root).ok()?;
336    let section_directory = relative.parent()?.components().find_map(|component| {
337        let component = component.as_os_str().to_str()?;
338        component
339            .strip_prefix("man")
340            .filter(|section| !section.is_empty())
341            .map(ToOwned::to_owned)
342    });
343    let filename = path.file_name()?.to_str()?;
344    let stem = SUPPORTED_COMPRESSION_SUFFIXES
345        .iter()
346        .find_map(|suffix| filename.strip_suffix(suffix))
347        .unwrap_or(filename);
348    let (name, file_section) = stem.rsplit_once('.')?;
349    let section_matches_directory = section_directory.as_ref().is_some_and(|directory| {
350        file_section == directory
351            || file_section
352                .strip_prefix(directory)
353                .is_some_and(|suffix| !suffix.is_empty() && suffix.chars().all(char::is_alphabetic))
354    });
355    let flat_root_page = relative.components().count() == 1 && valid_flat_section(file_section);
356    if name.is_empty() || (!section_matches_directory && !flat_root_page) {
357        return None;
358    }
359    Some((name.to_owned(), file_section.to_owned()))
360}
361
362fn valid_flat_section(section: &str) -> bool {
363    let mut characters = section.chars();
364    match characters.next() {
365        Some('1'..='9') => characters.all(char::is_alphabetic),
366        Some('l' | 'n') => characters.next().is_none(),
367        _ => false,
368    }
369}
370
371fn locale_priority(root: &Path, path: &Path, locale: Option<&str>) -> u8 {
372    let relative = path.strip_prefix(root).unwrap_or(path);
373    let localized = relative.components().next().is_some_and(|component| {
374        component
375            .as_os_str()
376            .to_str()
377            .is_some_and(|component| !component.starts_with("man"))
378    });
379    let Some(locale) = locale else {
380        return u8::from(localized);
381    };
382    if !localized {
383        return 2;
384    }
385    let language = locale.split('_').next().unwrap_or(locale);
386    let component = relative
387        .components()
388        .next()
389        .and_then(|component| component.as_os_str().to_str())
390        .unwrap_or_default();
391    if component == locale {
392        0
393    } else if component == language {
394        1
395    } else {
396        3
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use std::{collections::HashMap, ffi::OsString, fs, path::PathBuf};
403
404    use super::{
405        LocateError, ManualIndex, ManualRequest, deduplicate_paths, discover_manual_roots_with,
406        locate_manual_source_in, normalize_locale,
407    };
408
409    fn temporary_root(label: &str) -> PathBuf {
410        std::env::temp_dir().join(format!(
411            "mant-manual-index-{label}-{}-{:?}",
412            std::process::id(),
413            std::thread::current().id()
414        ))
415    }
416
417    #[test]
418    fn indexes_supported_sources_and_resolves_sections_without_man() {
419        let root = temporary_root("lookup");
420        fs::create_dir_all(root.join("man1")).expect("manual section");
421        fs::create_dir_all(root.join("man3")).expect("manual section");
422        fs::write(root.join("man1/printf.1.gz"), b"gzip placeholder").expect("manual");
423        fs::write(root.join("man3/printf.3.zst"), b"zstd placeholder").expect("manual");
424        fs::write(root.join("flat-tool.1"), b"flat manual").expect("flat manual");
425        fs::write(root.join("README.md"), b"not a manual").expect("readme");
426        fs::write(root.join("man1/ignored.1.xz"), b"unsupported").expect("manual");
427
428        let index = ManualIndex::from_roots(vec![root.clone()]);
429        assert_eq!(index.pages().len(), 3);
430        assert_eq!(
431            locate_manual_source_in(&ManualRequest::new("printf", None), &index)
432                .expect("default section")
433                .path,
434            root.join("man1/printf.1.gz")
435        );
436        assert_eq!(
437            locate_manual_source_in(&ManualRequest::new("printf", Some("3".to_owned())), &index,)
438                .expect("selected section")
439                .path,
440            root.join("man3/printf.3.zst")
441        );
442        assert_eq!(index.pages()[0].manual_root, root);
443        assert_eq!(
444            locate_manual_source_in(&ManualRequest::new("flat-tool", None), &index)
445                .expect("flat root page")
446                .path,
447            root.join("flat-tool.1")
448        );
449        assert!(matches!(
450            locate_manual_source_in(&ManualRequest::new("ignored", None), &index),
451            Err(LocateError::NotFound { .. })
452        ));
453
454        fs::remove_dir_all(root).expect("remove fixture");
455    }
456
457    #[cfg(unix)]
458    #[test]
459    fn indexes_only_symlinks_that_remain_inside_the_manual_root() {
460        use std::os::unix::fs::symlink;
461
462        let base = temporary_root("symlink-boundary");
463        let root = base.join("root");
464        let man1 = root.join("man1");
465        fs::create_dir_all(&man1).expect("manual section");
466        fs::write(man1.join("target.1"), ".TH TARGET 1\n").expect("inside target");
467        fs::write(base.join("outside.1"), ".TH OUTSIDE 1\n").expect("outside target");
468        symlink(man1.join("target.1"), man1.join("inside.1")).expect("inside symlink");
469        symlink(base.join("outside.1"), man1.join("outside.1")).expect("outside symlink");
470
471        let index = ManualIndex::from_roots(vec![root]);
472        assert!(index.find("inside", Some("1")).is_some());
473        assert!(index.find("outside", Some("1")).is_none());
474        fs::remove_dir_all(base).expect("remove fixture");
475    }
476
477    #[test]
478    fn root_and_locale_precedence_are_deterministic() {
479        let root = temporary_root("precedence");
480        let first = root.join("first");
481        let second = root.join("second");
482        for path in [
483            first.join("man1/tool.1"),
484            first.join("zh/man1/tool.1"),
485            first.join("zh_CN/man1/tool.1"),
486            second.join("man1/tool.1"),
487        ] {
488            fs::create_dir_all(path.parent().expect("manual parent")).expect("manual section");
489            fs::write(path, b"manual").expect("manual");
490        }
491
492        let index = ManualIndex::from_roots_with_locale(vec![first.clone(), second], Some("zh_CN"));
493        assert_eq!(index.pages()[0].path, first.join("zh_CN/man1/tool.1"));
494
495        fs::remove_dir_all(root).expect("remove fixture");
496    }
497
498    #[test]
499    fn locale_names_drop_encodings_modifiers_and_language_fallbacks() {
500        assert_eq!(
501            normalize_locale("zh_CN.UTF-8@variant"),
502            Some("zh_CN".to_owned())
503        );
504        assert_eq!(normalize_locale("de_DE:en_US"), Some("de_DE".to_owned()));
505        assert_eq!(normalize_locale("C"), None);
506    }
507
508    #[cfg(unix)]
509    #[test]
510    fn explicit_manpath_overrides_conventions_and_empty_components_restore_them() {
511        let explicit = PathBuf::from("/opt/manuals");
512        let mut environment = HashMap::from([
513            (OsString::from("HOME"), OsString::from("/home/demo")),
514            (OsString::from("PATH"), OsString::from("/opt/bin:/usr/bin")),
515            (
516                OsString::from("MANT_MANPATH"),
517                explicit.as_os_str().to_owned(),
518            ),
519        ]);
520        assert_eq!(discover_manual_roots_with(&environment), vec![explicit]);
521
522        environment.remove(&OsString::from("MANT_MANPATH"));
523        environment.insert(OsString::from("MANPATH"), OsString::from(":"));
524        let roots = discover_manual_roots_with(&environment);
525        assert!(roots.contains(&PathBuf::from("/home/demo/.local/share/man")));
526        assert!(roots.contains(&PathBuf::from("/opt/share/man")));
527        assert!(roots.contains(&PathBuf::from("/usr/share/man")));
528    }
529
530    #[cfg(windows)]
531    #[test]
532    fn windows_defaults_to_user_share_man_and_honors_manpath() {
533        let profile = PathBuf::from(r"C:\Users\demo");
534        let environment = HashMap::from([(
535            OsString::from("USERPROFILE"),
536            profile.as_os_str().to_owned(),
537        )]);
538        assert_eq!(
539            discover_manual_roots_with(&environment),
540            vec![profile.join(".local/share/man")]
541        );
542
543        let custom = PathBuf::from(r"D:\manuals");
544        let mut environment = environment;
545        environment.insert(
546            OsString::from("MANPATH"),
547            std::env::join_paths([&custom]).expect("join Windows MANPATH"),
548        );
549        assert_eq!(discover_manual_roots_with(&environment), vec![custom]);
550    }
551
552    #[test]
553    fn relative_manual_roots_are_resolved_for_stable_catalog_paths() {
554        let roots = deduplicate_paths([PathBuf::from("project-man")]);
555        assert_eq!(roots.len(), 1);
556        assert!(roots[0].is_absolute());
557        assert!(roots[0].ends_with("project-man"));
558    }
559
560    #[test]
561    fn invalid_requests_fail_before_lookup() {
562        let index = ManualIndex::default();
563        assert_eq!(
564            locate_manual_source_in(&ManualRequest::new(" ", None), &index),
565            Err(LocateError::EmptyName)
566        );
567        assert_eq!(
568            locate_manual_source_in(&ManualRequest::new("git", Some(" ".to_owned())), &index,),
569            Err(LocateError::InvalidSection)
570        );
571    }
572
573    #[cfg(windows)]
574    #[test]
575    fn windows_manual_names_are_ascii_case_insensitive() {
576        let index = ManualIndex {
577            roots: vec![PathBuf::from(r"C:\man")],
578            pages: vec![super::ManualPage {
579                name: "cargo.exe".to_owned(),
580                section: "1".to_owned(),
581                path: PathBuf::from(r"C:\man\cargo.exe.1"),
582                manual_root: PathBuf::from(r"C:\man"),
583            }],
584        };
585
586        assert!(index.find("cargo.EXE", None).is_some());
587    }
588}