Skip to main content

mant_core/
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    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/// One validated manual lookup independent from CLI token syntax.
23#[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/// One effective local manual page after path and locale precedence.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct ManualPage {
42    pub name: String,
43    pub section: String,
44    pub path: PathBuf,
45}
46
47/// Immutable index shared by discovery and exact manual lookup.
48#[derive(Clone, Debug, Default, Eq, PartialEq)]
49pub struct ManualIndex {
50    roots: Vec<PathBuf>,
51    pages: Vec<ManualPage>,
52}
53
54impl ManualIndex {
55    /// Scan explicit roots in precedence order.
56    #[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    /// Roots searched by this index, in precedence order.
79    #[must_use]
80    pub fn roots(&self) -> &[PathBuf] {
81        &self.roots
82    }
83
84    /// Effective pages sorted by name and section.
85    #[must_use]
86    pub fn pages(&self) -> &[ManualPage] {
87        &self.pages
88    }
89
90    /// Resolve one page using an optional exact section selector.
91    #[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/// Minimal subprocess result shared by external data update operations.
102#[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/// Expected source-discovery failures suitable for a user-facing CLI error.
110#[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/// Build or retrieve the process-wide native manual index.
132#[must_use]
133pub fn system_manual_index() -> &'static ManualIndex {
134    SYSTEM_INDEX.get_or_init(|| ManualIndex::from_roots(discover_manual_roots()))
135}
136
137/// Discover manual roots from explicit variables and platform conventions.
138#[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
144/// Locate a manual through the process-wide native index.
145///
146/// # Errors
147///
148/// Returns [`LocateError`] for invalid requests and missing local sources.
149pub fn locate_manual_source(request: &ManualRequest) -> Result<PathBuf, LocateError> {
150    locate_manual_source_in(request, system_manual_index())
151}
152
153/// Locate a manual in an explicit immutable index.
154///
155/// # Errors
156///
157/// Returns [`LocateError`] for invalid requests and missing local sources.
158pub 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(&section_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}