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 = fs::read_to_string(entry.path())?;
370 pages.insert(name.to_string(), content);
371 }
372 }
373 }
374 Ok(pages)
375}
376
377fn gather_manual_pages_from_upstream(
390 pkg: &types::Package,
391 registry_handle: Option<&str>
392) -> Result<BTreeMap<String, String>> {
393 let source = registry_handle.map_or_else(
395 || pkg.name.clone(),
396 |handle| format!("#{handle}@{}", pkg.name)
397 );
398
399 let (mut graph, _) =
400 crate::pkg::install::resolver::resolve_dependency_graph(
401 &[source],
402 None,
403 false,
404 true,
405 true,
406 None,
407 true,
408 None
409 )?;
410
411 if graph.nodes.is_empty() {
412 return Ok(BTreeMap::new());
413 }
414
415 let node_id = graph
416 .nodes
417 .keys()
418 .next()
419 .expect("Graph should not be empty")
420 .clone();
421 let node = graph
422 .nodes
423 .remove(&node_id)
424 .expect("Node should exist in graph");
425
426 let install_plan = crate::pkg::install::plan::create_install_plan(
427 &HashMap::from([(node_id.clone(), node.clone())]),
428 None,
429 false
430 )?;
431
432 let action = install_plan
433 .get(&node_id)
434 .ok_or_else(|| anyhow!("No install action for package"))?;
435
436 let prepared = crate::pkg::install::installer::prepare_node(
438 &node, action, None, None, false
439 )?;
440
441 let temp_dir = tempfile::Builder::new()
443 .prefix("zoi-man-extract-")
444 .tempdir()?;
445 let extract_path = temp_dir.path();
446
447 if prepared.archive_path.exists() {
448 let file = fs::File::open(&prepared.archive_path)?;
449 let decoder = zstd::stream::read::Decoder::new(file)?;
450 let mut archive = tar::Archive::new(decoder);
451 archive.unpack(extract_path)?;
452 }
453
454 let mut pages = BTreeMap::new();
462
463 let pooled_manifest = extract_path.join("manifest.json");
464 if pooled_manifest.exists() {
465 let content = fs::read_to_string(&pooled_manifest)?;
466 let manifest: types::PooledZpaManifest =
467 serde_json::from_str(&content)?;
468 let pool_dir = extract_path.join("pool");
469
470 for (sub_name, sub_mapping) in manifest.mappings {
471 for (scope, scope_mapping) in sub_mapping.scopes {
472 for file in scope_mapping.files {
473 if file.dest.contains("/man/")
474 || file.dest.ends_with(".1")
475 || file.dest.ends_with(".5")
476 {
477 let pool_file = pool_dir.join(&file.hash);
478 if pool_file.exists() {
479 let content = fs::read_to_string(pool_file)?;
480 let file_name = Path::new(&file.dest)
481 .file_name()
482 .expect("Dest should have a file name")
483 .to_string_lossy();
484 let display_name =
485 format!("{file_name}[{sub_name}:{scope:?}]");
486 pages.insert(display_name, content);
487 }
488 }
489 }
490 }
491 }
492 }
493
494 let legacy_man = extract_path.join("data/pkgstore/man");
495 if legacy_man.exists() {
496 pages
497 .extend(find_local_man_pages(&extract_path.join("data/pkgstore"))?);
498 }
499
500 Ok(pages)
501}
502
503fn find_local_man_pages(latest_dir: &Path) -> Result<BTreeMap<String, String>> {
505 let mut pages = BTreeMap::new();
506
507 let md_path = latest_dir.join("man.md");
508 let txt_path = latest_dir.join("man.txt");
509
510 if md_path.exists() {
511 pages.insert("main".to_string(), fs::read_to_string(md_path)?);
512 return Ok(pages);
513 }
514
515 if txt_path.exists() {
516 pages.insert("main".to_string(), fs::read_to_string(txt_path)?);
517 return Ok(pages);
518 }
519
520 let search_dirs =
521 [latest_dir.join("share").join("man"), latest_dir.join("man")];
522
523 for dir in search_dirs {
524 if dir.exists() {
525 for entry in WalkDir::new(dir) {
526 let entry = entry?;
527 if entry.file_type().is_file() {
528 let path = entry.path();
529 let name = path
530 .file_name()
531 .expect("Path should have a file name")
532 .to_string_lossy()
533 .to_string();
534 let content = fs::read_to_string(path)?;
535 pages.insert(name, content);
536 }
537 }
538 }
539 }
540
541 Ok(pages)
542}