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