usage/docs/manpage/
renderer.rs

1use crate::docs::models::{Spec, SpecArg, SpecCommand, SpecFlag};
2use crate::error::UsageErr;
3use itertools::Itertools;
4use roff::{bold, italic, roman, Roff};
5
6/// Renderer for generating Unix man pages from Usage specifications
7#[derive(Debug, Clone)]
8pub struct ManpageRenderer {
9    spec: Spec,
10    section: u8,
11}
12
13impl ManpageRenderer {
14    /// Create a new manpage renderer for the given spec
15    pub fn new(spec: crate::Spec) -> Self {
16        Self {
17            spec: spec.into(),
18            section: 1,
19        }
20    }
21
22    /// Set the manual section number (default: 1)
23    ///
24    /// Common sections:
25    /// - 1: User commands
26    /// - 5: File formats
27    /// - 7: Miscellaneous
28    /// - 8: System administration commands
29    pub fn with_section(mut self, section: u8) -> Self {
30        self.section = section;
31        self
32    }
33
34    /// Render the complete man page
35    pub fn render(&self) -> Result<String, UsageErr> {
36        let mut roff = Roff::new();
37
38        // TH (Title Header) - program name, section, date, source, manual
39        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        // NAME section
46        self.render_name(&mut roff);
47
48        // SYNOPSIS section
49        self.render_synopsis(&mut roff);
50
51        // DESCRIPTION section
52        self.render_description(&mut roff);
53
54        // Render the main command
55        self.render_command(&mut roff, &self.spec.cmd, true);
56
57        // Render detailed sections for each subcommand
58        self.render_subcommand_details(&mut roff, &self.spec.cmd, &self.spec.bin);
59
60        // EXAMPLES section (spec-level)
61        if !self.spec.examples.is_empty() {
62            roff.control("SH", ["EXAMPLES"]);
63            for (i, example) in self.spec.examples.iter().enumerate() {
64                // Add spacing between examples (but not before the first one)
65                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        // AUTHOR section (if present)
82        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        // Add flags summary
111        if !cmd.flags.is_empty() {
112            parts.push("[OPTIONS]".to_string());
113        }
114
115        // Add arguments
116        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        // Add subcommands indicator
128        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            // Split into paragraphs and render each
144            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        // OPTIONS section
166        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        // ARGUMENTS section (if not root or has notable args)
174        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        // SUBCOMMANDS section - show all subcommands recursively
190        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        // EXAMPLES section
197        if !cmd.examples.is_empty() {
198            roff.control("SH", ["EXAMPLES"]);
199            for (i, example) in cmd.examples.iter().enumerate() {
200                // Add spacing between examples (but not before the first one)
201                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        // Build flag usage line
222        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        // Flag help text
244        if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) {
245            roff.text([roman(help.as_str())]);
246        }
247
248        // Default value
249        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        // Environment variable
257        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            // Recursively render nested subcommands
299            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            // Only render detailed section if the subcommand has flags, args with help, or examples
316            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                // Section header for this subcommand
325                roff.control("SH", [full_name.to_uppercase().as_str()]);
326
327                // Description
328                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                // Synopsis
334                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                // Render flags if any
345                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                // Render args if any with help
354                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                // Render examples if any
363                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                        // Add spacing between examples (but not before the first one)
368                        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            // Recursively render nested subcommands
386            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        // Prefer help_long, fall back to help
395        if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
396            // Take just the first line for the summary
397            let first_line = help.lines().next().unwrap_or("");
398            roff.text([roman(first_line)]);
399        }
400
401        // Show aliases if any
402        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        // Basic checks
436        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        // Should include ARGUMENTS section even though only help_long is present
498        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        // Should use help_long for subcommand summary
519        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        // Should prefer help_long over help
539        assert!(output.contains("Long detailed help"));
540    }
541}