Skip to main content

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        // CONFIGURATION section
82        self.render_configuration(&mut roff);
83
84        if let Some(license) = &self.spec.license {
85            roff.control("SH", ["LICENSE"]);
86            roff.text([roman(license)]);
87        }
88
89        if let Some(repository) = &self.spec.repository {
90            roff.control("SH", ["SOURCE"]);
91            roff.text([roman(repository)]);
92        }
93
94        // AUTHOR section (if present)
95        if let Some(author) = &self.spec.author {
96            roff.control("SH", ["AUTHOR"]);
97            roff.text([roman(author)]);
98        }
99
100        Ok(roff.to_roff())
101    }
102
103    /// The settings, where a man page conventionally describes them: after the commands and
104    /// before the author.
105    ///
106    /// Deliberately terser than the markdown: a man page is read in a terminal, so each
107    /// setting gets its type, its default and how to set it, and the long-form prose stays
108    /// on the web page.
109    fn render_configuration(&self, roff: &mut Roff) {
110        let config = &self.spec.config;
111        // The same predicate the markdown page uses: a block that declares only where files
112        // live is worth a CONFIGURATION section, and gating on props alone meant the same
113        // spec documented its file chain in one output format and not the other.
114        if config.is_empty() {
115            return;
116        }
117        roff.control("SH", ["CONFIGURATION"]);
118        if !config.files.is_empty() {
119            roff.text([roman("Read from the following, in ascending precedence:")]);
120            roff.control("RS", ["4"]);
121            for file in &config.files {
122                let mut line = file.path.clone();
123                if file.findup {
124                    line.push_str(" (and in every parent directory)");
125                }
126                roff.control("PP", [] as [&str; 0]);
127                roff.text([roman(line)]);
128            }
129            roff.control("RE", [] as [&str; 0]);
130        }
131        // By heading group, like the markdown page: the docs model already partitions the
132        // settings so the two formats stay aligned, and walking the flat list dropped every
133        // `help_heading` and interleaved headed settings with unheaded ones.
134        for group in &config.prop_groups {
135            if let Some(heading) = &group.heading {
136                roff.control("SS", [heading.as_str()]);
137            }
138            for prop in &group.items {
139                self.render_prop(roff, prop);
140            }
141        }
142    }
143
144    /// One setting: a paragraph, its help, and its facts on one line.
145    fn render_prop(&self, roff: &mut Roff, prop: &crate::docs::models::SpecConfigProp) {
146        {
147            roff.control("PP", [] as [&str; 0]);
148            roff.text([bold(&prop.key)]);
149            roff.control("RS", ["4"]);
150            if let Some(help) = prop.help.as_deref() {
151                roff.text([roman(help)]);
152            }
153            let mut facts = Vec::new();
154            if let Some(ty) = &prop.type_ {
155                facts.push(format!("type: {ty}"));
156            }
157            if !prop.aliases.is_empty() {
158                facts.push(format!("aliases: {}", prop.aliases.join(", ")));
159            }
160            if let Some(optional) = prop.optional {
161                facts.push(format!("optional: {optional}"));
162            }
163            if let Some(default) = &prop.default {
164                facts.push(format!("default: {default}"));
165            }
166            if !prop.sources.is_empty() {
167                // The markdown's backticks would be literal here.
168                let plain: Vec<String> = prop
169                    .sources
170                    .iter()
171                    .map(|source| source.replace('`', ""))
172                    .collect();
173                facts.push(format!("set with: {}", plain.join(", ")));
174            }
175            // What the setting accepts, which for a constrained one is the fact a reader most
176            // needs and the manpage did not carry at all. Values only: a choice's own help
177            // belongs on the page, where there is room for it.
178            if !prop.choices.is_empty() {
179                let values: Vec<&str> = prop.choices.iter().map(|c| c.value.as_str()).collect();
180                facts.push(format!("one of: {}", values.join(", ")));
181            }
182            if !facts.is_empty() {
183                roff.control("PP", [] as [&str; 0]);
184                roff.text([roman(facts.join("; "))]);
185            }
186            if let Some(deprecated) = &prop.deprecated {
187                roff.control("PP", [] as [&str; 0]);
188                // With the version it goes away in, as the markdown page says: a deprecation
189                // notice without the date leaves the reader with nothing to plan around, and
190                // the terminal is the one place this is *supposed* to surface.
191                let mut notice = format!("Deprecated: {deprecated}");
192                if let Some(remove_at) = &prop.deprecated_remove_at {
193                    notice.push_str(&format!(" Removed in {remove_at}."));
194                }
195                roff.text([roman(notice)]);
196            }
197            roff.control("RE", [] as [&str; 0]);
198        }
199    }
200
201    fn render_name(&self, roff: &mut Roff) {
202        roff.control("SH", ["NAME"]);
203        let description = self
204            .spec
205            .about
206            .as_deref()
207            .unwrap_or("No description available");
208        roff.text([roman(format!("{} - {}", self.spec.name, description))]);
209    }
210
211    fn render_synopsis(&self, roff: &mut Roff) {
212        roff.control("SH", ["SYNOPSIS"]);
213
214        if !self.spec.usage.trim().is_empty() {
215            for line in self.spec.usage.lines() {
216                let line = line.trim().strip_prefix("Usage: ").unwrap_or(line.trim());
217                if let Some(rest) = line.strip_prefix(&self.spec.bin) {
218                    roff.text([bold(&self.spec.bin), roman(rest)]);
219                } else {
220                    roff.text([roman(line)]);
221                }
222            }
223            return;
224        }
225
226        let synopsis = self.build_synopsis(&self.spec.cmd, &self.spec.bin);
227        roff.text([bold(&self.spec.bin), roman(" "), roman(&synopsis)]);
228    }
229
230    fn build_synopsis(&self, cmd: &SpecCommand, _prefix: &str) -> String {
231        let mut parts = Vec::new();
232
233        // Add flags summary
234        if !cmd.flags.is_empty() {
235            parts.push("[OPTIONS]".to_string());
236        }
237
238        // Add arguments
239        for arg in &cmd.args {
240            if arg.required {
241                parts.push(format!("<{}>", arg.name));
242            } else {
243                parts.push(format!("[<{}>]", arg.name));
244            }
245            if arg.var {
246                parts.push("...".to_string());
247            }
248        }
249
250        // Add subcommands indicator
251        if !cmd.subcommands.is_empty() {
252            if cmd.subcommand_required {
253                parts.push("<COMMAND>".to_string());
254            } else {
255                parts.push("[COMMAND]".to_string());
256            }
257        }
258
259        parts.join(" ")
260    }
261
262    fn render_description(&self, roff: &mut Roff) {
263        roff.control("SH", ["DESCRIPTION"]);
264
265        if let Some(about) = &self.spec.about_long.as_ref().or(self.spec.about.as_ref()) {
266            // Split into paragraphs and render each
267            for paragraph in about.split("\n\n") {
268                roff.text([roman(paragraph.trim())]);
269                roff.control("PP", [] as [&str; 0]);
270            }
271        }
272
273        if let Some(help) = &self
274            .spec
275            .cmd
276            .help_long
277            .as_ref()
278            .or(self.spec.cmd.help.as_ref())
279        {
280            for paragraph in help.split("\n\n") {
281                roff.text([roman(paragraph.trim())]);
282                roff.control("PP", [] as [&str; 0]);
283            }
284        }
285        if let Some(notice) = deprecation_notice(
286            self.spec.cmd.deprecated.as_deref(),
287            self.spec.cmd.deprecated_warn_at.as_deref(),
288            self.spec.cmd.deprecated_remove_at.as_deref(),
289        ) {
290            roff.text([italic(notice)]);
291            roff.control("PP", [] as [&str; 0]);
292        }
293    }
294
295    fn render_command(&self, roff: &mut Roff, cmd: &SpecCommand, is_root: bool) {
296        // OPTIONS section
297        if !cmd.flags.is_empty() {
298            roff.control("SH", ["OPTIONS"]);
299            for flag in &cmd.flags {
300                self.render_flag(roff, flag);
301            }
302        }
303
304        // ARGUMENTS section (if not root or has notable args)
305        if !cmd.args.is_empty()
306            && (!is_root
307                || cmd
308                    .args
309                    .iter()
310                    .any(|a| a.help.is_some() || a.help_long.is_some()))
311        {
312            if is_root {
313                roff.control("SH", ["ARGUMENTS"]);
314            }
315            for arg in &cmd.args {
316                self.render_arg(roff, arg);
317            }
318        }
319
320        // SUBCOMMANDS section - show all subcommands recursively
321        let all_subcommands = cmd.all_subcommands();
322        if !all_subcommands.is_empty() {
323            roff.control("SH", ["COMMANDS"]);
324            self.render_all_subcommands(roff, &self.spec.cmd, "");
325        }
326
327        // EXAMPLES section
328        if !cmd.examples.is_empty() {
329            roff.control("SH", ["EXAMPLES"]);
330            for (i, example) in cmd.examples.iter().enumerate() {
331                // Add spacing between examples (but not before the first one)
332                if i > 0 {
333                    roff.control("PP", [] as [&str; 0]);
334                }
335                if let Some(header) = &example.header {
336                    roff.text([bold(header)]);
337                }
338                if let Some(help) = &example.help {
339                    roff.text([roman(help.as_str())]);
340                }
341                roff.control("PP", [] as [&str; 0]);
342                roff.control("RS", ["4"]);
343                roff.text([roman(example.code.as_str())]);
344                roff.control("RE", [] as [&str; 0]);
345            }
346        }
347    }
348
349    fn render_flag(&self, roff: &mut Roff, flag: &SpecFlag) {
350        roff.control("TP", [] as [&str; 0]);
351
352        // Build flag usage line
353        let mut flag_parts = Vec::new();
354
355        for short in &flag.short {
356            flag_parts.push(format!("-{}", short));
357        }
358        for long in &flag.long {
359            flag_parts.push(format!("--{}", long));
360        }
361
362        let flag_usage = flag_parts.join(", ");
363
364        if let Some(arg) = &flag.arg {
365            roff.text([
366                bold(&flag_usage),
367                roman(" "),
368                italic(format!("<{}>", arg.name)),
369            ]);
370        } else {
371            roff.text([bold(&flag_usage)]);
372        }
373
374        // Flag help text
375        if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) {
376            roff.text([roman(help.as_str())]);
377        }
378        if let Some(notice) = deprecation_notice(
379            flag.deprecated.as_deref(),
380            flag.deprecated_warn_at.as_deref(),
381            flag.deprecated_remove_at.as_deref(),
382        ) {
383            roff.text([italic(notice)]);
384        }
385
386        // Default value
387        if !flag.default.is_empty() {
388            roff.control("RS", [] as [&str; 0]);
389            let default_str = flag.default.join(", ");
390            roff.text([italic("Default: "), roman(default_str.as_str())]);
391            roff.control("RE", [] as [&str; 0]);
392        }
393
394        // Environment variable
395        if let Some(env) = &flag.env {
396            roff.control("RS", [] as [&str; 0]);
397            roff.text([italic("Environment: "), bold(env.as_str())]);
398            roff.control("RE", [] as [&str; 0]);
399        }
400        for env in &flag.env_fallback {
401            roff.control("RS", [] as [&str; 0]);
402            roff.text([italic("Environment fallback: "), bold(env.as_str())]);
403            roff.control("RE", [] as [&str; 0]);
404        }
405        for env in &flag.deprecated_env {
406            roff.control("RS", [] as [&str; 0]);
407            roff.text([italic("Deprecated environment: "), bold(env.as_str())]);
408            roff.control("RE", [] as [&str; 0]);
409        }
410    }
411
412    fn render_arg(&self, roff: &mut Roff, arg: &SpecArg) {
413        if arg.help.is_none() && arg.help_long.is_none() {
414            return;
415        }
416
417        roff.control("TP", [] as [&str; 0]);
418        roff.text([bold(format!("<{}>", arg.name))]);
419
420        if let Some(help) = &arg.help_long.as_ref().or(arg.help.as_ref()) {
421            roff.text([roman(help.as_str())]);
422        }
423
424        if !arg.default.is_empty() {
425            roff.control("RS", [] as [&str; 0]);
426            let default_str = arg.default.join(", ");
427            roff.text([italic("Default: "), roman(default_str.as_str())]);
428            roff.control("RE", [] as [&str; 0]);
429        }
430
431        if let Some(env) = &arg.env {
432            roff.control("RS", [] as [&str; 0]);
433            roff.text([italic("Environment: "), bold(env.as_str())]);
434            roff.control("RE", [] as [&str; 0]);
435        }
436        for env in &arg.env_fallback {
437            roff.control("RS", [] as [&str; 0]);
438            roff.text([italic("Environment fallback: "), bold(env.as_str())]);
439            roff.control("RE", [] as [&str; 0]);
440        }
441        for env in &arg.deprecated_env {
442            roff.control("RS", [] as [&str; 0]);
443            roff.text([italic("Deprecated environment: "), bold(env.as_str())]);
444            roff.control("RE", [] as [&str; 0]);
445        }
446    }
447
448    fn render_all_subcommands(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
449        for (name, subcmd) in &cmd.subcommands {
450            if subcmd.hide {
451                continue;
452            }
453
454            let full_name = if prefix.is_empty() {
455                name.to_string()
456            } else {
457                format!("{} {}", prefix, name)
458            };
459
460            self.render_subcommand_summary(roff, &full_name, subcmd);
461
462            // Recursively render nested subcommands
463            self.render_all_subcommands(roff, subcmd, &full_name);
464        }
465    }
466
467    fn render_subcommand_details(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
468        for (name, subcmd) in &cmd.subcommands {
469            if subcmd.hide {
470                continue;
471            }
472
473            let full_name = if prefix.is_empty() {
474                name.to_string()
475            } else {
476                format!("{} {}", prefix, name)
477            };
478
479            // Only render detailed section if the subcommand has flags, args with help, or examples
480            let has_flags = !subcmd.flags.is_empty();
481            let has_documented_args = subcmd
482                .args
483                .iter()
484                .any(|a| a.help.is_some() || a.help_long.is_some());
485            let has_examples = !subcmd.examples.is_empty();
486
487            if has_flags || has_documented_args || has_examples {
488                // Section header for this subcommand
489                roff.control("SH", [full_name.to_uppercase().as_str()]);
490
491                // Description
492                if let Some(help) = &subcmd.help_long.as_ref().or(subcmd.help.as_ref()) {
493                    roff.text([roman(help.as_str())]);
494                    roff.control("PP", [] as [&str; 0]);
495                }
496                if let Some(notice) = deprecation_notice(
497                    subcmd.deprecated.as_deref(),
498                    subcmd.deprecated_warn_at.as_deref(),
499                    subcmd.deprecated_remove_at.as_deref(),
500                ) {
501                    roff.text([italic(notice)]);
502                    roff.control("PP", [] as [&str; 0]);
503                }
504
505                // Synopsis
506                let synopsis = self.build_synopsis(subcmd, &full_name);
507                roff.text([
508                    bold("Usage:"),
509                    roman(" "),
510                    roman(&full_name),
511                    roman(" "),
512                    roman(&synopsis),
513                ]);
514                roff.control("PP", [] as [&str; 0]);
515
516                // Render flags if any
517                if !subcmd.flags.is_empty() {
518                    roff.text([bold("Options:")]);
519                    roff.control("PP", [] as [&str; 0]);
520                    for flag in &subcmd.flags {
521                        self.render_flag(roff, flag);
522                    }
523                }
524
525                // Render args if any with help
526                if has_documented_args {
527                    roff.text([bold("Arguments:")]);
528                    roff.control("PP", [] as [&str; 0]);
529                    for arg in &subcmd.args {
530                        self.render_arg(roff, arg);
531                    }
532                }
533
534                // Render examples if any
535                if has_examples {
536                    roff.text([bold("Examples:")]);
537                    roff.control("PP", [] as [&str; 0]);
538                    for (i, example) in subcmd.examples.iter().enumerate() {
539                        // Add spacing between examples (but not before the first one)
540                        if i > 0 {
541                            roff.control("PP", [] as [&str; 0]);
542                        }
543                        if let Some(header) = &example.header {
544                            roff.text([bold(header)]);
545                        }
546                        if let Some(help) = &example.help {
547                            roff.text([roman(help.as_str())]);
548                        }
549                        roff.control("PP", [] as [&str; 0]);
550                        roff.control("RS", ["4"]);
551                        roff.text([roman(example.code.as_str())]);
552                        roff.control("RE", [] as [&str; 0]);
553                    }
554                }
555            }
556
557            // Recursively render nested subcommands
558            self.render_subcommand_details(roff, subcmd, &full_name);
559        }
560    }
561
562    fn render_subcommand_summary(&self, roff: &mut Roff, name: &str, cmd: &SpecCommand) {
563        roff.control("TP", [] as [&str; 0]);
564        roff.text([bold(name)]);
565
566        // Prefer help_long, fall back to help
567        if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
568            // Take just the first line for the summary
569            let first_line = help.lines().next().unwrap_or("");
570            roff.text([roman(first_line)]);
571        }
572        if let Some(notice) = deprecation_notice(
573            cmd.deprecated.as_deref(),
574            cmd.deprecated_warn_at.as_deref(),
575            cmd.deprecated_remove_at.as_deref(),
576        ) {
577            roff.text([italic(notice)]);
578        }
579
580        // Show aliases if any
581        if !cmd.aliases.is_empty() {
582            let aliases = cmd.aliases.iter().join(", ");
583            roff.control("RS", [] as [&str; 0]);
584            roff.text([italic("Aliases: "), roman(aliases.as_str())]);
585            roff.control("RE", [] as [&str; 0]);
586        }
587    }
588}
589
590fn deprecation_notice(
591    message: Option<&str>,
592    warn_at: Option<&str>,
593    remove_at: Option<&str>,
594) -> Option<String> {
595    if message.is_none() && warn_at.is_none() && remove_at.is_none() {
596        return None;
597    }
598    let mut parts = Vec::new();
599    if let Some(message) = message {
600        parts.push(message.to_string());
601    }
602    if let Some(at) = warn_at {
603        parts.push(format!("warns at {at}"));
604    }
605    if let Some(at) = remove_at {
606        parts.push(format!("removed at {at}"));
607    }
608    Some(format!("Deprecated: {}", parts.join("; ")))
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::Spec;
615
616    #[test]
617    fn the_settings_get_a_section_of_their_own() {
618        let spec: Spec = r##"
619name "hk"
620bin "hk"
621config {
622    source "git" name="git config" doc_hint="git config `{key}`"
623    file "hk.toml" findup=#true
624    prop "jobs" type="uint" default=4 help="Number of parallel jobs" {
625        cli "--jobs" "-j"
626        env "HK_JOBS"
627        source "git" "hk.jobs"
628    }
629    prop "old" deprecated="Use jobs instead." deprecated_remove_at="2027.12.0" help="Old"
630    prop "stash" type="string" help="How to stash" {
631        choices {
632            choice "git" help="Use `git stash`"
633            choice "none" help="No stashing"
634        }
635    }
636    prop "secret" hide=#true help="Not in the page"
637}
638"##
639        .parse()
640        .unwrap();
641        let page = ManpageRenderer::new(spec).render().unwrap();
642
643        assert!(page.contains(".SH CONFIGURATION"), "{page}");
644        assert!(
645            page.contains("hk.toml (and in every parent directory)"),
646            "{page}"
647        );
648        assert!(page.contains("jobs"), "{page}");
649        // Facts on one line. Hyphens arrive as `\-`, which is how roff spells them.
650        assert!(
651            page.contains(
652                "type: uint; default: 4; set with: \\-\\-jobs, \\-j, HK_JOBS, git config hk.jobs"
653            ),
654            "{page}"
655        );
656        // With the version it goes away in, which is the part a reader can plan around.
657        assert!(
658            page.contains("Deprecated: Use jobs instead. Removed in 2027.12.0."),
659            "{page}"
660        );
661        // And what a constrained setting accepts, which is the fact a reader most needs.
662        assert!(page.contains("one of: git, none"), "{page}");
663        assert!(
664            !page.contains("secret"),
665            "a hidden prop should not be here:\n{page}"
666        );
667        assert!(!page.contains('`'), "no backticks in a man page:\n{page}");
668    }
669
670    #[test]
671    fn the_manpage_groups_settings_by_heading_like_the_page_does() {
672        // The docs model already partitions settings by `help_heading` so the two formats stay
673        // aligned. The manpage walked the flat list instead, dropping every heading and
674        // interleaving headed settings with unheaded ones in one alphabetical run.
675        let spec: Spec = r##"
676name "hk"
677bin "hk"
678config {
679    prop "jobs" type="uint" help="How many" help_heading="Performance"
680    prop "cache" type="bool" help="Cache things" help_heading="Performance"
681    prop "colour" type="bool" help="Colourize"
682}
683"##
684        .parse()
685        .unwrap();
686        let page = ManpageRenderer::new(spec).render().unwrap();
687        assert!(page.contains(".SS Performance"), "{page}");
688        // The unheaded setting comes first, as the markdown page also orders it, and the two
689        // headed ones sit together under the heading rather than either side of it.
690        let colour = page.find("colour").expect("colour");
691        let heading = page.find(".SS Performance").expect("heading");
692        let jobs = page.find("jobs").expect("jobs");
693        let cache = page.find("cache").expect("cache");
694        assert!(colour < heading, "unheaded settings come first:\n{page}");
695        assert!(heading < cache && heading < jobs, "{page}");
696    }
697
698    #[test]
699    fn a_cli_with_no_settings_has_no_configuration_section() {
700        let spec: Spec = "name \"ex\"\nbin \"ex\"\n".parse().unwrap();
701        let page = ManpageRenderer::new(spec).render().unwrap();
702        assert!(!page.contains("CONFIGURATION"), "{page}");
703    }
704
705    #[test]
706    fn an_explicit_usage_renders_each_alternative_in_the_synopsis() {
707        let spec: Spec = r#"
708name "ex"
709bin "ex"
710usage "Usage: ex <COMMAND>\n       ex --print-spec"
711cmd "run"
712"#
713        .parse()
714        .unwrap();
715        let page = ManpageRenderer::new(spec).render().unwrap();
716        assert!(page.contains("\\fBex\\fR <COMMAND>"), "{page}");
717        assert!(page.contains("\\fBex\\fR \\-\\-print\\-spec"), "{page}");
718        assert!(!page.contains("[COMMAND]"), "{page}");
719    }
720
721    #[test]
722    fn where_the_files_live_is_documented_even_with_nothing_to_put_in_them() {
723        // A CLI can describe its config file chain before it declares a single setting —
724        // usefully, since the chain is the part a reader cannot guess. Gating the section on
725        // props meant this spec documented its files on the markdown page and nowhere else.
726        let spec: Spec = r##"
727name "ex"
728bin "ex"
729config {
730    file "/etc/ex/config.toml" scope="system"
731    file "ex.toml" findup=#true
732}
733"##
734        .parse()
735        .unwrap();
736        let page = ManpageRenderer::new(spec).render().unwrap();
737        assert!(page.contains(".SH CONFIGURATION"), "{page}");
738        assert!(
739            page.contains("ex.toml (and in every parent directory)"),
740            "{page}"
741        );
742    }
743
744    #[test]
745    fn test_basic_manpage() {
746        let spec: Spec = r#"
747            name "mycli"
748            bin "mycli"
749            about "A sample CLI tool"
750
751            flag "-v --verbose" help="Enable verbose output"
752            flag "-o --output <file>" help="Output file path"
753            arg "<input>" help="Input file to process"
754        "#
755        .parse()
756        .unwrap();
757
758        let renderer = ManpageRenderer::new(spec);
759        let output = renderer.render().unwrap();
760
761        println!("Generated manpage:\n{}", output);
762
763        // Basic checks
764        assert!(output.contains(".TH MYCLI 1"));
765        assert!(output.contains(".SH NAME"));
766        assert!(output.contains(".SH SYNOPSIS"));
767        assert!(output.contains(".SH DESCRIPTION"));
768        assert!(output.contains(".SH OPTIONS"));
769        assert!(output.contains("verbose"));
770        assert!(output.contains("output"));
771    }
772
773    #[test]
774    fn package_metadata_reaches_the_manpage() {
775        let spec: Spec = r#"
776            name "metadata"
777            bin "metadata"
778            author "Example Maintainers"
779            license "MIT OR Apache-2.0"
780            repository "https://example.com/tool"
781        "#
782        .parse()
783        .unwrap();
784        let output = ManpageRenderer::new(spec).render().unwrap();
785
786        assert!(output.contains(".SH LICENSE"), "{output}");
787        assert!(output.contains("MIT OR Apache\\-2.0"), "{output}");
788        assert!(output.contains(".SH SOURCE"), "{output}");
789        assert!(output.contains("https://example.com/tool"), "{output}");
790        assert!(output.contains(".SH AUTHOR"), "{output}");
791    }
792
793    #[test]
794    fn test_with_custom_section() {
795        let spec: Spec = r#"
796            name "myconfig"
797            bin "myconfig"
798            about "A configuration file format"
799        "#
800        .parse()
801        .unwrap();
802
803        let renderer = ManpageRenderer::new(spec).with_section(5);
804        let output = renderer.render().unwrap();
805
806        assert!(output.contains(".TH MYCONFIG 5"));
807    }
808
809    #[test]
810    fn test_with_subcommands() {
811        let spec: Spec = r#"
812            name "git"
813            bin "git"
814            about "The Git version control system"
815
816            cmd "clone" help="Clone a repository"
817            cmd "commit" help="Record changes to the repository"
818        "#
819        .parse()
820        .unwrap();
821
822        let renderer = ManpageRenderer::new(spec);
823        let output = renderer.render().unwrap();
824
825        assert!(output.contains(".SH COMMANDS"));
826        assert!(output.contains("clone"));
827        assert!(output.contains("commit"));
828    }
829
830    #[test]
831    fn test_arguments_with_only_long_help() {
832        let spec: Spec = r#"
833            name "mycli"
834            bin "mycli"
835            about "A CLI tool"
836
837            arg "<input>" help_long="This is a long help text for the input argument"
838        "#
839        .parse()
840        .unwrap();
841
842        let renderer = ManpageRenderer::new(spec);
843        let output = renderer.render().unwrap();
844
845        // Should include ARGUMENTS section even though only help_long is present
846        assert!(output.contains(".SH ARGUMENTS"));
847        assert!(output.contains("<input>"));
848        assert!(output.contains("long help text"));
849    }
850
851    #[test]
852    fn test_subcommand_with_only_long_help() {
853        let spec: Spec = r#"
854            name "mycli"
855            bin "mycli"
856            about "A CLI tool"
857
858            cmd "deploy" help_long="This is a detailed deployment command description that should appear in the summary"
859        "#
860        .parse()
861        .unwrap();
862
863        let renderer = ManpageRenderer::new(spec);
864        let output = renderer.render().unwrap();
865
866        // Should use help_long for subcommand summary
867        assert!(output.contains("deploy"));
868        assert!(output.contains("detailed deployment command"));
869    }
870
871    #[test]
872    fn test_subcommand_prefers_long_over_short_help() {
873        let spec: Spec = r#"
874            name "mycli"
875            bin "mycli"
876            about "A CLI tool"
877
878            cmd "test" help="Short help" help_long="Long detailed help that should be preferred"
879        "#
880        .parse()
881        .unwrap();
882
883        let renderer = ManpageRenderer::new(spec);
884        let output = renderer.render().unwrap();
885
886        // Should prefer help_long over help
887        assert!(output.contains("Long detailed help"));
888    }
889}