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 !flag.default.is_empty() {
250 roff.control("RS", [] as [&str; 0]);
251 let default_str = flag.default.join(", ");
252 roff.text([italic("Default: "), roman(default_str.as_str())]);
253 roff.control("RE", [] as [&str; 0]);
254 }
255
256 if let Some(env) = &flag.env {
258 roff.control("RS", [] as [&str; 0]);
259 roff.text([italic("Environment: "), bold(env.as_str())]);
260 roff.control("RE", [] as [&str; 0]);
261 }
262 }
263
264 fn render_arg(&self, roff: &mut Roff, arg: &SpecArg) {
265 if arg.help.is_none() && arg.help_long.is_none() {
266 return;
267 }
268
269 roff.control("TP", [] as [&str; 0]);
270 roff.text([bold(format!("<{}>", arg.name))]);
271
272 if let Some(help) = &arg.help_long.as_ref().or(arg.help.as_ref()) {
273 roff.text([roman(help.as_str())]);
274 }
275
276 if !arg.default.is_empty() {
277 roff.control("RS", [] as [&str; 0]);
278 let default_str = arg.default.join(", ");
279 roff.text([italic("Default: "), roman(default_str.as_str())]);
280 roff.control("RE", [] as [&str; 0]);
281 }
282 }
283
284 fn render_all_subcommands(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
285 for (name, subcmd) in &cmd.subcommands {
286 if subcmd.hide {
287 continue;
288 }
289
290 let full_name = if prefix.is_empty() {
291 name.to_string()
292 } else {
293 format!("{} {}", prefix, name)
294 };
295
296 self.render_subcommand_summary(roff, &full_name, subcmd);
297
298 self.render_all_subcommands(roff, subcmd, &full_name);
300 }
301 }
302
303 fn render_subcommand_details(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
304 for (name, subcmd) in &cmd.subcommands {
305 if subcmd.hide {
306 continue;
307 }
308
309 let full_name = if prefix.is_empty() {
310 name.to_string()
311 } else {
312 format!("{} {}", prefix, name)
313 };
314
315 let has_flags = !subcmd.flags.is_empty();
317 let has_documented_args = subcmd
318 .args
319 .iter()
320 .any(|a| a.help.is_some() || a.help_long.is_some());
321 let has_examples = !subcmd.examples.is_empty();
322
323 if has_flags || has_documented_args || has_examples {
324 roff.control("SH", [full_name.to_uppercase().as_str()]);
326
327 if let Some(help) = &subcmd.help_long.as_ref().or(subcmd.help.as_ref()) {
329 roff.text([roman(help.as_str())]);
330 roff.control("PP", [] as [&str; 0]);
331 }
332
333 let synopsis = self.build_synopsis(subcmd, &full_name);
335 roff.text([
336 bold("Usage:"),
337 roman(" "),
338 roman(&full_name),
339 roman(" "),
340 roman(&synopsis),
341 ]);
342 roff.control("PP", [] as [&str; 0]);
343
344 if !subcmd.flags.is_empty() {
346 roff.text([bold("Options:")]);
347 roff.control("PP", [] as [&str; 0]);
348 for flag in &subcmd.flags {
349 self.render_flag(roff, flag);
350 }
351 }
352
353 if has_documented_args {
355 roff.text([bold("Arguments:")]);
356 roff.control("PP", [] as [&str; 0]);
357 for arg in &subcmd.args {
358 self.render_arg(roff, arg);
359 }
360 }
361
362 if has_examples {
364 roff.text([bold("Examples:")]);
365 roff.control("PP", [] as [&str; 0]);
366 for (i, example) in subcmd.examples.iter().enumerate() {
367 if i > 0 {
369 roff.control("PP", [] as [&str; 0]);
370 }
371 if let Some(header) = &example.header {
372 roff.text([bold(header)]);
373 }
374 if let Some(help) = &example.help {
375 roff.text([roman(help.as_str())]);
376 }
377 roff.control("PP", [] as [&str; 0]);
378 roff.control("RS", ["4"]);
379 roff.text([roman(example.code.as_str())]);
380 roff.control("RE", [] as [&str; 0]);
381 }
382 }
383 }
384
385 self.render_subcommand_details(roff, subcmd, &full_name);
387 }
388 }
389
390 fn render_subcommand_summary(&self, roff: &mut Roff, name: &str, cmd: &SpecCommand) {
391 roff.control("TP", [] as [&str; 0]);
392 roff.text([bold(name)]);
393
394 if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
396 let first_line = help.lines().next().unwrap_or("");
398 roff.text([roman(first_line)]);
399 }
400
401 if !cmd.aliases.is_empty() {
403 let aliases = cmd.aliases.iter().join(", ");
404 roff.control("RS", [] as [&str; 0]);
405 roff.text([italic("Aliases: "), roman(aliases.as_str())]);
406 roff.control("RE", [] as [&str; 0]);
407 }
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::Spec;
415
416 #[test]
417 fn test_basic_manpage() {
418 let spec: Spec = r#"
419 name "mycli"
420 bin "mycli"
421 about "A sample CLI tool"
422
423 flag "-v --verbose" help="Enable verbose output"
424 flag "-o --output <file>" help="Output file path"
425 arg "<input>" help="Input file to process"
426 "#
427 .parse()
428 .unwrap();
429
430 let renderer = ManpageRenderer::new(spec);
431 let output = renderer.render().unwrap();
432
433 println!("Generated manpage:\n{}", output);
434
435 assert!(output.contains(".TH MYCLI 1"));
437 assert!(output.contains(".SH NAME"));
438 assert!(output.contains(".SH SYNOPSIS"));
439 assert!(output.contains(".SH DESCRIPTION"));
440 assert!(output.contains(".SH OPTIONS"));
441 assert!(output.contains("verbose"));
442 assert!(output.contains("output"));
443 }
444
445 #[test]
446 fn test_with_custom_section() {
447 let spec: Spec = r#"
448 name "myconfig"
449 bin "myconfig"
450 about "A configuration file format"
451 "#
452 .parse()
453 .unwrap();
454
455 let renderer = ManpageRenderer::new(spec).with_section(5);
456 let output = renderer.render().unwrap();
457
458 assert!(output.contains(".TH MYCONFIG 5"));
459 }
460
461 #[test]
462 fn test_with_subcommands() {
463 let spec: Spec = r#"
464 name "git"
465 bin "git"
466 about "The Git version control system"
467
468 cmd "clone" help="Clone a repository"
469 cmd "commit" help="Record changes to the repository"
470 "#
471 .parse()
472 .unwrap();
473
474 let renderer = ManpageRenderer::new(spec);
475 let output = renderer.render().unwrap();
476
477 assert!(output.contains(".SH COMMANDS"));
478 assert!(output.contains("clone"));
479 assert!(output.contains("commit"));
480 }
481
482 #[test]
483 fn test_arguments_with_only_long_help() {
484 let spec: Spec = r#"
485 name "mycli"
486 bin "mycli"
487 about "A CLI tool"
488
489 arg "<input>" help_long="This is a long help text for the input argument"
490 "#
491 .parse()
492 .unwrap();
493
494 let renderer = ManpageRenderer::new(spec);
495 let output = renderer.render().unwrap();
496
497 assert!(output.contains(".SH ARGUMENTS"));
499 assert!(output.contains("<input>"));
500 assert!(output.contains("long help text"));
501 }
502
503 #[test]
504 fn test_subcommand_with_only_long_help() {
505 let spec: Spec = r#"
506 name "mycli"
507 bin "mycli"
508 about "A CLI tool"
509
510 cmd "deploy" help_long="This is a detailed deployment command description that should appear in the summary"
511 "#
512 .parse()
513 .unwrap();
514
515 let renderer = ManpageRenderer::new(spec);
516 let output = renderer.render().unwrap();
517
518 assert!(output.contains("deploy"));
520 assert!(output.contains("detailed deployment command"));
521 }
522
523 #[test]
524 fn test_subcommand_prefers_long_over_short_help() {
525 let spec: Spec = r#"
526 name "mycli"
527 bin "mycli"
528 about "A CLI tool"
529
530 cmd "test" help="Short help" help_long="Long detailed help that should be preferred"
531 "#
532 .parse()
533 .unwrap();
534
535 let renderer = ManpageRenderer::new(spec);
536 let output = renderer.render().unwrap();
537
538 assert!(output.contains("Long detailed help"));
540 }
541}