Skip to main content

dev_prune/commands/
man.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `devp man`.
5//
6// The pages are rendered from the same clap definition the binary parses arguments
7// with — the same `long_about` texts `--help` prints — so a flag cannot exist in the
8// manual and be missing from the program, or the other way round. That is the whole
9// reason this is a subcommand rather than checked-in roff files that go stale.
10
11use std::fs;
12use std::io::{IsTerminal, Write};
13use std::path::Path;
14
15use anyhow::{Context, Result};
16use clap::CommandFactory;
17use clap_mangen::Man;
18use colored::Colorize;
19
20use crate::Cli;
21use crate::help;
22use crate::output;
23
24/// Render the manual: a readable contents page on a terminal, one command's page when
25/// a command is named, roff when redirected, and with `--dir` the full set of files —
26/// `devp.1`, `dev-prune.1` (the same page under the binary's other name) and one
27/// `devp-<command>.1` per subcommand.
28pub fn run(command_name: Option<&str>, dir: Option<&str>, roff: bool) -> Result<()> {
29    let mut command = Cli::command().name("devp");
30    command.build();
31
32    // A named command is a request to read one page, and it is the same page `--dir`
33    // would write for it: roff when the output is going somewhere that formats roff,
34    // that command's own long help when a person is reading it.
35    if let Some(name) = command_name {
36        let Some(sub) = command
37            .get_subcommands()
38            .find(|s| s.get_name() == name || s.get_all_aliases().any(|a| a == name))
39            .cloned()
40        else {
41            let names: Vec<&str> = command
42                .get_subcommands()
43                .map(|s| s.get_name())
44                .filter(|n| *n != "help")
45                .collect();
46            anyhow::bail!(
47                "no such command: `{name}`. Try one of: {}",
48                names.join(", ")
49            );
50        };
51        if roff || !std::io::stdout().is_terminal() {
52            let page = format!("devp-{name}");
53            let mut out = Vec::new();
54            Man::new(sub.name(page.leak() as &str)).render(&mut out)?;
55            std::io::stdout().write_all(&out)?;
56            return Ok(());
57        }
58        let mut sub = sub;
59        sub.print_long_help()?;
60        return Ok(());
61    }
62
63    let Some(dir) = dir else {
64        // Someone who ran `devp man` at a prompt used to get raw troff — `.TH`,
65        // `\fB\-\-dry\-run\fR` and the rest — because the output assumed a `man`
66        // on the other end of a pipe. On Windows there is no `man` to pipe into at
67        // all, so the markup was the whole experience. Redirected output still gets
68        // roff, so `devp man > devp.1` and `devp man | man -l -` are unchanged.
69        if roff || !std::io::stdout().is_terminal() {
70            let mut out = Vec::new();
71            Man::new(command).render(&mut out)?;
72            std::io::stdout().write_all(&out)?;
73            return Ok(());
74        }
75
76        // A contents page rather than the top-level long help. The long help is one
77        // screen-and-a-half of prose followed by every subcommand's one-liner, which
78        // is a reasonable answer to `devp --help` and a poor answer to "show me the
79        // manual": nothing on it tells the reader where they are or how to get to the
80        // page they actually want. This says both, in that order.
81        print_contents();
82        return Ok(());
83    };
84
85    let dir = Path::new(dir);
86    fs::create_dir_all(dir)
87        .with_context(|| format!("could not create {}", output::clean_path(dir)))?;
88
89    let mut written = 0usize;
90    let mut render_to = |name: &str, man: Man| -> Result<()> {
91        let path = dir.join(format!("{name}.1"));
92        let mut buf = Vec::new();
93        man.render(&mut buf)?;
94        fs::write(&path, buf)
95            .with_context(|| format!("could not write {}", output::clean_path(&path)))?;
96        written += 1;
97        Ok(())
98    };
99
100    for sub in command.get_subcommands() {
101        // `help` documents itself; a `devp-help.1` would be a page about a page.
102        if sub.get_name() == "help" {
103            continue;
104        }
105        let name = format!("devp-{}", sub.get_name());
106        // clap's `Str` only converts from `&'static str` without its "string" feature;
107        // leaking a dozen page names in a process about to exit is the honest trade.
108        render_to(
109            &name,
110            Man::new(sub.clone().name(name.clone().leak() as &str)),
111        )?;
112    }
113    render_to("devp", Man::new(command.clone()))?;
114    // The same executable answers to both names, and `man dev-prune` should work for
115    // the person who never learned the short one.
116    render_to("dev-prune", Man::new(command.clone().name("dev-prune")))?;
117
118    output::print_success(&format!(
119        "{written} man pages written to {}",
120        output::clean_path(dir)
121    ));
122    output::print_info(
123        "Install them by copying into a directory on `manpath`, e.g. `/usr/local/share/man/man1/`.",
124    );
125    Ok(())
126}
127
128/// The manual's contents page: what this is, what every command does in one line each,
129/// and the one command that opens any of them.
130///
131/// Grouped rather than alphabetical. `devp --help` already lists them in definition
132/// order, and definition order answers "what exists"; a reader who opens the manual is
133/// usually asking "which one do I want", and that is a question about what a command is
134/// *for*.
135fn print_contents() {
136    output::print_header("dev-prune manual");
137    println!();
138    output::print_wrapped(
139        "  ",
140        "Every page below is generated from the definitions the binary parses arguments \
141         with, so the manual cannot describe a flag the program does not have.",
142    );
143    println!();
144    println!("  {}", "Read one page:".bold());
145    println!("    devp man <command>          e.g. `devp man run`, `devp man config`");
146    println!("    devp <command> --help       the same text, from the command itself");
147    println!();
148
149    for (title, entries) in help::COMMAND_GROUPS {
150        println!("  {}", title.bold());
151        for (name, line) in entries {
152            println!("    {:<12}  {line}", name.cyan());
153        }
154        println!();
155    }
156
157    // Named separately because they are the ones that go *before* the subcommand, and
158    // that is the mistake everyone makes once.
159    println!("  {}", "Flags that go before the command".bold());
160    println!("    --dry-run                   simulate, delete nothing");
161    println!("    --ignore-idle               prune repositories you are still working in");
162    println!("    --yes / -y                  answer yes to confirmations");
163    println!();
164    println!("  {}", "Exit codes".bold());
165    println!("    0 success    1 failure    2 usage error");
166    println!();
167    output::print_info(
168        "`devp man --roff` prints the roff source; `devp man --dir <DIR>` writes the full set of pages.",
169    );
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn the_full_set_covers_every_subcommand() {
178        let tmp = tempfile::tempdir().unwrap();
179        run(None, Some(tmp.path().to_str().unwrap()), false).unwrap();
180
181        // One page per visible subcommand, plus the two top-level names.
182        let mut command = Cli::command();
183        command.build();
184        for sub in command.get_subcommands() {
185            if sub.get_name() == "help" {
186                continue;
187            }
188            let page = tmp.path().join(format!("devp-{}.1", sub.get_name()));
189            assert!(page.exists(), "missing {}", page.display());
190        }
191        assert!(tmp.path().join("devp.1").exists());
192        assert!(tmp.path().join("dev-prune.1").exists());
193    }
194
195    #[test]
196    fn a_page_carries_the_long_about_text() {
197        let tmp = tempfile::tempdir().unwrap();
198        run(None, Some(tmp.path().to_str().unwrap()), false).unwrap();
199        let run_page = fs::read_to_string(tmp.path().join("devp-run.1")).unwrap();
200        // A phrase from help::RUN_LONG — the proof the manual and --help are one text.
201        assert!(run_page.contains("gauntlet"), "{run_page}");
202    }
203
204    #[test]
205    fn the_contents_page_names_every_command_and_no_others() {
206        // The grouping is hand-written, so it is the one part of this file that can
207        // drift from the CLI. A command added without a group would be missing from
208        // the manual's only navigable page, which is exactly the failure this whole
209        // command exists to fix.
210        let mut command = Cli::command();
211        command.build();
212        let real: Vec<&str> = command
213            .get_subcommands()
214            .map(|s| s.get_name())
215            .filter(|n| *n != "help")
216            .collect();
217        let listed: Vec<&str> = help::COMMAND_GROUPS
218            .iter()
219            .flat_map(|(_, e)| e.iter().map(|(n, _)| *n))
220            .collect();
221
222        for name in &real {
223            assert!(listed.contains(name), "`{name}` is in no manual group");
224        }
225        for name in &listed {
226            assert!(
227                real.contains(name),
228                "manual lists `{name}`, which is not a command"
229            );
230        }
231    }
232
233    #[test]
234    fn a_named_command_renders_its_own_page() {
235        // Not a terminal under `cargo test`, so this is the roff branch — which is
236        // the one that has to name the right page.
237        let mut command = Cli::command().name("devp");
238        command.build();
239        let sub = command
240            .get_subcommands()
241            .find(|s| s.get_name() == "run")
242            .cloned()
243            .unwrap();
244        let mut out = Vec::new();
245        Man::new(sub.name("devp-run")).render(&mut out).unwrap();
246        let page = String::from_utf8(out).unwrap();
247        assert!(page.contains("devp"), "{page}");
248        assert!(page.contains("gauntlet"), "{page}");
249    }
250
251    #[test]
252    fn an_unknown_command_lists_the_real_ones() {
253        let err = run(Some("nosuchthing"), None, false)
254            .unwrap_err()
255            .to_string();
256        assert!(err.contains("no such command"), "{err}");
257        assert!(err.contains("run"), "{err}");
258    }
259}