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_configuration(&mut roff);
83
84 if let Some(license) = &self.spec.license {
85 roff.control("SH", ["LICENSE"]);
86 roff.text([roman(license)]);
87 }
88
89 if let Some(repository) = &self.spec.repository {
90 roff.control("SH", ["SOURCE"]);
91 roff.text([roman(repository)]);
92 }
93
94 if let Some(author) = &self.spec.author {
96 roff.control("SH", ["AUTHOR"]);
97 roff.text([roman(author)]);
98 }
99
100 Ok(roff.to_roff())
101 }
102
103 fn render_configuration(&self, roff: &mut Roff) {
110 let config = &self.spec.config;
111 if config.is_empty() {
115 return;
116 }
117 roff.control("SH", ["CONFIGURATION"]);
118 if !config.files.is_empty() {
119 roff.text([roman("Read from the following, in ascending precedence:")]);
120 roff.control("RS", ["4"]);
121 for file in &config.files {
122 let mut line = file.path.clone();
123 if file.findup {
124 line.push_str(" (and in every parent directory)");
125 }
126 roff.control("PP", [] as [&str; 0]);
127 roff.text([roman(line)]);
128 }
129 roff.control("RE", [] as [&str; 0]);
130 }
131 for group in &config.prop_groups {
135 if let Some(heading) = &group.heading {
136 roff.control("SS", [heading.as_str()]);
137 }
138 for prop in &group.items {
139 self.render_prop(roff, prop);
140 }
141 }
142 }
143
144 fn render_prop(&self, roff: &mut Roff, prop: &crate::docs::models::SpecConfigProp) {
146 {
147 roff.control("PP", [] as [&str; 0]);
148 roff.text([bold(&prop.key)]);
149 roff.control("RS", ["4"]);
150 if let Some(help) = prop.help.as_deref() {
151 roff.text([roman(help)]);
152 }
153 let mut facts = Vec::new();
154 if let Some(ty) = &prop.type_ {
155 facts.push(format!("type: {ty}"));
156 }
157 if !prop.aliases.is_empty() {
158 facts.push(format!("aliases: {}", prop.aliases.join(", ")));
159 }
160 if let Some(optional) = prop.optional {
161 facts.push(format!("optional: {optional}"));
162 }
163 if let Some(default) = &prop.default {
164 facts.push(format!("default: {default}"));
165 }
166 if !prop.sources.is_empty() {
167 let plain: Vec<String> = prop
169 .sources
170 .iter()
171 .map(|source| source.replace('`', ""))
172 .collect();
173 facts.push(format!("set with: {}", plain.join(", ")));
174 }
175 if !prop.choices.is_empty() {
179 let values: Vec<&str> = prop.choices.iter().map(|c| c.value.as_str()).collect();
180 facts.push(format!("one of: {}", values.join(", ")));
181 }
182 if !facts.is_empty() {
183 roff.control("PP", [] as [&str; 0]);
184 roff.text([roman(facts.join("; "))]);
185 }
186 if let Some(deprecated) = &prop.deprecated {
187 roff.control("PP", [] as [&str; 0]);
188 let mut notice = format!("Deprecated: {deprecated}");
192 if let Some(remove_at) = &prop.deprecated_remove_at {
193 notice.push_str(&format!(" Removed in {remove_at}."));
194 }
195 roff.text([roman(notice)]);
196 }
197 roff.control("RE", [] as [&str; 0]);
198 }
199 }
200
201 fn render_name(&self, roff: &mut Roff) {
202 roff.control("SH", ["NAME"]);
203 let description = self
204 .spec
205 .about
206 .as_deref()
207 .unwrap_or("No description available");
208 roff.text([roman(format!("{} - {}", self.spec.name, description))]);
209 }
210
211 fn render_synopsis(&self, roff: &mut Roff) {
212 roff.control("SH", ["SYNOPSIS"]);
213
214 if !self.spec.usage.trim().is_empty() {
215 for line in self.spec.usage.lines() {
216 let line = line.trim().strip_prefix("Usage: ").unwrap_or(line.trim());
217 if let Some(rest) = line.strip_prefix(&self.spec.bin) {
218 roff.text([bold(&self.spec.bin), roman(rest)]);
219 } else {
220 roff.text([roman(line)]);
221 }
222 }
223 return;
224 }
225
226 let synopsis = self.build_synopsis(&self.spec.cmd, &self.spec.bin);
227 roff.text([bold(&self.spec.bin), roman(" "), roman(&synopsis)]);
228 }
229
230 fn build_synopsis(&self, cmd: &SpecCommand, _prefix: &str) -> String {
231 let mut parts = Vec::new();
232
233 if !cmd.flags.is_empty() {
235 parts.push("[OPTIONS]".to_string());
236 }
237
238 for arg in &cmd.args {
240 if arg.required {
241 parts.push(format!("<{}>", arg.name));
242 } else {
243 parts.push(format!("[<{}>]", arg.name));
244 }
245 if arg.var {
246 parts.push("...".to_string());
247 }
248 }
249
250 if !cmd.subcommands.is_empty() {
252 if cmd.subcommand_required {
253 parts.push("<COMMAND>".to_string());
254 } else {
255 parts.push("[COMMAND]".to_string());
256 }
257 }
258
259 parts.join(" ")
260 }
261
262 fn render_description(&self, roff: &mut Roff) {
263 roff.control("SH", ["DESCRIPTION"]);
264
265 if let Some(about) = &self.spec.about_long.as_ref().or(self.spec.about.as_ref()) {
266 for paragraph in about.split("\n\n") {
268 roff.text([roman(paragraph.trim())]);
269 roff.control("PP", [] as [&str; 0]);
270 }
271 }
272
273 if let Some(help) = &self
274 .spec
275 .cmd
276 .help_long
277 .as_ref()
278 .or(self.spec.cmd.help.as_ref())
279 {
280 for paragraph in help.split("\n\n") {
281 roff.text([roman(paragraph.trim())]);
282 roff.control("PP", [] as [&str; 0]);
283 }
284 }
285 if let Some(notice) = deprecation_notice(
286 self.spec.cmd.deprecated.as_deref(),
287 self.spec.cmd.deprecated_warn_at.as_deref(),
288 self.spec.cmd.deprecated_remove_at.as_deref(),
289 ) {
290 roff.text([italic(notice)]);
291 roff.control("PP", [] as [&str; 0]);
292 }
293 }
294
295 fn render_command(&self, roff: &mut Roff, cmd: &SpecCommand, is_root: bool) {
296 if !cmd.flags.is_empty() {
298 roff.control("SH", ["OPTIONS"]);
299 for flag in &cmd.flags {
300 self.render_flag(roff, flag);
301 }
302 }
303
304 if !cmd.args.is_empty()
306 && (!is_root
307 || cmd
308 .args
309 .iter()
310 .any(|a| a.help.is_some() || a.help_long.is_some()))
311 {
312 if is_root {
313 roff.control("SH", ["ARGUMENTS"]);
314 }
315 for arg in &cmd.args {
316 self.render_arg(roff, arg);
317 }
318 }
319
320 let all_subcommands = cmd.all_subcommands();
322 if !all_subcommands.is_empty() {
323 roff.control("SH", ["COMMANDS"]);
324 self.render_all_subcommands(roff, &self.spec.cmd, "");
325 }
326
327 if !cmd.examples.is_empty() {
329 roff.control("SH", ["EXAMPLES"]);
330 for (i, example) in cmd.examples.iter().enumerate() {
331 if i > 0 {
333 roff.control("PP", [] as [&str; 0]);
334 }
335 if let Some(header) = &example.header {
336 roff.text([bold(header)]);
337 }
338 if let Some(help) = &example.help {
339 roff.text([roman(help.as_str())]);
340 }
341 roff.control("PP", [] as [&str; 0]);
342 roff.control("RS", ["4"]);
343 roff.text([roman(example.code.as_str())]);
344 roff.control("RE", [] as [&str; 0]);
345 }
346 }
347 }
348
349 fn render_flag(&self, roff: &mut Roff, flag: &SpecFlag) {
350 roff.control("TP", [] as [&str; 0]);
351
352 let mut flag_parts = Vec::new();
354
355 for short in &flag.short {
356 flag_parts.push(format!("-{}", short));
357 }
358 for long in &flag.long {
359 flag_parts.push(format!("--{}", long));
360 }
361
362 let flag_usage = flag_parts.join(", ");
363
364 if let Some(arg) = &flag.arg {
365 roff.text([
366 bold(&flag_usage),
367 roman(" "),
368 italic(format!("<{}>", arg.name)),
369 ]);
370 } else {
371 roff.text([bold(&flag_usage)]);
372 }
373
374 if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) {
376 roff.text([roman(help.as_str())]);
377 }
378 if let Some(notice) = deprecation_notice(
379 flag.deprecated.as_deref(),
380 flag.deprecated_warn_at.as_deref(),
381 flag.deprecated_remove_at.as_deref(),
382 ) {
383 roff.text([italic(notice)]);
384 }
385
386 if !flag.default.is_empty() {
388 roff.control("RS", [] as [&str; 0]);
389 let default_str = flag.default.join(", ");
390 roff.text([italic("Default: "), roman(default_str.as_str())]);
391 roff.control("RE", [] as [&str; 0]);
392 }
393
394 if let Some(env) = &flag.env {
396 roff.control("RS", [] as [&str; 0]);
397 roff.text([italic("Environment: "), bold(env.as_str())]);
398 roff.control("RE", [] as [&str; 0]);
399 }
400 for env in &flag.env_fallback {
401 roff.control("RS", [] as [&str; 0]);
402 roff.text([italic("Environment fallback: "), bold(env.as_str())]);
403 roff.control("RE", [] as [&str; 0]);
404 }
405 for env in &flag.deprecated_env {
406 roff.control("RS", [] as [&str; 0]);
407 roff.text([italic("Deprecated environment: "), bold(env.as_str())]);
408 roff.control("RE", [] as [&str; 0]);
409 }
410 }
411
412 fn render_arg(&self, roff: &mut Roff, arg: &SpecArg) {
413 if arg.help.is_none() && arg.help_long.is_none() {
414 return;
415 }
416
417 roff.control("TP", [] as [&str; 0]);
418 roff.text([bold(format!("<{}>", arg.name))]);
419
420 if let Some(help) = &arg.help_long.as_ref().or(arg.help.as_ref()) {
421 roff.text([roman(help.as_str())]);
422 }
423
424 if !arg.default.is_empty() {
425 roff.control("RS", [] as [&str; 0]);
426 let default_str = arg.default.join(", ");
427 roff.text([italic("Default: "), roman(default_str.as_str())]);
428 roff.control("RE", [] as [&str; 0]);
429 }
430
431 if let Some(env) = &arg.env {
432 roff.control("RS", [] as [&str; 0]);
433 roff.text([italic("Environment: "), bold(env.as_str())]);
434 roff.control("RE", [] as [&str; 0]);
435 }
436 for env in &arg.env_fallback {
437 roff.control("RS", [] as [&str; 0]);
438 roff.text([italic("Environment fallback: "), bold(env.as_str())]);
439 roff.control("RE", [] as [&str; 0]);
440 }
441 for env in &arg.deprecated_env {
442 roff.control("RS", [] as [&str; 0]);
443 roff.text([italic("Deprecated environment: "), bold(env.as_str())]);
444 roff.control("RE", [] as [&str; 0]);
445 }
446 }
447
448 fn render_all_subcommands(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
449 for (name, subcmd) in &cmd.subcommands {
450 if subcmd.hide {
451 continue;
452 }
453
454 let full_name = if prefix.is_empty() {
455 name.to_string()
456 } else {
457 format!("{} {}", prefix, name)
458 };
459
460 self.render_subcommand_summary(roff, &full_name, subcmd);
461
462 self.render_all_subcommands(roff, subcmd, &full_name);
464 }
465 }
466
467 fn render_subcommand_details(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
468 for (name, subcmd) in &cmd.subcommands {
469 if subcmd.hide {
470 continue;
471 }
472
473 let full_name = if prefix.is_empty() {
474 name.to_string()
475 } else {
476 format!("{} {}", prefix, name)
477 };
478
479 let has_flags = !subcmd.flags.is_empty();
481 let has_documented_args = subcmd
482 .args
483 .iter()
484 .any(|a| a.help.is_some() || a.help_long.is_some());
485 let has_examples = !subcmd.examples.is_empty();
486
487 if has_flags || has_documented_args || has_examples {
488 roff.control("SH", [full_name.to_uppercase().as_str()]);
490
491 if let Some(help) = &subcmd.help_long.as_ref().or(subcmd.help.as_ref()) {
493 roff.text([roman(help.as_str())]);
494 roff.control("PP", [] as [&str; 0]);
495 }
496 if let Some(notice) = deprecation_notice(
497 subcmd.deprecated.as_deref(),
498 subcmd.deprecated_warn_at.as_deref(),
499 subcmd.deprecated_remove_at.as_deref(),
500 ) {
501 roff.text([italic(notice)]);
502 roff.control("PP", [] as [&str; 0]);
503 }
504
505 let synopsis = self.build_synopsis(subcmd, &full_name);
507 roff.text([
508 bold("Usage:"),
509 roman(" "),
510 roman(&full_name),
511 roman(" "),
512 roman(&synopsis),
513 ]);
514 roff.control("PP", [] as [&str; 0]);
515
516 if !subcmd.flags.is_empty() {
518 roff.text([bold("Options:")]);
519 roff.control("PP", [] as [&str; 0]);
520 for flag in &subcmd.flags {
521 self.render_flag(roff, flag);
522 }
523 }
524
525 if has_documented_args {
527 roff.text([bold("Arguments:")]);
528 roff.control("PP", [] as [&str; 0]);
529 for arg in &subcmd.args {
530 self.render_arg(roff, arg);
531 }
532 }
533
534 if has_examples {
536 roff.text([bold("Examples:")]);
537 roff.control("PP", [] as [&str; 0]);
538 for (i, example) in subcmd.examples.iter().enumerate() {
539 if i > 0 {
541 roff.control("PP", [] as [&str; 0]);
542 }
543 if let Some(header) = &example.header {
544 roff.text([bold(header)]);
545 }
546 if let Some(help) = &example.help {
547 roff.text([roman(help.as_str())]);
548 }
549 roff.control("PP", [] as [&str; 0]);
550 roff.control("RS", ["4"]);
551 roff.text([roman(example.code.as_str())]);
552 roff.control("RE", [] as [&str; 0]);
553 }
554 }
555 }
556
557 self.render_subcommand_details(roff, subcmd, &full_name);
559 }
560 }
561
562 fn render_subcommand_summary(&self, roff: &mut Roff, name: &str, cmd: &SpecCommand) {
563 roff.control("TP", [] as [&str; 0]);
564 roff.text([bold(name)]);
565
566 if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
568 let first_line = help.lines().next().unwrap_or("");
570 roff.text([roman(first_line)]);
571 }
572 if let Some(notice) = deprecation_notice(
573 cmd.deprecated.as_deref(),
574 cmd.deprecated_warn_at.as_deref(),
575 cmd.deprecated_remove_at.as_deref(),
576 ) {
577 roff.text([italic(notice)]);
578 }
579
580 if !cmd.aliases.is_empty() {
582 let aliases = cmd.aliases.iter().join(", ");
583 roff.control("RS", [] as [&str; 0]);
584 roff.text([italic("Aliases: "), roman(aliases.as_str())]);
585 roff.control("RE", [] as [&str; 0]);
586 }
587 }
588}
589
590fn deprecation_notice(
591 message: Option<&str>,
592 warn_at: Option<&str>,
593 remove_at: Option<&str>,
594) -> Option<String> {
595 if message.is_none() && warn_at.is_none() && remove_at.is_none() {
596 return None;
597 }
598 let mut parts = Vec::new();
599 if let Some(message) = message {
600 parts.push(message.to_string());
601 }
602 if let Some(at) = warn_at {
603 parts.push(format!("warns at {at}"));
604 }
605 if let Some(at) = remove_at {
606 parts.push(format!("removed at {at}"));
607 }
608 Some(format!("Deprecated: {}", parts.join("; ")))
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::Spec;
615
616 #[test]
617 fn the_settings_get_a_section_of_their_own() {
618 let spec: Spec = r##"
619name "hk"
620bin "hk"
621config {
622 source "git" name="git config" doc_hint="git config `{key}`"
623 file "hk.toml" findup=#true
624 prop "jobs" type="uint" default=4 help="Number of parallel jobs" {
625 cli "--jobs" "-j"
626 env "HK_JOBS"
627 source "git" "hk.jobs"
628 }
629 prop "old" deprecated="Use jobs instead." deprecated_remove_at="2027.12.0" help="Old"
630 prop "stash" type="string" help="How to stash" {
631 choices {
632 choice "git" help="Use `git stash`"
633 choice "none" help="No stashing"
634 }
635 }
636 prop "secret" hide=#true help="Not in the page"
637}
638"##
639 .parse()
640 .unwrap();
641 let page = ManpageRenderer::new(spec).render().unwrap();
642
643 assert!(page.contains(".SH CONFIGURATION"), "{page}");
644 assert!(
645 page.contains("hk.toml (and in every parent directory)"),
646 "{page}"
647 );
648 assert!(page.contains("jobs"), "{page}");
649 assert!(
651 page.contains(
652 "type: uint; default: 4; set with: \\-\\-jobs, \\-j, HK_JOBS, git config hk.jobs"
653 ),
654 "{page}"
655 );
656 assert!(
658 page.contains("Deprecated: Use jobs instead. Removed in 2027.12.0."),
659 "{page}"
660 );
661 assert!(page.contains("one of: git, none"), "{page}");
663 assert!(
664 !page.contains("secret"),
665 "a hidden prop should not be here:\n{page}"
666 );
667 assert!(!page.contains('`'), "no backticks in a man page:\n{page}");
668 }
669
670 #[test]
671 fn the_manpage_groups_settings_by_heading_like_the_page_does() {
672 let spec: Spec = r##"
676name "hk"
677bin "hk"
678config {
679 prop "jobs" type="uint" help="How many" help_heading="Performance"
680 prop "cache" type="bool" help="Cache things" help_heading="Performance"
681 prop "colour" type="bool" help="Colourize"
682}
683"##
684 .parse()
685 .unwrap();
686 let page = ManpageRenderer::new(spec).render().unwrap();
687 assert!(page.contains(".SS Performance"), "{page}");
688 let colour = page.find("colour").expect("colour");
691 let heading = page.find(".SS Performance").expect("heading");
692 let jobs = page.find("jobs").expect("jobs");
693 let cache = page.find("cache").expect("cache");
694 assert!(colour < heading, "unheaded settings come first:\n{page}");
695 assert!(heading < cache && heading < jobs, "{page}");
696 }
697
698 #[test]
699 fn a_cli_with_no_settings_has_no_configuration_section() {
700 let spec: Spec = "name \"ex\"\nbin \"ex\"\n".parse().unwrap();
701 let page = ManpageRenderer::new(spec).render().unwrap();
702 assert!(!page.contains("CONFIGURATION"), "{page}");
703 }
704
705 #[test]
706 fn an_explicit_usage_renders_each_alternative_in_the_synopsis() {
707 let spec: Spec = r#"
708name "ex"
709bin "ex"
710usage "Usage: ex <COMMAND>\n ex --print-spec"
711cmd "run"
712"#
713 .parse()
714 .unwrap();
715 let page = ManpageRenderer::new(spec).render().unwrap();
716 assert!(page.contains("\\fBex\\fR <COMMAND>"), "{page}");
717 assert!(page.contains("\\fBex\\fR \\-\\-print\\-spec"), "{page}");
718 assert!(!page.contains("[COMMAND]"), "{page}");
719 }
720
721 #[test]
722 fn where_the_files_live_is_documented_even_with_nothing_to_put_in_them() {
723 let spec: Spec = r##"
727name "ex"
728bin "ex"
729config {
730 file "/etc/ex/config.toml" scope="system"
731 file "ex.toml" findup=#true
732}
733"##
734 .parse()
735 .unwrap();
736 let page = ManpageRenderer::new(spec).render().unwrap();
737 assert!(page.contains(".SH CONFIGURATION"), "{page}");
738 assert!(
739 page.contains("ex.toml (and in every parent directory)"),
740 "{page}"
741 );
742 }
743
744 #[test]
745 fn test_basic_manpage() {
746 let spec: Spec = r#"
747 name "mycli"
748 bin "mycli"
749 about "A sample CLI tool"
750
751 flag "-v --verbose" help="Enable verbose output"
752 flag "-o --output <file>" help="Output file path"
753 arg "<input>" help="Input file to process"
754 "#
755 .parse()
756 .unwrap();
757
758 let renderer = ManpageRenderer::new(spec);
759 let output = renderer.render().unwrap();
760
761 println!("Generated manpage:\n{}", output);
762
763 assert!(output.contains(".TH MYCLI 1"));
765 assert!(output.contains(".SH NAME"));
766 assert!(output.contains(".SH SYNOPSIS"));
767 assert!(output.contains(".SH DESCRIPTION"));
768 assert!(output.contains(".SH OPTIONS"));
769 assert!(output.contains("verbose"));
770 assert!(output.contains("output"));
771 }
772
773 #[test]
774 fn package_metadata_reaches_the_manpage() {
775 let spec: Spec = r#"
776 name "metadata"
777 bin "metadata"
778 author "Example Maintainers"
779 license "MIT OR Apache-2.0"
780 repository "https://example.com/tool"
781 "#
782 .parse()
783 .unwrap();
784 let output = ManpageRenderer::new(spec).render().unwrap();
785
786 assert!(output.contains(".SH LICENSE"), "{output}");
787 assert!(output.contains("MIT OR Apache\\-2.0"), "{output}");
788 assert!(output.contains(".SH SOURCE"), "{output}");
789 assert!(output.contains("https://example.com/tool"), "{output}");
790 assert!(output.contains(".SH AUTHOR"), "{output}");
791 }
792
793 #[test]
794 fn test_with_custom_section() {
795 let spec: Spec = r#"
796 name "myconfig"
797 bin "myconfig"
798 about "A configuration file format"
799 "#
800 .parse()
801 .unwrap();
802
803 let renderer = ManpageRenderer::new(spec).with_section(5);
804 let output = renderer.render().unwrap();
805
806 assert!(output.contains(".TH MYCONFIG 5"));
807 }
808
809 #[test]
810 fn test_with_subcommands() {
811 let spec: Spec = r#"
812 name "git"
813 bin "git"
814 about "The Git version control system"
815
816 cmd "clone" help="Clone a repository"
817 cmd "commit" help="Record changes to the repository"
818 "#
819 .parse()
820 .unwrap();
821
822 let renderer = ManpageRenderer::new(spec);
823 let output = renderer.render().unwrap();
824
825 assert!(output.contains(".SH COMMANDS"));
826 assert!(output.contains("clone"));
827 assert!(output.contains("commit"));
828 }
829
830 #[test]
831 fn test_arguments_with_only_long_help() {
832 let spec: Spec = r#"
833 name "mycli"
834 bin "mycli"
835 about "A CLI tool"
836
837 arg "<input>" help_long="This is a long help text for the input argument"
838 "#
839 .parse()
840 .unwrap();
841
842 let renderer = ManpageRenderer::new(spec);
843 let output = renderer.render().unwrap();
844
845 assert!(output.contains(".SH ARGUMENTS"));
847 assert!(output.contains("<input>"));
848 assert!(output.contains("long help text"));
849 }
850
851 #[test]
852 fn test_subcommand_with_only_long_help() {
853 let spec: Spec = r#"
854 name "mycli"
855 bin "mycli"
856 about "A CLI tool"
857
858 cmd "deploy" help_long="This is a detailed deployment command description that should appear in the summary"
859 "#
860 .parse()
861 .unwrap();
862
863 let renderer = ManpageRenderer::new(spec);
864 let output = renderer.render().unwrap();
865
866 assert!(output.contains("deploy"));
868 assert!(output.contains("detailed deployment command"));
869 }
870
871 #[test]
872 fn test_subcommand_prefers_long_over_short_help() {
873 let spec: Spec = r#"
874 name "mycli"
875 bin "mycli"
876 about "A CLI tool"
877
878 cmd "test" help="Short help" help_long="Long detailed help that should be preferred"
879 "#
880 .parse()
881 .unwrap();
882
883 let renderer = ManpageRenderer::new(spec);
884 let output = renderer.render().unwrap();
885
886 assert!(output.contains("Long detailed help"));
888 }
889}