1use crate::docs::models::{Spec, SpecArg, SpecCommand, SpecFlag};
2use crate::error::UsageErr;
3use itertools::Itertools;
4use roff::{bold, italic, roman, Roff};
5
6#[derive(Debug, Clone)]
8pub struct ManpageRenderer {
9 spec: Spec,
10 section: u8,
11}
12
13impl ManpageRenderer {
14 pub fn new(spec: crate::Spec) -> Self {
16 Self {
17 spec: spec.into(),
18 section: 1,
19 }
20 }
21
22 pub fn with_section(mut self, section: u8) -> Self {
30 self.section = section;
31 self
32 }
33
34 pub fn render(&self) -> Result<String, UsageErr> {
36 let mut roff = Roff::new();
37
38 let section_str = self.section.to_string();
40 roff.control(
41 "TH",
42 [self.spec.name.to_uppercase().as_str(), section_str.as_str()],
43 );
44
45 self.render_name(&mut roff);
47
48 self.render_synopsis(&mut roff);
50
51 self.render_description(&mut roff);
53
54 self.render_command(&mut roff, &self.spec.cmd, true);
56
57 self.render_subcommand_details(&mut roff, &self.spec.cmd, &self.spec.bin);
59
60 if let Some(author) = &self.spec.author {
62 roff.control("SH", ["AUTHOR"]);
63 roff.text([roman(author)]);
64 }
65
66 Ok(roff.to_roff())
67 }
68
69 fn render_name(&self, roff: &mut Roff) {
70 roff.control("SH", ["NAME"]);
71 let description = self
72 .spec
73 .about
74 .as_deref()
75 .unwrap_or("No description available");
76 roff.text([roman(format!("{} - {}", self.spec.name, description))]);
77 }
78
79 fn render_synopsis(&self, roff: &mut Roff) {
80 roff.control("SH", ["SYNOPSIS"]);
81
82 let synopsis = self.build_synopsis(&self.spec.cmd, &self.spec.bin);
83 roff.text([bold(&self.spec.bin), roman(" "), roman(&synopsis)]);
84 }
85
86 fn build_synopsis(&self, cmd: &SpecCommand, _prefix: &str) -> String {
87 let mut parts = Vec::new();
88
89 if !cmd.flags.is_empty() {
91 parts.push("[OPTIONS]".to_string());
92 }
93
94 for arg in &cmd.args {
96 if arg.required {
97 parts.push(format!("<{}>", arg.name));
98 } else {
99 parts.push(format!("[<{}>]", arg.name));
100 }
101 if arg.var {
102 parts.push("...".to_string());
103 }
104 }
105
106 if !cmd.subcommands.is_empty() {
108 if cmd.subcommand_required {
109 parts.push("<COMMAND>".to_string());
110 } else {
111 parts.push("[COMMAND]".to_string());
112 }
113 }
114
115 parts.join(" ")
116 }
117
118 fn render_description(&self, roff: &mut Roff) {
119 roff.control("SH", ["DESCRIPTION"]);
120
121 if let Some(about) = &self.spec.about_long.as_ref().or(self.spec.about.as_ref()) {
122 for paragraph in about.split("\n\n") {
124 roff.text([roman(paragraph.trim())]);
125 roff.control("PP", [] as [&str; 0]);
126 }
127 }
128
129 if let Some(help) = &self
130 .spec
131 .cmd
132 .help_long
133 .as_ref()
134 .or(self.spec.cmd.help.as_ref())
135 {
136 for paragraph in help.split("\n\n") {
137 roff.text([roman(paragraph.trim())]);
138 roff.control("PP", [] as [&str; 0]);
139 }
140 }
141 }
142
143 fn render_command(&self, roff: &mut Roff, cmd: &SpecCommand, is_root: bool) {
144 if !cmd.flags.is_empty() {
146 roff.control("SH", ["OPTIONS"]);
147 for flag in &cmd.flags {
148 self.render_flag(roff, flag);
149 }
150 }
151
152 if !cmd.args.is_empty()
154 && (!is_root
155 || cmd
156 .args
157 .iter()
158 .any(|a| a.help.is_some() || a.help_long.is_some()))
159 {
160 if is_root {
161 roff.control("SH", ["ARGUMENTS"]);
162 }
163 for arg in &cmd.args {
164 self.render_arg(roff, arg);
165 }
166 }
167
168 let all_subcommands = cmd.all_subcommands();
170 if !all_subcommands.is_empty() {
171 roff.control("SH", ["COMMANDS"]);
172 self.render_all_subcommands(roff, &self.spec.cmd, "");
173 }
174
175 if !cmd.examples.is_empty() {
177 roff.control("SH", ["EXAMPLES"]);
178 for example in &cmd.examples {
179 if let Some(header) = &example.header {
180 roff.text([bold(header)]);
181 }
182 if let Some(help) = &example.help {
183 roff.text([roman(help.as_str())]);
184 }
185 roff.control("PP", [] as [&str; 0]);
186 roff.control("RS", ["4"]);
187 roff.text([roman(example.code.as_str())]);
188 roff.control("RE", [] as [&str; 0]);
189 roff.control("PP", [] as [&str; 0]);
190 }
191 }
192 }
193
194 fn render_flag(&self, roff: &mut Roff, flag: &SpecFlag) {
195 roff.control("TP", [] as [&str; 0]);
196
197 let mut flag_parts = Vec::new();
199
200 for short in &flag.short {
201 flag_parts.push(format!("-{}", short));
202 }
203 for long in &flag.long {
204 flag_parts.push(format!("--{}", long));
205 }
206
207 let flag_usage = flag_parts.join(", ");
208
209 if let Some(arg) = &flag.arg {
210 roff.text([
211 bold(&flag_usage),
212 roman(" "),
213 italic(format!("<{}>", arg.name)),
214 ]);
215 } else {
216 roff.text([bold(&flag_usage)]);
217 }
218
219 if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) {
221 roff.text([roman(help.as_str())]);
222 }
223
224 if let Some(default) = &flag.default {
226 roff.control("RS", [] as [&str; 0]);
227 roff.text([italic("Default: "), roman(default.as_str())]);
228 roff.control("RE", [] as [&str; 0]);
229 }
230
231 if let Some(env) = &flag.env {
233 roff.control("RS", [] as [&str; 0]);
234 roff.text([italic("Environment: "), bold(env.as_str())]);
235 roff.control("RE", [] as [&str; 0]);
236 }
237 }
238
239 fn render_arg(&self, roff: &mut Roff, arg: &SpecArg) {
240 if arg.help.is_none() && arg.help_long.is_none() {
241 return;
242 }
243
244 roff.control("TP", [] as [&str; 0]);
245 roff.text([bold(format!("<{}>", arg.name))]);
246
247 if let Some(help) = &arg.help_long.as_ref().or(arg.help.as_ref()) {
248 roff.text([roman(help.as_str())]);
249 }
250
251 if let Some(default) = &arg.default {
252 roff.control("RS", [] as [&str; 0]);
253 roff.text([italic("Default: "), roman(default.as_str())]);
254 roff.control("RE", [] as [&str; 0]);
255 }
256 }
257
258 fn render_all_subcommands(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
259 for (name, subcmd) in &cmd.subcommands {
260 if subcmd.hide {
261 continue;
262 }
263
264 let full_name = if prefix.is_empty() {
265 name.to_string()
266 } else {
267 format!("{} {}", prefix, name)
268 };
269
270 self.render_subcommand_summary(roff, &full_name, subcmd);
271
272 self.render_all_subcommands(roff, subcmd, &full_name);
274 }
275 }
276
277 fn render_subcommand_details(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
278 for (name, subcmd) in &cmd.subcommands {
279 if subcmd.hide {
280 continue;
281 }
282
283 let full_name = if prefix.is_empty() {
284 name.to_string()
285 } else {
286 format!("{} {}", prefix, name)
287 };
288
289 let has_flags = !subcmd.flags.is_empty();
291 let has_documented_args = subcmd
292 .args
293 .iter()
294 .any(|a| a.help.is_some() || a.help_long.is_some());
295
296 if has_flags || has_documented_args {
297 roff.control("SH", [full_name.to_uppercase().as_str()]);
299
300 if let Some(help) = &subcmd.help_long.as_ref().or(subcmd.help.as_ref()) {
302 roff.text([roman(help.as_str())]);
303 roff.control("PP", [] as [&str; 0]);
304 }
305
306 let synopsis = self.build_synopsis(subcmd, &full_name);
308 roff.text([
309 bold("Usage:"),
310 roman(" "),
311 roman(&full_name),
312 roman(" "),
313 roman(&synopsis),
314 ]);
315 roff.control("PP", [] as [&str; 0]);
316
317 if !subcmd.flags.is_empty() {
319 roff.text([bold("Options:")]);
320 roff.control("PP", [] as [&str; 0]);
321 for flag in &subcmd.flags {
322 self.render_flag(roff, flag);
323 }
324 }
325
326 if has_documented_args {
328 roff.text([bold("Arguments:")]);
329 roff.control("PP", [] as [&str; 0]);
330 for arg in &subcmd.args {
331 self.render_arg(roff, arg);
332 }
333 }
334 }
335
336 self.render_subcommand_details(roff, subcmd, &full_name);
338 }
339 }
340
341 fn render_subcommand_summary(&self, roff: &mut Roff, name: &str, cmd: &SpecCommand) {
342 roff.control("TP", [] as [&str; 0]);
343 roff.text([bold(name)]);
344
345 if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
347 let first_line = help.lines().next().unwrap_or("");
349 roff.text([roman(first_line)]);
350 }
351
352 if !cmd.aliases.is_empty() {
354 let aliases = cmd.aliases.iter().join(", ");
355 roff.control("RS", [] as [&str; 0]);
356 roff.text([italic("Aliases: "), roman(aliases.as_str())]);
357 roff.control("RE", [] as [&str; 0]);
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::Spec;
366
367 #[test]
368 fn test_basic_manpage() {
369 let spec: Spec = r#"
370 name "mycli"
371 bin "mycli"
372 about "A sample CLI tool"
373
374 flag "-v --verbose" help="Enable verbose output"
375 flag "-o --output <file>" help="Output file path"
376 arg "<input>" help="Input file to process"
377 "#
378 .parse()
379 .unwrap();
380
381 let renderer = ManpageRenderer::new(spec);
382 let output = renderer.render().unwrap();
383
384 println!("Generated manpage:\n{}", output);
385
386 assert!(output.contains(".TH MYCLI 1"));
388 assert!(output.contains(".SH NAME"));
389 assert!(output.contains(".SH SYNOPSIS"));
390 assert!(output.contains(".SH DESCRIPTION"));
391 assert!(output.contains(".SH OPTIONS"));
392 assert!(output.contains("verbose"));
393 assert!(output.contains("output"));
394 }
395
396 #[test]
397 fn test_with_custom_section() {
398 let spec: Spec = r#"
399 name "myconfig"
400 bin "myconfig"
401 about "A configuration file format"
402 "#
403 .parse()
404 .unwrap();
405
406 let renderer = ManpageRenderer::new(spec).with_section(5);
407 let output = renderer.render().unwrap();
408
409 assert!(output.contains(".TH MYCONFIG 5"));
410 }
411
412 #[test]
413 fn test_with_subcommands() {
414 let spec: Spec = r#"
415 name "git"
416 bin "git"
417 about "The Git version control system"
418
419 cmd "clone" help="Clone a repository"
420 cmd "commit" help="Record changes to the repository"
421 "#
422 .parse()
423 .unwrap();
424
425 let renderer = ManpageRenderer::new(spec);
426 let output = renderer.render().unwrap();
427
428 assert!(output.contains(".SH COMMANDS"));
429 assert!(output.contains("clone"));
430 assert!(output.contains("commit"));
431 }
432
433 #[test]
434 fn test_arguments_with_only_long_help() {
435 let spec: Spec = r#"
436 name "mycli"
437 bin "mycli"
438 about "A CLI tool"
439
440 arg "<input>" help_long="This is a long help text for the input argument"
441 "#
442 .parse()
443 .unwrap();
444
445 let renderer = ManpageRenderer::new(spec);
446 let output = renderer.render().unwrap();
447
448 assert!(output.contains(".SH ARGUMENTS"));
450 assert!(output.contains("<input>"));
451 assert!(output.contains("long help text"));
452 }
453
454 #[test]
455 fn test_subcommand_with_only_long_help() {
456 let spec: Spec = r#"
457 name "mycli"
458 bin "mycli"
459 about "A CLI tool"
460
461 cmd "deploy" help_long="This is a detailed deployment command description that should appear in the summary"
462 "#
463 .parse()
464 .unwrap();
465
466 let renderer = ManpageRenderer::new(spec);
467 let output = renderer.render().unwrap();
468
469 assert!(output.contains("deploy"));
471 assert!(output.contains("detailed deployment command"));
472 }
473
474 #[test]
475 fn test_subcommand_prefers_long_over_short_help() {
476 let spec: Spec = r#"
477 name "mycli"
478 bin "mycli"
479 about "A CLI tool"
480
481 cmd "test" help="Short help" help_long="Long detailed help that should be preferred"
482 "#
483 .parse()
484 .unwrap();
485
486 let renderer = ManpageRenderer::new(spec);
487 let output = renderer.render().unwrap();
488
489 assert!(output.contains("Long detailed help"));
491 }
492}