1use 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
23pub 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 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 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 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 if sub.get_name() == "help" {
102 continue;
103 }
104 let name = format!("devp-{}", sub.get_name());
105 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 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
127const 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
184fn 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 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 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 assert!(run_page.contains("gauntlet"), "{run_page}");
258 }
259
260 #[test]
261 fn the_contents_page_names_every_command_and_no_others() {
262 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 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}