1use crate::docs::models::{Spec, SpecArg, SpecCommand, SpecFlag};
2use crate::error::UsageErr;
3use itertools::Itertools;
4use roff::{bold, italic, roman, Roff};
5
6#[derive(Debug, Clone)]
8pub struct ManpageRenderer {
9 spec: Spec,
10 section: u8,
11}
12
13impl ManpageRenderer {
14 pub fn new(spec: crate::Spec) -> Self {
16 Self {
17 spec: spec.into(),
18 section: 1,
19 }
20 }
21
22 pub fn with_section(mut self, section: u8) -> Self {
30 self.section = section;
31 self
32 }
33
34 pub fn render(&self) -> Result<String, UsageErr> {
36 let mut roff = Roff::new();
37
38 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 self.render_name(&mut roff);
47
48 self.render_synopsis(&mut roff);
50
51 self.render_description(&mut roff);
53
54 self.render_command(&mut roff, &self.spec.cmd, true);
56
57 self.render_subcommand_details(&mut roff, &self.spec.cmd, &self.spec.bin);
59
60 if !self.spec.examples.is_empty() {
62 roff.control("SH", ["EXAMPLES"]);
63 for (i, example) in self.spec.examples.iter().enumerate() {
64 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 self.render_exit_status(&mut roff);
84
85 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 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 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 fn render_configuration(&self, roff: &mut Roff) {
131 let config = &self.spec.config;
132 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 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 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 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 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 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 if !cmd.flags.is_empty() {
256 parts.push("[OPTIONS]".to_string());
257 }
258
259 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 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 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 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 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 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 if !cmd.examples.is_empty() {
358 roff.control("SH", ["EXAMPLES"]);
359 for (i, example) in cmd.examples.iter().enumerate() {
360 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 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 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 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 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 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 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 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 roff.control("SH", [full_name.to_uppercase().as_str()]);
529
530 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 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 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 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 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 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 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 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 if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
655 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 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 assert!(
738 page.contains(
739 "type: uint; default: 4; set with: \\-\\-jobs, \\-j, HK_JOBS, git config hk.jobs"
740 ),
741 "{page}"
742 );
743 assert!(
745 page.contains("Deprecated: Use jobs instead. Removed in 2027.12.0."),
746 "{page}"
747 );
748 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 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 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 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 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 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 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 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 assert!(page.contains(r#".SH "EXIT STATUS""#), "{page}");
1044 assert!(page.contains("interrupted"), "{page}");
1045
1046 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 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 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}