use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::{fs, io};
use anyhow::{Result, anyhow};
use walkdir::WalkDir;
use crate::pkg::types::{self};
use crate::pkg::{db, local, resolve};
pub fn run(
package_name: &str,
upstream: bool,
raw: bool,
page: Option<&str>
) -> Result<()> {
let (pkg, registry_handle) = resolve_package_for_man(package_name)?;
let pages =
gather_manual_pages(&pkg, registry_handle.as_deref(), upstream)?;
if pages.is_empty() {
return Err(anyhow!(
"Package '{}' does not have any manual pages.",
pkg.name
));
}
let (name, content) = select_page(&pages, &pkg.name, page)?;
if raw {
write_stdout_lossless(content)?;
return Ok(());
}
show_with_viewer(name, content)?;
Ok(())
}
pub fn select_page<'a>(
pages: &'a BTreeMap<String, String>,
pkg_name: &str,
requested: Option<&str>
) -> Result<(&'a String, &'a String)> {
let available = || {
let mut names: Vec<&str> = pages.keys().map(String::as_str).collect();
names.sort_unstable();
names.join(", ")
};
if let Some(want) = requested {
if let Some((key, content)) =
pages.iter().find(|(key, _)| page_key_matches(key, want))
{
return Ok((key, content));
}
return Err(anyhow!(
"Package '{pkg_name}' has no manual page '{want}'. Available: {}",
available()
));
}
let default = format!("{pkg_name}.1");
if let Some((key, content)) = pages
.iter()
.find(|(key, _)| page_key_matches(key, &default))
{
return Ok((key, content));
}
if let Some((key, content)) = pages.iter().next() {
return Ok((key, content));
}
Err(anyhow!(
"Package '{pkg_name}' has no '{default}' page. Pick one with --page. \
Available: {}",
available()
))
}
fn page_key_matches(key: &str, want: &str) -> bool {
key == want || strip_page_suffix(key) == want
}
fn strip_page_suffix(key: &str) -> &str {
if let Some(idx) = key.find('[') {
key[..idx].trim_end()
} else {
key
}
}
fn show_with_viewer(name: &str, content: &str) -> Result<()> {
let dir = tempfile::Builder::new().prefix("zoi-man-").tempdir()?;
let path = dir.path().join(sanitize_filename(name));
fs::write(&path, content)?;
match try_viewer("man", &path)? {
ViewerOutcome::Shown => return Ok(()),
ViewerOutcome::Missing => {}
}
match try_viewer("info", &path)? {
ViewerOutcome::Shown => return Ok(()),
ViewerOutcome::Missing => {}
}
eprintln!(
"Neither 'man' nor 'info' is installed; printing the raw page instead."
);
write_stdout_lossless(content)?;
Ok(())
}
enum ViewerOutcome {
Shown,
Missing
}
fn try_viewer(program: &str, path: &Path) -> Result<ViewerOutcome> {
let mut child = match std::process::Command::new(program).arg(path).spawn()
{
Ok(child) => child,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Ok(ViewerOutcome::Missing);
}
Err(e) => {
return Err(anyhow!("Failed to spawn '{program}': {e}"));
}
};
let status = child.wait()?;
if status.success() {
Ok(ViewerOutcome::Shown)
} else {
Err(anyhow!("'{program}' exited with status {status}"))
}
}
fn sanitize_filename(name: &str) -> PathBuf {
let mut sanitized: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
while sanitized.ends_with(['_', '.']) {
sanitized.pop();
}
if sanitized.is_empty() {
sanitized.push_str("manpage");
}
PathBuf::from(sanitized)
}
fn write_stdout_lossless(content: &str) -> Result<()> {
use std::io::Write;
let stdout = io::stdout();
let mut out = stdout.lock();
match out.write_all(content.as_bytes()) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e.into())
}
match out.flush() {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
Err(e) => return Err(e.into())
}
Ok(())
}
pub fn resolve_package_for_man(
term: &str
) -> Result<(types::Package, Option<String>)> {
if let Ok((pkg, _, _, _, registry_handle, _, _)) =
resolve::resolve_package_and_version(term, None, false, false)
{
return Ok((pkg, registry_handle));
}
let config = crate::pkg::config::read_config()?;
let mut registries = Vec::new();
if let Some(default) = &config.default_registry {
registries.push(default.handle.clone());
}
for reg in &config.added_registries {
registries.push(reg.handle.clone());
}
for handle in registries {
if let Ok(results) = db::find_provides(&handle, term)
&& let Some(result) = results.first()
{
return Ok((result.0.clone(), Some(handle)));
}
}
Err(anyhow!("Could not find package or binary named '{term}'."))
}
pub fn gather_manual_pages(
pkg: &types::Package,
registry_handle: Option<&str>,
upstream: bool
) -> Result<BTreeMap<String, String>> {
let mut pages = BTreeMap::new();
if !upstream {
let handle = registry_handle.unwrap_or("local");
let scopes_to_check = [
types::Scope::Project,
types::Scope::User,
types::Scope::System
];
for scope in scopes_to_check {
if let Ok(package_dir) =
local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)
{
let latest_dir = package_dir.join("latest");
if latest_dir.exists() {
let local_pages = find_local_man_pages(&latest_dir)?;
if !local_pages.is_empty() {
eprintln!(
"Displaying locally installed manual from \
{scope:?} scope..."
);
pages.extend(local_pages);
break;
}
}
}
if scope == types::Scope::System {
let system_man = Path::new("/usr/share/man");
if system_man.exists() {
let system_pages =
find_man_pages_in_hierarchy(system_man, &pkg.name)?;
if !system_pages.is_empty() {
eprintln!(
"Displaying manual from system /usr/share/man..."
);
pages.extend(system_pages);
break;
}
}
}
}
}
if pages.is_empty() {
eprintln!(
"Package not installed or local manual not found. Fetching from \
upstream..."
);
let upstream_pages =
gather_manual_pages_from_upstream(pkg, registry_handle)?;
pages.extend(upstream_pages);
}
Ok(pages)
}
fn find_man_pages_in_hierarchy(
root: &Path,
term: &str
) -> Result<BTreeMap<String, String>> {
let mut pages = BTreeMap::new();
if !root.exists() {
return Ok(pages);
}
for entry in WalkDir::new(root).max_depth(3) {
let entry = entry?;
if entry.file_type().is_file() {
let name = entry.file_name().to_string_lossy();
if name.starts_with(term) {
let content =
crate::pkg::utils::read_file_within(root, entry.path())?;
pages.insert(name.to_string(), content);
}
}
}
Ok(pages)
}
fn gather_manual_pages_from_upstream(
pkg: &types::Package,
registry_handle: Option<&str>
) -> Result<BTreeMap<String, String>> {
let source = registry_handle.map_or_else(
|| pkg.name.clone(),
|handle| format!("#{handle}@{}", pkg.name)
);
let (mut graph, _) =
crate::pkg::install::resolver::resolve_dependency_graph(
&[source],
None,
false,
true,
true,
None,
true,
None
)?;
if graph.nodes.is_empty() {
return Ok(BTreeMap::new());
}
let node_id = graph
.nodes
.keys()
.next()
.expect("Graph should not be empty")
.clone();
let node = graph
.nodes
.remove(&node_id)
.expect("Node should exist in graph");
let install_plan = crate::pkg::install::plan::create_install_plan(
&HashMap::from([(node_id.clone(), node.clone())]),
None,
false
)?;
let action = install_plan
.get(&node_id)
.ok_or_else(|| anyhow!("No install action for package"))?;
let prepared = crate::pkg::install::installer::prepare_node(
&node, action, None, None, false, false
)?;
let temp_dir = tempfile::Builder::new()
.prefix("zoi-man-extract-")
.tempdir()?;
let extract_path = temp_dir.path();
if prepared.archive_path.exists() {
let file = fs::File::open(&prepared.archive_path)?;
let decoder = zstd::stream::read::Decoder::new(file)?;
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_path_buf();
entry.unpack_in(extract_path).map_err(|e| {
anyhow!("Failed to unpack file '{}': {}", path.display(), e)
})?;
}
}
let mut pages = BTreeMap::new();
let pooled_manifest = extract_path.join("manifest.json");
if pooled_manifest.exists() {
let content = fs::read_to_string(&pooled_manifest)?;
let manifest: types::PooledZpaManifest =
serde_json::from_str(&content)?;
let pool_dir = extract_path.join("pool");
for (sub_name, sub_mapping) in manifest.mappings {
for (scope, scope_mapping) in sub_mapping.scopes {
for file in scope_mapping.files {
if file.dest.contains("/man/")
|| file.dest.ends_with(".1")
|| file.dest.ends_with(".5")
{
let pool_file = crate::pkg::utils::safe_join(
&pool_dir,
Path::new(&file.hash)
)?;
if pool_file.exists() {
let content = crate::pkg::utils::read_file_within(
&pool_dir, &pool_file
)?;
let file_name = Path::new(&file.dest)
.file_name()
.expect("Dest should have a file name")
.to_string_lossy();
let display_name =
format!("{file_name}[{sub_name}:{scope:?}]");
pages.insert(display_name, content);
}
}
}
}
}
}
let legacy_man = extract_path.join("data/pkgstore/man");
if legacy_man.exists() {
pages
.extend(find_local_man_pages(&extract_path.join("data/pkgstore"))?);
}
Ok(pages)
}
fn find_local_man_pages(latest_dir: &Path) -> Result<BTreeMap<String, String>> {
let mut pages = BTreeMap::new();
let md_path = latest_dir.join("man.md");
let txt_path = latest_dir.join("man.txt");
if md_path.exists() {
pages.insert(
"main".to_string(),
crate::pkg::utils::read_file_within(latest_dir, &md_path)?
);
return Ok(pages);
}
if txt_path.exists() {
pages.insert(
"main".to_string(),
crate::pkg::utils::read_file_within(latest_dir, &txt_path)?
);
return Ok(pages);
}
let search_dirs =
[latest_dir.join("share").join("man"), latest_dir.join("man")];
for dir in search_dirs {
if dir.exists() {
for entry in WalkDir::new(&dir) {
let entry = entry?;
if entry.file_type().is_file() {
let path = entry.path();
let name = path
.file_name()
.expect("Path should have a file name")
.to_string_lossy()
.to_string();
let content =
crate::pkg::utils::read_file_within(&dir, path)?;
pages.insert(name, content);
}
}
}
}
Ok(pages)
}