Skip to main content

mant_loader/
source.rs

1//! Discovers and resolves local manual sources without invoking a man program.
2
3mod ordering;
4mod scan;
5#[cfg(test)]
6mod tests;
7
8use std::{collections::BTreeMap, env, fmt, path::PathBuf};
9
10use ordering::{
11    compare_manual_sections, default_manual_section_order, manual_name_key, manual_names_equal,
12    parse_manual_section_order,
13};
14pub(crate) use scan::deduplicate_paths;
15#[cfg(test)]
16use scan::normalize_locale;
17use scan::{current_locale, scan_manual_root};
18
19/// One validated manual lookup independent from CLI token syntax.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct ManualRequest {
22    /// Manual topic without its category suffix.
23    pub name: String,
24    /// Optional exact native category such as `1` or `3p`.
25    pub manual_section: Option<String>,
26}
27
28impl ManualRequest {
29    /// Construct a normalized lookup request without performing I/O.
30    #[must_use]
31    pub fn new(name: impl Into<String>, manual_section: Option<String>) -> Self {
32        Self {
33            name: name.into(),
34            manual_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    /// Indexed manual topic.
43    pub name: String,
44    /// Native manual category derived from the containing `man<section>` tree.
45    pub section: String,
46    /// Physical source path, possibly compressed.
47    pub path: PathBuf,
48    /// Approved hierarchy root used to resolve this page's `.so` redirects.
49    ///
50    /// The indexed leaf itself may be a file symlink whose target is outside
51    /// this root. Redirect targets must still remain inside it.
52    pub manual_root: PathBuf,
53}
54
55/// Immutable index shared by discovery and exact manual lookup.
56#[derive(Clone, Debug, Default, Eq, PartialEq)]
57pub struct ManualIndex {
58    roots: Vec<PathBuf>,
59    pages: Vec<ManualPage>,
60}
61
62impl ManualIndex {
63    /// Scan explicit roots in precedence order.
64    #[must_use]
65    pub fn from_roots(roots: Vec<PathBuf>) -> Self {
66        let locale = current_locale();
67        let section_order = current_manual_section_order();
68        Self::from_roots_with_locale_and_sections(roots, locale.as_deref(), &section_order)
69    }
70
71    #[cfg(test)]
72    fn from_roots_with_locale(roots: Vec<PathBuf>, locale: Option<&str>) -> Self {
73        Self::from_roots_with_locale_and_sections(roots, locale, &default_manual_section_order())
74    }
75
76    fn from_roots_with_locale_and_sections(
77        roots: Vec<PathBuf>,
78        locale: Option<&str>,
79        section_order: &[String],
80    ) -> Self {
81        let roots = deduplicate_paths(roots);
82        let mut effective = BTreeMap::<(String, String), ManualPage>::new();
83        for root in &roots {
84            for page in scan_manual_root(root, locale) {
85                effective
86                    .entry((manual_name_key(&page.name), page.section.clone()))
87                    .or_insert(page);
88            }
89        }
90        let mut pages = effective.into_values().collect::<Vec<_>>();
91        pages.sort_by(|left, right| {
92            manual_name_key(&left.name)
93                .cmp(&manual_name_key(&right.name))
94                .then_with(|| compare_manual_sections(&left.section, &right.section, section_order))
95        });
96        Self { roots, pages }
97    }
98
99    /// Roots searched by this index, in precedence order.
100    #[must_use]
101    pub fn roots(&self) -> &[PathBuf] {
102        &self.roots
103    }
104
105    /// Effective pages sorted by name and section.
106    #[must_use]
107    pub fn pages(&self) -> &[ManualPage] {
108        &self.pages
109    }
110
111    /// Resolve one page using an optional exact manual category.
112    #[must_use]
113    pub fn find(&self, name: &str, section: Option<&str>) -> Option<&ManualPage> {
114        let name = name.trim();
115        let section = section.map(str::trim);
116        self.pages.iter().find(|page| {
117            manual_names_equal(&page.name, name)
118                && section.is_none_or(|section| page.section == section)
119        })
120    }
121
122    /// Exact manual categories available for one logical page name.
123    #[must_use]
124    pub fn available_manual_sections(&self, name: &str) -> Vec<String> {
125        let name = name.trim();
126        self.pages
127            .iter()
128            .filter(|page| manual_names_equal(&page.name, name))
129            .map(|page| page.section.clone())
130            .collect()
131    }
132}
133
134fn current_manual_section_order() -> Vec<String> {
135    env::var("MANSECT")
136        .ok()
137        .and_then(|value| parse_manual_section_order(&value))
138        .unwrap_or_else(default_manual_section_order)
139}
140
141/// Expected source-discovery failures suitable for a user-facing CLI error.
142#[derive(Debug, Clone, Eq, PartialEq)]
143pub enum LocateError {
144    /// The requested manual name was empty.
145    EmptyName,
146    /// An explicitly requested manual category was malformed.
147    InvalidManualSection,
148    /// No indexed page satisfied the request.
149    NotFound {
150        /// Requested manual topic.
151        name: String,
152        /// Exact requested native category, when supplied.
153        requested_manual_section: Option<String>,
154        /// Other indexed categories available for the same topic.
155        available_manual_sections: Vec<String>,
156    },
157}
158
159impl LocateError {
160    /// Render this error as detail nested below an already identified topic.
161    pub(crate) fn load_detail(&self) -> String {
162        match self {
163            Self::NotFound {
164                requested_manual_section: Some(requested),
165                available_manual_sections,
166                ..
167            } if !available_manual_sections.is_empty() => format!(
168                "manual section '{requested}' is unavailable; available sections: {}",
169                available_manual_sections.join(", ")
170            ),
171            Self::NotFound {
172                requested_manual_section: Some(requested),
173                ..
174            } => format!("no source was found in manual section '{requested}'"),
175            Self::NotFound { .. } => "no local manual source was found".to_owned(),
176            Self::EmptyName | Self::InvalidManualSection => self.to_string(),
177        }
178    }
179}
180
181impl fmt::Display for LocateError {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            Self::EmptyName => formatter.write_str("manual page name must not be empty"),
185            Self::InvalidManualSection => formatter.write_str(
186                "manual section must be a conventional number or the single letter 'l' or 'n'",
187            ),
188            Self::NotFound {
189                name,
190                requested_manual_section: Some(requested),
191                available_manual_sections,
192            } if !available_manual_sections.is_empty() => write!(
193                formatter,
194                "requested manual section '{requested}' is unavailable for '{name}'; available manual sections: {}",
195                available_manual_sections.join(", ")
196            ),
197            Self::NotFound {
198                name,
199                requested_manual_section: Some(requested),
200                ..
201            } => write!(
202                formatter,
203                "no local manual source was found for '{name}' in manual section '{requested}'"
204            ),
205            Self::NotFound { name, .. } => {
206                write!(formatter, "no local manual source was found for '{name}'")
207            }
208        }
209    }
210}
211
212impl std::error::Error for LocateError {}
213
214/// Locate a manual in an explicit immutable index.
215///
216/// # Errors
217///
218/// Returns [`LocateError`] for invalid requests and missing local sources.
219pub fn locate_manual_source_in(
220    request: &ManualRequest,
221    index: &ManualIndex,
222) -> Result<ManualPage, LocateError> {
223    let name = request.name.trim();
224    if name.is_empty() {
225        return Err(LocateError::EmptyName);
226    }
227    let section = request.manual_section.as_deref().map(str::trim);
228    if section.is_some_and(|section| !crate::is_manual_section(section)) {
229        return Err(LocateError::InvalidManualSection);
230    }
231    index
232        .find(name, section)
233        .cloned()
234        .ok_or_else(|| LocateError::NotFound {
235            name: name.to_owned(),
236            requested_manual_section: section.map(ToOwned::to_owned),
237            available_manual_sections: index.available_manual_sections(name),
238        })
239}