Skip to main content

mant_engine/
source.rs

1//! Discovers and resolves local manual sources without invoking a man program.
2
3use std::{
4    collections::{BTreeMap, HashSet},
5    env, fmt, fs,
6    path::{Path, PathBuf},
7};
8
9const SUPPORTED_COMPRESSION_SUFFIXES: [&str; 2] = [".gz", ".zst"];
10const DEFAULT_MANUAL_SECTIONS: [&str; 16] = [
11    "1", "1p", "n", "l", "8", "3", "3p", "0", "0p", "2", "3type", "5", "4", "9", "6", "7",
12];
13
14/// One validated manual lookup independent from CLI token syntax.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ManualRequest {
17    /// Manual topic without its category suffix.
18    pub name: String,
19    /// Optional exact native category such as `1` or `3p`.
20    pub manual_section: Option<String>,
21}
22
23impl ManualRequest {
24    /// Construct a normalized lookup request without performing I/O.
25    #[must_use]
26    pub fn new(name: impl Into<String>, manual_section: Option<String>) -> Self {
27        Self {
28            name: name.into(),
29            manual_section,
30        }
31    }
32}
33
34/// One effective local manual page after path and locale precedence.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct ManualPage {
37    /// Indexed manual topic.
38    pub name: String,
39    /// Native manual category derived from the containing `man<section>` tree.
40    pub section: String,
41    /// Physical source path, possibly compressed.
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        let section_order = current_manual_section_order();
63        Self::from_roots_with_locale_and_sections(roots, locale.as_deref(), &section_order)
64    }
65
66    #[cfg(test)]
67    fn from_roots_with_locale(roots: Vec<PathBuf>, locale: Option<&str>) -> Self {
68        Self::from_roots_with_locale_and_sections(roots, locale, &default_manual_section_order())
69    }
70
71    fn from_roots_with_locale_and_sections(
72        roots: Vec<PathBuf>,
73        locale: Option<&str>,
74        section_order: &[String],
75    ) -> Self {
76        let roots = deduplicate_paths(roots);
77        let mut effective = BTreeMap::<(String, String), ManualPage>::new();
78        for root in &roots {
79            for page in scan_manual_root(root, locale) {
80                effective
81                    .entry((manual_name_key(&page.name), page.section.clone()))
82                    .or_insert(page);
83            }
84        }
85        let mut pages = effective.into_values().collect::<Vec<_>>();
86        pages.sort_by(|left, right| {
87            manual_name_key(&left.name)
88                .cmp(&manual_name_key(&right.name))
89                .then_with(|| compare_manual_sections(&left.section, &right.section, section_order))
90        });
91        Self { roots, pages }
92    }
93
94    /// Roots searched by this index, in precedence order.
95    #[must_use]
96    pub fn roots(&self) -> &[PathBuf] {
97        &self.roots
98    }
99
100    /// Effective pages sorted by name and section.
101    #[must_use]
102    pub fn pages(&self) -> &[ManualPage] {
103        &self.pages
104    }
105
106    /// Resolve one page using an optional exact manual category.
107    #[must_use]
108    pub fn find(&self, name: &str, section: Option<&str>) -> Option<&ManualPage> {
109        let name = name.trim();
110        let section = section.map(str::trim);
111        self.pages.iter().find(|page| {
112            manual_names_equal(&page.name, name)
113                && section.is_none_or(|section| page.section == section)
114        })
115    }
116
117    /// Exact manual categories available for one logical page name.
118    #[must_use]
119    pub fn available_manual_sections(&self, name: &str) -> Vec<String> {
120        let name = name.trim();
121        self.pages
122            .iter()
123            .filter(|page| manual_names_equal(&page.name, name))
124            .map(|page| page.section.clone())
125            .collect()
126    }
127}
128
129fn manual_names_equal(left: &str, right: &str) -> bool {
130    #[cfg(windows)]
131    {
132        left.eq_ignore_ascii_case(right)
133    }
134    #[cfg(not(windows))]
135    {
136        left == right
137    }
138}
139
140fn manual_name_key(name: &str) -> String {
141    #[cfg(windows)]
142    {
143        name.to_ascii_lowercase()
144    }
145    #[cfg(not(windows))]
146    {
147        name.to_owned()
148    }
149}
150
151fn current_manual_section_order() -> Vec<String> {
152    env::var("MANSECT")
153        .ok()
154        .and_then(|value| parse_manual_section_order(&value))
155        .unwrap_or_else(default_manual_section_order)
156}
157
158fn default_manual_section_order() -> Vec<String> {
159    DEFAULT_MANUAL_SECTIONS.map(str::to_owned).to_vec()
160}
161
162fn parse_manual_section_order(value: &str) -> Option<Vec<String>> {
163    let mut sections = Vec::new();
164    for section in value
165        .split(':')
166        .map(str::trim)
167        .filter(|value| !value.is_empty())
168    {
169        if !sections.iter().any(|existing| existing == section) {
170            sections.push(section.to_owned());
171        }
172    }
173    (!sections.is_empty()).then_some(sections)
174}
175
176fn compare_manual_sections(
177    left: &str,
178    right: &str,
179    section_order: &[String],
180) -> std::cmp::Ordering {
181    let rank = |section: &str| {
182        section_order
183            .iter()
184            .position(|candidate| candidate == section)
185    };
186    match (rank(left), rank(right)) {
187        (Some(left), Some(right)) => left.cmp(&right),
188        (Some(_), None) => std::cmp::Ordering::Less,
189        (None, Some(_)) => std::cmp::Ordering::Greater,
190        (None, None) => left.cmp(right),
191    }
192}
193
194/// Expected source-discovery failures suitable for a user-facing CLI error.
195#[derive(Debug, Clone, Eq, PartialEq)]
196pub enum LocateError {
197    /// The requested manual name was empty.
198    EmptyName,
199    /// An explicitly requested manual category was malformed.
200    InvalidManualSection,
201    /// No indexed page satisfied the request.
202    NotFound {
203        /// Requested manual topic.
204        name: String,
205        /// Exact requested native category, when supplied.
206        requested_manual_section: Option<String>,
207        /// Other indexed categories available for the same topic.
208        available_manual_sections: Vec<String>,
209    },
210}
211
212impl LocateError {
213    /// Render this error as detail nested below an already identified topic.
214    pub(crate) fn load_detail(&self) -> String {
215        match self {
216            Self::NotFound {
217                requested_manual_section: Some(requested),
218                available_manual_sections,
219                ..
220            } if !available_manual_sections.is_empty() => format!(
221                "manual section '{requested}' is unavailable; available sections: {}",
222                available_manual_sections.join(", ")
223            ),
224            Self::NotFound {
225                requested_manual_section: Some(requested),
226                ..
227            } => format!("no source was found in manual section '{requested}'"),
228            Self::NotFound { .. } => "no local manual source was found".to_owned(),
229            Self::EmptyName | Self::InvalidManualSection => self.to_string(),
230        }
231    }
232}
233
234impl fmt::Display for LocateError {
235    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
236        match self {
237            Self::EmptyName => formatter.write_str("manual page name must not be empty"),
238            Self::InvalidManualSection => formatter.write_str(
239                "manual section must be a conventional number or the single letter 'l' or 'n'",
240            ),
241            Self::NotFound {
242                name,
243                requested_manual_section: Some(requested),
244                available_manual_sections,
245            } if !available_manual_sections.is_empty() => write!(
246                formatter,
247                "requested manual section '{requested}' is unavailable for '{name}'; available manual sections: {}",
248                available_manual_sections.join(", ")
249            ),
250            Self::NotFound {
251                name,
252                requested_manual_section: Some(requested),
253                ..
254            } => write!(
255                formatter,
256                "no local manual source was found for '{name}' in manual section '{requested}'"
257            ),
258            Self::NotFound { name, .. } => {
259                write!(formatter, "no local manual source was found for '{name}'")
260            }
261        }
262    }
263}
264
265impl std::error::Error for LocateError {}
266
267/// Locate a manual in an explicit immutable index.
268///
269/// # Errors
270///
271/// Returns [`LocateError`] for invalid requests and missing local sources.
272pub fn locate_manual_source_in(
273    request: &ManualRequest,
274    index: &ManualIndex,
275) -> Result<ManualPage, LocateError> {
276    let name = request.name.trim();
277    if name.is_empty() {
278        return Err(LocateError::EmptyName);
279    }
280    let section = request.manual_section.as_deref().map(str::trim);
281    if section.is_some_and(|section| !crate::is_manual_section(section)) {
282        return Err(LocateError::InvalidManualSection);
283    }
284    index
285        .find(name, section)
286        .cloned()
287        .ok_or_else(|| LocateError::NotFound {
288            name: name.to_owned(),
289            requested_manual_section: section.map(ToOwned::to_owned),
290            available_manual_sections: index.available_manual_sections(name),
291        })
292}
293
294pub(crate) fn deduplicate_paths(paths: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
295    let current_directory = env::current_dir().ok();
296    let mut seen = HashSet::new();
297    paths
298        .into_iter()
299        .filter(|path| !path.as_os_str().is_empty())
300        .filter_map(|path| {
301            if path.is_absolute() {
302                Some(path)
303            } else {
304                current_directory.as_ref().map(|current| current.join(path))
305            }
306        })
307        .filter(|path| seen.insert(path.clone()))
308        .collect()
309}
310
311fn current_locale() -> Option<String> {
312    ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]
313        .into_iter()
314        .find_map(|name| env::var(name).ok().filter(|value| !value.is_empty()))
315        .and_then(|value| normalize_locale(&value))
316}
317
318fn normalize_locale(locale: &str) -> Option<String> {
319    let locale = locale.split(['.', '@', ':']).next()?.trim();
320    (!locale.is_empty() && locale != "C" && locale != "POSIX").then(|| locale.to_owned())
321}
322
323fn scan_manual_root(root: &Path, locale: Option<&str>) -> Vec<ManualPage> {
324    let mut candidates = BTreeMap::<(String, String), (u8, PathBuf)>::new();
325    scan_directory(root, root, locale, &mut candidates);
326    candidates
327        .into_iter()
328        .map(|((name, section), (_, path))| ManualPage {
329            name,
330            section,
331            path,
332            manual_root: root.to_path_buf(),
333        })
334        .collect()
335}
336
337fn scan_directory(
338    root: &Path,
339    directory: &Path,
340    locale: Option<&str>,
341    candidates: &mut BTreeMap<(String, String), (u8, PathBuf)>,
342) {
343    let Ok(entries) = fs::read_dir(directory) else {
344        return;
345    };
346    let mut entries = entries.flatten().collect::<Vec<_>>();
347    entries.sort_unstable_by_key(fs::DirEntry::file_name);
348    for entry in entries {
349        let path = entry.path();
350        let Ok(file_type) = entry.file_type() else {
351            continue;
352        };
353        if file_type.is_dir() {
354            scan_directory(root, &path, locale, candidates);
355            continue;
356        }
357        if !file_type.is_file() && !file_type.is_symlink() {
358            continue;
359        }
360        // Follow an explicit leaf link only far enough to prove it names a
361        // regular file. Directory links are never traversed, and broken links
362        // are not indexed.
363        if !fs::metadata(&path).is_ok_and(|metadata| metadata.is_file()) {
364            continue;
365        }
366        let Some((name, section)) = manual_identity(root, &path) else {
367            continue;
368        };
369        let priority = locale_priority(root, &path, locale);
370        let key = (name, section);
371        match candidates.get(&key) {
372            Some((current_priority, current_path))
373                if (*current_priority, current_path) <= (priority, &path) => {}
374            _ => {
375                candidates.insert(key, (priority, path));
376            }
377        }
378    }
379}
380
381fn manual_identity(root: &Path, path: &Path) -> Option<(String, String)> {
382    let relative = path.strip_prefix(root).ok()?;
383    let section_directory = relative.parent()?.components().find_map(|component| {
384        let component = component.as_os_str().to_str()?;
385        component
386            .strip_prefix("man")
387            .filter(|section| !section.is_empty())
388            .map(ToOwned::to_owned)
389    });
390    let filename = path.file_name()?.to_str()?;
391    let stem = SUPPORTED_COMPRESSION_SUFFIXES
392        .iter()
393        .find_map(|suffix| filename.strip_suffix(suffix))
394        .unwrap_or(filename);
395    let (name, file_section) = stem.rsplit_once('.')?;
396    let section_matches_directory = section_directory.as_ref().is_some_and(|directory| {
397        file_section == directory
398            || file_section
399                .strip_prefix(directory)
400                .is_some_and(|suffix| !suffix.is_empty() && suffix.chars().all(char::is_alphabetic))
401    });
402    let flat_root_page = relative.components().count() == 1 && valid_flat_section(file_section);
403    if name.is_empty() || (!section_matches_directory && !flat_root_page) {
404        return None;
405    }
406    Some((name.to_owned(), file_section.to_owned()))
407}
408
409fn valid_flat_section(section: &str) -> bool {
410    let mut characters = section.chars();
411    match characters.next() {
412        Some('1'..='9') => characters.all(char::is_alphabetic),
413        Some('l' | 'n') => characters.next().is_none(),
414        _ => false,
415    }
416}
417
418fn locale_priority(root: &Path, path: &Path, locale: Option<&str>) -> u8 {
419    let relative = path.strip_prefix(root).unwrap_or(path);
420    let localized = relative.components().next().is_some_and(|component| {
421        component
422            .as_os_str()
423            .to_str()
424            .is_some_and(|component| !component.starts_with("man"))
425    });
426    let Some(locale) = locale else {
427        return u8::from(localized);
428    };
429    if !localized {
430        return 2;
431    }
432    let language = locale.split('_').next().unwrap_or(locale);
433    let component = relative
434        .components()
435        .next()
436        .and_then(|component| component.as_os_str().to_str())
437        .unwrap_or_default();
438    if component == locale {
439        0
440    } else if component == language {
441        1
442    } else {
443        3
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use std::{collections::HashMap, ffi::OsString, fs, path::PathBuf};
450
451    use super::{
452        LocateError, ManualIndex, ManualRequest, deduplicate_paths, locate_manual_source_in,
453        normalize_locale, parse_manual_section_order,
454    };
455    use crate::manual_paths::discover_manual_roots_with;
456
457    fn temporary_root(label: &str) -> PathBuf {
458        std::env::temp_dir().join(format!(
459            "mant-manual-index-{label}-{}-{:?}",
460            std::process::id(),
461            std::thread::current().id()
462        ))
463    }
464
465    #[cfg(unix)]
466    fn symlink_file(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
467        std::os::unix::fs::symlink(target, link)
468    }
469
470    #[cfg(windows)]
471    fn symlink_file(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
472        std::os::windows::fs::symlink_file(target, link)
473    }
474
475    #[cfg(unix)]
476    fn symlink_directory(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
477        std::os::unix::fs::symlink(target, link)
478    }
479
480    #[cfg(windows)]
481    fn symlink_directory(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
482        std::os::windows::fs::symlink_dir(target, link)
483    }
484
485    #[cfg(any(unix, windows))]
486    fn created_link(result: std::io::Result<()>) -> bool {
487        match result {
488            Ok(()) => true,
489            Err(error) if cfg!(windows) && error.kind() == std::io::ErrorKind::PermissionDenied => {
490                false
491            }
492            Err(error) => panic!("create fixture symlink: {error}"),
493        }
494    }
495
496    #[test]
497    fn indexes_supported_sources_and_resolves_sections_without_man() {
498        let root = temporary_root("lookup");
499        fs::create_dir_all(root.join("man1")).expect("manual section");
500        fs::create_dir_all(root.join("man3")).expect("manual section");
501        fs::write(root.join("man1/printf.1.gz"), b"gzip placeholder").expect("manual");
502        fs::write(root.join("man3/printf.3.zst"), b"zstd placeholder").expect("manual");
503        fs::write(root.join("flat-tool.1"), b"flat manual").expect("flat manual");
504        fs::write(root.join("README.md"), b"not a manual").expect("readme");
505        fs::write(root.join("man1/ignored.1.xz"), b"unsupported").expect("manual");
506
507        let index = ManualIndex::from_roots(vec![root.clone()]);
508        assert_eq!(index.pages().len(), 3);
509        assert_eq!(
510            locate_manual_source_in(&ManualRequest::new("printf", None), &index)
511                .expect("default section")
512                .path,
513            root.join("man1/printf.1.gz")
514        );
515        assert_eq!(
516            locate_manual_source_in(&ManualRequest::new("printf", Some("3".to_owned())), &index,)
517                .expect("selected section")
518                .path,
519            root.join("man3/printf.3.zst")
520        );
521        assert_eq!(index.pages()[0].manual_root, root);
522        assert_eq!(
523            locate_manual_source_in(&ManualRequest::new("flat-tool", None), &index)
524                .expect("flat root page")
525                .path,
526            root.join("flat-tool.1")
527        );
528        assert!(matches!(
529            locate_manual_source_in(&ManualRequest::new("ignored", None), &index),
530            Err(LocateError::NotFound { .. })
531        ));
532
533        let invalid = locate_manual_source_in(
534            &ManualRequest::new("printf", Some("DESCRIPTION".to_owned())),
535            &index,
536        )
537        .expect_err("document heading is not a manual section");
538        assert_eq!(invalid, LocateError::InvalidManualSection);
539
540        let unavailable =
541            locate_manual_source_in(&ManualRequest::new("printf", Some("5".to_owned())), &index)
542                .expect_err("valid but unavailable manual section");
543        assert_eq!(
544            unavailable.to_string(),
545            "requested manual section '5' is unavailable for 'printf'; available manual sections: 1, 3"
546        );
547        assert_eq!(
548            unavailable.load_detail(),
549            "manual section '5' is unavailable; available sections: 1, 3"
550        );
551
552        fs::remove_dir_all(root).expect("remove fixture");
553    }
554
555    #[test]
556    fn unqualified_lookup_uses_manual_section_precedence_instead_of_lexical_order() {
557        let root = temporary_root("section-precedence");
558        fs::create_dir_all(root.join("man5")).expect("section 5");
559        fs::create_dir_all(root.join("man8")).expect("section 8");
560        fs::write(root.join("man5/btrfs.5"), b".TH BTRFS 5\n").expect("section 5 page");
561        fs::write(root.join("man8/btrfs.8"), b".TH BTRFS 8\n").expect("section 8 page");
562
563        let index = ManualIndex::from_roots_with_locale(vec![root.clone()], None);
564
565        assert_eq!(
566            index.find("btrfs", None).map(|page| page.section.as_str()),
567            Some("8")
568        );
569        assert_eq!(index.available_manual_sections("btrfs"), ["8", "5"]);
570        fs::remove_dir_all(root).expect("remove fixture");
571    }
572
573    #[test]
574    fn explicit_manual_section_order_controls_unqualified_lookup() {
575        let root = temporary_root("explicit-section-precedence");
576        fs::create_dir_all(root.join("man5")).expect("section 5");
577        fs::create_dir_all(root.join("man8")).expect("section 8");
578        fs::write(root.join("man5/btrfs.5"), b".TH BTRFS 5\n").expect("section 5 page");
579        fs::write(root.join("man8/btrfs.8"), b".TH BTRFS 8\n").expect("section 8 page");
580        let order = vec!["5".to_owned(), "8".to_owned()];
581
582        let index =
583            ManualIndex::from_roots_with_locale_and_sections(vec![root.clone()], None, &order);
584
585        assert_eq!(
586            index.find("btrfs", None).map(|page| page.section.as_str()),
587            Some("5")
588        );
589        assert_eq!(index.available_manual_sections("btrfs"), ["5", "8"]);
590        fs::remove_dir_all(root).expect("remove fixture");
591    }
592
593    #[test]
594    fn mansect_is_colon_separated_trimmed_and_deduplicated() {
595        assert_eq!(
596            parse_manual_section_order(" 8:1:8::5 "),
597            Some(vec!["8".to_owned(), "1".to_owned(), "5".to_owned()])
598        );
599        assert_eq!(parse_manual_section_order(":: "), None);
600    }
601
602    #[cfg(any(unix, windows))]
603    #[test]
604    fn indexes_leaf_file_symlinks_without_traversing_linked_directories() {
605        let base = temporary_root("symlink-boundary");
606        let root = base.join("root");
607        let man1 = root.join("man1");
608        let linked_tree = base.join("linked-tree/man1");
609        fs::create_dir_all(&man1).expect("manual section");
610        fs::create_dir_all(&linked_tree).expect("linked manual section");
611        fs::write(man1.join("target.1"), ".TH TARGET 1\n").expect("inside target");
612        fs::write(base.join("outside.1"), ".TH OUTSIDE 1\n").expect("outside target");
613        fs::write(linked_tree.join("nested.1"), ".TH NESTED 1\n").expect("nested target");
614        if !created_link(symlink_file(&man1.join("target.1"), &man1.join("inside.1")))
615            || !created_link(symlink_file(
616                &base.join("outside.1"),
617                &man1.join("outside.1"),
618            ))
619            || !created_link(symlink_file(
620                &base.join("missing.1"),
621                &man1.join("broken.1"),
622            ))
623            || !created_link(symlink_directory(
624                &base.join("linked-tree"),
625                &root.join("linked-tree"),
626            ))
627        {
628            fs::remove_dir_all(base).expect("remove unsupported symlink fixture");
629            return;
630        }
631
632        let index = ManualIndex::from_roots(vec![root]);
633        assert!(index.find("inside", Some("1")).is_some());
634        assert!(index.find("outside", Some("1")).is_some());
635        assert!(index.find("broken", Some("1")).is_none());
636        assert!(index.find("nested", Some("1")).is_none());
637        fs::remove_dir_all(base).expect("remove fixture");
638    }
639
640    #[test]
641    fn root_and_locale_precedence_are_deterministic() {
642        let root = temporary_root("precedence");
643        let first = root.join("first");
644        let second = root.join("second");
645        for path in [
646            first.join("man1/tool.1"),
647            first.join("zh/man1/tool.1"),
648            first.join("zh_CN/man1/tool.1"),
649            second.join("man1/tool.1"),
650        ] {
651            fs::create_dir_all(path.parent().expect("manual parent")).expect("manual section");
652            fs::write(path, b"manual").expect("manual");
653        }
654
655        let index = ManualIndex::from_roots_with_locale(vec![first.clone(), second], Some("zh_CN"));
656        assert_eq!(index.pages()[0].path, first.join("zh_CN/man1/tool.1"));
657
658        fs::remove_dir_all(root).expect("remove fixture");
659    }
660
661    #[test]
662    fn locale_names_drop_encodings_modifiers_and_language_fallbacks() {
663        assert_eq!(
664            normalize_locale("zh_CN.UTF-8@variant"),
665            Some("zh_CN".to_owned())
666        );
667        assert_eq!(normalize_locale("de_DE:en_US"), Some("de_DE".to_owned()));
668        assert_eq!(normalize_locale("C"), None);
669    }
670
671    #[cfg(unix)]
672    #[test]
673    fn explicit_manpath_overrides_conventions_and_empty_components_restore_them() {
674        let explicit = PathBuf::from("/opt/manuals");
675        let mut environment = HashMap::from([
676            (OsString::from("HOME"), OsString::from("/home/demo")),
677            (OsString::from("PATH"), OsString::from("/opt/bin:/usr/bin")),
678            (
679                OsString::from("MANT_MANPATH"),
680                explicit.as_os_str().to_owned(),
681            ),
682        ]);
683        assert_eq!(discover_manual_roots_with(&environment), vec![explicit]);
684
685        environment.remove(&OsString::from("MANT_MANPATH"));
686        environment.insert(OsString::from("MANPATH"), OsString::from(":"));
687        let roots = discover_manual_roots_with(&environment);
688        assert!(roots.contains(&PathBuf::from("/home/demo/.local/share/man")));
689        assert!(roots.contains(&PathBuf::from("/opt/share/man")));
690        assert!(roots.contains(&PathBuf::from("/usr/share/man")));
691    }
692
693    #[cfg(windows)]
694    #[test]
695    fn windows_defaults_to_user_share_man_and_honors_manpath() {
696        let profile = PathBuf::from(r"C:\Users\demo");
697        let environment = HashMap::from([(
698            OsString::from("USERPROFILE"),
699            profile.as_os_str().to_owned(),
700        )]);
701        assert_eq!(
702            discover_manual_roots_with(&environment),
703            vec![profile.join(".local/share/man")]
704        );
705
706        let custom = PathBuf::from(r"D:\manuals");
707        let mut environment = environment;
708        environment.insert(
709            OsString::from("MANPATH"),
710            std::env::join_paths([&custom]).expect("join Windows MANPATH"),
711        );
712        assert_eq!(discover_manual_roots_with(&environment), vec![custom]);
713    }
714
715    #[test]
716    fn relative_manual_roots_are_resolved_for_stable_catalog_paths() {
717        let roots = deduplicate_paths([PathBuf::from("project-man")]);
718        assert_eq!(roots.len(), 1);
719        assert!(roots[0].is_absolute());
720        assert!(roots[0].ends_with("project-man"));
721    }
722
723    #[test]
724    fn invalid_requests_fail_before_lookup() {
725        let index = ManualIndex::default();
726        assert_eq!(
727            locate_manual_source_in(&ManualRequest::new(" ", None), &index),
728            Err(LocateError::EmptyName)
729        );
730        assert_eq!(
731            locate_manual_source_in(&ManualRequest::new("git", Some(" ".to_owned())), &index,),
732            Err(LocateError::InvalidManualSection)
733        );
734    }
735
736    #[cfg(windows)]
737    #[test]
738    fn windows_manual_names_are_ascii_case_insensitive() {
739        let index = ManualIndex {
740            roots: vec![PathBuf::from(r"C:\man")],
741            pages: vec![super::ManualPage {
742                name: "cargo.exe".to_owned(),
743                section: "1".to_owned(),
744                path: PathBuf::from(r"C:\man\cargo.exe.1"),
745                manual_root: PathBuf::from(r"C:\man"),
746            }],
747        };
748
749        assert!(index.find("cargo.EXE", None).is_some());
750    }
751}