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