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::output;
22
23/// Render the manual: a readable contents page on a terminal, one command's page when
24/// a command is named, roff when redirected, and with `--dir` the full set of files —
25/// `devp.1`, `dev-prune.1` (the same page under the binary's other name) and one
26/// `devp-<command>.1` per subcommand.
27pub fn run(command_name: Option<&str>, dir: Option<&str>, roff: bool) -> Result<()> {
28    let mut command = Cli::command().name("devp");
29    command.build();
30
31    // A named command is a request to read one page, and it is the same page `--dir`
32    // would write for it: roff when the output is going somewhere that formats roff,
33    // that command's own long help when a person is reading it.
34    if let Some(name) = command_name {
35        let Some(sub) = command
36            .get_subcommands()
37            .find(|s| s.get_name() == name || s.get_all_aliases().any(|a| a == name))
38            .cloned()
39        else {
40            let names: Vec<&str> = command
41                .get_subcommands()
42                .map(|s| s.get_name())
43                .filter(|n| *n != "help")
44                .collect();
45            anyhow::bail!(
46                "no such command: `{name}`. Try one of: {}",
47                names.join(", ")
48            );
49        };
50        if roff || !std::io::stdout().is_terminal() {
51            let page = format!("devp-{name}");
52            let mut out = Vec::new();
53            Man::new(sub.name(page.leak() as &str)).render(&mut out)?;
54            std::io::stdout().write_all(&out)?;
55            return Ok(());
56        }
57        let mut sub = sub;
58        sub.print_long_help()?;
59        return Ok(());
60    }
61
62    let Some(dir) = dir else {
63        // Someone who ran `devp man` at a prompt used to get raw troff — `.TH`,
64        // `\fB\-\-dry\-run\fR` and the rest — because the output assumed a `man`
65        // on the other end of a pipe. On Windows there is no `man` to pipe into at
66        // all, so the markup was the whole experience. Redirected output still gets
67        // roff, so `devp man > devp.1` and `devp man | man -l -` are unchanged.
68        if roff || !std::io::stdout().is_terminal() {
69            let mut out = Vec::new();
70            Man::new(command).render(&mut out)?;
71            std::io::stdout().write_all(&out)?;
72            return Ok(());
73        }
74
75        // A contents page rather than the top-level long help. The long help is one
76        // screen-and-a-half of prose followed by every subcommand's one-liner, which
77        // is a reasonable answer to `devp --help` and a poor answer to "show me the
78        // manual": nothing on it tells the reader where they are or how to get to the
79        // page they actually want. This says both, in that order.
80        print_contents();
81        return Ok(());
82    };
83
84    let dir = Path::new(dir);
85    fs::create_dir_all(dir)
86        .with_context(|| format!("could not create {}", output::clean_path(dir)))?;
87
88    let mut written = 0usize;
89    let mut render_to = |name: &str, man: Man| -> Result<()> {
90        let path = dir.join(format!("{name}.1"));
91        let mut buf = Vec::new();
92        man.render(&mut buf)?;
93        fs::write(&path, buf)
94            .with_context(|| format!("could not write {}", output::clean_path(&path)))?;
95        written += 1;
96        Ok(())
97    };
98
99    for sub in command.get_subcommands() {
100        // `help` documents itself; a `devp-help.1` would be a page about a page.
101        if sub.get_name() == "help" {
102            continue;
103        }
104        let name = format!("devp-{}", sub.get_name());
105        // clap's `Str` only converts from `&'static str` without its "string" feature;
106        // leaking a dozen page names in a process about to exit is the honest trade.
107        render_to(
108            &name,
109            Man::new(sub.clone().name(name.clone().leak() as &str)),
110        )?;
111    }
112    render_to("devp", Man::new(command.clone()))?;
113    // The same executable answers to both names, and `man dev-prune` should work for
114    // the person who never learned the short one.
115    render_to("dev-prune", Man::new(command.clone().name("dev-prune")))?;
116
117    output::print_success(&format!(
118        "{written} man pages written to {}",
119        output::clean_path(dir)
120    ));
121    output::print_info(
122        "Install them by copying into a directory on `manpath`, e.g. `/usr/local/share/man/man1/`.",
123    );
124    Ok(())
125}
126
127/// How the contents page groups the commands, and the one line each gets.
128///
129/// The lines are written here rather than taken from clap's `about`, which is phrased
130/// to sit in a `--help` listing and truncates into nonsense at this width ("Export
131/// SKILL", "View system dashboard"). A test checks this table against the real command
132/// list, so a command added without a line here fails the build rather than going
133/// missing from the only page a reader navigates from.
134const CONTENTS_GROUPS: [(&str, &[(&str, &str)]); 5] = [
135    (
136        "Register repositories",
137        &[
138            (
139                "init",
140                "find every Git repository under a path, register them",
141            ),
142            ("link", "register one repository"),
143            ("unlink", "forget one — deletes nothing"),
144            ("undo", "revert the last init or link"),
145        ],
146    ),
147    (
148        "Prune and put back",
149        &[
150            ("run", "delete what a lockfile proves comes back"),
151            ("restore", "reinstall what was deleted"),
152        ],
153    ),
154    (
155        "Look at what is going on",
156        &[
157            ("status", "every repository, its size and its idle days"),
158            ("stats", "space reclaimed over time"),
159            ("caches", "package manager caches on this machine"),
160            ("doctor", "what is broken, and how to fix it"),
161            ("trust", "what this program may do on this machine"),
162        ],
163    ),
164    (
165        "Settings and integration",
166        &[
167            ("config", "settings, the scheduler, Git hooks, icons"),
168            ("setup", "install whatever integration is missing"),
169            ("skill", "rules files for your editor's AI agent"),
170            ("completions", "a completion script for your shell"),
171            ("man", "this manual"),
172        ],
173    ),
174    (
175        "The program itself",
176        &[
177            ("update", "check for a newer release, and install it"),
178            ("install", "move it to another package manager"),
179            ("uninstall", "remove it, integration included"),
180        ],
181    ),
182];
183
184/// The manual's contents page: what this is, what every command does in one line each,
185/// and the one command that opens any of them.
186///
187/// Grouped rather than alphabetical. `devp --help` already lists them in definition
188/// order, and definition order answers "what exists"; a reader who opens the manual is
189/// usually asking "which one do I want", and that is a question about what a command is
190/// *for*.
191fn print_contents() {
192    output::print_header("dev-prune manual");
193    println!();
194    output::print_wrapped(
195        "  ",
196        "Every page below is generated from the definitions the binary parses arguments \
197         with, so the manual cannot describe a flag the program does not have.",
198    );
199    println!();
200    println!("  {}", "Read one page:".bold());
201    println!("    devp man <command>          e.g. `devp man run`, `devp man config`");
202    println!("    devp <command> --help       the same text, from the command itself");
203    println!();
204
205    for (title, entries) in CONTENTS_GROUPS {
206        println!("  {}", title.bold());
207        for (name, line) in entries {
208            println!("    {:<12}  {line}", name.cyan());
209        }
210        println!();
211    }
212
213    // Named separately because they are the ones that go *before* the subcommand, and
214    // that is the mistake everyone makes once.
215    println!("  {}", "Flags that go before the command".bold());
216    println!("    --dry-run                   simulate, delete nothing");
217    println!("    --ignore-idle               prune repositories you are still working in");
218    println!("    --yes / -y                  answer yes to confirmations");
219    println!();
220    println!("  {}", "Exit codes".bold());
221    println!("    0 success    1 failure    2 usage error");
222    println!();
223    output::print_info(
224        "`devp man --roff` prints the roff source; `devp man --dir <DIR>` writes the full set of pages.",
225    );
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn the_full_set_covers_every_subcommand() {
234        let tmp = tempfile::tempdir().unwrap();
235        run(None, Some(tmp.path().to_str().unwrap()), false).unwrap();
236
237        // One page per visible subcommand, plus the two top-level names.
238        let mut command = Cli::command();
239        command.build();
240        for sub in command.get_subcommands() {
241            if sub.get_name() == "help" {
242                continue;
243            }
244            let page = tmp.path().join(format!("devp-{}.1", sub.get_name()));
245            assert!(page.exists(), "missing {}", page.display());
246        }
247        assert!(tmp.path().join("devp.1").exists());
248        assert!(tmp.path().join("dev-prune.1").exists());
249    }
250
251    #[test]
252    fn a_page_carries_the_long_about_text() {
253        let tmp = tempfile::tempdir().unwrap();
254        run(None, Some(tmp.path().to_str().unwrap()), false).unwrap();
255        let run_page = fs::read_to_string(tmp.path().join("devp-run.1")).unwrap();
256        // A phrase from help::RUN_LONG — the proof the manual and --help are one text.
257        assert!(run_page.contains("gauntlet"), "{run_page}");
258    }
259
260    #[test]
261    fn the_contents_page_names_every_command_and_no_others() {
262        // The grouping is hand-written, so it is the one part of this file that can
263        // drift from the CLI. A command added without a group would be missing from
264        // the manual's only navigable page, which is exactly the failure this whole
265        // command exists to fix.
266        let mut command = Cli::command();
267        command.build();
268        let real: Vec<&str> = command
269            .get_subcommands()
270            .map(|s| s.get_name())
271            .filter(|n| *n != "help")
272            .collect();
273        let listed: Vec<&str> = CONTENTS_GROUPS
274            .iter()
275            .flat_map(|(_, e)| e.iter().map(|(n, _)| *n))
276            .collect();
277
278        for name in &real {
279            assert!(listed.contains(name), "`{name}` is in no manual group");
280        }
281        for name in &listed {
282            assert!(
283                real.contains(name),
284                "manual lists `{name}`, which is not a command"
285            );
286        }
287    }
288
289    #[test]
290    fn a_named_command_renders_its_own_page() {
291        // Not a terminal under `cargo test`, so this is the roff branch — which is
292        // the one that has to name the right page.
293        let mut command = Cli::command().name("devp");
294        command.build();
295        let sub = command
296            .get_subcommands()
297            .find(|s| s.get_name() == "run")
298            .cloned()
299            .unwrap();
300        let mut out = Vec::new();
301        Man::new(sub.name("devp-run")).render(&mut out).unwrap();
302        let page = String::from_utf8(out).unwrap();
303        assert!(page.contains("devp"), "{page}");
304        assert!(page.contains("gauntlet"), "{page}");
305    }
306
307    #[test]
308    fn an_unknown_command_lists_the_real_ones() {
309        let err = run(Some("nosuchthing"), None, false)
310            .unwrap_err()
311            .to_string();
312        assert!(err.contains("no such command"), "{err}");
313        assert!(err.contains("run"), "{err}");
314    }
315}