Skip to main content

zoi_cli/cmd/
man.rs

1//! Command for displaying manual pages for packages.
2//!
3//! Manual pages staged with `zman()` are opened with the system's `man`
4//! viewer, falling back to `info` and finally to plain stdout. `--page`
5//! selects which page to show when a package ships several, and `--raw`
6//! prints the raw page source so it can be piped elsewhere.
7
8use std::collections::{BTreeMap, HashMap};
9use std::path::{Path, PathBuf};
10use std::{fs, io};
11
12use anyhow::{Result, anyhow};
13use walkdir::WalkDir;
14
15use crate::pkg::types::{self};
16use crate::pkg::{db, local, resolve};
17
18/// Runs the manual page viewer for the specified package.
19///
20/// Without `--page`, the page matching the package name in section 1
21/// (`<name>.1`) is shown. Pass `--raw` to print the raw page source to
22/// stdout instead of opening a viewer.
23///
24/// # Errors
25///
26/// Returns an error if:
27/// - The package cannot be resolved.
28/// - No manual pages can be found, or the requested `--page` does not exist.
29/// - The selected viewer fails.
30pub fn run(
31    package_name: &str,
32    upstream: bool,
33    raw: bool,
34    page: Option<&str>
35) -> Result<()> {
36    let (pkg, registry_handle) = resolve_package_for_man(package_name)?;
37
38    let pages =
39        gather_manual_pages(&pkg, registry_handle.as_deref(), upstream)?;
40
41    if pages.is_empty() {
42        return Err(anyhow!(
43            "Package '{}' does not have any manual pages.",
44            pkg.name
45        ));
46    }
47
48    let (name, content) = select_page(&pages, &pkg.name, page)?;
49
50    if raw {
51        write_stdout_lossless(content)?;
52        return Ok(());
53    }
54
55    show_with_viewer(name, content)?;
56    Ok(())
57}
58
59/// Picks which manual page to display.
60///
61/// An explicit `--page` value matches a page file name exactly
62/// (e.g. `zbsdiff.3`). Without it, the `<package>.1` page is preferred;
63/// a lone page is used directly regardless of its name.
64///
65/// # Errors
66///
67/// Returns an error if the requested page does not exist, or if no page
68/// can be chosen by default. The error lists the available pages.
69pub fn select_page<'a>(
70    pages: &'a BTreeMap<String, String>,
71    pkg_name: &str,
72    requested: Option<&str>
73) -> Result<(&'a String, &'a String)> {
74    let available = || {
75        let mut names: Vec<&str> = pages.keys().map(String::as_str).collect();
76        names.sort_unstable();
77        names.join(", ")
78    };
79
80    if let Some(want) = requested {
81        if let Some((key, content)) =
82            pages.iter().find(|(key, _)| page_key_matches(key, want))
83        {
84            return Ok((key, content));
85        }
86        return Err(anyhow!(
87            "Package '{pkg_name}' has no manual page '{want}'. Available: {}",
88            available()
89        ));
90    }
91
92    let default = format!("{pkg_name}.1");
93    if let Some((key, content)) = pages
94        .iter()
95        .find(|(key, _)| page_key_matches(key, &default))
96    {
97        return Ok((key, content));
98    }
99
100    if let Some((key, content)) = pages.iter().next() {
101        return Ok((key, content));
102    }
103
104    Err(anyhow!(
105        "Package '{pkg_name}' has no '{default}' page. Pick one with --page. \
106         Available: {}",
107        available()
108    ))
109}
110
111/// Checks whether a stored page key refers to the requested page name.
112///
113/// Upstream pages may carry a `[sub:Scope]` suffix on their key, so both
114/// the full key and the suffix-stripped form are accepted.
115fn page_key_matches(key: &str, want: &str) -> bool {
116    key == want || strip_page_suffix(key) == want
117}
118
119/// Removes a trailing `[sub:Scope]` display suffix from a page key.
120fn strip_page_suffix(key: &str) -> &str {
121    if let Some(idx) = key.find('[') {
122        key[..idx].trim_end()
123    } else {
124        key
125    }
126}
127
128/// Displays a page with the system's `man` viewer, falling back to `info`
129/// and finally to plain stdout when neither viewer is installed.
130///
131/// The viewer runs in the foreground so it must finish before the
132/// temporary file holding the page is cleaned up.
133///
134/// # Errors
135///
136/// Returns an error if the temporary file cannot be written or a viewer
137/// fails after starting successfully.
138fn show_with_viewer(name: &str, content: &str) -> Result<()> {
139    let dir = tempfile::Builder::new().prefix("zoi-man-").tempdir()?;
140    let path = dir.path().join(sanitize_filename(name));
141    fs::write(&path, content)?;
142
143    match try_viewer("man", &path)? {
144        ViewerOutcome::Shown => return Ok(()),
145        ViewerOutcome::Missing => {}
146    }
147    match try_viewer("info", &path)? {
148        ViewerOutcome::Shown => return Ok(()),
149        ViewerOutcome::Missing => {}
150    }
151
152    eprintln!(
153        "Neither 'man' nor 'info' is installed; printing the raw page instead."
154    );
155    write_stdout_lossless(content)?;
156    Ok(())
157}
158
159/// Outcome of attempting to display a page with an external viewer.
160enum ViewerOutcome {
161    /// The viewer displayed the page.
162    Shown,
163    /// The viewer program is not installed.
164    Missing
165}
166
167/// Runs an external viewer (`man`, `info`) on a page file and waits for it.
168///
169/// # Errors
170///
171/// Returns an error if the viewer starts but exits unsuccessfully.
172fn try_viewer(program: &str, path: &Path) -> Result<ViewerOutcome> {
173    let mut child = match std::process::Command::new(program).arg(path).spawn()
174    {
175        Ok(child) => child,
176        Err(e) if e.kind() == io::ErrorKind::NotFound => {
177            return Ok(ViewerOutcome::Missing);
178        }
179        Err(e) => {
180            return Err(anyhow!("Failed to spawn '{program}': {e}"));
181        }
182    };
183    let status = child.wait()?;
184    if status.success() {
185        Ok(ViewerOutcome::Shown)
186    } else {
187        Err(anyhow!("'{program}' exited with status {status}"))
188    }
189}
190
191/// Makes a page key safe to use as a temporary file name while keeping its
192/// man section extension (e.g. `zbsdiff.1[main:User]` becomes
193/// `zbsdiff.1_main_User`).
194fn sanitize_filename(name: &str) -> PathBuf {
195    let mut sanitized: String = name
196        .chars()
197        .map(|c| {
198            if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
199                c
200            } else {
201                '_'
202            }
203        })
204        .collect();
205    while sanitized.ends_with(['_', '.']) {
206        sanitized.pop();
207    }
208    if sanitized.is_empty() {
209        sanitized.push_str("manpage");
210    }
211    PathBuf::from(sanitized)
212}
213
214/// Writes to stdout without panicking on a closed pipe, so invocations like
215/// `zoi man <pkg> --raw | man` or `... | head` exit quietly instead of
216/// panicking with a broken pipe error.
217///
218/// # Errors
219///
220/// Returns an error for any I/O failure other than a broken pipe.
221fn write_stdout_lossless(content: &str) -> Result<()> {
222    use std::io::Write;
223    let stdout = io::stdout();
224    let mut out = stdout.lock();
225    match out.write_all(content.as_bytes()) {
226        Ok(()) => {}
227        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => return Ok(()),
228        Err(e) => return Err(e.into())
229    }
230    match out.flush() {
231        Ok(()) => {}
232        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
233        Err(e) => return Err(e.into())
234    }
235    Ok(())
236}
237
238/// Resolves a package and optional registry handle for a given search term.
239///
240/// # Errors
241///
242/// Returns an error if:
243/// - The package or binary cannot be found.
244/// - The project configuration cannot be read.
245pub fn resolve_package_for_man(
246    term: &str
247) -> Result<(types::Package, Option<String>)> {
248    if let Ok((pkg, _, _, _, registry_handle, _, _)) =
249        resolve::resolve_package_and_version(term, None, false, false)
250    {
251        return Ok((pkg, registry_handle));
252    }
253
254    let config = crate::pkg::config::read_config()?;
255    let mut registries = Vec::new();
256    if let Some(default) = &config.default_registry {
257        registries.push(default.handle.clone());
258    }
259    for reg in &config.added_registries {
260        registries.push(reg.handle.clone());
261    }
262
263    for handle in registries {
264        if let Ok(results) = db::find_provides(&handle, term)
265            && let Some(result) = results.first()
266        {
267            return Ok((result.0.clone(), Some(handle)));
268        }
269    }
270
271    Err(anyhow!("Could not find package or binary named '{term}'."))
272}
273
274/// Gathers raw manual page sources for a package, checking locally first
275/// and then upstream.
276///
277/// Pages are returned as `file name -> raw content` with no reformatting,
278/// so they stay pipeable into `man` and render natively in viewers.
279/// Informational notes go to stderr to keep stdout clean for `--raw`.
280///
281/// # Errors
282///
283/// Returns an error if:
284/// - Local manual pages cannot be found.
285/// - Upstream manual pages cannot be gathered.
286pub fn gather_manual_pages(
287    pkg: &types::Package,
288    registry_handle: Option<&str>,
289    upstream: bool
290) -> Result<BTreeMap<String, String>> {
291    let mut pages = BTreeMap::new();
292
293    if !upstream {
294        let handle = registry_handle.unwrap_or("local");
295        let scopes_to_check = [
296            types::Scope::Project,
297            types::Scope::User,
298            types::Scope::System
299        ];
300
301        for scope in scopes_to_check {
302            if let Ok(package_dir) =
303                local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)
304            {
305                let latest_dir = package_dir.join("latest");
306                if latest_dir.exists() {
307                    let local_pages = find_local_man_pages(&latest_dir)?;
308                    if !local_pages.is_empty() {
309                        eprintln!(
310                            "Displaying locally installed manual from \
311                             {scope:?} scope..."
312                        );
313                        pages.extend(local_pages);
314                        break;
315                    }
316                }
317            }
318
319            // Also check standard system locations if scope is system
320            if scope == types::Scope::System {
321                let system_man = Path::new("/usr/share/man");
322                if system_man.exists() {
323                    let system_pages =
324                        find_man_pages_in_hierarchy(system_man, &pkg.name)?;
325                    if !system_pages.is_empty() {
326                        eprintln!(
327                            "Displaying manual from system /usr/share/man..."
328                        );
329                        pages.extend(system_pages);
330                        break;
331                    }
332                }
333            }
334        }
335    }
336
337    if pages.is_empty() {
338        eprintln!(
339            "Package not installed or local manual not found. Fetching from \
340             upstream..."
341        );
342        let upstream_pages =
343            gather_manual_pages_from_upstream(pkg, registry_handle)?;
344        pages.extend(upstream_pages);
345    }
346
347    Ok(pages)
348}
349
350/// Recursively finds manual pages in a directory hierarchy.
351///
352/// # Errors
353///
354/// Returns an error if directory traversal or file reading fails.
355fn find_man_pages_in_hierarchy(
356    root: &Path,
357    term: &str
358) -> Result<BTreeMap<String, String>> {
359    let mut pages = BTreeMap::new();
360    if !root.exists() {
361        return Ok(pages);
362    }
363
364    for entry in WalkDir::new(root).max_depth(3) {
365        let entry = entry?;
366        if entry.file_type().is_file() {
367            let name = entry.file_name().to_string_lossy();
368            if name.starts_with(term) {
369                let content =
370                    crate::pkg::utils::read_file_within(root, entry.path())?;
371                pages.insert(name.to_string(), content);
372            }
373        }
374    }
375    Ok(pages)
376}
377
378/// Fetches manual pages for a package from the upstream registry.
379///
380/// # Errors
381///
382/// Returns an error if:
383/// - Dependency resolution fails.
384/// - The package archive cannot be downloaded or extracted.
385/// - File reading fails.
386///
387/// # Panics
388///
389/// Panics if the internal dependency graph is inconsistent.
390fn gather_manual_pages_from_upstream(
391    pkg: &types::Package,
392    registry_handle: Option<&str>
393) -> Result<BTreeMap<String, String>> {
394    // Resolve the package to get its archive source
395    let source = registry_handle.map_or_else(
396        || pkg.name.clone(),
397        |handle| format!("#{handle}@{}", pkg.name)
398    );
399
400    let (mut graph, _) =
401        crate::pkg::install::resolver::resolve_dependency_graph(
402            &[source],
403            None,
404            false,
405            true,
406            true,
407            None,
408            true,
409            None
410        )?;
411
412    if graph.nodes.is_empty() {
413        return Ok(BTreeMap::new());
414    }
415
416    let node_id = graph
417        .nodes
418        .keys()
419        .next()
420        .expect("Graph should not be empty")
421        .clone();
422    let node = graph
423        .nodes
424        .remove(&node_id)
425        .expect("Node should exist in graph");
426
427    let install_plan = crate::pkg::install::plan::create_install_plan(
428        &HashMap::from([(node_id.clone(), node.clone())]),
429        None,
430        false
431    )?;
432
433    let action = install_plan
434        .get(&node_id)
435        .ok_or_else(|| anyhow!("No install action for package"))?;
436
437    // Prepare the node (download/build)
438    let prepared = crate::pkg::install::installer::prepare_node(
439        &node, action, None, None, false, false
440    )?;
441
442    // Extract to a temp directory
443    let temp_dir = tempfile::Builder::new()
444        .prefix("zoi-man-extract-")
445        .tempdir()?;
446    let extract_path = temp_dir.path();
447
448    if prepared.archive_path.exists() {
449        let file = fs::File::open(&prepared.archive_path)?;
450        let decoder = zstd::stream::read::Decoder::new(file)?;
451        let mut archive = tar::Archive::new(decoder);
452        for entry in archive.entries()? {
453            let mut entry = entry?;
454            let path = entry.path()?.to_path_buf();
455            entry.unpack_in(extract_path).map_err(|e| {
456                anyhow!("Failed to unpack file '{}': {}", path.display(), e)
457            })?;
458        }
459    }
460
461    // Look for man pages in the extracted content
462    // We check:
463    // - manifest.json (for pooled ZPA)
464    // - data/pkgstore/man/
465    // - data/usrroot/usr/share/man/
466    // - any .pkg.lua in the root
467
468    let mut pages = BTreeMap::new();
469
470    let pooled_manifest = extract_path.join("manifest.json");
471    if pooled_manifest.exists() {
472        let content = fs::read_to_string(&pooled_manifest)?;
473        let manifest: types::PooledZpaManifest =
474            serde_json::from_str(&content)?;
475        let pool_dir = extract_path.join("pool");
476
477        for (sub_name, sub_mapping) in manifest.mappings {
478            for (scope, scope_mapping) in sub_mapping.scopes {
479                for file in scope_mapping.files {
480                    if file.dest.contains("/man/")
481                        || file.dest.ends_with(".1")
482                        || file.dest.ends_with(".5")
483                    {
484                        let pool_file = crate::pkg::utils::safe_join(
485                            &pool_dir,
486                            Path::new(&file.hash)
487                        )?;
488                        if pool_file.exists() {
489                            let content = crate::pkg::utils::read_file_within(
490                                &pool_dir, &pool_file
491                            )?;
492                            let file_name = Path::new(&file.dest)
493                                .file_name()
494                                .expect("Dest should have a file name")
495                                .to_string_lossy();
496                            let display_name =
497                                format!("{file_name}[{sub_name}:{scope:?}]");
498                            pages.insert(display_name, content);
499                        }
500                    }
501                }
502            }
503        }
504    }
505
506    let legacy_man = extract_path.join("data/pkgstore/man");
507    if legacy_man.exists() {
508        pages
509            .extend(find_local_man_pages(&extract_path.join("data/pkgstore"))?);
510    }
511
512    Ok(pages)
513}
514
515/// Finds manual pages in a package's installation directory.
516fn find_local_man_pages(latest_dir: &Path) -> Result<BTreeMap<String, String>> {
517    let mut pages = BTreeMap::new();
518
519    let md_path = latest_dir.join("man.md");
520    let txt_path = latest_dir.join("man.txt");
521
522    if md_path.exists() {
523        pages.insert(
524            "main".to_string(),
525            crate::pkg::utils::read_file_within(latest_dir, &md_path)?
526        );
527        return Ok(pages);
528    }
529
530    if txt_path.exists() {
531        pages.insert(
532            "main".to_string(),
533            crate::pkg::utils::read_file_within(latest_dir, &txt_path)?
534        );
535        return Ok(pages);
536    }
537
538    let search_dirs =
539        [latest_dir.join("share").join("man"), latest_dir.join("man")];
540
541    for dir in search_dirs {
542        if dir.exists() {
543            for entry in WalkDir::new(&dir) {
544                let entry = entry?;
545                if entry.file_type().is_file() {
546                    let path = entry.path();
547                    let name = path
548                        .file_name()
549                        .expect("Path should have a file name")
550                        .to_string_lossy()
551                        .to_string();
552                    let content =
553                        crate::pkg::utils::read_file_within(&dir, path)?;
554                    pages.insert(name, content);
555                }
556            }
557        }
558    }
559
560    Ok(pages)
561}