1mod 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#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct ManualRequest {
22 pub name: String,
24 pub manual_section: Option<String>,
26}
27
28impl ManualRequest {
29 #[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#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct ManualPage {
42 pub name: String,
44 pub section: String,
46 pub path: PathBuf,
48 pub manual_root: PathBuf,
53}
54
55#[derive(Clone, Debug, Default, Eq, PartialEq)]
57pub struct ManualIndex {
58 roots: Vec<PathBuf>,
59 pages: Vec<ManualPage>,
60}
61
62impl ManualIndex {
63 #[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(), §ion_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 #[must_use]
101 pub fn roots(&self) -> &[PathBuf] {
102 &self.roots
103 }
104
105 #[must_use]
107 pub fn pages(&self) -> &[ManualPage] {
108 &self.pages
109 }
110
111 #[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 #[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#[derive(Debug, Clone, Eq, PartialEq)]
143pub enum LocateError {
144 EmptyName,
146 InvalidManualSection,
148 NotFound {
150 name: String,
152 requested_manual_section: Option<String>,
154 available_manual_sections: Vec<String>,
156 },
157}
158
159impl LocateError {
160 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
214pub 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}