1use std::{
4 collections::{BTreeMap, HashMap, HashSet},
5 env,
6 ffi::{OsStr, OsString},
7 fmt, fs,
8 path::{Path, PathBuf},
9 sync::OnceLock,
10};
11
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
20static SYSTEM_INDEX: OnceLock<ManualIndex> = OnceLock::new();
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct ManualRequest {
25 pub name: String,
26 pub section: Option<String>,
27}
28
29impl ManualRequest {
30 #[must_use]
31 pub fn new(name: impl Into<String>, section: Option<String>) -> Self {
32 Self {
33 name: name.into(),
34 section,
35 }
36 }
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct ManualPage {
42 pub name: String,
43 pub section: String,
44 pub path: PathBuf,
45}
46
47#[derive(Clone, Debug, Default, Eq, PartialEq)]
49pub struct ManualIndex {
50 roots: Vec<PathBuf>,
51 pages: Vec<ManualPage>,
52}
53
54impl ManualIndex {
55 #[must_use]
57 pub fn from_roots(roots: Vec<PathBuf>) -> Self {
58 let locale = current_locale();
59 Self::from_roots_with_locale(roots, locale.as_deref())
60 }
61
62 fn from_roots_with_locale(roots: Vec<PathBuf>, locale: Option<&str>) -> Self {
63 let roots = deduplicate_paths(roots);
64 let mut effective = BTreeMap::<(String, String), ManualPage>::new();
65 for root in &roots {
66 for page in scan_manual_root(root, locale) {
67 effective
68 .entry((page.name.clone(), page.section.clone()))
69 .or_insert(page);
70 }
71 }
72 Self {
73 roots,
74 pages: effective.into_values().collect(),
75 }
76 }
77
78 #[must_use]
80 pub fn roots(&self) -> &[PathBuf] {
81 &self.roots
82 }
83
84 #[must_use]
86 pub fn pages(&self) -> &[ManualPage] {
87 &self.pages
88 }
89
90 #[must_use]
92 pub fn find(&self, name: &str, section: Option<&str>) -> Option<&ManualPage> {
93 let name = name.trim();
94 let section = section.map(str::trim);
95 self.pages
96 .iter()
97 .find(|page| page.name == name && section.is_none_or(|section| page.section == section))
98 }
99}
100
101#[derive(Clone, Debug, Default, Eq, PartialEq)]
103pub struct CommandOutput {
104 pub stdout: Vec<u8>,
105 pub stderr: Vec<u8>,
106 pub exit_code: i32,
107}
108
109#[derive(Debug, Clone, Eq, PartialEq)]
111pub enum LocateError {
112 EmptyName,
113 InvalidSection,
114 NotFound { name: String },
115}
116
117impl fmt::Display for LocateError {
118 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Self::EmptyName => formatter.write_str("manual page name must not be empty"),
121 Self::InvalidSection => formatter.write_str("manual section must not be empty"),
122 Self::NotFound { name } => {
123 write!(formatter, "no local manual source was found for '{name}'")
124 }
125 }
126 }
127}
128
129impl std::error::Error for LocateError {}
130
131#[must_use]
133pub fn system_manual_index() -> &'static ManualIndex {
134 SYSTEM_INDEX.get_or_init(|| ManualIndex::from_roots(discover_manual_roots()))
135}
136
137#[must_use]
139pub fn discover_manual_roots() -> Vec<PathBuf> {
140 let environment = env::vars_os().collect::<HashMap<_, _>>();
141 discover_manual_roots_with(&environment)
142}
143
144pub fn locate_manual_source(request: &ManualRequest) -> Result<PathBuf, LocateError> {
150 locate_manual_source_in(request, system_manual_index())
151}
152
153pub fn locate_manual_source_in(
159 request: &ManualRequest,
160 index: &ManualIndex,
161) -> Result<PathBuf, LocateError> {
162 let name = request.name.trim();
163 if name.is_empty() {
164 return Err(LocateError::EmptyName);
165 }
166 let section = request.section.as_deref().map(str::trim);
167 if section.is_some_and(str::is_empty) {
168 return Err(LocateError::InvalidSection);
169 }
170 index
171 .find(name, section)
172 .map(|page| page.path.clone())
173 .ok_or_else(|| LocateError::NotFound {
174 name: name.to_owned(),
175 })
176}
177
178fn discover_manual_roots_with(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
179 if cfg!(not(unix)) {
180 return Vec::new();
181 }
182 if let Some(explicit) = environment.get(OsStr::new("MANT_MANPATH")) {
183 return deduplicate_paths(
184 env::split_paths(explicit).filter(|path| !path.as_os_str().is_empty()),
185 );
186 }
187
188 let defaults = conventional_manual_roots(environment);
189 if let Some(manpath) = environment.get(OsStr::new("MANPATH")) {
190 let mut roots = Vec::new();
191 for path in env::split_paths(manpath) {
192 if path.as_os_str().is_empty() {
193 roots.extend(defaults.iter().cloned());
194 } else {
195 roots.push(path);
196 }
197 }
198 return deduplicate_paths(roots);
199 }
200 defaults
201}
202
203fn conventional_manual_roots(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
204 let mut roots = Vec::new();
205 if let Some(home) = environment.get(OsStr::new("HOME")).map(PathBuf::from) {
206 roots.push(home.join(".local/share/man"));
207 roots.push(home.join(".local/man"));
208 roots.push(home.join("man"));
209 }
210 if let Some(data_home) = environment
211 .get(OsStr::new("XDG_DATA_HOME"))
212 .map(PathBuf::from)
213 {
214 roots.push(data_home.join("man"));
215 }
216 if let Some(data_dirs) = environment.get(OsStr::new("XDG_DATA_DIRS")) {
217 roots.extend(env::split_paths(data_dirs).map(|root| root.join("man")));
218 }
219 if let Some(path) = environment.get(OsStr::new("PATH")) {
220 for binary_dir in env::split_paths(path) {
221 if let Some(prefix) = binary_dir.parent() {
222 roots.push(prefix.join("share/man"));
223 roots.push(prefix.join("man"));
224 }
225 }
226 }
227 roots.extend(DEFAULT_MANUAL_ROOTS.map(PathBuf::from));
228 deduplicate_paths(roots)
229}
230
231fn deduplicate_paths(paths: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
232 let current_directory = env::current_dir().ok();
233 let mut seen = HashSet::new();
234 paths
235 .into_iter()
236 .filter(|path| !path.as_os_str().is_empty())
237 .filter_map(|path| {
238 if path.is_absolute() {
239 Some(path)
240 } else {
241 current_directory.as_ref().map(|current| current.join(path))
242 }
243 })
244 .filter(|path| seen.insert(path.clone()))
245 .collect()
246}
247
248fn current_locale() -> Option<String> {
249 ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]
250 .into_iter()
251 .find_map(|name| env::var(name).ok().filter(|value| !value.is_empty()))
252 .and_then(|value| normalize_locale(&value))
253}
254
255fn normalize_locale(locale: &str) -> Option<String> {
256 let locale = locale.split(['.', '@', ':']).next()?.trim();
257 (!locale.is_empty() && locale != "C" && locale != "POSIX").then(|| locale.to_owned())
258}
259
260fn scan_manual_root(root: &Path, locale: Option<&str>) -> Vec<ManualPage> {
261 let mut candidates = BTreeMap::<(String, String), (u8, PathBuf)>::new();
262 scan_directory(root, root, locale, &mut candidates);
263 candidates
264 .into_iter()
265 .map(|((name, section), (_, path))| ManualPage {
266 name,
267 section,
268 path,
269 })
270 .collect()
271}
272
273fn scan_directory(
274 root: &Path,
275 directory: &Path,
276 locale: Option<&str>,
277 candidates: &mut BTreeMap<(String, String), (u8, PathBuf)>,
278) {
279 let Ok(entries) = fs::read_dir(directory) else {
280 return;
281 };
282 let mut entries = entries.flatten().collect::<Vec<_>>();
283 entries.sort_unstable_by_key(fs::DirEntry::file_name);
284 for entry in entries {
285 let path = entry.path();
286 let Ok(file_type) = entry.file_type() else {
287 continue;
288 };
289 if file_type.is_dir() {
290 scan_directory(root, &path, locale, candidates);
291 continue;
292 }
293 if !file_type.is_file() && !file_type.is_symlink() {
294 continue;
295 }
296 let Some((name, section)) = manual_identity(root, &path) else {
297 continue;
298 };
299 let priority = locale_priority(root, &path, locale);
300 let key = (name, section);
301 match candidates.get(&key) {
302 Some((current_priority, current_path))
303 if (*current_priority, current_path) <= (priority, &path) => {}
304 _ => {
305 candidates.insert(key, (priority, path));
306 }
307 }
308 }
309}
310
311fn manual_identity(root: &Path, path: &Path) -> Option<(String, String)> {
312 let relative = path.strip_prefix(root).ok()?;
313 let section_directory = relative.parent()?.components().find_map(|component| {
314 let component = component.as_os_str().to_str()?;
315 component
316 .strip_prefix("man")
317 .filter(|section| !section.is_empty())
318 .map(ToOwned::to_owned)
319 })?;
320 let filename = path.file_name()?.to_str()?;
321 let stem = SUPPORTED_COMPRESSION_SUFFIXES
322 .iter()
323 .find_map(|suffix| filename.strip_suffix(suffix))
324 .unwrap_or(filename);
325 let (name, file_section) = stem.rsplit_once('.')?;
326 let section_matches_directory = file_section == section_directory
327 || file_section
328 .strip_prefix(§ion_directory)
329 .is_some_and(|suffix| !suffix.is_empty() && suffix.chars().all(char::is_alphabetic));
330 if name.is_empty() || file_section.is_empty() || !section_matches_directory {
331 return None;
332 }
333 Some((name.to_owned(), file_section.to_owned()))
334}
335
336fn locale_priority(root: &Path, path: &Path, locale: Option<&str>) -> u8 {
337 let relative = path.strip_prefix(root).unwrap_or(path);
338 let localized = relative.components().next().is_some_and(|component| {
339 component
340 .as_os_str()
341 .to_str()
342 .is_some_and(|component| !component.starts_with("man"))
343 });
344 let Some(locale) = locale else {
345 return u8::from(localized);
346 };
347 if !localized {
348 return 2;
349 }
350 let language = locale.split('_').next().unwrap_or(locale);
351 let component = relative
352 .components()
353 .next()
354 .and_then(|component| component.as_os_str().to_str())
355 .unwrap_or_default();
356 if component == locale {
357 0
358 } else if component == language {
359 1
360 } else {
361 3
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use std::{fs, path::PathBuf};
368
369 #[cfg(unix)]
370 use std::{collections::HashMap, ffi::OsString};
371
372 use super::{
373 LocateError, ManualIndex, ManualRequest, deduplicate_paths, locate_manual_source_in,
374 normalize_locale,
375 };
376
377 #[cfg(unix)]
378 use super::discover_manual_roots_with;
379
380 fn temporary_root(label: &str) -> PathBuf {
381 std::env::temp_dir().join(format!(
382 "mant-manual-index-{label}-{}-{:?}",
383 std::process::id(),
384 std::thread::current().id()
385 ))
386 }
387
388 #[test]
389 fn indexes_supported_sources_and_resolves_sections_without_man() {
390 let root = temporary_root("lookup");
391 fs::create_dir_all(root.join("man1")).expect("manual section");
392 fs::create_dir_all(root.join("man3")).expect("manual section");
393 fs::write(root.join("man1/printf.1.gz"), b"gzip placeholder").expect("manual");
394 fs::write(root.join("man3/printf.3.zst"), b"zstd placeholder").expect("manual");
395 fs::write(root.join("man1/ignored.1.xz"), b"unsupported").expect("manual");
396
397 let index = ManualIndex::from_roots(vec![root.clone()]);
398 assert_eq!(index.pages().len(), 2);
399 assert_eq!(
400 locate_manual_source_in(&ManualRequest::new("printf", None), &index)
401 .expect("default section"),
402 root.join("man1/printf.1.gz")
403 );
404 assert_eq!(
405 locate_manual_source_in(&ManualRequest::new("printf", Some("3".to_owned())), &index,)
406 .expect("selected section"),
407 root.join("man3/printf.3.zst")
408 );
409 assert!(matches!(
410 locate_manual_source_in(&ManualRequest::new("ignored", None), &index),
411 Err(LocateError::NotFound { .. })
412 ));
413
414 fs::remove_dir_all(root).expect("remove fixture");
415 }
416
417 #[test]
418 fn root_and_locale_precedence_are_deterministic() {
419 let root = temporary_root("precedence");
420 let first = root.join("first");
421 let second = root.join("second");
422 for path in [
423 first.join("man1/tool.1"),
424 first.join("zh/man1/tool.1"),
425 first.join("zh_CN/man1/tool.1"),
426 second.join("man1/tool.1"),
427 ] {
428 fs::create_dir_all(path.parent().expect("manual parent")).expect("manual section");
429 fs::write(path, b"manual").expect("manual");
430 }
431
432 let index = ManualIndex::from_roots_with_locale(vec![first.clone(), second], Some("zh_CN"));
433 assert_eq!(index.pages()[0].path, first.join("zh_CN/man1/tool.1"));
434
435 fs::remove_dir_all(root).expect("remove fixture");
436 }
437
438 #[test]
439 fn locale_names_drop_encodings_modifiers_and_language_fallbacks() {
440 assert_eq!(
441 normalize_locale("zh_CN.UTF-8@variant"),
442 Some("zh_CN".to_owned())
443 );
444 assert_eq!(normalize_locale("de_DE:en_US"), Some("de_DE".to_owned()));
445 assert_eq!(normalize_locale("C"), None);
446 }
447
448 #[cfg(unix)]
449 #[test]
450 fn explicit_manpath_overrides_conventions_and_empty_components_restore_them() {
451 let explicit = PathBuf::from("/opt/manuals");
452 let mut environment = HashMap::from([
453 (OsString::from("HOME"), OsString::from("/home/demo")),
454 (OsString::from("PATH"), OsString::from("/opt/bin:/usr/bin")),
455 (
456 OsString::from("MANT_MANPATH"),
457 explicit.as_os_str().to_owned(),
458 ),
459 ]);
460 assert_eq!(discover_manual_roots_with(&environment), vec![explicit]);
461
462 environment.remove(&OsString::from("MANT_MANPATH"));
463 environment.insert(OsString::from("MANPATH"), OsString::from(":"));
464 let roots = discover_manual_roots_with(&environment);
465 assert!(roots.contains(&PathBuf::from("/home/demo/.local/share/man")));
466 assert!(roots.contains(&PathBuf::from("/opt/share/man")));
467 assert!(roots.contains(&PathBuf::from("/usr/share/man")));
468 }
469
470 #[test]
471 fn relative_manual_roots_are_resolved_for_stable_catalog_paths() {
472 let roots = deduplicate_paths([PathBuf::from("project-man")]);
473 assert_eq!(roots.len(), 1);
474 assert!(roots[0].is_absolute());
475 assert!(roots[0].ends_with("project-man"));
476 }
477
478 #[test]
479 fn invalid_requests_fail_before_lookup() {
480 let index = ManualIndex::default();
481 assert_eq!(
482 locate_manual_source_in(&ManualRequest::new(" ", None), &index),
483 Err(LocateError::EmptyName)
484 );
485 assert_eq!(
486 locate_manual_source_in(&ManualRequest::new("git", Some(" ".to_owned())), &index,),
487 Err(LocateError::InvalidSection)
488 );
489 }
490}