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