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    use super::*;
883
884    /// Every example parses as a command line, as written: its flags exist and take
885    /// what it gives them. The runner checks that each does what it says.
886    #[test]
887    fn every_example_parses_as_written() {
888        let examples = examples();
889        assert!(examples.len() >= 4);
890        for example in &examples {
891            assert!(!example.description.is_empty(), "{example:?}");
892            assert!(
893                matches!(
894                    example.expect.as_deref(),
895                    None | Some("rows" | "screen" | "exit")
896                ),
897                "{example:?}"
898            );
899            // Every datui command in it: after a pipe or `&&`, behind `VAR=value`,
900            // up to a redirection.
901            let mut any = false;
902            for part in example.command.split("&&").flat_map(|p| p.split(" | ")) {
903                let mut words = shell_words(part);
904                while words
905                    .first()
906                    .is_some_and(|w| w.contains('=') && !w.starts_with('-'))
907                {
908                    words.remove(0);
909                }
910                if let Some(at) = words.iter().position(|w| w == ">") {
911                    words.truncate(at);
912                }
913                if words.first().map(String::as_str) != Some("datui") {
914                    continue;
915                }
916                any = true;
917                if let Err(e) = Args::try_parse_from(&words) {
918                    panic!("{}: {e}", example.command);
919                }
920            }
921            assert!(any, "no datui command: {example:?}");
922        }
923        assert!(examples_help().contains("| datui"), "the help shows a pipe");
924    }
925
926    /// Split a command line as a shell does, for the quoting the examples use.
927    fn shell_words(line: &str) -> Vec<String> {
928        let mut words = Vec::new();
929        let mut word = String::new();
930        let mut quote: Option<char> = None;
931        let mut any = false;
932        for c in line.chars() {
933            match (quote, c) {
934                (Some(q), c) if c == q => quote = None,
935                (Some(_), c) => word.push(c),
936                (None, '\'' | '"') => {
937                    quote = Some(c);
938                    any = true;
939                }
940                (None, c) if c.is_whitespace() => {
941                    if any || !word.is_empty() {
942                        words.push(std::mem::take(&mut word));
943                        any = false;
944                    }
945                }
946                (None, c) => word.push(c),
947            }
948        }
949        if any || !word.is_empty() {
950            words.push(word);
951        }
952        words
953    }
954
955    /// `-` is a path like any other to the parser: standard input, with the reading
956    /// flags beside it.
957    #[test]
958    fn a_dash_names_standard_input() {
959        let args = Args::try_parse_from(["datui", "-", "--format", "jsonl"]).unwrap();
960        assert_eq!(args.paths, vec![std::path::PathBuf::from("-")]);
961        assert_eq!(args.format, Some(FormatChoice::Builtin(FileFormat::Jsonl)));
962        let args = Args::try_parse_from(["datui", "--delimiter", ";", "-"]).unwrap();
963        assert_eq!(args.paths, vec![std::path::PathBuf::from("-")]);
964        assert_eq!(args.delimiter, Some(b';'));
965    }
966
967    /// `--format` takes a built-in format, a spec's namespaced name, or a spec's file:
968    /// a path only with a `/` or a `.toml` ending.
969    #[test]
970    fn a_format_is_built_in_a_spec_name_or_a_spec_file() {
971        let format = |value: &str| {
972            Args::try_parse_from(["datui", "x", "--format", value])
973                .map(|a| a.format.unwrap())
974                .map_err(|e| e.to_string())
975        };
976        assert_eq!(
977            format("acme.l2feed"),
978            Ok(FormatChoice::Spec("acme.l2feed".into()))
979        );
980        assert_eq!(format("CSV"), Ok(FormatChoice::Builtin(FileFormat::Csv)));
981        assert_eq!(format("./acme"), Ok(FormatChoice::File("./acme".into())));
982        assert_eq!(
983            format("acme.toml"),
984            Ok(FormatChoice::File("acme.toml".into()))
985        );
986        assert_eq!(
987            format("specs/acme.TOML"),
988            Ok(FormatChoice::File("specs/acme.TOML".into()))
989        );
990        let refused = format("cvs").unwrap_err().to_string();
991        assert!(
992            refused.contains("a spec's name") && refused.contains("ending .toml"),
993            "{refused}"
994        );
995        // No spec of that name ships, so the user-facing text names none.
996        assert!(!refused.contains("acme"), "{refused}");
997        let args = Args::try_parse_from(["datui", "x", "-F", "parquet"]).unwrap();
998        assert_eq!(
999            args.format,
1000            Some(FormatChoice::Builtin(FileFormat::Parquet))
1001        );
1002    }
1003
1004    /// A flag whose value is optional takes it only after `=`, so the path after it
1005    /// stays a path.
1006    #[test]
1007    fn an_optional_value_needs_equals() {
1008        let args = Args::try_parse_from(["datui", "--infer-types", "data.csv"]).unwrap();
1009        assert_eq!(args.infer_types, Some(InferTypes::All));
1010        assert_eq!(args.paths, vec![std::path::PathBuf::from("data.csv")]);
1011        let args = Args::try_parse_from(["datui", "--infer-types=off", "d.csv"]).unwrap();
1012        assert_eq!(args.infer_types, Some(InferTypes::Off));
1013        let args = Args::try_parse_from(["datui", "--infer-types=a, b,a", "d.csv"]).unwrap();
1014        assert_eq!(
1015            args.infer_types,
1016            Some(InferTypes::Columns(vec!["a".into(), "b".into()]))
1017        );
1018        for flag in [
1019            "--row-numbers",
1020            "--mouse",
1021            "--skip-initial-space",
1022            "--ignore-errors",
1023        ] {
1024            let args = Args::try_parse_from(["datui", flag, "data.csv"]).unwrap();
1025            assert_eq!(
1026                args.paths,
1027                vec![std::path::PathBuf::from("data.csv")],
1028                "{flag}"
1029            );
1030            let off = Args::try_parse_from(["datui", &format!("{flag}=false"), "d.csv"]).unwrap();
1031            assert_eq!(off.paths.len(), 1, "{flag}");
1032        }
1033        let args = Args::try_parse_from(["datui", "--mouse=false"]).unwrap();
1034        assert_eq!(args.mouse, Some(false));
1035        let args = Args::try_parse_from(["datui", "--row-numbers"]).unwrap();
1036        assert_eq!(args.row_numbers, Some(true));
1037    }
1038
1039    #[test]
1040    fn a_delimiter_is_written_as_people_write_it() {
1041        for (text, byte) in [
1042            (";", b';'),
1043            ("tab", b'\t'),
1044            ("\\t", b'\t'),
1045            ("0x1f", 0x1f),
1046            ("|", b'|'),
1047        ] {
1048            assert_eq!(parse_delimiter(text), Ok(byte), "{text}");
1049        }
1050        for refused in ["59", "ab", "é", "0xzz", "\""] {
1051            assert!(parse_delimiter(refused).is_err(), "{refused}");
1052        }
1053    }
1054
1055    /// Every subcommand parses; any other first word is still a path.
1056    #[test]
1057    fn every_command_parses_and_paths_stay_paths() {
1058        let command = |argv: &[&str]| Args::try_parse_from(argv).unwrap().command;
1059        assert!(matches!(
1060            command(&["datui", "formats"]),
1061            Some(Command::Formats { action: None })
1062        ));
1063        let Some(Command::Formats {
1064            action: Some(FormatsAction::Check { spec, file }),
1065        }) = command(&["datui", "formats", "check", "a.b", "f.bin"])
1066        else {
1067            panic!("a check");
1068        };
1069        assert_eq!((spec.as_str(), file), ("a.b", Some("f.bin".into())));
1070        assert!(matches!(
1071            command(&["datui", "config", "init", "--force"]),
1072            Some(Command::Config {
1073                action: ConfigAction::Init { force: true }
1074            })
1075        ));
1076        assert!(matches!(
1077            command(&["datui", "config", "path"]),
1078            Some(Command::Config {
1079                action: ConfigAction::Path
1080            })
1081        ));
1082        assert!(matches!(
1083            command(&["datui", "config", "keys"]),
1084            Some(Command::Config {
1085                action: ConfigAction::Keys
1086            })
1087        ));
1088        assert!(matches!(
1089            command(&["datui", "theme", "list"]),
1090            Some(Command::Theme {
1091                action: ThemeAction::List
1092            })
1093        ));
1094        assert!(matches!(
1095            command(&["datui", "theme", "show", "night-market"]),
1096            Some(Command::Theme {
1097                action: ThemeAction::Show { name }
1098            }) if name == "night-market"
1099        ));
1100        assert!(matches!(
1101            command(&["datui", "cache", "clear"]),
1102            Some(Command::Cache {
1103                action: CacheAction::Clear { recents: false }
1104            })
1105        ));
1106        assert!(matches!(
1107            command(&["datui", "cache", "clear", "--recents"]),
1108            Some(Command::Cache {
1109                action: CacheAction::Clear { recents: true }
1110            })
1111        ));
1112        assert!(matches!(
1113            command(&["datui", "views", "list"]),
1114            Some(Command::Views {
1115                action: ViewsAction::List
1116            })
1117        ));
1118        assert!(matches!(
1119            command(&["datui", "views", "rm", "daily"]),
1120            Some(Command::Views {
1121                action: ViewsAction::Rm { name }
1122            }) if name == "daily"
1123        ));
1124        assert!(matches!(
1125            command(&["datui", "views", "clear"]),
1126            Some(Command::Views {
1127                action: ViewsAction::Clear
1128            })
1129        ));
1130        let args = Args::try_parse_from(["datui", "data.csv", "--format", "./s.toml"]).unwrap();
1131        assert_eq!(args.paths, vec![std::path::PathBuf::from("data.csv")]);
1132        assert!(args.command.is_none());
1133    }
1134
1135    /// Every shell's script names the commands and the flags.
1136    #[test]
1137    fn completions_cover_commands_and_flags() {
1138        use clap::ValueEnum;
1139        for shell in clap_complete::Shell::value_variants() {
1140            let script = completions(*shell);
1141            assert!(!script.is_empty(), "{shell}");
1142            assert!(script.contains("config"), "{shell}: a subcommand");
1143            assert!(script.contains("infer-types"), "{shell}: a flag");
1144        }
1145        let args = Args::try_parse_from(["datui", "completions", "fish"]).unwrap();
1146        assert!(matches!(
1147            args.command,
1148            Some(Command::Completions {
1149                shell: clap_complete::Shell::Fish
1150            })
1151        ));
1152        assert!(Args::try_parse_from(["datui", "completions", "tcsh"]).is_err());
1153    }
1154
1155    /// Removed flags are gone, not hidden: the config key or command replaces each.
1156    #[test]
1157    fn removed_flags_are_refused() {
1158        for flag in [
1159            "--generate-config",
1160            "--clear-cache",
1161            "--clear-recents",
1162            "--remove-templates",
1163            "--s3-endpoint-url=x",
1164            "--s3-region=x",
1165            "--workaround-pivot-date-index=true",
1166            "--debug",
1167            "--sheet=x",
1168            "--variant=x",
1169            "--spec=x",
1170            "--fix-dict=x",
1171            "--dbc=x",
1172            "--parse-dates",
1173            "--parse-strings",
1174            "--no-parse-strings",
1175            "--polars-streaming",
1176            "--pages-lookahead=3",
1177            "--row-start-index=0",
1178            "--column-colors",
1179            "--align-numeric-right",
1180            "--cloud-discover=all",
1181            "--normalize",
1182            "--single-spine-schema",
1183            "--decompress-in-memory",
1184            "--template=x",
1185            "--skip-tail-rows=1",
1186            "--comment-char=#",
1187            "--infer-schema-length=9",
1188            "--null-value=x",
1189            "--record-size=4",
1190        ] {
1191            assert!(Args::try_parse_from(["datui", flag]).is_err(), "{flag}");
1192        }
1193    }
1194
1195    /// Python's keywords are the registry's: one name each, and each open option's
1196    /// flag is a flag of `Args`.
1197    #[test]
1198    fn python_keywords_are_unique_and_open_options_are_flags() {
1199        let cmd = Args::command();
1200        let mut names: Vec<&str> = settings::OPEN.iter().map(|o| o.kwarg).collect();
1201        names.extend(settings::SETTINGS.iter().filter_map(|s| s.kwarg));
1202        let mut sorted = names.clone();
1203        sorted.sort_unstable();
1204        sorted.dedup();
1205        assert_eq!(sorted.len(), names.len(), "a keyword names two options");
1206        assert!(!names.contains(&"config"), "config is the dict of any key");
1207        for open in settings::OPEN {
1208            assert!(
1209                cmd.get_arguments().any(|a| a.get_long() == Some(open.flag)),
1210                "--{}",
1211                open.flag
1212            );
1213        }
1214    }
1215
1216    /// Each registered flag is a flag of `Args`, and its help is the key's doc; no flag
1217    /// of `Args` claims a key the registry does not give it.
1218    #[test]
1219    fn registered_flags_are_args_and_share_the_doc() {
1220        let cmd = Args::command();
1221        for setting in settings::SETTINGS {
1222            let Some(flag) = setting.flag else { continue };
1223            let arg = cmd
1224                .get_arguments()
1225                .find(|a| a.get_long() == Some(flag))
1226                .unwrap_or_else(|| {
1227                    panic!("--{flag} is registered for {} but not an arg", setting.key)
1228                });
1229            let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default();
1230            assert!(
1231                help.contains(setting.doc) && help.contains(setting.key),
1232                "--{flag}: {help}"
1233            );
1234        }
1235        for arg in cmd.get_arguments() {
1236            let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default();
1237            if help.contains("[config: ") {
1238                let flag = arg.get_long().unwrap_or_default();
1239                assert!(settings::by_flag(flag).is_some(), "--{flag}");
1240            }
1241        }
1242    }
1243
1244    #[test]
1245    fn test_compression_detection() {
1246        assert_eq!(
1247            CompressionFormat::from_extension(Path::new("file.csv.gz")),
1248            Some(CompressionFormat::Gzip)
1249        );
1250        assert_eq!(
1251            CompressionFormat::from_extension(Path::new("file.csv.zst")),
1252            Some(CompressionFormat::Zstd)
1253        );
1254        assert_eq!(
1255            CompressionFormat::from_extension(Path::new("file.csv.bz2")),
1256            Some(CompressionFormat::Bzip2)
1257        );
1258        assert_eq!(
1259            CompressionFormat::from_extension(Path::new("file.csv.xz")),
1260            Some(CompressionFormat::Xz)
1261        );
1262        assert_eq!(
1263            CompressionFormat::from_extension(Path::new("file.csv")),
1264            None
1265        );
1266        assert_eq!(CompressionFormat::from_extension(Path::new("file")), None);
1267    }
1268
1269    #[test]
1270    fn test_compression_extension() {
1271        assert_eq!(CompressionFormat::Gzip.extension(), "gz");
1272        assert_eq!(CompressionFormat::Zstd.extension(), "zst");
1273        assert_eq!(CompressionFormat::Bzip2.extension(), "bz2");
1274        assert_eq!(CompressionFormat::Xz.extension(), "xz");
1275    }
1276
1277    #[test]
1278    fn test_file_format_from_path() {
1279        assert_eq!(
1280            FileFormat::from_path(Path::new("data.parquet")),
1281            Some(FileFormat::Parquet)
1282        );
1283        assert_eq!(
1284            FileFormat::from_path(Path::new("data.csv")),
1285            Some(FileFormat::Csv)
1286        );
1287        assert_eq!(
1288            FileFormat::from_path(Path::new("file.jsonl")),
1289            Some(FileFormat::Jsonl)
1290        );
1291        assert_eq!(FileFormat::from_path(Path::new("noext")), None);
1292        assert_eq!(
1293            FileFormat::from_path(Path::new("file.NDJSON")),
1294            Some(FileFormat::Jsonl)
1295        );
1296        assert_eq!(
1297            FileFormat::from_path(Path::new("model.gguf")),
1298            Some(FileFormat::Gguf)
1299        );
1300        assert_eq!(
1301            FileFormat::from_path(Path::new("model-00001-of-00002.safetensors")),
1302            Some(FileFormat::Safetensors)
1303        );
1304        assert_eq!(
1305            FileFormat::from_path(Path::new("model.safetensors.index.json")),
1306            Some(FileFormat::Safetensors),
1307            "an index is read as the shards it names"
1308        );
1309        assert_eq!(
1310            FileFormat::from_path(Path::new("song.MID")),
1311            Some(FileFormat::Midi)
1312        );
1313        assert_eq!(
1314            FileFormat::from_path(Path::new("karaoke.kar")),
1315            Some(FileFormat::Midi)
1316        );
1317        assert_eq!(
1318            FileFormat::from_path(Path::new("dump.vcd")),
1319            Some(FileFormat::Vcd)
1320        );
1321        for name in ["lib.sdf", "lib.SD"] {
1322            assert_eq!(
1323                FileFormat::from_path(Path::new(name)),
1324                Some(FileFormat::Sdf),
1325                "{name}"
1326            );
1327        }
1328        assert_eq!(
1329            FileFormat::from_path(Path::new("config.json")),
1330            Some(FileFormat::Json)
1331        );
1332        for name in [
1333            "take.wav",
1334            "take.BWF",
1335            "mix.rf64",
1336            "loop.aif",
1337            "loop.aiff",
1338            "x.aifc",
1339        ] {
1340            assert_eq!(
1341                FileFormat::from_path(Path::new(name)),
1342                Some(FileFormat::Audio),
1343                "{name}"
1344            );
1345        }
1346    }
1347}
1348
1349#[cfg(test)]
1350mod format_tests {
1351    use super::{FileFormat, FormatChoice, ReadMode, RemoteRead, Stored, Summary};
1352
1353    /// `ALL` is the list `from_name` searches, so a format missing from it cannot be
1354    /// read back from a stored name. The match below is exhaustive, so a new variant
1355    /// does not compile until it is written here — the reminder to add it to `ALL`,
1356    /// which nothing can enforce outright.
1357    #[test]
1358    fn every_format_is_listed() {
1359        fn listed(f: FileFormat) -> bool {
1360            match f {
1361                FileFormat::Parquet
1362                | FileFormat::Csv
1363                | FileFormat::Tsv
1364                | FileFormat::Psv
1365                | FileFormat::Json
1366                | FileFormat::Jsonl
1367                | FileFormat::Arrow
1368                | FileFormat::Avro
1369                | FileFormat::Orc
1370                | FileFormat::Excel
1371                | FileFormat::Safetensors
1372                | FileFormat::Gguf
1373                | FileFormat::Nmea
1374                | FileFormat::Gpx
1375                | FileFormat::Audio
1376                | FileFormat::Midi
1377                | FileFormat::Sqlite
1378                | FileFormat::Vcd
1379                | FileFormat::Fix
1380                | FileFormat::Sdf
1381                | FileFormat::Numpy
1382                | FileFormat::Elf
1383                | FileFormat::Ulog
1384                | FileFormat::Dataflash
1385                | FileFormat::Candump
1386                | FileFormat::Text
1387                | FileFormat::Journal => FileFormat::ALL.contains(&f),
1388            }
1389        }
1390        for format in FileFormat::ALL {
1391            assert!(listed(format), "{format:?}");
1392            assert_eq!(FileFormat::from_name(format.name()), Some(format));
1393        }
1394
1395        // And the names themselves, because they are not only labels. `Holds` keeps a
1396        // format as this string and the dataset cache writes it out, so renaming one
1397        // changes what every directory of that format reads *and* makes the records
1398        // already on disk unreadable — `from_name` then answers `None`, which the
1399        // enrich gate takes for "not Parquet" and blanks the directory's size. The round
1400        // trip above holds for any string; this is what says which.
1401        assert_eq!(
1402            FileFormat::ALL.map(|f| f.name()),
1403            [
1404                "parquet",
1405                "csv",
1406                "tsv",
1407                "psv",
1408                "json",
1409                "jsonl",
1410                "arrow",
1411                "avro",
1412                "orc",
1413                "excel",
1414                "safetensors",
1415                "gguf",
1416                "nmea",
1417                "gpx",
1418                "audio",
1419                "midi",
1420                "sqlite",
1421                "vcd",
1422                "fix",
1423                "sdf",
1424                "numpy",
1425                "elf",
1426                "ulog",
1427                "dataflash",
1428                "candump",
1429                "text",
1430                "journal"
1431            ]
1432        );
1433    }
1434
1435    /// Each way a file can sit on disk reads as the format says, and the formats that
1436    /// take the whole file into memory are the ones whose readers do.
1437    #[test]
1438    fn read_mode_by_format_and_storage() {
1439        use ReadMode::*;
1440        let plain = |f: FileFormat| f.read_mode(Stored::Plain);
1441        assert_eq!(plain(FileFormat::Parquet), Some(Lazy));
1442        assert_eq!(plain(FileFormat::Csv), Some(Lazy));
1443        assert_eq!(plain(FileFormat::Arrow), Some(Lazy));
1444        assert_eq!(plain(FileFormat::Audio), Some(Lazy));
1445        assert_eq!(plain(FileFormat::Sqlite), Some(Lazy));
1446        assert_eq!(plain(FileFormat::Nmea), Some(Converted));
1447        assert_eq!(plain(FileFormat::Gpx), Some(Converted));
1448        for f in [
1449            FileFormat::Json,
1450            FileFormat::Jsonl,
1451            FileFormat::Avro,
1452            FileFormat::Orc,
1453            FileFormat::Excel,
1454            FileFormat::Safetensors,
1455            FileFormat::Gguf,
1456            FileFormat::Midi,
1457        ] {
1458            assert_eq!(plain(f), Some(InMemory), "{}", f.name());
1459        }
1460        for f in FileFormat::ALL {
1461            assert!(plain(f).is_some(), "every format opens: {}", f.name());
1462        }
1463
1464        // An IPC stream is converted; nothing else is one.
1465        assert_eq!(FileFormat::Arrow.read_mode(Stored::Stream), Some(Converted));
1466        assert_eq!(FileFormat::Parquet.read_mode(Stored::Stream), None);
1467
1468        // Compressed text is decompressed once to a file, or read in memory when asked;
1469        // a GPS log is decompressed as it is converted; nothing else opens compressed.
1470        let compressed = |f: FileFormat, in_memory| f.read_mode(Stored::Compressed { in_memory });
1471        for f in [FileFormat::Csv, FileFormat::Tsv, FileFormat::Psv] {
1472            assert_eq!(compressed(f, false), Some(Decompressed));
1473            assert_eq!(compressed(f, true), Some(InMemory));
1474        }
1475        assert_eq!(compressed(FileFormat::Nmea, true), Some(Converted));
1476        assert_eq!(compressed(FileFormat::Parquet, false), None);
1477        assert_eq!(compressed(FileFormat::Json, false), None);
1478        assert_eq!(compressed(FileFormat::Sqlite, false), None);
1479
1480        // A spec maps its file, or the decompressed copy of it.
1481        let spec = FormatChoice::Spec("acme.l2feed".into());
1482        assert_eq!(spec.read_mode(Stored::Plain), Some(Lazy));
1483        assert_eq!(
1484            spec.read_mode(Stored::Compressed { in_memory: true }),
1485            Some(Decompressed)
1486        );
1487        assert_eq!(spec.bucket_object(Stored::Plain), RemoteRead::Downloaded);
1488
1489        // Parquet objects, Arrow IPC files and model headers are read in place; CSV
1490        // and NDJSON prefixes are too. Over HTTP only a model's header is.
1491        let model = |f: FileFormat| matches!(f, FileFormat::Safetensors | FileFormat::Gguf);
1492        for f in FileFormat::ALL {
1493            let in_place = f.bucket_object(Stored::Plain) == RemoteRead::InPlace;
1494            assert_eq!(
1495                in_place,
1496                matches!(f, FileFormat::Parquet | FileFormat::Arrow) || model(f),
1497                "{}",
1498                f.name()
1499            );
1500            assert_eq!(
1501                f.bucket_object(Stored::Compressed { in_memory: false }),
1502                RemoteRead::Downloaded
1503            );
1504            assert_eq!(
1505                f.http_file() == RemoteRead::InPlace,
1506                model(f),
1507                "{}",
1508                f.name()
1509            );
1510        }
1511        assert_eq!(spec.http_file(), RemoteRead::Downloaded);
1512        let prefixes: Vec<_> = FileFormat::ALL
1513            .into_iter()
1514            .filter(|f| f.reads_bucket_prefix())
1515            .collect();
1516        assert_eq!(
1517            prefixes,
1518            [
1519                FileFormat::Parquet,
1520                FileFormat::Csv,
1521                FileFormat::Jsonl,
1522                FileFormat::Arrow,
1523                FileFormat::Safetensors,
1524                FileFormat::Gguf
1525            ]
1526        );
1527        // Streams have no footer: one object, or a prefix of them, is downloaded.
1528        assert_eq!(
1529            FileFormat::Arrow.bucket_object(Stored::Stream),
1530            RemoteRead::Downloaded
1531        );
1532        assert_eq!(
1533            FileFormat::Arrow.bucket_prefix(Stored::Stream),
1534            Some(RemoteRead::Downloaded)
1535        );
1536    }
1537
1538    /// The dataset-info page's table of tabs says what each descriptor says: the
1539    /// format's tab of the Info panel, and what the home screen lists inside a file of
1540    /// it. Every format has a row, matched by its title.
1541    #[test]
1542    fn the_docs_tab_table_agrees_with_the_descriptors() {
1543        let page = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1544            .join("../../docs/user-guide/dataset-info.md");
1545        let text = std::fs::read_to_string(&page).expect("the dataset-info page");
1546        let header = "| Format | Tab | Lists inside the file |";
1547        let start = text.find(header).expect("the table of tabs");
1548        let mut seen: Vec<FileFormat> = Vec::new();
1549        for line in text[start..]
1550            .lines()
1551            .skip(2)
1552            .take_while(|l| l.starts_with('|'))
1553        {
1554            let cells: Vec<&str> = line.trim_matches('|').split(" | ").map(str::trim).collect();
1555            let [formats, tab, tables] = cells[..] else {
1556                panic!("three cells: {line}");
1557            };
1558            for title in formats.split(", ") {
1559                let title = title.trim_matches('*');
1560                let format = FileFormat::ALL
1561                    .into_iter()
1562                    .find(|f| f.title().eq_ignore_ascii_case(title))
1563                    .unwrap_or_else(|| panic!("{title} is a format's title"));
1564                seen.push(format);
1565                let said = match format.descriptor().summary {
1566                    Summary::Tab(tab) => format!("**{tab}**"),
1567                    Summary::None(why) => {
1568                        assert!(text.contains(why), "{title}: the page says why: {why}");
1569                        "none".to_string()
1570                    }
1571                };
1572                assert_eq!(tab, said, "{title}: Tab");
1573                let listed = format
1574                    .descriptor()
1575                    .tables
1576                    .as_ref()
1577                    .map_or("no", |_| "tables");
1578                assert_eq!(tables, listed, "{title}: Lists inside the file");
1579            }
1580        }
1581        for f in FileFormat::ALL {
1582            assert!(seen.contains(&f), "{} has a row", f.title());
1583        }
1584    }
1585}