1use 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#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct ManualRequest {
23 pub name: String,
25 pub manual_section: Option<String>,
27}
28
29impl ManualRequest {
30 #[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#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct ManualPage {
43 pub name: String,
45 pub section: String,
47 pub path: PathBuf,
49 pub manual_root: PathBuf,
54}
55
56#[derive(Clone, Debug, Default, Eq, PartialEq)]
58pub struct ManualIndex {
59 roots: Vec<PathBuf>,
60 pages: Vec<ManualPage>,
61}
62
63impl ManualIndex {
64 #[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 #[must_use]
89 pub fn roots(&self) -> &[PathBuf] {
90 &self.roots
91 }
92
93 #[must_use]
95 pub fn pages(&self) -> &[ManualPage] {
96 &self.pages
97 }
98
99 #[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 #[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#[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#[derive(Debug, Clone, Eq, PartialEq)]
154pub enum LocateError {
155 EmptyName,
157 InvalidManualSection,
159 NotFound {
161 name: String,
163 requested_manual_section: Option<String>,
165 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#[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
208pub 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 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}