mod ordering;
mod scan;
#[cfg(test)]
mod tests;
use std::{collections::BTreeMap, env, fmt, path::PathBuf};
use ordering::{
compare_manual_sections, default_manual_section_order, manual_name_key, manual_names_equal,
parse_manual_section_order,
};
pub(crate) use scan::deduplicate_paths;
#[cfg(test)]
use scan::normalize_locale;
use scan::{current_locale, scan_manual_root};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManualRequest {
pub name: String,
pub manual_section: Option<String>,
}
impl ManualRequest {
#[must_use]
pub fn new(name: impl Into<String>, manual_section: Option<String>) -> Self {
Self {
name: name.into(),
manual_section,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManualPage {
pub name: String,
pub section: String,
pub path: PathBuf,
pub manual_root: PathBuf,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ManualIndex {
roots: Vec<PathBuf>,
pages: Vec<ManualPage>,
}
impl ManualIndex {
#[must_use]
pub fn from_roots(roots: Vec<PathBuf>) -> Self {
let locale = current_locale();
let section_order = current_manual_section_order();
Self::from_roots_with_locale_and_sections(roots, locale.as_deref(), §ion_order)
}
#[cfg(test)]
fn from_roots_with_locale(roots: Vec<PathBuf>, locale: Option<&str>) -> Self {
Self::from_roots_with_locale_and_sections(roots, locale, &default_manual_section_order())
}
fn from_roots_with_locale_and_sections(
roots: Vec<PathBuf>,
locale: Option<&str>,
section_order: &[String],
) -> Self {
let roots = deduplicate_paths(roots);
let mut effective = BTreeMap::<(String, String), ManualPage>::new();
for root in &roots {
for page in scan_manual_root(root, locale) {
effective
.entry((manual_name_key(&page.name), page.section.clone()))
.or_insert(page);
}
}
let mut pages = effective.into_values().collect::<Vec<_>>();
pages.sort_by(|left, right| {
manual_name_key(&left.name)
.cmp(&manual_name_key(&right.name))
.then_with(|| compare_manual_sections(&left.section, &right.section, section_order))
});
Self { roots, pages }
}
#[must_use]
pub fn roots(&self) -> &[PathBuf] {
&self.roots
}
#[must_use]
pub fn pages(&self) -> &[ManualPage] {
&self.pages
}
#[must_use]
pub fn find(&self, name: &str, section: Option<&str>) -> Option<&ManualPage> {
let name = name.trim();
let section = section.map(str::trim);
self.pages.iter().find(|page| {
manual_names_equal(&page.name, name)
&& section.is_none_or(|section| page.section == section)
})
}
#[must_use]
pub fn available_manual_sections(&self, name: &str) -> Vec<String> {
let name = name.trim();
self.pages
.iter()
.filter(|page| manual_names_equal(&page.name, name))
.map(|page| page.section.clone())
.collect()
}
}
fn current_manual_section_order() -> Vec<String> {
env::var("MANSECT")
.ok()
.and_then(|value| parse_manual_section_order(&value))
.unwrap_or_else(default_manual_section_order)
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum LocateError {
EmptyName,
InvalidManualSection,
NotFound {
name: String,
requested_manual_section: Option<String>,
available_manual_sections: Vec<String>,
},
}
impl LocateError {
pub(crate) fn load_detail(&self) -> String {
match self {
Self::NotFound {
requested_manual_section: Some(requested),
available_manual_sections,
..
} if !available_manual_sections.is_empty() => format!(
"manual section '{requested}' is unavailable; available sections: {}",
available_manual_sections.join(", ")
),
Self::NotFound {
requested_manual_section: Some(requested),
..
} => format!("no source was found in manual section '{requested}'"),
Self::NotFound { .. } => "no local manual source was found".to_owned(),
Self::EmptyName | Self::InvalidManualSection => self.to_string(),
}
}
}
impl fmt::Display for LocateError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyName => formatter.write_str("manual page name must not be empty"),
Self::InvalidManualSection => formatter.write_str(
"manual section must be a conventional number or the single letter 'l' or 'n'",
),
Self::NotFound {
name,
requested_manual_section: Some(requested),
available_manual_sections,
} if !available_manual_sections.is_empty() => write!(
formatter,
"requested manual section '{requested}' is unavailable for '{name}'; available manual sections: {}",
available_manual_sections.join(", ")
),
Self::NotFound {
name,
requested_manual_section: Some(requested),
..
} => write!(
formatter,
"no local manual source was found for '{name}' in manual section '{requested}'"
),
Self::NotFound { name, .. } => {
write!(formatter, "no local manual source was found for '{name}'")
}
}
}
}
impl std::error::Error for LocateError {}
pub fn locate_manual_source_in(
request: &ManualRequest,
index: &ManualIndex,
) -> Result<ManualPage, LocateError> {
let name = request.name.trim();
if name.is_empty() {
return Err(LocateError::EmptyName);
}
let section = request.manual_section.as_deref().map(str::trim);
if section.is_some_and(|section| !crate::is_manual_section(section)) {
return Err(LocateError::InvalidManualSection);
}
index
.find(name, section)
.cloned()
.ok_or_else(|| LocateError::NotFound {
name: name.to_owned(),
requested_manual_section: section.map(ToOwned::to_owned),
available_manual_sections: index.available_manual_sections(name),
})
}