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