1use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
7use std::path::Path;
8
9pub mod docgen;
10pub mod exit;
11mod formats;
12pub use formats::*;
13pub mod keys;
14pub mod man;
15pub mod settings;
16pub mod units;
17
18#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
20pub enum CompressionFormat {
21 Gzip,
23 Zstd,
25 Bzip2,
27 Xz,
29}
30
31impl CompressionFormat {
32 pub fn from_extension(path: &Path) -> Option<Self> {
34 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
35 match ext.to_lowercase().as_str() {
36 "gz" => Some(Self::Gzip),
37 "zst" | "zstd" => Some(Self::Zstd),
38 "bz2" | "bz" => Some(Self::Bzip2),
39 "xz" => Some(Self::Xz),
40 _ => None,
41 }
42 } else {
43 None
44 }
45 }
46
47 pub fn extension(&self) -> &'static str {
49 match self {
50 Self::Gzip => "gz",
51 Self::Zstd => "zst",
52 Self::Bzip2 => "bz2",
53 Self::Xz => "xz",
54 }
55 }
56}
57
58pub const NUMBER_FORMAT_VALUES: &[&str] = &[
65 "none",
66 "thousands",
67 "european",
68 "si",
69 "swiss",
70 "indian",
71 "underscore",
72 "system",
73];
74
75pub const EXAMPLES_TOML: &str = include_str!("../examples.toml");
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ExampleTest {
83 Run,
85 Network,
87 Interactive,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Example {
94 pub command: String,
95 pub description: String,
96 pub test: ExampleTest,
97 pub expect: Option<String>,
99 pub pages: Vec<String>,
101 pub files: Vec<ExampleFile>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExampleFile {
108 pub name: String,
109 pub text: String,
110}
111
112pub fn examples() -> Vec<Example> {
115 let file: toml::Table = EXAMPLES_TOML.parse().expect("examples.toml is TOML");
116 let entries = file
117 .get("example")
118 .and_then(toml::Value::as_array)
119 .expect("examples.toml has [[example]] entries");
120 entries
121 .iter()
122 .map(|entry| {
123 let table = entry.as_table().expect("an [[example]] is a table");
124 for key in table.keys() {
125 assert!(
126 matches!(
127 key.as_str(),
128 "command" | "description" | "test" | "expect" | "pages" | "files"
129 ),
130 "examples.toml: unknown key {key}"
131 );
132 }
133 let text = |key: &str| {
134 table
135 .get(key)
136 .and_then(toml::Value::as_str)
137 .map(str::to_string)
138 };
139 let test = match text("test").as_deref() {
140 Some("run") => ExampleTest::Run,
141 Some("network") => ExampleTest::Network,
142 Some("interactive") => ExampleTest::Interactive,
143 other => panic!("examples.toml: test = {other:?}"),
144 };
145 Example {
146 command: text("command").expect("an example's command"),
147 description: text("description").expect("an example's description"),
148 test,
149 expect: text("expect"),
150 pages: match table.get("pages") {
151 None => vec!["datui.1".to_string()],
152 Some(pages) => pages
153 .as_array()
154 .expect("an example's pages are a list")
155 .iter()
156 .map(|p| p.as_str().expect("a page is NAME.SECTION").to_string())
157 .collect(),
158 },
159 files: table
160 .get("files")
161 .and_then(toml::Value::as_array)
162 .map(|files| {
163 files
164 .iter()
165 .map(|f| {
166 let text = |key: &str| {
167 f.get(key)
168 .and_then(toml::Value::as_str)
169 .unwrap_or_else(|| panic!("an example's file has a {key}"))
170 .to_string()
171 };
172 ExampleFile {
173 name: text("name"),
174 text: text("text"),
175 }
176 })
177 .collect()
178 })
179 .unwrap_or_default(),
180 }
181 })
182 .collect()
183}
184
185pub fn examples_of(page: &str) -> Vec<Example> {
187 examples()
188 .into_iter()
189 .filter(|e| e.pages.iter().any(|p| p == page))
190 .collect()
191}
192
193pub fn examples_help() -> String {
195 let mut out = String::from("Examples:\n");
196 for example in examples_of("datui.1") {
197 out.push_str(&format!(
198 " {}\n {}\n",
199 example.command, example.description
200 ));
201 }
202 out.push_str("\nDocs: https://derekwisong.github.io/datui/ and `datui man`\n");
203 out.push_str("Keys: datui man keys\n");
204 out
205}
206
207fn command_examples(command: &str) -> String {
209 let mut out = String::from("Examples:\n");
210 for example in examples_of(&format!("datui-{command}.1")) {
211 out.push_str(&format!(
212 " {}\n {}\n",
213 example.command, example.description
214 ));
215 }
216 let files: Vec<String> = examples_of(&format!("datui-{command}.1"))
217 .into_iter()
218 .flat_map(|e| e.files.into_iter().map(|f| f.name))
219 .collect();
220 if !files.is_empty() {
221 out.push_str(&format!(
222 "\nThe files they read ({}) are in the manual.",
223 files.join(", ")
224 ));
225 }
226 out.push_str(&format!("\nManual: datui man {command}\n"));
227 out
228}
229
230#[derive(Clone, Parser, Debug)]
236#[command(
237 name = "datui",
238 version,
239 about = "Terminal UI for tabular data",
240 long_about = include_str!("../long_about.txt"),
241 after_help = examples_help()
242)]
243pub struct Args {
244 #[arg(num_args = 0.., value_name = "PATH")]
246 pub paths: Vec<std::path::PathBuf>,
247
248 #[arg(short = 'F', long = "format", value_name = "FMT", value_parser = parse_format, help = format_help(), help_heading = "Open")]
249 pub format: Option<FormatChoice>,
250
251 #[arg(short = 't', long = "table", value_name = "NAME", help = table_help(), help_heading = "Open")]
252 pub table: Option<String>,
253
254 #[arg(long = "hive", action, help_heading = "Open")]
256 pub hive: bool,
257
258 #[arg(
260 long = "compression",
261 value_name = "C",
262 value_enum,
263 hide_possible_values = true,
264 help_heading = "Open"
265 )]
266 pub compression: Option<CompressionFormat>,
267
268 #[arg(long = "dict", value_name = "FILE", help_heading = "Open")]
270 pub dict: Vec<std::path::PathBuf>,
271
272 #[arg(short = 'f', long = "follow", action, help_heading = "Open")]
274 pub follow: bool,
275
276 #[arg(long = "tee", value_name = "FILE", help_heading = "Open")]
278 pub tee: Option<std::path::PathBuf>,
279
280 #[arg(long = "tee-raw", requires = "tee", action, help_heading = "Open")]
282 pub tee_raw: bool,
283
284 #[arg(long = "force", action, requires = "tee", help_heading = "Open")]
286 pub force: bool,
287
288 #[arg(long = "hex", action, help_heading = "Open")]
290 pub hex: bool,
291
292 #[arg(long = "hex-width", value_name = "N", value_parser = clap::value_parser!(u16).range(1..=4096), help_heading = "Open")]
294 pub hex_width: Option<u16>,
295
296 #[arg(long = "view", value_name = "NAME", help_heading = "Open")]
298 pub view: Option<String>,
299
300 #[arg(long = "temp-dir", value_name = "DIR", help = settings::flag_help("temp-dir"), help_heading = "Open")]
301 pub temp_dir: Option<std::path::PathBuf>,
302
303 #[arg(long = "delimiter", value_name = "C", value_parser = parse_delimiter, help_heading = "Delimited text")]
305 pub delimiter: Option<u8>,
306
307 #[arg(long = "no-header", action, help_heading = "Delimited text")]
309 pub no_header: bool,
310
311 #[arg(
313 long = "header-rows",
314 value_name = "N[,M...]",
315 value_delimiter = ',',
316 value_parser = clap::value_parser!(u64).range(1..),
317 help_heading = "Delimited text"
318 )]
319 pub header_rows: Vec<u64>,
320
321 #[arg(
323 long = "footer-rows",
324 value_name = "N",
325 help_heading = "Delimited text"
326 )]
327 pub footer_rows: Option<usize>,
328
329 #[arg(long = "skip-rows", value_name = "N", help_heading = "Delimited text")]
331 pub skip_rows: Option<usize>,
332
333 #[arg(long = "skip-lines", value_name = "N", help_heading = "Delimited text")]
335 pub skip_lines: Option<usize>,
336
337 #[arg(long = "comment", value_name = "PREFIX", value_parser = parse_comment_char, help = settings::flag_help("comment"), help_heading = "Delimited text")]
338 pub comment: Option<String>,
339
340 #[arg(long = "skip-initial-space", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("skip-initial-space"), help_heading = "Delimited text")]
341 pub skip_initial_space: Option<bool>,
342
343 #[arg(long = "null", value_name = "VAL", help = settings::flag_help("null"), help_heading = "Delimited text")]
344 pub null: Vec<String>,
345
346 #[arg(long = "infer-types", value_name = "COLS|off", num_args = 0..=1, require_equals = true, default_missing_value = "all", value_parser = parse_infer_types, help = settings::flag_help("infer-types"), help_heading = "Delimited text")]
347 pub infer_types: Option<InferTypes>,
348
349 #[arg(long = "infer-rows", value_name = "N", help = settings::flag_help("infer-rows"), help_heading = "Delimited text")]
350 pub infer_rows: Option<usize>,
351
352 #[arg(long = "ignore-errors", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("ignore-errors"), help_heading = "Delimited text")]
353 pub ignore_errors: Option<bool>,
354
355 #[arg(long = "row-numbers", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("row-numbers"), help_heading = "Display")]
356 pub row_numbers: Option<bool>,
357
358 #[arg(long = "number-format", value_name = "F", value_parser = clap::builder::PossibleValuesParser::new(NUMBER_FORMAT_VALUES), hide_possible_values = true, help = settings::flag_help("number-format"), help_heading = "Display")]
359 pub number_format: Option<String>,
360
361 #[arg(long = "mouse", value_name = "BOOL", num_args = 0..=1, require_equals = true, default_missing_value = "true", value_parser = settings::parse_bool, help = settings::flag_help("mouse"), help_heading = "Display")]
362 pub mouse: Option<bool>,
363
364 #[arg(long = "sample-rows", value_name = "N", help = settings::flag_help("sample-rows"), help_heading = "Display")]
365 pub sample_rows: Option<usize>,
366
367 #[arg(
369 short = 'c',
370 long = "config",
371 value_name = "KEY=VALUE",
372 global = true,
373 help_heading = "Config"
374 )]
375 pub config: Vec<settings::Override>,
376
377 #[arg(long = "log-file", value_name = "PATH", help = settings::flag_help("log-file"), help_heading = "Logging")]
378 pub log_file: Option<std::path::PathBuf>,
379
380 #[arg(long = "log-level", value_name = "LEVEL", value_parser = clap::builder::PossibleValuesParser::new(LOG_LEVELS), hide_possible_values = true, help = settings::flag_help("log-level"), help_heading = "Logging")]
381 pub log_level: Option<String>,
382
383 #[command(subcommand)]
384 pub command: Option<Command>,
385}
386
387pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace", "off"];
389
390#[derive(Debug, Clone, PartialEq, Eq)]
392pub enum InferTypes {
393 All,
395 Off,
396 Columns(Vec<String>),
397}
398
399fn parse_infer_types(text: &str) -> Result<InferTypes, String> {
401 match text.trim() {
402 "" | "all" | "true" => Ok(InferTypes::All),
403 "off" | "false" | "none" => Ok(InferTypes::Off),
404 cols => {
405 let mut columns: Vec<String> = Vec::new();
406 for col in cols.split(',').map(str::trim).filter(|c| !c.is_empty()) {
407 if !columns.iter().any(|c| c == col) {
408 columns.push(col.to_string());
409 }
410 }
411 Ok(InferTypes::Columns(columns))
412 }
413 }
414}
415
416pub fn parse_delimiter(text: &str) -> Result<u8, String> {
418 let byte = match text {
419 "tab" | "\\t" | "\t" => b'\t',
420 "space" => b' ',
421 _ => {
422 if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
423 u8::from_str_radix(hex, 16)
424 .map_err(|_| format!("\"{text}\" is not a byte code such as 0x1f"))?
425 } else {
426 let mut chars = text.chars();
427 match (chars.next(), chars.next()) {
428 (Some(c), None) if c.is_ascii() => c as u8,
429 _ => {
430 return Err(format!(
431 "\"{text}\" is not one ASCII character, tab, \\t, or a code such as 0x1f"
432 ));
433 }
434 }
435 }
436 }
437 };
438 if matches!(byte, b'\n' | b'\r' | b'"') {
439 return Err(format!("{byte:#04x} cannot separate columns"));
440 }
441 Ok(byte)
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
447pub enum FormatChoice {
448 Builtin(FileFormat),
449 Spec(String),
451 File(std::path::PathBuf),
453}
454
455impl FormatChoice {
456 pub fn builtin(&self) -> Option<FileFormat> {
458 match self {
459 Self::Builtin(format) => Some(*format),
460 Self::Spec(_) | Self::File(_) => None,
461 }
462 }
463
464 pub fn read_mode(&self, stored: Stored) -> Option<ReadMode> {
468 match self {
469 Self::Builtin(format) => format.read_mode(stored),
470 Self::Spec(_) | Self::File(_) => match stored {
471 Stored::Plain => Some(ReadMode::Lazy),
472 Stored::Compressed { .. } => Some(ReadMode::Decompressed),
473 Stored::Stream => None,
474 },
475 }
476 }
477
478 pub fn bucket_object(&self, stored: Stored) -> RemoteRead {
481 match self {
482 Self::Builtin(format) => format.bucket_object(stored),
483 Self::Spec(_) | Self::File(_) => RemoteRead::Downloaded,
484 }
485 }
486
487 pub fn http_file(&self) -> RemoteRead {
489 match self {
490 Self::Builtin(format) => format.http_file(),
491 Self::Spec(_) | Self::File(_) => RemoteRead::Downloaded,
492 }
493 }
494
495 pub fn bucket_prefix(&self, stored: Stored) -> Option<RemoteRead> {
497 match self {
498 Self::Builtin(format) => format.bucket_prefix(stored),
499 Self::Spec(_) | Self::File(_) => None,
500 }
501 }
502
503 pub fn spec(&self) -> Option<&str> {
505 match self {
506 Self::Spec(name) => Some(name),
507 Self::Builtin(_) | Self::File(_) => None,
508 }
509 }
510
511 pub fn spec_file(&self) -> Option<&std::path::Path> {
513 match self {
514 Self::File(path) => Some(path),
515 Self::Builtin(_) | Self::Spec(_) => None,
516 }
517 }
518}
519
520fn parse_format(text: &str) -> Result<FormatChoice, String> {
524 let is_path = text.contains('/')
525 || (cfg!(windows) && text.contains('\\'))
526 || text.to_ascii_lowercase().ends_with(".toml");
527 if is_path {
528 return Ok(FormatChoice::File(std::path::PathBuf::from(text)));
529 }
530 if let Some(format) = FileFormat::from_name(&text.to_ascii_lowercase()) {
531 return Ok(FormatChoice::Builtin(format));
532 }
533 let spec_like = text.contains('.')
534 && !text.starts_with('.')
535 && !text.ends_with('.')
536 && text
537 .chars()
538 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
539 if spec_like {
540 return Ok(FormatChoice::Spec(text.to_string()));
541 }
542 let names: Vec<&str> = FileFormat::ALL.iter().map(|f| f.name()).collect();
543 let file = if std::path::Path::new(text).is_file() {
544 format!(" A spec file in this directory needs ./ in front: --format ./{text}.")
545 } else {
546 String::new()
547 };
548 Err(format!(
549 "\"{text}\" is not a format: {}, a spec's name (`datui formats` lists them), or a spec file (a path with a / or ending .toml).{file}",
550 names.join(", ")
551 ))
552}
553
554#[derive(Clone, Debug, Subcommand)]
556pub enum Command {
557 #[command(after_help = command_examples("formats"))]
559 Formats {
560 #[command(subcommand)]
561 action: Option<FormatsAction>,
562 },
563 #[command(after_help = command_examples("config"))]
565 Config {
566 #[command(subcommand)]
567 action: ConfigAction,
568 },
569 #[command(after_help = command_examples("catalog"))]
571 Catalog {
572 #[command(subcommand)]
573 action: CatalogAction,
574 },
575 #[command(after_help = command_examples("theme"))]
577 Theme {
578 #[command(subcommand)]
579 action: ThemeAction,
580 },
581 #[command(after_help = command_examples("cache"))]
583 Cache {
584 #[command(subcommand)]
585 action: CacheAction,
586 },
587 #[command(after_help = command_examples("views"))]
589 Views {
590 #[command(subcommand)]
591 action: ViewsAction,
592 },
593 #[command(after_help = command_examples("completions"))]
595 Completions {
596 #[arg(value_name = "SHELL")]
597 shell: clap_complete::Shell,
598 },
599 #[command(after_help = command_examples("man"))]
601 Man {
602 #[arg(value_name = "PAGE")]
604 page: Option<String>,
605 #[arg(long, conflicts_with_all = ["page", "dir"])]
607 list: bool,
608 #[arg(long, value_name = "DIR", conflicts_with = "page")]
610 dir: Option<std::path::PathBuf>,
611 },
612}
613
614#[derive(Clone, Debug, Subcommand)]
616pub enum ConfigAction {
617 Init {
619 #[arg(long)]
621 force: bool,
622 },
623 Path,
625 Keys,
627}
628
629pub const COMPLETION_FILES: &[(clap_complete::Shell, &str)] = &[
632 (clap_complete::Shell::Bash, "datui.bash"),
633 (clap_complete::Shell::Zsh, "_datui"),
634 (clap_complete::Shell::Fish, "datui.fish"),
635 (clap_complete::Shell::PowerShell, "_datui.ps1"),
636 (clap_complete::Shell::Elvish, "datui.elv"),
637];
638
639pub fn completions(shell: clap_complete::Shell) -> String {
641 let mut out = Vec::new();
642 clap_complete::generate(shell, &mut Args::command(), "datui", &mut out);
643 String::from_utf8_lossy(&out).into_owned()
644}
645
646#[derive(Clone, Debug, Subcommand)]
648pub enum CatalogAction {
649 Show {
651 #[arg(value_name = "NAME")]
653 name: Option<String>,
654 },
655 Check {
657 #[arg(value_name = "FILE")]
659 file: std::path::PathBuf,
660 },
661}
662
663#[derive(Clone, Debug, Subcommand)]
665pub enum ThemeAction {
666 List,
668 Show {
670 #[arg(value_name = "NAME")]
672 name: String,
673 },
674}
675
676#[derive(Clone, Debug, Subcommand)]
678pub enum CacheAction {
679 Clear {
681 #[arg(long)]
683 recents: bool,
684 },
685}
686
687#[derive(Clone, Debug, Subcommand)]
689pub enum ViewsAction {
690 List,
692 Rm {
694 #[arg(value_name = "NAME")]
695 name: String,
696 },
697 Clear,
699}
700
701#[derive(Clone, Debug, Subcommand)]
703pub enum FormatsAction {
704 Check {
706 #[arg(value_name = "SPEC")]
708 spec: String,
709 #[arg(value_name = "FILE")]
711 file: Option<std::path::PathBuf>,
712 },
713}
714
715pub fn check_comment_char(c: &str) -> Result<(), String> {
718 if c.is_empty() {
719 return Err("must not be empty".into());
720 }
721 if c.contains(['\n', '\r']) {
722 return Err("must not contain a line break".into());
723 }
724 Ok(())
725}
726
727fn parse_comment_char(text: &str) -> Result<String, String> {
728 check_comment_char(text).map(|()| text.to_string())
729}
730
731fn escape_table_cell(s: &str) -> String {
733 s.replace('|', "\\|").replace(['\n', '\r'], " ")
734}
735
736fn option_label(arg: &clap::Arg) -> String {
738 let names = |arg: &clap::Arg| -> String {
739 arg.get_value_names()
740 .map(|names| {
741 names
742 .iter()
743 .map(|n| format!("<{}>", n.as_str()))
744 .collect::<Vec<_>>()
745 .join(" ")
746 })
747 .unwrap_or_default()
748 };
749 if arg.is_positional() {
750 return if arg.is_required_set() {
751 names(arg)
752 } else {
753 format!("[{}]...", names(arg))
754 };
755 }
756 let mut parts = Vec::new();
757 if let Some(s) = arg.get_short() {
758 parts.push(format!("-{s}"));
759 }
760 if let Some(l) = arg.get_long() {
761 parts.push(format!("--{l}"));
762 }
763 let op = parts.join(", ");
764 let value = if arg.get_action().takes_values() {
765 names(arg)
766 } else {
767 String::new()
768 };
769 if value.is_empty() {
770 op
771 } else if arg.get_num_args().is_some_and(|n| n.min_values() == 0) {
772 format!("{op}[={value}]")
774 } else {
775 format!("{op} {value}")
776 }
777}
778
779pub fn render_options_markdown() -> String {
783 let mut cmd = Args::command();
784 cmd.build();
785
786 let mut out = String::from(
787 "# Command-line options\n\n\
788 <!-- Generated from crates/datui-cli by `gen_docs`. Do not edit. -->\n\n\
789 `datui --help` prints these; `datui COMMAND --help` a command's own.\n\n```text\n",
790 );
791 out.push_str(&cmd.render_usage().to_string());
792 out.push_str("\n```\n");
793
794 let mut groups: Vec<Option<String>> = vec![None];
796 for arg in cmd.get_arguments() {
797 let heading = arg.get_help_heading().map(str::to_string);
798 if !groups.contains(&heading) {
799 groups.push(heading);
800 }
801 }
802 for group in groups {
803 let args: Vec<&clap::Arg> = cmd
804 .get_arguments()
805 .filter(|a| a.get_help_heading().map(str::to_string) == group)
806 .filter(|a| !a.is_hide_set())
807 .filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
808 .collect();
809 if args.is_empty() {
810 continue;
811 }
812 out.push_str(&format!(
813 "\n## {}\n\n| Option | Description |\n|---|---|\n",
814 group.as_deref().unwrap_or("Arguments")
815 ));
816 for arg in args {
817 let help = arg
818 .get_help()
819 .map(|h| escape_table_cell(&h.to_string()))
820 .unwrap_or_default();
821 out.push_str(&format!(
822 "| `{}` | {help} |\n",
823 escape_table_cell(&option_label(arg))
824 ));
825 }
826 }
827 out.push_str("\n`-h`, `--help` prints help; `-V`, `--version` the version.\n");
828
829 out.push_str("\n## Commands\n\n| Command | Does |\n|---|---|\n");
830 for sub in cmd.get_subcommands().filter(|c| c.get_name() != "help") {
831 let about = |c: &clap::Command| {
832 c.get_about()
833 .map(|a| escape_table_cell(&a.to_string()))
834 .unwrap_or_default()
835 };
836 out.push_str(&format!(
837 "| `datui {}` | {} |\n",
838 sub.get_name(),
839 about(sub)
840 ));
841 for action in sub.get_subcommands().filter(|c| c.get_name() != "help") {
842 let operands: Vec<String> = action
843 .get_arguments()
844 .filter(|a| a.is_positional())
845 .filter_map(|a| {
846 let name = a.get_value_names()?.first()?.to_string();
847 Some(if a.is_required_set() {
848 name
849 } else {
850 format!("[{name}]")
851 })
852 })
853 .collect();
854 let command = format!(
855 "datui {} {} {}",
856 sub.get_name(),
857 action.get_name(),
858 operands.join(" ")
859 );
860 out.push_str(&format!(
861 "| `{}` | {} |\n",
862 command.trim_end(),
863 about(action)
864 ));
865 }
866 }
867
868 out.push_str("\n## Examples\n\n| Command | Does |\n|---|---|\n");
869 for example in examples_of("datui.1") {
870 out.push_str(&format!(
871 "| `{}` | {} |\n",
872 escape_table_cell(&example.command),
873 escape_table_cell(&example.description)
874 ));
875 }
876
877 out
878}
879
880#[cfg(test)]
881mod tests;
882
883#[cfg(test)]
884mod format_tests;