Skip to main content

datui_cli/
lib.rs

1//! Shared CLI definitions for datui.
2//!
3//! Used by the main application and by the gen_docs binary, which writes the
4//! generated docs and the manpages from these definitions.
5
6use 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/// Compression format for data files
19#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
20pub enum CompressionFormat {
21    /// Gzip compression (.gz) - Most common, good balance of speed and compression
22    Gzip,
23    /// Zstandard compression (.zst) - Modern, fast compression with good ratios
24    Zstd,
25    /// Bzip2 compression (.bz2) - Good compression ratio, slower than gzip
26    Bzip2,
27    /// XZ compression (.xz) - Excellent compression ratio, slower than bzip2
28    Xz,
29}
30
31impl CompressionFormat {
32    /// Detect compression format from file extension
33    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    /// Get file extension for this compression format
48    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
58/// Accepted values for `--number-format`.
59///
60/// This crate cannot depend on datui-lib (the dependency runs the other way),
61/// so the list is duplicated here to give clap proper `--help` output and shell
62/// completion. `number_format_values_match_presets` in datui-lib asserts the two
63/// lists stay in sync.
64pub const NUMBER_FORMAT_VALUES: &[&str] = &[
65    "none",
66    "thousands",
67    "european",
68    "si",
69    "swiss",
70    "indian",
71    "underscore",
72    "system",
73];
74
75/// The examples: `--help` shows them, and the manpage, the command-line reference and
76/// the README render them. One file, so they cannot differ; each runs as written, and
77/// `scripts/docs/doc_examples.py` runs them.
78pub const EXAMPLES_TOML: &str = include_str!("../examples.toml");
79
80/// How the doc-example runner runs an [`Example`].
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ExampleTest {
83    /// Locally, on every pull request.
84    Run,
85    /// In the nightly job: it reads public data.
86    Network,
87    /// Locally; a producer that never ends is stopped once the first rows show.
88    Interactive,
89}
90
91/// One entry of [`EXAMPLES_TOML`]: a command and what it does.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Example {
94    pub command: String,
95    pub description: String,
96    pub test: ExampleTest,
97    /// What counts as working, when not the default: `rows`, `screen` or `exit`.
98    pub expect: Option<String>,
99    /// The manpages that show it, as `NAME.SECTION`: `datui.1`, `datui-config.5`.
100    pub pages: Vec<String>,
101    /// Files the command reads, written before it runs and shown above it.
102    pub files: Vec<ExampleFile>,
103}
104
105/// A file an [`Example`] reads: its name and its text.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExampleFile {
108    pub name: String,
109    pub text: String,
110}
111
112/// The entries of [`EXAMPLES_TOML`], in order. Panics on a malformed entry, which
113/// `every_example_parses_as_written` and every `--help` reach first.
114pub 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
185/// The examples of manpage `page` (`datui.1`), in order.
186pub 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
193/// The examples as `--help` shows them, after the options.
194pub 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
207/// A command's examples, as `datui COMMAND --help` shows them.
208fn 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/// Command-line arguments for datui.
231///
232/// A flag exists when one invocation needs it: what to open, how to read this file,
233/// what to do at start. Everything else is config, set for one run with `-c`. A flag
234/// that sets a config key takes its help from the option registry.
235#[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    /// Files, directories, globs or URLs to open; files of one shape are one table. - reads standard input, as does no PATH when data is piped in. No PATH opens the home screen
245    #[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    /// Read a glob as one partitioned table, or force partition columns on a directory whose layout does not say so. Ignored for a single file
255    #[arg(long = "hive", action, help_heading = "Open")]
256    pub hive: bool,
257
258    /// Compression, when the extension does not say: gzip, zstd, bzip2 or xz
259    #[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    /// A dictionary to decode with, over those on the format search path: QuickFIX XML (.xml) for FIX logs, DBC (.dbc) for CAN logs, or TOML with kind = "fix" or "dbc". Repeatable
269    #[arg(long = "dict", value_name = "FILE", help_heading = "Open")]
270    pub dict: Vec<std::path::PathBuf>,
271
272    /// Follow the file as it grows, as tail -f does: a local CSV, TSV, PSV or NDJSON file or Arrow IPC stream, or standard input (-). t pauses and resumes; Esc stops
273    #[arg(short = 'f', long = "follow", action, help_heading = "Open")]
274    pub follow: bool,
275
276    /// Record standard input to FILE while viewing it. With -, pass it on to standard output, as tee does, and draw on the terminal
277    #[arg(long = "tee", value_name = "FILE", help_heading = "Open")]
278    pub tee: Option<std::path::PathBuf>,
279
280    /// With --tee: keep FILE exactly as the bytes came. Otherwise a stream that left its header's sizes blank has them filled in when it ends
281    #[arg(long = "tee-raw", requires = "tee", action, help_heading = "Open")]
282    pub tee_raw: bool,
283
284    /// With --tee: replace FILE if it is there
285    #[arg(long = "force", action, requires = "tee", help_heading = "Open")]
286    pub force: bool,
287
288    /// Open in the hex view, whatever the file holds
289    #[arg(long = "hex", action, help_heading = "Open")]
290    pub hex: bool,
291
292    /// Bytes a row of the hex view holds, so records line up (default: 8, 16, 32 or 64, as many as fit)
293    #[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    /// Apply a saved view by name once the data is on screen
297    #[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    /// Column separator: one character, tab, \t or a code such as 0x1f (default: , for .csv, tab for .tsv, | for .psv)
304    #[arg(long = "delimiter", value_name = "C", value_parser = parse_delimiter, help_heading = "Delimited text")]
305    pub delimiter: Option<u8>,
306
307    /// Read the first row as data; columns are named column_1, column_2, ...
308    #[arg(long = "no-header", action, help_heading = "Delimited text")]
309    pub no_header: bool,
310
311    /// The line, or comma-separated lines, holding the header, counted from 1 before anything is skipped. Several are joined per column ([csv] header_join); the data starts after the last
312    #[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    /// Skip this many rows at the end, such as a footer. Reads the whole file to count rows
322    #[arg(
323        long = "footer-rows",
324        value_name = "N",
325        help_heading = "Delimited text"
326    )]
327    pub footer_rows: Option<usize>,
328
329    /// Skip this many rows at the start; the header is read after them. Quote-aware, unlike --skip-lines
330    #[arg(long = "skip-rows", value_name = "N", help_heading = "Delimited text")]
331    pub skip_rows: Option<usize>,
332
333    /// Skip this many raw lines at the start, split on newlines alone: a newline inside quotes counts
334    #[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    /// Set a config key for this run, as in the file: -c display.row_numbers=true. Repeatable; a flag of the key's own still wins. `datui config keys` lists them
368    #[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
387/// The levels `--log-level` and `DATUI_LOG` take.
388pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace", "off"];
389
390/// What `--infer-types` asks for.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub enum InferTypes {
393    /// Every string column.
394    All,
395    Off,
396    Columns(Vec<String>),
397}
398
399/// `--infer-types`: all (the bare flag), `off`, or columns separated by commas.
400fn 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
416/// A delimiter as people write it: `;`, `tab`, `\t`, or a byte code such as `0x1f`.
417pub 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/// What `--format` names: a format datui reads, a format spec on the search path, or
445/// a spec's file.
446#[derive(Debug, Clone, PartialEq, Eq)]
447pub enum FormatChoice {
448    Builtin(FileFormat),
449    /// A spec on the search path, by its namespaced name.
450    Spec(String),
451    /// A spec's file: `./acme.toml`.
452    File(std::path::PathBuf),
453}
454
455impl FormatChoice {
456    /// The built-in format, when that is what was named.
457    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    /// How a file read this way is read when opened: a built-in format's
465    /// [`FileFormat::read_mode`], or a spec's, whose records are decoded from a map of
466    /// the file (or of its decompressed copy) only where they are shown.
467    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    /// How one object read this way in a bucket is read. A spec reads local files, so
479    /// a spec's object is downloaded first.
480    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    /// How one HTTP(S) file read this way is read; a spec's is downloaded first.
488    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    /// How a bucket prefix read this way is read as one table; a spec's is not.
496    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    /// The spec's name, when a spec was named.
504    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    /// The spec's file, when a file was named.
512    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
520/// A built-in format's name, a spec's (namespaced, `vendor.format`, so one is never
521/// taken for the other), or a spec's file: a path only if it has a `/` or ends
522/// `.toml`, so `./vendor` names a file and `vendor.format` a spec.
523fn 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/// Commands besides opening data.
555#[derive(Clone, Debug, Subcommand)]
556pub enum Command {
557    /// List the format specs and dictionaries (FIX, DBC) on the search path: each one's name, what it matches, its file, and the copies it overrides
558    #[command(after_help = command_examples("formats"))]
559    Formats {
560        #[command(subcommand)]
561        action: Option<FormatsAction>,
562    },
563    /// Write the default config file, list the files read, or list every key
564    #[command(after_help = command_examples("config"))]
565    Config {
566        #[command(subcommand)]
567        action: ConfigAction,
568    },
569    /// Show the catalogs of named datasets on the home screen, or check a catalog file
570    #[command(after_help = command_examples("catalog"))]
571    Catalog {
572        #[command(subcommand)]
573        action: CatalogAction,
574    },
575    /// List the themes, built in and in the config directory's themes/, or print one as a file to start from
576    #[command(after_help = command_examples("theme"))]
577    Theme {
578        #[command(subcommand)]
579        action: ThemeAction,
580    },
581    /// Clear the cache: recents, history, schemas and copies
582    #[command(after_help = command_examples("cache"))]
583    Cache {
584        #[command(subcommand)]
585        action: CacheAction,
586    },
587    /// List or remove saved views
588    #[command(after_help = command_examples("views"))]
589    Views {
590        #[command(subcommand)]
591        action: ViewsAction,
592    },
593    /// Print the shell completion script for SHELL
594    #[command(after_help = command_examples("completions"))]
595    Completions {
596        #[arg(value_name = "SHELL")]
597        shell: clap_complete::Shell,
598    },
599    /// Show a manual page, list them, or write them all under a directory
600    #[command(after_help = command_examples("man"))]
601    Man {
602        /// The page: datui (the default), a command (config, catalog, theme, cache, views, formats, completions, man), config.5, keys, query or formats.7
603        #[arg(value_name = "PAGE")]
604        page: Option<String>,
605        /// List the pages and what each covers
606        #[arg(long, conflicts_with_all = ["page", "dir"])]
607        list: bool,
608        /// Write every page under DIR, in man1, man5 and man7, where man looks for them: ~/.local/share/man
609        #[arg(long, value_name = "DIR", conflicts_with = "page")]
610        dir: Option<std::path::PathBuf>,
611    },
612}
613
614/// What `datui config` does.
615#[derive(Clone, Debug, Subcommand)]
616pub enum ConfigAction {
617    /// Write the default config file, every key commented out at its default
618    Init {
619        /// Replace a config file that is there
620        #[arg(long)]
621        force: bool,
622    },
623    /// Print the config files read, lowest precedence first
624    Path,
625    /// List every key: its type, default, the value in effect and what set it
626    Keys,
627}
628
629/// Each shell's completion script and the file name its shell loads it by: what a
630/// package or the release archive installs.
631pub 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
639/// The completion script for `shell`, built from `Args`, for `datui completions`.
640pub 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/// What `datui catalog` does.
647#[derive(Clone, Debug, Subcommand)]
648pub enum CatalogAction {
649    /// With NAME, print that catalog's file (examples is the one datui ships); without, list the catalogs: id, label, datasets and file
650    Show {
651        /// The catalog's id: mine (catalog.toml), examples, or a listed file's name
652        #[arg(value_name = "NAME")]
653        name: Option<String>,
654    },
655    /// Check a catalog file and list its datasets; a mistake is named by its line, with the fix, and exits non-zero
656    Check {
657        /// The catalog file
658        #[arg(value_name = "FILE")]
659        file: std::path::PathBuf,
660    },
661}
662
663/// What `datui theme` does.
664#[derive(Clone, Debug, Subcommand)]
665pub enum ThemeAction {
666    /// List the themes: name, the mode it is set for, where it comes from and its description
667    List,
668    /// Print a theme as a file with every slot, to save into themes/ and edit
669    Show {
670        /// The theme: night-market, day-market, or a file's name in themes/
671        #[arg(value_name = "NAME")]
672        name: String,
673    },
674}
675
676/// What `datui cache` does.
677#[derive(Clone, Debug, Subcommand)]
678pub enum CacheAction {
679    /// Delete the cache directory's contents, or with --recents only the recent datasets
680    Clear {
681        /// Forget the recently opened datasets and keep the rest
682        #[arg(long)]
683        recents: bool,
684    },
685}
686
687/// What `datui views` does.
688#[derive(Clone, Debug, Subcommand)]
689pub enum ViewsAction {
690    /// List the saved views: name, what files they match, when last used
691    List,
692    /// Remove one saved view by name
693    Rm {
694        #[arg(value_name = "NAME")]
695        name: String,
696    },
697    /// Remove every saved view
698    Clear,
699}
700
701/// What `datui formats` does besides listing.
702#[derive(Clone, Debug, Subcommand)]
703pub enum FormatsAction {
704    /// Check a format spec or a dictionary (QuickFIX XML, DBC or TOML), by name or by file; with FILE, print its first decoded rows, or what a dictionary names in the log. Exits non-zero on an error
705    Check {
706        /// A format spec or dictionary on the search path, by name, or its file
707        #[arg(value_name = "SPEC")]
708        spec: String,
709        /// A file (or directory of column files) to read with it
710        #[arg(value_name = "FILE")]
711        file: Option<std::path::PathBuf>,
712    },
713}
714
715/// Why `c` cannot mark comment lines, if it cannot: it must be something, and on one
716/// line. `--comment`, its config key and the Python option share it.
717pub 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
731/// Escape `|` and newlines for use in markdown table cells.
732fn escape_table_cell(s: &str) -> String {
733    s.replace('|', "\\|").replace(['\n', '\r'], " ")
734}
735
736/// One option's line in the reference: `-f, --follow`, `--delimiter <C>`.
737fn 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        // The value is optional and, where one is given, spelled with `=`.
773        format!("{op}[={value}]")
774    } else {
775        format!("{op} {value}")
776    }
777}
778
779/// `docs/reference/command-line-options.md`: the usage, every option grouped as
780/// `--help` groups it, the commands and the examples. Written by `gen_docs`; a test
781/// fails while the committed page differs.
782pub 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    // Groups in the order `--help` shows them: the operands and ungrouped options first.
795    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;