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 !self.spec.examples.is_empty() {
62 roff.control("SH", ["EXAMPLES"]);
63 for (i, example) in self.spec.examples.iter().enumerate() {
64 if i > 0 {
66 roff.control("PP", [] as [&str; 0]);
67 }
68 if let Some(header) = &example.header {
69 roff.text([bold(header)]);
70 }
71 if let Some(help) = &example.help {
72 roff.text([roman(help.as_str())]);
73 }
74 roff.control("PP", [] as [&str; 0]);
75 roff.control("RS", ["4"]);
76 roff.text([roman(example.code.as_str())]);
77 roff.control("RE", [] as [&str; 0]);
78 }
79 }
80
81 if let Some(author) = &self.spec.author {
83 roff.control("SH", ["AUTHOR"]);
84 roff.text([roman(author)]);
85 }
86
87 Ok(roff.to_roff())
88 }
89
90 fn render_name(&self, roff: &mut Roff) {
91 roff.control("SH", ["NAME"]);
92 let description = self
93 .spec
94 .about
95 .as_deref()
96 .unwrap_or("No description available");
97 roff.text([roman(format!("{} - {}", self.spec.name, description))]);
98 }
99
100 fn render_synopsis(&self, roff: &mut Roff) {
101 roff.control("SH", ["SYNOPSIS"]);
102
103 let synopsis = self.build_synopsis(&self.spec.cmd, &self.spec.bin);
104 roff.text([bold(&self.spec.bin), roman(" "), roman(&synopsis)]);
105 }
106
107 fn build_synopsis(&self, cmd: &SpecCommand, _prefix: &str) -> String {
108 let mut parts = Vec::new();
109
110 if !cmd.flags.is_empty() {
112 parts.push("[OPTIONS]".to_string());
113 }
114
115 for arg in &cmd.args {
117 if arg.required {
118 parts.push(format!("<{}>", arg.name));
119 } else {
120 parts.push(format!("[<{}>]", arg.name));
121 }
122 if arg.var {
123 parts.push("...".to_string());
124 }
125 }
126
127 if !cmd.subcommands.is_empty() {
129 if cmd.subcommand_required {
130 parts.push("<COMMAND>".to_string());
131 } else {
132 parts.push("[COMMAND]".to_string());
133 }
134 }
135
136 parts.join(" ")
137 }
138
139 fn render_description(&self, roff: &mut Roff) {
140 roff.control("SH", ["DESCRIPTION"]);
141
142 if let Some(about) = &self.spec.about_long.as_ref().or(self.spec.about.as_ref()) {
143 for paragraph in about.split("\n\n") {
145 roff.text([roman(paragraph.trim())]);
146 roff.control("PP", [] as [&str; 0]);
147 }
148 }
149
150 if let Some(help) = &self
151 .spec
152 .cmd
153 .help_long
154 .as_ref()
155 .or(self.spec.cmd.help.as_ref())
156 {
157 for paragraph in help.split("\n\n") {
158 roff.text([roman(paragraph.trim())]);
159 roff.control("PP", [] as [&str; 0]);
160 }
161 }
162 }
163
164 fn render_command(&self, roff: &mut Roff, cmd: &SpecCommand, is_root: bool) {
165 if !cmd.flags.is_empty() {
167 roff.control("SH", ["OPTIONS"]);
168 for flag in &cmd.flags {
169 self.render_flag(roff, flag);
170 }
171 }
172
173 if !cmd.args.is_empty()
175 && (!is_root
176 || cmd
177 .args
178 .iter()
179 .any(|a| a.help.is_some() || a.help_long.is_some()))
180 {
181 if is_root {
182 roff.control("SH", ["ARGUMENTS"]);
183 }
184 for arg in &cmd.args {
185 self.render_arg(roff, arg);
186 }
187 }
188
189 let all_subcommands = cmd.all_subcommands();
191 if !all_subcommands.is_empty() {
192 roff.control("SH", ["COMMANDS"]);
193 self.render_all_subcommands(roff, &self.spec.cmd, "");
194 }
195
196 if !cmd.examples.is_empty() {
198 roff.control("SH", ["EXAMPLES"]);
199 for (i, example) in cmd.examples.iter().enumerate() {
200 if i > 0 {
202 roff.control("PP", [] as [&str; 0]);
203 }
204 if let Some(header) = &example.header {
205 roff.text([bold(header)]);
206 }
207 if let Some(help) = &example.help {
208 roff.text([roman(help.as_str())]);
209 }
210 roff.control("PP", [] as [&str; 0]);
211 roff.control("RS", ["4"]);
212 roff.text([roman(example.code.as_str())]);
213 roff.control("RE", [] as [&str; 0]);
214 }
215 }
216 }
217
218 fn render_flag(&self, roff: &mut Roff, flag: &SpecFlag) {
219 roff.control("TP", [] as [&str; 0]);
220
221 let mut flag_parts = Vec::new();
223
224 for short in &flag.short {
225 flag_parts.push(format!("-{}", short));
226 }
227 for long in &flag.long {
228 flag_parts.push(format!("--{}", long));
229 }
230
231 let flag_usage = flag_parts.join(", ");
232
233 if let Some(arg) = &flag.arg {
234 roff.text([
235 bold(&flag_usage),
236 roman(" "),
237 italic(format!("<{}>", arg.name)),
238 ]);
239 } else {
240 roff.text([bold(&flag_usage)]);
241 }
242
243 if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) {
245 roff.text([roman(help.as_str())]);
246 }
247
248 if let Some(default) = &flag.default {
250 roff.control("RS", [] as [&str; 0]);
251 roff.text([italic("Default: "), roman(default.as_str())]);
252 roff.control("RE", [] as [&str; 0]);
253 }
254
255 if let Some(env) = &flag.env {
257 roff.control("RS", [] as [&str; 0]);
258 roff.text([italic("Environment: "), bold(env.as_str())]);
259 roff.control("RE", [] as [&str; 0]);
260 }
261 }
262
263 fn render_arg(&self, roff: &mut Roff, arg: &SpecArg) {
264 if arg.help.is_none() && arg.help_long.is_none() {
265 return;
266 }
267
268 roff.control("TP", [] as [&str; 0]);
269 roff.text([bold(format!("<{}>", arg.name))]);
270
271 if let Some(help) = &arg.help_long.as_ref().or(arg.help.as_ref()) {
272 roff.text([roman(help.as_str())]);
273 }
274
275 if let Some(default) = &arg.default {
276 roff.control("RS", [] as [&str; 0]);
277 roff.text([italic("Default: "), roman(default.as_str())]);
278 roff.control("RE", [] as [&str; 0]);
279 }
280 }
281
282 fn render_all_subcommands(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
283 for (name, subcmd) in &cmd.subcommands {
284 if subcmd.hide {
285 continue;
286 }
287
288 let full_name = if prefix.is_empty() {
289 name.to_string()
290 } else {
291 format!("{} {}", prefix, name)
292 };
293
294 self.render_subcommand_summary(roff, &full_name, subcmd);
295
296 self.render_all_subcommands(roff, subcmd, &full_name);
298 }
299 }
300
301 fn render_subcommand_details(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
302 for (name, subcmd) in &cmd.subcommands {
303 if subcmd.hide {
304 continue;
305 }
306
307 let full_name = if prefix.is_empty() {
308 name.to_string()
309 } else {
310 format!("{} {}", prefix, name)
311 };
312
313 let has_flags = !subcmd.flags.is_empty();
315 let has_documented_args = subcmd
316 .args
317 .iter()
318 .any(|a| a.help.is_some() || a.help_long.is_some());
319 let has_examples = !subcmd.examples.is_empty();
320
321 if has_flags || has_documented_args || has_examples {
322 roff.control("SH", [full_name.to_uppercase().as_str()]);
324
325 if let Some(help) = &subcmd.help_long.as_ref().or(subcmd.help.as_ref()) {
327 roff.text([roman(help.as_str())]);
328 roff.control("PP", [] as [&str; 0]);
329 }
330
331 let synopsis = self.build_synopsis(subcmd, &full_name);
333 roff.text([
334 bold("Usage:"),
335 roman(" "),
336 roman(&full_name),
337 roman(" "),
338 roman(&synopsis),
339 ]);
340 roff.control("PP", [] as [&str; 0]);
341
342 if !subcmd.flags.is_empty() {
344 roff.text([bold("Options:")]);
345 roff.control("PP", [] as [&str; 0]);
346 for flag in &subcmd.flags {
347 self.render_flag(roff, flag);
348 }
349 }
350
351 if has_documented_args {
353 roff.text([bold("Arguments:")]);
354 roff.control("PP", [] as [&str; 0]);
355 for arg in &subcmd.args {
356 self.render_arg(roff, arg);
357 }
358 }
359
360 if has_examples {
362 roff.text([bold("Examples:")]);
363 roff.control("PP", [] as [&str; 0]);
364 for (i, example) in subcmd.examples.iter().enumerate() {
365 if i > 0 {
367 roff.control("PP", [] as [&str; 0]);
368 }
369 if let Some(header) = &example.header {
370 roff.text([bold(header)]);
371 }
372 if let Some(help) = &example.help {
373 roff.text([roman(help.as_str())]);
374 }
375 roff.control("PP", [] as [&str; 0]);
376 roff.control("RS", ["4"]);
377 roff.text([roman(example.code.as_str())]);
378 roff.control("RE", [] as [&str; 0]);
379 }
380 }
381 }
382
383 self.render_subcommand_details(roff, subcmd, &full_name);
385 }
386 }
387
388 fn render_subcommand_summary(&self, roff: &mut Roff, name: &str, cmd: &SpecCommand) {
389 roff.control("TP", [] as [&str; 0]);
390 roff.text([bold(name)]);
391
392 if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
394 let first_line = help.lines().next().unwrap_or("");
396 roff.text([roman(first_line)]);
397 }
398
399 if !cmd.aliases.is_empty() {
401 let aliases = cmd.aliases.iter().join(", ");
402 roff.control("RS", [] as [&str; 0]);
403 roff.text([italic("Aliases: "), roman(aliases.as_str())]);
404 roff.control("RE", [] as [&str; 0]);
405 }
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::Spec;
413
414 #[test]
415 fn test_basic_manpage() {
416 let spec: Spec = r#"
417 name "mycli"
418 bin "mycli"
419 about "A sample CLI tool"
420
421 flag "-v --verbose" help="Enable verbose output"
422 flag "-o --output <file>" help="Output file path"
423 arg "<input>" help="Input file to process"
424 "#
425 .parse()
426 .unwrap();
427
428 let renderer = ManpageRenderer::new(spec);
429 let output = renderer.render().unwrap();
430
431 println!("Generated manpage:\n{}", output);
432
433 assert!(output.contains(".TH MYCLI 1"));
435 assert!(output.contains(".SH NAME"));
436 assert!(output.contains(".SH SYNOPSIS"));
437 assert!(output.contains(".SH DESCRIPTION"));
438 assert!(output.contains(".SH OPTIONS"));
439 assert!(output.contains("verbose"));
440 assert!(output.contains("output"));
441 }
442
443 #[test]
444 fn test_with_custom_section() {
445 let spec: Spec = r#"
446 name "myconfig"
447 bin "myconfig"
448 about "A configuration file format"
449 "#
450 .parse()
451 .unwrap();
452
453 let renderer = ManpageRenderer::new(spec).with_section(5);
454 let output = renderer.render().unwrap();
455
456 assert!(output.contains(".TH MYCONFIG 5"));
457 }
458
459 #[test]
460 fn test_with_subcommands() {
461 let spec: Spec = r#"
462 name "git"
463 bin "git"
464 about "The Git version control system"
465
466 cmd "clone" help="Clone a repository"
467 cmd "commit" help="Record changes to the repository"
468 "#
469 .parse()
470 .unwrap();
471
472 let renderer = ManpageRenderer::new(spec);
473 let output = renderer.render().unwrap();
474
475 assert!(output.contains(".SH COMMANDS"));
476 assert!(output.contains("clone"));
477 assert!(output.contains("commit"));
478 }
479
480 #[test]
481 fn test_arguments_with_only_long_help() {
482 let spec: Spec = r#"
483 name "mycli"
484 bin "mycli"
485 about "A CLI tool"
486
487 arg "<input>" help_long="This is a long help text for the input argument"
488 "#
489 .parse()
490 .unwrap();
491
492 let renderer = ManpageRenderer::new(spec);
493 let output = renderer.render().unwrap();
494
495 assert!(output.contains(".SH ARGUMENTS"));
497 assert!(output.contains("<input>"));
498 assert!(output.contains("long help text"));
499 }
500
501 #[test]
502 fn test_subcommand_with_only_long_help() {
503 let spec: Spec = r#"
504 name "mycli"
505 bin "mycli"
506 about "A CLI tool"
507
508 cmd "deploy" help_long="This is a detailed deployment command description that should appear in the summary"
509 "#
510 .parse()
511 .unwrap();
512
513 let renderer = ManpageRenderer::new(spec);
514 let output = renderer.render().unwrap();
515
516 assert!(output.contains("deploy"));
518 assert!(output.contains("detailed deployment command"));
519 }
520
521 #[test]
522 fn test_subcommand_prefers_long_over_short_help() {
523 let spec: Spec = r#"
524 name "mycli"
525 bin "mycli"
526 about "A CLI tool"
527
528 cmd "test" help="Short help" help_long="Long detailed help that should be preferred"
529 "#
530 .parse()
531 .unwrap();
532
533 let renderer = ManpageRenderer::new(spec);
534 let output = renderer.render().unwrap();
535
536 assert!(output.contains("Long detailed help"));
538 }
539}