1use 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#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ManualRequest {
17 pub name: String,
19 pub manual_section: Option<String>,
21}
22
23impl ManualRequest {
24 #[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#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct ManualPage {
37 pub name: String,
39 pub section: String,
41 pub path: PathBuf,
43 pub manual_root: PathBuf,
48}
49
50#[derive(Clone, Debug, Default, Eq, PartialEq)]
52pub struct ManualIndex {
53 roots: Vec<PathBuf>,
54 pages: Vec<ManualPage>,
55}
56
57impl ManualIndex {
58 #[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(), §ion_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 #[must_use]
96 pub fn roots(&self) -> &[PathBuf] {
97 &self.roots
98 }
99
100 #[must_use]
102 pub fn pages(&self) -> &[ManualPage] {
103 &self.pages
104 }
105
106 #[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 #[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#[derive(Debug, Clone, Eq, PartialEq)]
196pub enum LocateError {
197 EmptyName,
199 InvalidManualSection,
201 NotFound {
203 name: String,
205 requested_manual_section: Option<String>,
207 available_manual_sections: Vec<String>,
209 },
210}
211
212impl LocateError {
213 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
267pub 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 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}