use std::fs;
use std::io::{IsTerminal, Write};
use std::path::Path;
use anyhow::{Context, Result};
use clap::CommandFactory;
use clap_mangen::Man;
use colored::Colorize;
use crate::Cli;
use crate::output;
pub fn run(command_name: Option<&str>, dir: Option<&str>, roff: bool) -> Result<()> {
let mut command = Cli::command().name("devp");
command.build();
if let Some(name) = command_name {
let Some(sub) = command
.get_subcommands()
.find(|s| s.get_name() == name || s.get_all_aliases().any(|a| a == name))
.cloned()
else {
let names: Vec<&str> = command
.get_subcommands()
.map(|s| s.get_name())
.filter(|n| *n != "help")
.collect();
anyhow::bail!(
"no such command: `{name}`. Try one of: {}",
names.join(", ")
);
};
if roff || !std::io::stdout().is_terminal() {
let page = format!("devp-{name}");
let mut out = Vec::new();
Man::new(sub.name(page.leak() as &str)).render(&mut out)?;
std::io::stdout().write_all(&out)?;
return Ok(());
}
let mut sub = sub;
sub.print_long_help()?;
return Ok(());
}
let Some(dir) = dir else {
if roff || !std::io::stdout().is_terminal() {
let mut out = Vec::new();
Man::new(command).render(&mut out)?;
std::io::stdout().write_all(&out)?;
return Ok(());
}
print_contents();
return Ok(());
};
let dir = Path::new(dir);
fs::create_dir_all(dir)
.with_context(|| format!("could not create {}", output::clean_path(dir)))?;
let mut written = 0usize;
let mut render_to = |name: &str, man: Man| -> Result<()> {
let path = dir.join(format!("{name}.1"));
let mut buf = Vec::new();
man.render(&mut buf)?;
fs::write(&path, buf)
.with_context(|| format!("could not write {}", output::clean_path(&path)))?;
written += 1;
Ok(())
};
for sub in command.get_subcommands() {
if sub.get_name() == "help" {
continue;
}
let name = format!("devp-{}", sub.get_name());
render_to(
&name,
Man::new(sub.clone().name(name.clone().leak() as &str)),
)?;
}
render_to("devp", Man::new(command.clone()))?;
render_to("dev-prune", Man::new(command.clone().name("dev-prune")))?;
output::print_success(&format!(
"{written} man pages written to {}",
output::clean_path(dir)
));
output::print_info(
"Install them by copying into a directory on `manpath`, e.g. `/usr/local/share/man/man1/`.",
);
Ok(())
}
const CONTENTS_GROUPS: [(&str, &[(&str, &str)]); 5] = [
(
"Register repositories",
&[
(
"init",
"find every Git repository under a path, register them",
),
("link", "register one repository"),
("unlink", "forget one — deletes nothing"),
("undo", "revert the last init or link"),
],
),
(
"Prune and put back",
&[
("run", "delete what a lockfile proves comes back"),
("restore", "reinstall what was deleted"),
],
),
(
"Look at what is going on",
&[
("status", "every repository, its size and its idle days"),
("stats", "space reclaimed over time"),
("caches", "package manager caches on this machine"),
("doctor", "what is broken, and how to fix it"),
("trust", "what this program may do on this machine"),
],
),
(
"Settings and integration",
&[
("config", "settings, the scheduler, Git hooks, icons"),
("setup", "install whatever integration is missing"),
("skill", "rules files for your editor's AI agent"),
("completions", "a completion script for your shell"),
("man", "this manual"),
],
),
(
"The program itself",
&[
("update", "check for a newer release, and install it"),
("install", "move it to another package manager"),
("uninstall", "remove it, integration included"),
],
),
];
fn print_contents() {
output::print_header("dev-prune manual");
println!();
output::print_wrapped(
" ",
"Every page below is generated from the definitions the binary parses arguments \
with, so the manual cannot describe a flag the program does not have.",
);
println!();
println!(" {}", "Read one page:".bold());
println!(" devp man <command> e.g. `devp man run`, `devp man config`");
println!(" devp <command> --help the same text, from the command itself");
println!();
for (title, entries) in CONTENTS_GROUPS {
println!(" {}", title.bold());
for (name, line) in entries {
println!(" {:<12} {line}", name.cyan());
}
println!();
}
println!(" {}", "Flags that go before the command".bold());
println!(" --dry-run simulate, delete nothing");
println!(" --ignore-idle prune repositories you are still working in");
println!(" --yes / -y answer yes to confirmations");
println!();
println!(" {}", "Exit codes".bold());
println!(" 0 success 1 failure 2 usage error");
println!();
output::print_info(
"`devp man --roff` prints the roff source; `devp man --dir <DIR>` writes the full set of pages.",
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_full_set_covers_every_subcommand() {
let tmp = tempfile::tempdir().unwrap();
run(None, Some(tmp.path().to_str().unwrap()), false).unwrap();
let mut command = Cli::command();
command.build();
for sub in command.get_subcommands() {
if sub.get_name() == "help" {
continue;
}
let page = tmp.path().join(format!("devp-{}.1", sub.get_name()));
assert!(page.exists(), "missing {}", page.display());
}
assert!(tmp.path().join("devp.1").exists());
assert!(tmp.path().join("dev-prune.1").exists());
}
#[test]
fn a_page_carries_the_long_about_text() {
let tmp = tempfile::tempdir().unwrap();
run(None, Some(tmp.path().to_str().unwrap()), false).unwrap();
let run_page = fs::read_to_string(tmp.path().join("devp-run.1")).unwrap();
assert!(run_page.contains("gauntlet"), "{run_page}");
}
#[test]
fn the_contents_page_names_every_command_and_no_others() {
let mut command = Cli::command();
command.build();
let real: Vec<&str> = command
.get_subcommands()
.map(|s| s.get_name())
.filter(|n| *n != "help")
.collect();
let listed: Vec<&str> = CONTENTS_GROUPS
.iter()
.flat_map(|(_, e)| e.iter().map(|(n, _)| *n))
.collect();
for name in &real {
assert!(listed.contains(name), "`{name}` is in no manual group");
}
for name in &listed {
assert!(
real.contains(name),
"manual lists `{name}`, which is not a command"
);
}
}
#[test]
fn a_named_command_renders_its_own_page() {
let mut command = Cli::command().name("devp");
command.build();
let sub = command
.get_subcommands()
.find(|s| s.get_name() == "run")
.cloned()
.unwrap();
let mut out = Vec::new();
Man::new(sub.name("devp-run")).render(&mut out).unwrap();
let page = String::from_utf8(out).unwrap();
assert!(page.contains("devp"), "{page}");
assert!(page.contains("gauntlet"), "{page}");
}
#[test]
fn an_unknown_command_lists_the_real_ones() {
let err = run(Some("nosuchthing"), None, false)
.unwrap_err()
.to_string();
assert!(err.contains("no such command"), "{err}");
assert!(err.contains("run"), "{err}");
}
}