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