use std::io::Write as _;
use std::path::Path;
use anyhow::{Context as _, Result};
use clap::{Command, CommandFactory as _};
use crate::cli::{Cli, RunAliasCli};
pub(crate) fn write_runner_page_to_stdout() -> Result<()> {
let roff = render_command(Cli::command(), "runner");
std::io::stdout()
.write_all(&roff)
.context("failed to write man page to stdout")
}
pub(crate) fn write_man_pages(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
for (stem, roff) in man_pages() {
let path = dir.join(format!("{stem}.1"));
std::fs::write(&path, roff)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Ok(())
}
const AUTHOR: &str = env!("RUNNER_AUTHOR_NAME");
const SOURCE: &str = concat!("runner ", env!("CARGO_PKG_VERSION"));
#[must_use]
fn man_pages() -> Vec<(String, Vec<u8>)> {
let runner = Cli::command();
let mut pages = Vec::new();
pages.push((
"runner".to_string(),
render_command(runner.clone(), "runner"),
));
for sub in runner.get_subcommands() {
if sub.is_hide_set() || sub.get_name() == "man" {
continue;
}
let stem = format!("runner-{}", sub.get_name());
pages.push((stem.clone(), render_command(sub.clone(), &stem)));
}
pages.push((
"run".to_string(),
render_command(RunAliasCli::command(), "run"),
));
pages
}
fn render_command(cmd: Command, page_name: &str) -> Vec<u8> {
let cmd = cmd.name(page_name.to_string()).author(AUTHOR);
let man = clap_mangen::Man::new(cmd)
.section("1")
.manual("Runner Manual")
.source(SOURCE);
let mut raw = Vec::new();
man.render(&mut raw)
.expect("rendering to a Vec cannot fail");
strip_ansi(&raw)
}
fn strip_ansi(input: &[u8]) -> Vec<u8> {
const ESC: u8 = 0x1b;
const BEL: u8 = 0x07;
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
if input[i] == ESC && i + 1 < input.len() {
match input[i + 1] {
b'[' => {
i += 2;
while i < input.len() && !(0x40..=0x7e).contains(&input[i]) {
i += 1;
}
i += 1; continue;
}
b']' => {
i += 2;
while i < input.len() {
if input[i] == BEL {
i += 1;
break;
}
if input[i] == ESC && i + 1 < input.len() && input[i + 1] == b'\\' {
i += 2;
break;
}
i += 1;
}
continue;
}
_ => {}
}
}
out.push(input[i]);
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::{man_pages, strip_ansi};
#[test]
fn strip_ansi_removes_csi_color_codes() {
let input = b"\x1b[36mnpm\x1b[0m and \x1b[36mbun\x1b[0m";
assert_eq!(strip_ansi(input), b"npm and bun");
}
#[test]
fn strip_ansi_removes_osc8_hyperlinks() {
let input = b"\x1b]8;;https://example.com\x1b\\label\x1b]8;;\x1b\\";
assert_eq!(strip_ansi(input), b"label");
}
#[test]
fn strip_ansi_leaves_plain_text_untouched() {
let input = b".TH RUNNER 1\nplain roff\n";
assert_eq!(strip_ansi(input), input);
}
#[test]
fn man_pages_cover_both_binaries_and_skip_hidden() {
let pages = man_pages();
let names: Vec<&str> = pages.iter().map(|(n, _)| n.as_str()).collect();
for expected in ["runner", "run", "runner-run", "runner-completions"] {
assert!(
names.contains(&expected),
"missing {expected} page; got {names:?}"
);
}
assert!(
!names.contains(&"runner-info"),
"hidden `info` subcommand should not produce a page; got {names:?}"
);
}
#[test]
fn rendered_pages_are_clean_roff() {
for (name, roff) in man_pages() {
assert!(
!roff.contains(&0x1b),
"page {name} still contains an ANSI escape after stripping"
);
assert!(
String::from_utf8_lossy(&roff).contains(".TH"),
"page {name} should be roff with a .TH title header"
);
}
}
}