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