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        // AUTHOR section (if present)
61        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        // Add flags summary
90        if !cmd.flags.is_empty() {
91            parts.push("[OPTIONS]".to_string());
92        }
93
94        // Add arguments
95        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        // Add subcommands indicator
107        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            // Split into paragraphs and render each
123            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        // OPTIONS section
145        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        // ARGUMENTS section (if not root or has notable args)
153        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        // SUBCOMMANDS section - show all subcommands recursively
169        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        // EXAMPLES section
176        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        // Build flag usage line
198        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        // Flag help text
220        if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) {
221            roff.text([roman(help.as_str())]);
222        }
223
224        // Default value
225        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        // Environment variable
232        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            // Recursively render nested subcommands
273            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            // Only render detailed section if the subcommand has flags or args with help
290            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                // Section header for this subcommand
298                roff.control("SH", [full_name.to_uppercase().as_str()]);
299
300                // Description
301                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                // Synopsis
307                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                // Render flags if any
318                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                // Render args if any with help
327                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            // Recursively render nested subcommands
337            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        // Prefer help_long, fall back to help
346        if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
347            // Take just the first line for the summary
348            let first_line = help.lines().next().unwrap_or("");
349            roff.text([roman(first_line)]);
350        }
351
352        // Show aliases if any
353        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        // Basic checks
387        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        // Should include ARGUMENTS section even though only help_long is present
449        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        // Should use help_long for subcommand summary
470        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        // Should prefer help_long over help
490        assert!(output.contains("Long detailed help"));
491    }
492}