Skip to main content

datui_cli/
lib.rs

1//! Shared CLI definitions for datui.
2//!
3//! Used by the main application and by the build script (manpage) and
4//! gen_docs binary (command-line-options markdown).
5
6use clap::{CommandFactory, Parser, ValueEnum};
7use std::path::Path;
8
9/// File format for data files (used to bypass extension-based detection).
10/// When `--format` is not specified, format is auto-detected from the file extension.
11#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
12pub enum FileFormat {
13    /// Parquet columnar format
14    Parquet,
15    /// Comma-separated values
16    Csv,
17    /// Tab-separated values
18    Tsv,
19    /// Pipe-separated values
20    Psv,
21    /// JSON array format
22    Json,
23    /// JSON Lines / NDJSON (one JSON object per line)
24    Jsonl,
25    /// Arrow IPC / Feather
26    Arrow,
27    /// Avro row format
28    Avro,
29    /// ORC columnar format
30    Orc,
31    /// Excel (.xls, .xlsx, .xlsm, .xlsb)
32    Excel,
33}
34
35impl FileFormat {
36    /// Detect file format from path extension. Returns None when extension is missing or unknown.
37    pub fn from_path(path: &Path) -> Option<Self> {
38        path.extension()
39            .and_then(|e| e.to_str())
40            .and_then(Self::from_extension)
41    }
42
43    /// Parse format from extension string (e.g. "parquet", "csv").
44    pub fn from_extension(ext: &str) -> Option<Self> {
45        match ext.to_lowercase().as_str() {
46            "parquet" => Some(Self::Parquet),
47            "csv" => Some(Self::Csv),
48            "tsv" => Some(Self::Tsv),
49            "psv" => Some(Self::Psv),
50            "json" => Some(Self::Json),
51            "jsonl" | "ndjson" => Some(Self::Jsonl),
52            "arrow" | "ipc" | "feather" => Some(Self::Arrow),
53            "avro" => Some(Self::Avro),
54            "orc" => Some(Self::Orc),
55            "xls" | "xlsx" | "xlsm" | "xlsb" => Some(Self::Excel),
56            _ => None,
57        }
58    }
59}
60
61/// Compression format for data files
62#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
63pub enum CompressionFormat {
64    /// Gzip compression (.gz) - Most common, good balance of speed and compression
65    Gzip,
66    /// Zstandard compression (.zst) - Modern, fast compression with good ratios
67    Zstd,
68    /// Bzip2 compression (.bz2) - Good compression ratio, slower than gzip
69    Bzip2,
70    /// XZ compression (.xz) - Excellent compression ratio, slower than bzip2
71    Xz,
72}
73
74impl CompressionFormat {
75    /// Detect compression format from file extension
76    pub fn from_extension(path: &Path) -> Option<Self> {
77        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
78            match ext.to_lowercase().as_str() {
79                "gz" => Some(Self::Gzip),
80                "zst" | "zstd" => Some(Self::Zstd),
81                "bz2" | "bz" => Some(Self::Bzip2),
82                "xz" => Some(Self::Xz),
83                _ => None,
84            }
85        } else {
86            None
87        }
88    }
89
90    /// Get file extension for this compression format
91    pub fn extension(&self) -> &'static str {
92        match self {
93            Self::Gzip => "gz",
94            Self::Zstd => "zst",
95            Self::Bzip2 => "bz2",
96            Self::Xz => "xz",
97        }
98    }
99}
100
101/// Accepted values for `--number-format`.
102///
103/// This crate cannot depend on datui-lib (the dependency runs the other way),
104/// so the list is duplicated here to give clap proper `--help` output and shell
105/// completion. `number_format_values_match_presets` in datui-lib asserts the two
106/// lists stay in sync.
107pub const NUMBER_FORMAT_VALUES: &[&str] = &[
108    "none",
109    "thousands",
110    "european",
111    "si",
112    "swiss",
113    "indian",
114    "underscore",
115    "system",
116];
117
118/// Command-line arguments for datui
119#[derive(Clone, Parser, Debug)]
120#[command(
121    name = "datui",
122    version,
123    about = "Data Exploration in the Terminal",
124    long_about = include_str!("../long_about.txt")
125)]
126pub struct Args {
127    /// Path(s) to the data file(s) to open.
128    /// Multiple files of the same format are concatenated into one table.
129    /// With no PATH, datui opens its home screen so you can pick a dataset.
130    #[arg(num_args = 0.., value_name = "PATH")]
131    pub paths: Vec<std::path::PathBuf>,
132
133    /// Skip this many lines when reading a file
134    #[arg(long = "skip-lines")]
135    pub skip_lines: Option<usize>,
136
137    /// Skip this many rows when reading a file
138    #[arg(long = "skip-rows")]
139    pub skip_rows: Option<usize>,
140
141    /// Skip this many rows at the end of the file (e.g. to ignore vendor footer or trailing garbage)
142    #[arg(long = "skip-tail-rows", value_name = "N")]
143    pub skip_tail_rows: Option<usize>,
144
145    /// Specify that the file has no header
146    #[arg(long = "no-header")]
147    pub no_header: Option<bool>,
148
149    /// Specify the delimiter to use when reading a delimited text file
150    #[arg(long = "delimiter")]
151    pub delimiter: Option<u8>,
152
153    /// Number of rows to use when inferring CSV schema (default: 1000). Larger values reduce risk of wrong type (e.g. int then N/A).
154    #[arg(long = "infer-schema-length", value_name = "N")]
155    pub infer_schema_length: Option<usize>,
156
157    /// When reading CSV, ignore parse errors and continue with the next batch (default: false)
158    #[arg(long = "ignore-errors", value_name = "BOOL", value_parser = clap::value_parser!(bool))]
159    pub ignore_errors: Option<bool>,
160
161    /// Treat these values as null when reading CSV. Use once per value; no "=" means all columns, COL=VAL means column COL only (first "=" separates column from value). Example: --null-value NA --null-value amount=
162    #[arg(long = "null-value", value_name = "VAL")]
163    pub null_value: Vec<String>,
164
165    /// Specify the compression format explicitly (gzip, zstd, bzip2, xz)
166    /// If not specified, compression is auto-detected from file extension.
167    #[arg(long = "compression", value_enum)]
168    pub compression: Option<CompressionFormat>,
169
170    /// Force file format (parquet, csv, tsv, psv, json, jsonl, arrow, avro, orc, excel).
171    /// By default format is auto-detected from the file extension. Use this for URLs or paths without an extension.
172    #[arg(long = "format", value_enum)]
173    pub format: Option<FileFormat>,
174
175    /// Enable debug mode to show operational information
176    #[arg(long = "debug", action)]
177    pub debug: bool,
178
179    /// Enable Hive-style partitioning for directory or glob paths; ignored for a single file
180    #[arg(long = "hive", action)]
181    pub hive: bool,
182
183    /// Infer Hive/partitioned Parquet schema from one file for faster load (default: true). Set to false to use full schema scan.
184    #[arg(long = "single-spine-schema", value_name = "BOOL", value_parser = clap::value_parser!(bool))]
185    pub single_spine_schema: Option<bool>,
186
187    /// Try to parse CSV string columns as dates (e.g. YYYY-MM-DD, ISO datetime). Default: true
188    #[arg(long = "parse-dates", value_name = "BOOL", value_parser = clap::value_parser!(bool))]
189    pub parse_dates: Option<bool>,
190
191    /// Trim whitespace and parse CSV string columns as date, datetime, time, duration, int, or float. Default: applied to all string columns. Use --parse-strings=COL (repeatable) to limit to specific columns, or --no-parse-strings to disable.
192    #[arg(long = "parse-strings", value_name = "COL", num_args = 0.., default_missing_value = "")]
193    pub parse_strings: Vec<String>,
194
195    /// Disable parse-strings for CSV (trim and type inference). Overrides config and default.
196    #[arg(long = "no-parse-strings", action)]
197    pub no_parse_strings: bool,
198
199    /// Decompress into memory. Default: decompress to temp file and use lazy scan
200    #[arg(long = "decompress-in-memory", default_missing_value = "true", num_args = 0..=1, value_parser = clap::value_parser!(bool))]
201    pub decompress_in_memory: Option<bool>,
202
203    /// Directory for decompression temp files (default: system temp, e.g. TMPDIR)
204    #[arg(long = "temp-dir", value_name = "DIR")]
205    pub temp_dir: Option<std::path::PathBuf>,
206
207    /// Excel sheet to load: 0-based index (e.g. 0) or sheet name (e.g. "Sales")
208    #[arg(long = "sheet", value_name = "SHEET")]
209    pub excel_sheet: Option<String>,
210
211    /// Forget every recently opened dataset and exit; other caches are kept
212    #[arg(long = "clear-recents", action)]
213    pub clear_recents: bool,
214
215    /// Clear all cache data and exit
216    #[arg(long = "clear-cache", action)]
217    pub clear_cache: bool,
218
219    /// Apply a template by name when starting the application
220    #[arg(long = "template")]
221    pub template: Option<String>,
222
223    /// Remove all templates and exit
224    #[arg(long = "remove-templates", action)]
225    pub remove_templates: bool,
226
227    /// When set, datasets with this many or more rows are sampled for analysis (faster, less memory).
228    /// Overrides config [performance] sampling_threshold. Use 0 to disable sampling (full dataset) for this run.
229    /// When omitted, config or full-dataset mode is used.
230    #[arg(long = "sampling-threshold", value_name = "N")]
231    pub sampling_threshold: Option<usize>,
232
233    /// Use Polars streaming engine for LazyFrame collect when available (default: true). Set to false to disable.
234    #[arg(long = "polars-streaming", value_name = "BOOL", value_parser = clap::value_parser!(bool))]
235    pub polars_streaming: Option<bool>,
236
237    /// Apply workaround for Polars 0.52 pivot with Date/Datetime index (default: true). Set to false to test without it.
238    #[arg(long = "workaround-pivot-date-index", value_name = "BOOL", value_parser = clap::value_parser!(bool))]
239    pub workaround_pivot_date_index: Option<bool>,
240
241    /// Number of pages to buffer ahead of the visible area (default: 3)
242    /// Larger values provide smoother scrolling but use more memory
243    #[arg(long = "pages-lookahead")]
244    pub pages_lookahead: Option<usize>,
245
246    /// Number of pages to buffer behind the visible area (default: 3)
247    /// Larger values provide smoother scrolling but use more memory
248    #[arg(long = "pages-lookback")]
249    pub pages_lookback: Option<usize>,
250
251    /// Display row numbers on the left side of the table
252    #[arg(long = "row-numbers", action)]
253    pub row_numbers: bool,
254
255    /// Starting index for row numbers (default: 1)
256    #[arg(long = "row-start-index")]
257    pub row_start_index: Option<usize>,
258
259    /// Colorize main table cells by column type (default: true). Set to false to disable.
260    #[arg(long = "column-colors", value_name = "BOOL", value_parser = clap::value_parser!(bool))]
261    pub column_colors: Option<bool>,
262
263    /// Digit grouping for numbers in the data table (default: none). Press F to toggle while running.
264    /// "system" reads LC_ALL/LC_NUMERIC/LANG and picks a matching style.
265    #[arg(long = "number-format", value_name = "FORMAT", value_parser = clap::builder::PossibleValuesParser::new(NUMBER_FORMAT_VALUES))]
266    pub number_format: Option<String>,
267
268    /// Right-align numeric columns and their headers (default: true). Set to false to left-align.
269    #[arg(long = "align-numeric-right", value_name = "BOOL", value_parser = clap::value_parser!(bool))]
270    pub align_numeric_right: Option<bool>,
271
272    /// Generate default configuration file at ~/.config/datui/config.toml
273    #[arg(long = "generate-config", action)]
274    pub generate_config: bool,
275
276    /// Force overwrite existing config file when using --generate-config
277    #[arg(long = "force", requires = "generate_config", action)]
278    pub force: bool,
279
280    /// S3-compatible endpoint URL (overrides config and AWS_ENDPOINT_URL). Example: http://localhost:9000
281    #[arg(long = "s3-endpoint-url", value_name = "URL")]
282    pub s3_endpoint_url: Option<String>,
283
284    /// S3 access key (overrides config and AWS_ACCESS_KEY_ID)
285    #[arg(long = "s3-access-key-id", value_name = "KEY")]
286    pub s3_access_key_id: Option<String>,
287
288    /// S3 secret key (overrides config and AWS_SECRET_ACCESS_KEY)
289    #[arg(long = "s3-secret-access-key", value_name = "SECRET")]
290    pub s3_secret_access_key: Option<String>,
291
292    /// S3 region (overrides config and AWS_REGION). Example: us-east-1
293    #[arg(long = "s3-region", value_name = "REGION")]
294    pub s3_region: Option<String>,
295}
296
297/// Escape `|` and newlines for use in markdown table cells.
298fn escape_table_cell(s: &str) -> String {
299    s.replace('|', "\\|").replace(['\n', '\r'], " ")
300}
301
302/// Render command-line options as markdown.
303///
304/// Used by the gen_docs binary; output is written to stdout and then
305/// to `docs/reference/command-line-options.md` by the docs build process.
306pub fn render_options_markdown() -> String {
307    let mut cmd = Args::command();
308    cmd.build();
309
310    let mut out = String::from("# Command Line Options\n\n");
311
312    out.push_str("## Usage\n\n```\n");
313    let usage = cmd.render_usage();
314    out.push_str(&usage.to_string());
315    out.push_str("\n```\n\n");
316
317    out.push_str("## Options\n\n");
318    out.push_str("| Option | Description |\n");
319    out.push_str("|--------|-------------|\n");
320
321    for arg in cmd.get_arguments() {
322        let id = arg.get_id().as_ref().to_string();
323        if id == "help" || id == "version" {
324            continue;
325        }
326
327        let option_str = if arg.is_positional() {
328            let placeholder: String = arg
329                .get_value_names()
330                .map(|names| {
331                    names
332                        .iter()
333                        .map(|n: &clap::builder::Str| format!("<{}>", n.as_ref() as &str))
334                        .collect::<Vec<_>>()
335                        .join(" ")
336                })
337                .unwrap_or_default();
338            if arg.is_required_set() {
339                placeholder
340            } else {
341                format!("[{placeholder}]")
342            }
343        } else {
344            let mut parts = Vec::new();
345            if let Some(s) = arg.get_short() {
346                parts.push(format!("-{s}"));
347            }
348            if let Some(l) = arg.get_long() {
349                parts.push(format!("--{l}"));
350            }
351            let op = parts.join(", ");
352            let takes_val = arg.get_action().takes_values();
353            let placeholder: String = if takes_val {
354                arg.get_value_names()
355                    .map(|names| {
356                        names
357                            .iter()
358                            .map(|n: &clap::builder::Str| format!("<{}>", n.as_ref() as &str))
359                            .collect::<Vec<_>>()
360                            .join(" ")
361                    })
362                    .unwrap_or_default()
363            } else {
364                String::new()
365            };
366            if placeholder.is_empty() {
367                op
368            } else {
369                format!("{op} {placeholder}")
370            }
371        };
372
373        let help = arg
374            .get_help()
375            .map(|h| escape_table_cell(&h.to_string()))
376            .unwrap_or_else(|| "-".to_string());
377
378        out.push_str(&format!("| `{option_str}` | {help} |\n"));
379    }
380
381    out
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_compression_detection() {
390        assert_eq!(
391            CompressionFormat::from_extension(Path::new("file.csv.gz")),
392            Some(CompressionFormat::Gzip)
393        );
394        assert_eq!(
395            CompressionFormat::from_extension(Path::new("file.csv.zst")),
396            Some(CompressionFormat::Zstd)
397        );
398        assert_eq!(
399            CompressionFormat::from_extension(Path::new("file.csv.bz2")),
400            Some(CompressionFormat::Bzip2)
401        );
402        assert_eq!(
403            CompressionFormat::from_extension(Path::new("file.csv.xz")),
404            Some(CompressionFormat::Xz)
405        );
406        assert_eq!(
407            CompressionFormat::from_extension(Path::new("file.csv")),
408            None
409        );
410        assert_eq!(CompressionFormat::from_extension(Path::new("file")), None);
411    }
412
413    #[test]
414    fn test_compression_extension() {
415        assert_eq!(CompressionFormat::Gzip.extension(), "gz");
416        assert_eq!(CompressionFormat::Zstd.extension(), "zst");
417        assert_eq!(CompressionFormat::Bzip2.extension(), "bz2");
418        assert_eq!(CompressionFormat::Xz.extension(), "xz");
419    }
420
421    #[test]
422    fn test_file_format_from_path() {
423        assert_eq!(
424            FileFormat::from_path(Path::new("data.parquet")),
425            Some(FileFormat::Parquet)
426        );
427        assert_eq!(
428            FileFormat::from_path(Path::new("data.csv")),
429            Some(FileFormat::Csv)
430        );
431        assert_eq!(
432            FileFormat::from_path(Path::new("file.jsonl")),
433            Some(FileFormat::Jsonl)
434        );
435        assert_eq!(FileFormat::from_path(Path::new("noext")), None);
436        assert_eq!(
437            FileFormat::from_path(Path::new("file.NDJSON")),
438            Some(FileFormat::Jsonl)
439        );
440    }
441}