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