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 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        // Environment variable
256        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            // Recursively render nested subcommands
297            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            // Only render detailed section if the subcommand has flags, args with help, or examples
314            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                // Section header for this subcommand
323                roff.control("SH", [full_name.to_uppercase().as_str()]);
324
325                // Description
326                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                // Synopsis
332                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                // Render flags if any
343                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                // Render args if any with help
352                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                // Render examples if any
361                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                        // Add spacing between examples (but not before the first one)
366                        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            // Recursively render nested subcommands
384            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        // Prefer help_long, fall back to help
393        if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
394            // Take just the first line for the summary
395            let first_line = help.lines().next().unwrap_or("");
396            roff.text([roman(first_line)]);
397        }
398
399        // Show aliases if any
400        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        // Basic checks
434        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        // Should include ARGUMENTS section even though only help_long is present
496        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        // Should use help_long for subcommand summary
517        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        // Should prefer help_long over help
537        assert!(output.contains("Long detailed help"));
538    }
539}