Skip to main content

mant_loader/
manual_paths.rs

1//! Native manual-root discovery without invoking a host `man` program.
2//!
3//! Unix manual lookup is not governed by one portable directory list.  The
4//! two common Linux implementations, the BSD family, and macOS each publish
5//! configuration that affects the effective path.  This module reads the
6//! small, declarative subset that determines *source roots*; it deliberately
7//! does not inherit pager, formatter, cache, or locale behaviour from the
8//! host implementation.
9
10mod bsd;
11mod config_file;
12mod macos;
13mod man_db;
14use bsd::mandoc_configured_manual_roots;
15#[cfg(test)]
16use bsd::{BsdManConfig, macos_configuration_roots, parse_bsd_man_config, parse_mandoc_manpaths};
17#[cfg(test)]
18use macos::developer_manual_roots;
19use macos::macos_configured_manual_roots;
20use man_db::linux_configured_manual_roots;
21#[cfg(unix)]
22use man_db::unmapped_man_db_roots;
23#[cfg(test)]
24use man_db::{ManDbConfig, expand_man_db_systems, man_db_manual_roots, parse_man_db_config};
25mod expansion;
26mod windows_config;
27use config_file::read_text as read_config_text;
28#[cfg(test)]
29use expansion::wildcard_matches;
30use expansion::{ExpansionOutcome, ScanBudget, expand_path_pattern_bounded};
31
32use std::{
33    collections::HashMap,
34    env,
35    ffi::{OsStr, OsString},
36    fs,
37    path::{Path, PathBuf},
38};
39
40use crate::source::deduplicate_paths;
41
42#[cfg(unix)]
43const DEFAULT_UNIX_MANUAL_ROOTS: [&str; 4] = [
44    "/usr/local/share/man",
45    "/usr/local/man",
46    "/usr/share/man",
47    "/usr/man",
48];
49const MAX_MANUAL_PATH_CONFIG_BYTES: u64 = 1024 * 1024;
50const MAX_EXPANDED_CONFIG_PATHS: usize = 256;
51const MAX_EXPANDED_CONFIG_CANDIDATES: usize = 4096;
52
53/// One rejected entry in the host manual-path configuration.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct ManualPathDiagnostic {
56    /// Configuration file containing the invalid directive.
57    pub config_path: PathBuf,
58    /// One-based source line, or `None` for a whole-file failure.
59    pub line: Option<usize>,
60    /// Bounded explanation suitable for a local doctor report.
61    pub message: String,
62}
63
64/// Effective manual roots plus non-fatal host-configuration findings.
65#[derive(Clone, Debug, Default, Eq, PartialEq)]
66pub struct ManualRootDiscovery {
67    /// Manual hierarchy roots in effective lookup precedence.
68    pub roots: Vec<PathBuf>,
69    /// Invalid configuration entries omitted from `roots`.
70    pub diagnostics: Vec<ManualPathDiagnostic>,
71}
72
73/// Discover effective manual hierarchy roots for the current host.
74///
75/// `MANT_MANPATH` is a complete `ManT` override.  Otherwise `MANPATH` follows
76/// conventional empty-component insertion, with host-derived defaults at each
77/// empty component.  When neither variable is set, platform configuration is
78/// read without spawning `man`, `manpath`, or any other external program.
79#[must_use]
80pub fn discover_manual_roots() -> Vec<PathBuf> {
81    inspect_manual_roots().roots
82}
83
84/// Inspect effective native-manual roots without mutating host state.
85///
86/// Explicit `MANT_MANPATH` and complete `MANPATH` overrides do not read or
87/// report an inactive host configuration. Diagnostics describe rejected or
88/// truncated BSD/mandoc and ManT-owned Windows configuration; ordinary queries use
89/// only [`discover_manual_roots`].
90#[must_use]
91pub fn inspect_manual_roots() -> ManualRootDiscovery {
92    let environment = env::vars_os().collect::<HashMap<_, _>>();
93    if environment_value(&environment, "MANT_MANPATH").is_some() {
94        return ManualRootDiscovery {
95            roots: discover_manual_roots_from(&environment, Vec::new()),
96            diagnostics: Vec::new(),
97        };
98    }
99    if environment_value(&environment, "MANPATH")
100        .is_some_and(|value| env::split_paths(value).all(|path| !path.as_os_str().is_empty()))
101    {
102        return ManualRootDiscovery {
103            roots: discover_manual_roots_from(&environment, Vec::new()),
104            diagnostics: Vec::new(),
105        };
106    }
107
108    let platform = host_platform();
109    let mant_config = (platform == ManualPathPlatform::Windows)
110        .then(|| {
111            mant_sources::document_paths()
112                .ok()
113                .map(|paths| paths.root.join("man.conf"))
114        })
115        .flatten();
116    let context = DiscoveryContext {
117        environment: &environment,
118        platform,
119        mant_config: mant_config.as_deref(),
120    };
121    let defaults = host_default_manual_roots(&context);
122    ManualRootDiscovery {
123        roots: discover_manual_roots_from(&environment, defaults.roots),
124        diagnostics: defaults.diagnostics,
125    }
126}
127
128#[cfg(test)]
129pub(crate) fn discover_manual_roots_with(
130    environment: &HashMap<OsString, OsString>,
131) -> Vec<PathBuf> {
132    discover_manual_roots_from(environment, fallback_manual_roots(environment))
133}
134
135fn discover_manual_roots_from(
136    environment: &HashMap<OsString, OsString>,
137    defaults: Vec<PathBuf>,
138) -> Vec<PathBuf> {
139    discover_manual_roots_from_for(environment, defaults, host_platform())
140}
141
142fn discover_manual_roots_from_for(
143    environment: &HashMap<OsString, OsString>,
144    defaults: Vec<PathBuf>,
145    platform: ManualPathPlatform,
146) -> Vec<PathBuf> {
147    if let Some(explicit) = environment_value_for(environment, "MANT_MANPATH", platform) {
148        return deduplicate_manual_paths(
149            env::split_paths(explicit).filter(|path| !path.as_os_str().is_empty()),
150            platform,
151        );
152    }
153
154    if let Some(manpath) = environment_value_for(environment, "MANPATH", platform) {
155        let mut roots = Vec::new();
156        for path in env::split_paths(manpath) {
157            if path.as_os_str().is_empty() {
158                roots.extend(defaults.iter().cloned());
159            } else {
160                roots.push(path);
161            }
162        }
163        return deduplicate_manual_paths(roots, platform);
164    }
165    defaults
166}
167
168/// All host input is sampled at the public boundary, not during config parsing.
169struct DiscoveryContext<'a> {
170    environment: &'a HashMap<OsString, OsString>,
171    platform: ManualPathPlatform,
172    mant_config: Option<&'a Path>,
173}
174
175fn host_default_manual_roots(context: &DiscoveryContext<'_>) -> ManualRootDiscovery {
176    let environment = context.environment;
177    let mut discovery = match context.platform {
178        ManualPathPlatform::Linux => linux_configured_manual_roots(environment),
179        ManualPathPlatform::Macos => macos_configured_manual_roots(environment),
180        ManualPathPlatform::Windows => mant_configured_manual_roots(context),
181        ManualPathPlatform::OtherUnix => mandoc_configured_manual_roots(Path::new("/etc/man.conf")),
182    };
183    if discovery.roots.is_empty() {
184        discovery.roots = if context.platform == ManualPathPlatform::Windows {
185            deduplicate_manual_paths(
186                supplemental_manual_roots_for(environment, context.platform),
187                context.platform,
188            )
189        } else {
190            fallback_manual_roots(environment)
191        };
192    } else {
193        discovery
194            .roots
195            .extend(supplemental_manual_roots_for(environment, context.platform));
196        discovery.roots = deduplicate_manual_paths(discovery.roots, context.platform);
197    }
198    discovery
199}
200
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
202enum ManualPathPlatform {
203    Linux,
204    Macos,
205    Windows,
206    OtherUnix,
207}
208
209fn environment_value<'a>(
210    environment: &'a HashMap<OsString, OsString>,
211    name: &str,
212) -> Option<&'a OsString> {
213    environment_value_for(environment, name, host_platform())
214}
215
216fn environment_value_for<'a>(
217    environment: &'a HashMap<OsString, OsString>,
218    name: &str,
219    platform: ManualPathPlatform,
220) -> Option<&'a OsString> {
221    environment.get(OsStr::new(name)).or_else(|| {
222        (platform == ManualPathPlatform::Windows).then(|| {
223            environment.iter().find_map(|(candidate, value)| {
224                candidate
225                    .to_string_lossy()
226                    .eq_ignore_ascii_case(name)
227                    .then_some(value)
228            })
229        })?
230    })
231}
232
233fn deduplicate_manual_paths(
234    paths: impl IntoIterator<Item = PathBuf>,
235    platform: ManualPathPlatform,
236) -> Vec<PathBuf> {
237    let paths = paths.into_iter().collect::<Vec<_>>();
238    if platform == ManualPathPlatform::Windows {
239        windows_config::deduplicate_windows_paths(paths)
240    } else {
241        deduplicate_paths(paths)
242    }
243}
244
245const fn host_platform() -> ManualPathPlatform {
246    if cfg!(windows) {
247        ManualPathPlatform::Windows
248    } else if cfg!(target_os = "macos") {
249        ManualPathPlatform::Macos
250    } else if cfg!(target_os = "linux") {
251        ManualPathPlatform::Linux
252    } else {
253        ManualPathPlatform::OtherUnix
254    }
255}
256
257#[cfg(unix)]
258fn fallback_manual_roots(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
259    let mut roots = supplemental_manual_roots(environment);
260    roots.extend(path_derived_manual_roots(environment));
261    roots.extend(DEFAULT_UNIX_MANUAL_ROOTS.map(PathBuf::from));
262    deduplicate_paths(roots)
263}
264
265#[cfg(not(unix))]
266fn fallback_manual_roots(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
267    deduplicate_manual_paths(supplemental_manual_roots(environment), host_platform())
268}
269
270fn supplemental_manual_roots(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
271    supplemental_manual_roots_for(environment, host_platform())
272}
273
274fn supplemental_manual_roots_for(
275    environment: &HashMap<OsString, OsString>,
276    platform: ManualPathPlatform,
277) -> Vec<PathBuf> {
278    let mut roots = Vec::new();
279    if platform == ManualPathPlatform::Windows {
280        if let Some(data_root) =
281            environment_value_for(environment, "APPDATA", platform).map(PathBuf::from)
282        {
283            roots.push(data_root.join("ManT").join("man"));
284        }
285        if let Some(profile) =
286            environment_value_for(environment, "USERPROFILE", platform).map(PathBuf::from)
287        {
288            roots.push(profile.join(".local/share/man"));
289        }
290        return roots;
291    }
292
293    if let Some(home) = environment_value_for(environment, "HOME", platform).map(PathBuf::from) {
294        roots.push(home.join(".local/share/man"));
295        roots.push(home.join(".local/man"));
296        roots.push(home.join("man"));
297    }
298    if let Some(data_home) =
299        environment_value_for(environment, "XDG_DATA_HOME", platform).map(PathBuf::from)
300    {
301        roots.push(data_home.join("man"));
302    }
303    if let Some(data_dirs) = environment_value_for(environment, "XDG_DATA_DIRS", platform) {
304        roots.extend(env::split_paths(data_dirs).map(|root| root.join("man")));
305    }
306    roots
307}
308
309#[cfg(unix)]
310fn path_derived_manual_roots(environment: &HashMap<OsString, OsString>) -> Vec<PathBuf> {
311    let mut roots = Vec::new();
312    if let Some(path) = environment_value(environment, "PATH") {
313        for binary_dir in env::split_paths(path) {
314            roots.extend(unmapped_man_db_roots(&binary_dir));
315        }
316    }
317    roots
318}
319
320fn mant_configured_manual_roots(context: &DiscoveryContext<'_>) -> ManualRootDiscovery {
321    context
322        .mant_config
323        .map(|path| {
324            let executable_paths =
325                environment_value_for(context.environment, "PATH", context.platform)
326                    .map(|value| env::split_paths(value).collect::<Vec<_>>())
327                    .unwrap_or_default();
328            windows_config::load(path, context.environment, &executable_paths)
329        })
330        .unwrap_or_default()
331}
332
333fn read_config(path: &Path) -> Option<String> {
334    read_config_text(path, MAX_MANUAL_PATH_CONFIG_BYTES).ok()
335}
336
337fn read_path_list(path: &Path) -> Vec<PathBuf> {
338    read_config(path)
339        .map(|text| parse_path_list(&text))
340        .unwrap_or_default()
341}
342
343fn parse_path_list(text: &str) -> Vec<PathBuf> {
344    config_lines(text)
345        .take(MAX_EXPANDED_CONFIG_CANDIDATES)
346        .map(str::trim)
347        .filter(|path| !path.is_empty())
348        .map(PathBuf::from)
349        .collect()
350}
351
352fn config_lines(text: &str) -> impl Iterator<Item = &str> {
353    text.lines()
354        .map(str::trim)
355        .filter(|line| !line.is_empty() && !line.starts_with('#'))
356}
357
358fn config_directive(line: &str) -> Option<(&str, &str)> {
359    let (directive, value) = line.split_once(char::is_whitespace)?;
360    let value = value.trim();
361    (!value.is_empty()).then_some((directive, value))
362}
363
364#[cfg(all(test, windows))]
365fn expand_path_pattern(pattern: &Path) -> Vec<PathBuf> {
366    let mut budget = ScanBudget::new(MAX_EXPANDED_CONFIG_CANDIDATES);
367    expand_path_pattern_bounded(pattern, &mut budget)
368        .paths
369        .into_iter()
370        .take(MAX_EXPANDED_CONFIG_PATHS)
371        .collect()
372}
373
374#[cfg(test)]
375mod tests;