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