1use 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
18pub 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
59pub 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
111fn page_key_matches(key: &str, want: &str) -> bool {
116 key == want || strip_page_suffix(key) == want
117}
118
119fn 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
128fn 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
159enum ViewerOutcome {
161 Shown,
163 Missing
165}
166
167fn 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
191fn 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
214fn 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
238pub 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
274pub 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 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
350fn 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
378fn gather_manual_pages_from_upstream(
391 pkg: &types::Package,
392 registry_handle: Option<&str>
393) -> Result<BTreeMap<String, String>> {
394 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 let prepared = crate::pkg::install::installer::prepare_node(
439 &node, action, None, None, false, false
440 )?;
441
442 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 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
515fn 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}