use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use std::path::Path;
pub mod docgen;
pub mod exit;
mod formats;
pub use formats::*;
pub mod keys;
pub mod man;
pub mod settings;
pub mod units;
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum CompressionFormat {
Gzip,
Zstd,
Bzip2,
Xz,
}
impl CompressionFormat {
pub fn from_extension(path: &Path) -> Option<Self> {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext.to_lowercase().as_str() {
"gz" => Some(Self::Gzip),
"zst" | "zstd" => Some(Self::Zstd),
"bz2" | "bz" => Some(Self::Bzip2),
"xz" => Some(Self::Xz),
_ => None,
}
} else {
None
}
}
pub fn extension(&self) -> &'static str {
match self {
Self::Gzip => "gz",
Self::Zstd => "zst",
Self::Bzip2 => "bz2",
Self::Xz => "xz",
}
}
}
pub const NUMBER_FORMAT_VALUES: &[&str] = &[
"none",
"thousands",
"european",
"si",
"swiss",
"indian",
"underscore",
"system",
];
pub const EXAMPLES_TOML: &str = include_str!("../examples.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExampleTest {
Run,
Network,
Interactive,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Example {
pub command: String,
pub description: String,
pub test: ExampleTest,
pub expect: Option<String>,
pub pages: Vec<String>,
pub files: Vec<ExampleFile>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExampleFile {
pub name: String,
pub text: String,
}
pub fn examples() -> Vec<Example> {
let file: toml::Table = EXAMPLES_TOML.parse().expect("examples.toml is TOML");
let entries = file
.get("example")
.and_then(toml::Value::as_array)
.expect("examples.toml has [[example]] entries");
entries
.iter()
.map(|entry| {
let table = entry.as_table().expect("an [[example]] is a table");
for key in table.keys() {
assert!(
matches!(
key.as_str(),
"command" | "description" | "test" | "expect" | "pages" | "files"
),
"examples.toml: unknown key {key}"
);
}
let text = |key: &str| {
table
.get(key)
.and_then(toml::Value::as_str)
.map(str::to_string)
};
let test = match text("test").as_deref() {
Some("run") => ExampleTest::Run,
Some("network") => ExampleTest::Network,
Some("interactive") => ExampleTest::Interactive,
other => panic!("examples.toml: test = {other:?}"),
};
Example {
command: text("command").expect("an example's command"),
description: text("description").expect("an example's description"),
test,
expect: text("expect"),
pages: match table.get("pages") {
None => vec!["datui.1".to_string()],
Some(pages) => pages
.as_array()
.expect("an example's pages are a list")
.iter()
.map(|p| p.as_str().expect("a page is NAME.SECTION").to_string())
.collect(),
},
files: table
.get("files")
.and_then(toml::Value::as_array)
.map(|files| {
files
.iter()
.map(|f| {
let text = |key: &str| {
f.get(key)
.and_then(toml::Value::as_str)
.unwrap_or_else(|| panic!("an example's file has a {key}"))
.to_string()
};
ExampleFile {
name: text("name"),
text: text("text"),
}
})
.collect()
})
.unwrap_or_default(),
}
})
.collect()
}
pub fn examples_of(page: &str) -> Vec<Example> {
examples()
.into_iter()
.filter(|e| e.pages.iter().any(|p| p == page))
.collect()
}
pub fn examples_help() -> String {
let mut out = String::from("Examples:\n");
for example in examples_of("datui.1") {
out.push_str(&format!(
" {}\n {}\n",
example.command, example.description
));
}
out.push_str("\nDocs: https://derekwisong.github.io/datui/ and `datui man`\n");
out.push_str("Keys: datui man keys\n");
out
}
fn command_examples(command: &str) -> String {
let mut out = String::from("Examples:\n");
for example in examples_of(&format!("datui-{command}.1")) {
out.push_str(&format!(
" {}\n {}\n",
example.command, example.description
));
}
let files: Vec<String> = examples_of(&format!("datui-{command}.1"))
.into_iter()
.flat_map(|e| e.files.into_iter().map(|f| f.name))
.collect();
if !files.is_empty() {
out.push_str(&format!(
"\nThe files they read ({}) are in the manual.",
files.join(", ")
));
}
out.push_str(&format!("\nManual: datui man {command}\n"));
out
}
#[derive(Clone, Parser, Debug)]
#[command(
name = "datui",
version,
about = "Terminal UI for tabular data",
long_about = include_str!("../long_about.txt"),
after_help = examples_help()
)]
pub struct Args {
#[arg(num_args = 0.., value_name = "PATH")]
pub paths: Vec<std::path::PathBuf>,
#[arg(short = 'F', long = "format", value_name = "FMT", value_parser = parse_format, help = format_help(), help_heading = "Open")]
pub format: Option<FormatChoice>,
#[arg(short = 't', long = "table", value_name = "NAME", help = table_help(), help_heading = "Open")]
pub table: Option<String>,
#[arg(long = "hive", action, help_heading = "Open")]
pub hive: bool,
#[arg(
long = "compression",
value_name = "C",
value_enum,
hide_possible_values = true,
help_heading = "Open"
)]
pub compression: Option<CompressionFormat>,
#[arg(long = "dict", value_name = "FILE", help_heading = "Open")]
pub dict: Vec<std::path::PathBuf>,
#[arg(short = 'f', long = "follow", action, help_heading = "Open")]
pub follow: bool,
#[arg(long = "tee", value_name = "FILE", help_heading = "Open")]
pub tee: Option<std::path::PathBuf>,
#[arg(long = "tee-raw", requires = "tee", action, help_heading = "Open")]
pub tee_raw: bool,
#[arg(long = "force", action, requires = "tee", help_heading = "Open")]
pub force: bool,
#[arg(long = "hex", action, help_heading = "Open")]
pub hex: bool,
#[arg(long = "hex-width", value_name = "N", value_parser = clap::value_parser!(u16).range(1..=4096), help_heading = "Open")]
pub hex_width: Option<u16>,
#[arg(long = "view", value_name = "NAME", help_heading = "Open")]
pub view: Option<String>,
#[arg(long = "temp-dir", value_name = "DIR", help = settings::flag_help("temp-dir"), help_heading = "Open")]
pub temp_dir: Option<std::path::PathBuf>,
#[arg(long = "delimiter", value_name = "C", value_parser = parse_delimiter, help_heading = "Delimited text")]
pub delimiter: Option<u8>,
#[arg(long = "no-header", action, help_heading = "Delimited text")]
pub no_header: bool,
#[arg(
long = "header-rows",
value_name = "N[,M...]",
value_delimiter = ',',
value_parser = clap::value_parser!(u64).range(1..),
help_heading = "Delimited text"
)]
pub header_rows: Vec<u64>,
#[arg(
long = "footer-rows",
value_name = "N",
help_heading = "Delimited text"
)]
pub footer_rows: Option<usize>,
#[arg(long = "skip-rows", value_name = "N", help_heading = "Delimited text")]
pub skip_rows: Option<usize>,
#[arg(long = "skip-lines", value_name = "N", help_heading = "Delimited text")]
pub skip_lines: Option<usize>,
#[arg(long = "comment", value_name = "PREFIX", value_parser = parse_comment_char, help = settings::flag_help("comment"), help_heading = "Delimited text")]
pub comment: Option<String>,
#[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")]
pub skip_initial_space: Option<bool>,
#[arg(long = "null", value_name = "VAL", help = settings::flag_help("null"), help_heading = "Delimited text")]
pub null: Vec<String>,
#[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")]
pub infer_types: Option<InferTypes>,
#[arg(long = "infer-rows", value_name = "N", help = settings::flag_help("infer-rows"), help_heading = "Delimited text")]
pub infer_rows: Option<usize>,
#[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")]
pub ignore_errors: Option<bool>,
#[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")]
pub row_numbers: Option<bool>,
#[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")]
pub number_format: Option<String>,
#[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")]
pub mouse: Option<bool>,
#[arg(long = "sample-rows", value_name = "N", help = settings::flag_help("sample-rows"), help_heading = "Display")]
pub sample_rows: Option<usize>,
#[arg(
short = 'c',
long = "config",
value_name = "KEY=VALUE",
global = true,
help_heading = "Config"
)]
pub config: Vec<settings::Override>,
#[arg(long = "log-file", value_name = "PATH", help = settings::flag_help("log-file"), help_heading = "Logging")]
pub log_file: Option<std::path::PathBuf>,
#[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")]
pub log_level: Option<String>,
#[command(subcommand)]
pub command: Option<Command>,
}
pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace", "off"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InferTypes {
All,
Off,
Columns(Vec<String>),
}
fn parse_infer_types(text: &str) -> Result<InferTypes, String> {
match text.trim() {
"" | "all" | "true" => Ok(InferTypes::All),
"off" | "false" | "none" => Ok(InferTypes::Off),
cols => {
let mut columns: Vec<String> = Vec::new();
for col in cols.split(',').map(str::trim).filter(|c| !c.is_empty()) {
if !columns.iter().any(|c| c == col) {
columns.push(col.to_string());
}
}
Ok(InferTypes::Columns(columns))
}
}
}
pub fn parse_delimiter(text: &str) -> Result<u8, String> {
let byte = match text {
"tab" | "\\t" | "\t" => b'\t',
"space" => b' ',
_ => {
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
u8::from_str_radix(hex, 16)
.map_err(|_| format!("\"{text}\" is not a byte code such as 0x1f"))?
} else {
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_ascii() => c as u8,
_ => {
return Err(format!(
"\"{text}\" is not one ASCII character, tab, \\t, or a code such as 0x1f"
));
}
}
}
}
};
if matches!(byte, b'\n' | b'\r' | b'"') {
return Err(format!("{byte:#04x} cannot separate columns"));
}
Ok(byte)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatChoice {
Builtin(FileFormat),
Spec(String),
File(std::path::PathBuf),
}
impl FormatChoice {
pub fn builtin(&self) -> Option<FileFormat> {
match self {
Self::Builtin(format) => Some(*format),
Self::Spec(_) | Self::File(_) => None,
}
}
pub fn read_mode(&self, stored: Stored) -> Option<ReadMode> {
match self {
Self::Builtin(format) => format.read_mode(stored),
Self::Spec(_) | Self::File(_) => match stored {
Stored::Plain => Some(ReadMode::Lazy),
Stored::Compressed { .. } => Some(ReadMode::Decompressed),
Stored::Stream => None,
},
}
}
pub fn bucket_object(&self, stored: Stored) -> RemoteRead {
match self {
Self::Builtin(format) => format.bucket_object(stored),
Self::Spec(_) | Self::File(_) => RemoteRead::Downloaded,
}
}
pub fn http_file(&self) -> RemoteRead {
match self {
Self::Builtin(format) => format.http_file(),
Self::Spec(_) | Self::File(_) => RemoteRead::Downloaded,
}
}
pub fn bucket_prefix(&self, stored: Stored) -> Option<RemoteRead> {
match self {
Self::Builtin(format) => format.bucket_prefix(stored),
Self::Spec(_) | Self::File(_) => None,
}
}
pub fn spec(&self) -> Option<&str> {
match self {
Self::Spec(name) => Some(name),
Self::Builtin(_) | Self::File(_) => None,
}
}
pub fn spec_file(&self) -> Option<&std::path::Path> {
match self {
Self::File(path) => Some(path),
Self::Builtin(_) | Self::Spec(_) => None,
}
}
}
fn parse_format(text: &str) -> Result<FormatChoice, String> {
let is_path = text.contains('/')
|| (cfg!(windows) && text.contains('\\'))
|| text.to_ascii_lowercase().ends_with(".toml");
if is_path {
return Ok(FormatChoice::File(std::path::PathBuf::from(text)));
}
if let Some(format) = FileFormat::from_name(&text.to_ascii_lowercase()) {
return Ok(FormatChoice::Builtin(format));
}
let spec_like = text.contains('.')
&& !text.starts_with('.')
&& !text.ends_with('.')
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if spec_like {
return Ok(FormatChoice::Spec(text.to_string()));
}
let names: Vec<&str> = FileFormat::ALL.iter().map(|f| f.name()).collect();
let file = if std::path::Path::new(text).is_file() {
format!(" A spec file in this directory needs ./ in front: --format ./{text}.")
} else {
String::new()
};
Err(format!(
"\"{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}",
names.join(", ")
))
}
#[derive(Clone, Debug, Subcommand)]
pub enum Command {
#[command(after_help = command_examples("formats"))]
Formats {
#[command(subcommand)]
action: Option<FormatsAction>,
},
#[command(after_help = command_examples("config"))]
Config {
#[command(subcommand)]
action: ConfigAction,
},
#[command(after_help = command_examples("catalog"))]
Catalog {
#[command(subcommand)]
action: CatalogAction,
},
#[command(after_help = command_examples("theme"))]
Theme {
#[command(subcommand)]
action: ThemeAction,
},
#[command(after_help = command_examples("cache"))]
Cache {
#[command(subcommand)]
action: CacheAction,
},
#[command(after_help = command_examples("views"))]
Views {
#[command(subcommand)]
action: ViewsAction,
},
#[command(after_help = command_examples("completions"))]
Completions {
#[arg(value_name = "SHELL")]
shell: clap_complete::Shell,
},
#[command(after_help = command_examples("man"))]
Man {
#[arg(value_name = "PAGE")]
page: Option<String>,
#[arg(long, conflicts_with_all = ["page", "dir"])]
list: bool,
#[arg(long, value_name = "DIR", conflicts_with = "page")]
dir: Option<std::path::PathBuf>,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum ConfigAction {
Init {
#[arg(long)]
force: bool,
},
Path,
Keys,
}
pub const COMPLETION_FILES: &[(clap_complete::Shell, &str)] = &[
(clap_complete::Shell::Bash, "datui.bash"),
(clap_complete::Shell::Zsh, "_datui"),
(clap_complete::Shell::Fish, "datui.fish"),
(clap_complete::Shell::PowerShell, "_datui.ps1"),
(clap_complete::Shell::Elvish, "datui.elv"),
];
pub fn completions(shell: clap_complete::Shell) -> String {
let mut out = Vec::new();
clap_complete::generate(shell, &mut Args::command(), "datui", &mut out);
String::from_utf8_lossy(&out).into_owned()
}
#[derive(Clone, Debug, Subcommand)]
pub enum CatalogAction {
Show {
#[arg(value_name = "NAME")]
name: Option<String>,
},
Check {
#[arg(value_name = "FILE")]
file: std::path::PathBuf,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum ThemeAction {
List,
Show {
#[arg(value_name = "NAME")]
name: String,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum CacheAction {
Clear {
#[arg(long)]
recents: bool,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum ViewsAction {
List,
Rm {
#[arg(value_name = "NAME")]
name: String,
},
Clear,
}
#[derive(Clone, Debug, Subcommand)]
pub enum FormatsAction {
Check {
#[arg(value_name = "SPEC")]
spec: String,
#[arg(value_name = "FILE")]
file: Option<std::path::PathBuf>,
},
}
pub fn check_comment_char(c: &str) -> Result<(), String> {
if c.is_empty() {
return Err("must not be empty".into());
}
if c.contains(['\n', '\r']) {
return Err("must not contain a line break".into());
}
Ok(())
}
fn parse_comment_char(text: &str) -> Result<String, String> {
check_comment_char(text).map(|()| text.to_string())
}
fn escape_table_cell(s: &str) -> String {
s.replace('|', "\\|").replace(['\n', '\r'], " ")
}
fn option_label(arg: &clap::Arg) -> String {
let names = |arg: &clap::Arg| -> String {
arg.get_value_names()
.map(|names| {
names
.iter()
.map(|n| format!("<{}>", n.as_str()))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
};
if arg.is_positional() {
return if arg.is_required_set() {
names(arg)
} else {
format!("[{}]...", names(arg))
};
}
let mut parts = Vec::new();
if let Some(s) = arg.get_short() {
parts.push(format!("-{s}"));
}
if let Some(l) = arg.get_long() {
parts.push(format!("--{l}"));
}
let op = parts.join(", ");
let value = if arg.get_action().takes_values() {
names(arg)
} else {
String::new()
};
if value.is_empty() {
op
} else if arg.get_num_args().is_some_and(|n| n.min_values() == 0) {
format!("{op}[={value}]")
} else {
format!("{op} {value}")
}
}
pub fn render_options_markdown() -> String {
let mut cmd = Args::command();
cmd.build();
let mut out = String::from(
"# Command-line options\n\n\
<!-- Generated from crates/datui-cli by `gen_docs`. Do not edit. -->\n\n\
`datui --help` prints these; `datui COMMAND --help` a command's own.\n\n```text\n",
);
out.push_str(&cmd.render_usage().to_string());
out.push_str("\n```\n");
let mut groups: Vec<Option<String>> = vec![None];
for arg in cmd.get_arguments() {
let heading = arg.get_help_heading().map(str::to_string);
if !groups.contains(&heading) {
groups.push(heading);
}
}
for group in groups {
let args: Vec<&clap::Arg> = cmd
.get_arguments()
.filter(|a| a.get_help_heading().map(str::to_string) == group)
.filter(|a| !a.is_hide_set())
.filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
.collect();
if args.is_empty() {
continue;
}
out.push_str(&format!(
"\n## {}\n\n| Option | Description |\n|---|---|\n",
group.as_deref().unwrap_or("Arguments")
));
for arg in args {
let help = arg
.get_help()
.map(|h| escape_table_cell(&h.to_string()))
.unwrap_or_default();
out.push_str(&format!(
"| `{}` | {help} |\n",
escape_table_cell(&option_label(arg))
));
}
}
out.push_str("\n`-h`, `--help` prints help; `-V`, `--version` the version.\n");
out.push_str("\n## Commands\n\n| Command | Does |\n|---|---|\n");
for sub in cmd.get_subcommands().filter(|c| c.get_name() != "help") {
let about = |c: &clap::Command| {
c.get_about()
.map(|a| escape_table_cell(&a.to_string()))
.unwrap_or_default()
};
out.push_str(&format!(
"| `datui {}` | {} |\n",
sub.get_name(),
about(sub)
));
for action in sub.get_subcommands().filter(|c| c.get_name() != "help") {
let operands: Vec<String> = action
.get_arguments()
.filter(|a| a.is_positional())
.filter_map(|a| {
let name = a.get_value_names()?.first()?.to_string();
Some(if a.is_required_set() {
name
} else {
format!("[{name}]")
})
})
.collect();
let command = format!(
"datui {} {} {}",
sub.get_name(),
action.get_name(),
operands.join(" ")
);
out.push_str(&format!(
"| `{}` | {} |\n",
command.trim_end(),
about(action)
));
}
}
out.push_str("\n## Examples\n\n| Command | Does |\n|---|---|\n");
for example in examples_of("datui.1") {
out.push_str(&format!(
"| `{}` | {} |\n",
escape_table_cell(&example.command),
escape_table_cell(&example.description)
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_example_parses_as_written() {
let examples = examples();
assert!(examples.len() >= 4);
for example in &examples {
assert!(!example.description.is_empty(), "{example:?}");
assert!(
matches!(
example.expect.as_deref(),
None | Some("rows" | "screen" | "exit")
),
"{example:?}"
);
let mut any = false;
for part in example.command.split("&&").flat_map(|p| p.split(" | ")) {
let mut words = shell_words(part);
while words
.first()
.is_some_and(|w| w.contains('=') && !w.starts_with('-'))
{
words.remove(0);
}
if let Some(at) = words.iter().position(|w| w == ">") {
words.truncate(at);
}
if words.first().map(String::as_str) != Some("datui") {
continue;
}
any = true;
if let Err(e) = Args::try_parse_from(&words) {
panic!("{}: {e}", example.command);
}
}
assert!(any, "no datui command: {example:?}");
}
assert!(examples_help().contains("| datui"), "the help shows a pipe");
}
fn shell_words(line: &str) -> Vec<String> {
let mut words = Vec::new();
let mut word = String::new();
let mut quote: Option<char> = None;
let mut any = false;
for c in line.chars() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), c) => word.push(c),
(None, '\'' | '"') => {
quote = Some(c);
any = true;
}
(None, c) if c.is_whitespace() => {
if any || !word.is_empty() {
words.push(std::mem::take(&mut word));
any = false;
}
}
(None, c) => word.push(c),
}
}
if any || !word.is_empty() {
words.push(word);
}
words
}
#[test]
fn a_dash_names_standard_input() {
let args = Args::try_parse_from(["datui", "-", "--format", "jsonl"]).unwrap();
assert_eq!(args.paths, vec![std::path::PathBuf::from("-")]);
assert_eq!(args.format, Some(FormatChoice::Builtin(FileFormat::Jsonl)));
let args = Args::try_parse_from(["datui", "--delimiter", ";", "-"]).unwrap();
assert_eq!(args.paths, vec![std::path::PathBuf::from("-")]);
assert_eq!(args.delimiter, Some(b';'));
}
#[test]
fn a_format_is_built_in_a_spec_name_or_a_spec_file() {
let format = |value: &str| {
Args::try_parse_from(["datui", "x", "--format", value])
.map(|a| a.format.unwrap())
.map_err(|e| e.to_string())
};
assert_eq!(
format("acme.l2feed"),
Ok(FormatChoice::Spec("acme.l2feed".into()))
);
assert_eq!(format("CSV"), Ok(FormatChoice::Builtin(FileFormat::Csv)));
assert_eq!(format("./acme"), Ok(FormatChoice::File("./acme".into())));
assert_eq!(
format("acme.toml"),
Ok(FormatChoice::File("acme.toml".into()))
);
assert_eq!(
format("specs/acme.TOML"),
Ok(FormatChoice::File("specs/acme.TOML".into()))
);
let refused = format("cvs").unwrap_err().to_string();
assert!(
refused.contains("a spec's name") && refused.contains("ending .toml"),
"{refused}"
);
assert!(!refused.contains("acme"), "{refused}");
let args = Args::try_parse_from(["datui", "x", "-F", "parquet"]).unwrap();
assert_eq!(
args.format,
Some(FormatChoice::Builtin(FileFormat::Parquet))
);
}
#[test]
fn an_optional_value_needs_equals() {
let args = Args::try_parse_from(["datui", "--infer-types", "data.csv"]).unwrap();
assert_eq!(args.infer_types, Some(InferTypes::All));
assert_eq!(args.paths, vec![std::path::PathBuf::from("data.csv")]);
let args = Args::try_parse_from(["datui", "--infer-types=off", "d.csv"]).unwrap();
assert_eq!(args.infer_types, Some(InferTypes::Off));
let args = Args::try_parse_from(["datui", "--infer-types=a, b,a", "d.csv"]).unwrap();
assert_eq!(
args.infer_types,
Some(InferTypes::Columns(vec!["a".into(), "b".into()]))
);
for flag in [
"--row-numbers",
"--mouse",
"--skip-initial-space",
"--ignore-errors",
] {
let args = Args::try_parse_from(["datui", flag, "data.csv"]).unwrap();
assert_eq!(
args.paths,
vec![std::path::PathBuf::from("data.csv")],
"{flag}"
);
let off = Args::try_parse_from(["datui", &format!("{flag}=false"), "d.csv"]).unwrap();
assert_eq!(off.paths.len(), 1, "{flag}");
}
let args = Args::try_parse_from(["datui", "--mouse=false"]).unwrap();
assert_eq!(args.mouse, Some(false));
let args = Args::try_parse_from(["datui", "--row-numbers"]).unwrap();
assert_eq!(args.row_numbers, Some(true));
}
#[test]
fn a_delimiter_is_written_as_people_write_it() {
for (text, byte) in [
(";", b';'),
("tab", b'\t'),
("\\t", b'\t'),
("0x1f", 0x1f),
("|", b'|'),
] {
assert_eq!(parse_delimiter(text), Ok(byte), "{text}");
}
for refused in ["59", "ab", "é", "0xzz", "\""] {
assert!(parse_delimiter(refused).is_err(), "{refused}");
}
}
#[test]
fn every_command_parses_and_paths_stay_paths() {
let command = |argv: &[&str]| Args::try_parse_from(argv).unwrap().command;
assert!(matches!(
command(&["datui", "formats"]),
Some(Command::Formats { action: None })
));
let Some(Command::Formats {
action: Some(FormatsAction::Check { spec, file }),
}) = command(&["datui", "formats", "check", "a.b", "f.bin"])
else {
panic!("a check");
};
assert_eq!((spec.as_str(), file), ("a.b", Some("f.bin".into())));
assert!(matches!(
command(&["datui", "config", "init", "--force"]),
Some(Command::Config {
action: ConfigAction::Init { force: true }
})
));
assert!(matches!(
command(&["datui", "config", "path"]),
Some(Command::Config {
action: ConfigAction::Path
})
));
assert!(matches!(
command(&["datui", "config", "keys"]),
Some(Command::Config {
action: ConfigAction::Keys
})
));
assert!(matches!(
command(&["datui", "theme", "list"]),
Some(Command::Theme {
action: ThemeAction::List
})
));
assert!(matches!(
command(&["datui", "theme", "show", "night-market"]),
Some(Command::Theme {
action: ThemeAction::Show { name }
}) if name == "night-market"
));
assert!(matches!(
command(&["datui", "cache", "clear"]),
Some(Command::Cache {
action: CacheAction::Clear { recents: false }
})
));
assert!(matches!(
command(&["datui", "cache", "clear", "--recents"]),
Some(Command::Cache {
action: CacheAction::Clear { recents: true }
})
));
assert!(matches!(
command(&["datui", "views", "list"]),
Some(Command::Views {
action: ViewsAction::List
})
));
assert!(matches!(
command(&["datui", "views", "rm", "daily"]),
Some(Command::Views {
action: ViewsAction::Rm { name }
}) if name == "daily"
));
assert!(matches!(
command(&["datui", "views", "clear"]),
Some(Command::Views {
action: ViewsAction::Clear
})
));
let args = Args::try_parse_from(["datui", "data.csv", "--format", "./s.toml"]).unwrap();
assert_eq!(args.paths, vec![std::path::PathBuf::from("data.csv")]);
assert!(args.command.is_none());
}
#[test]
fn completions_cover_commands_and_flags() {
use clap::ValueEnum;
for shell in clap_complete::Shell::value_variants() {
let script = completions(*shell);
assert!(!script.is_empty(), "{shell}");
assert!(script.contains("config"), "{shell}: a subcommand");
assert!(script.contains("infer-types"), "{shell}: a flag");
}
let args = Args::try_parse_from(["datui", "completions", "fish"]).unwrap();
assert!(matches!(
args.command,
Some(Command::Completions {
shell: clap_complete::Shell::Fish
})
));
assert!(Args::try_parse_from(["datui", "completions", "tcsh"]).is_err());
}
#[test]
fn removed_flags_are_refused() {
for flag in [
"--generate-config",
"--clear-cache",
"--clear-recents",
"--remove-templates",
"--s3-endpoint-url=x",
"--s3-region=x",
"--workaround-pivot-date-index=true",
"--debug",
"--sheet=x",
"--variant=x",
"--spec=x",
"--fix-dict=x",
"--dbc=x",
"--parse-dates",
"--parse-strings",
"--no-parse-strings",
"--polars-streaming",
"--pages-lookahead=3",
"--row-start-index=0",
"--column-colors",
"--align-numeric-right",
"--cloud-discover=all",
"--normalize",
"--single-spine-schema",
"--decompress-in-memory",
"--template=x",
"--skip-tail-rows=1",
"--comment-char=#",
"--infer-schema-length=9",
"--null-value=x",
"--record-size=4",
] {
assert!(Args::try_parse_from(["datui", flag]).is_err(), "{flag}");
}
}
#[test]
fn python_keywords_are_unique_and_open_options_are_flags() {
let cmd = Args::command();
let mut names: Vec<&str> = settings::OPEN.iter().map(|o| o.kwarg).collect();
names.extend(settings::SETTINGS.iter().filter_map(|s| s.kwarg));
let mut sorted = names.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), names.len(), "a keyword names two options");
assert!(!names.contains(&"config"), "config is the dict of any key");
for open in settings::OPEN {
assert!(
cmd.get_arguments().any(|a| a.get_long() == Some(open.flag)),
"--{}",
open.flag
);
}
}
#[test]
fn registered_flags_are_args_and_share_the_doc() {
let cmd = Args::command();
for setting in settings::SETTINGS {
let Some(flag) = setting.flag else { continue };
let arg = cmd
.get_arguments()
.find(|a| a.get_long() == Some(flag))
.unwrap_or_else(|| {
panic!("--{flag} is registered for {} but not an arg", setting.key)
});
let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default();
assert!(
help.contains(setting.doc) && help.contains(setting.key),
"--{flag}: {help}"
);
}
for arg in cmd.get_arguments() {
let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default();
if help.contains("[config: ") {
let flag = arg.get_long().unwrap_or_default();
assert!(settings::by_flag(flag).is_some(), "--{flag}");
}
}
}
#[test]
fn test_compression_detection() {
assert_eq!(
CompressionFormat::from_extension(Path::new("file.csv.gz")),
Some(CompressionFormat::Gzip)
);
assert_eq!(
CompressionFormat::from_extension(Path::new("file.csv.zst")),
Some(CompressionFormat::Zstd)
);
assert_eq!(
CompressionFormat::from_extension(Path::new("file.csv.bz2")),
Some(CompressionFormat::Bzip2)
);
assert_eq!(
CompressionFormat::from_extension(Path::new("file.csv.xz")),
Some(CompressionFormat::Xz)
);
assert_eq!(
CompressionFormat::from_extension(Path::new("file.csv")),
None
);
assert_eq!(CompressionFormat::from_extension(Path::new("file")), None);
}
#[test]
fn test_compression_extension() {
assert_eq!(CompressionFormat::Gzip.extension(), "gz");
assert_eq!(CompressionFormat::Zstd.extension(), "zst");
assert_eq!(CompressionFormat::Bzip2.extension(), "bz2");
assert_eq!(CompressionFormat::Xz.extension(), "xz");
}
#[test]
fn test_file_format_from_path() {
assert_eq!(
FileFormat::from_path(Path::new("data.parquet")),
Some(FileFormat::Parquet)
);
assert_eq!(
FileFormat::from_path(Path::new("data.csv")),
Some(FileFormat::Csv)
);
assert_eq!(
FileFormat::from_path(Path::new("file.jsonl")),
Some(FileFormat::Jsonl)
);
assert_eq!(FileFormat::from_path(Path::new("noext")), None);
assert_eq!(
FileFormat::from_path(Path::new("file.NDJSON")),
Some(FileFormat::Jsonl)
);
assert_eq!(
FileFormat::from_path(Path::new("model.gguf")),
Some(FileFormat::Gguf)
);
assert_eq!(
FileFormat::from_path(Path::new("model-00001-of-00002.safetensors")),
Some(FileFormat::Safetensors)
);
assert_eq!(
FileFormat::from_path(Path::new("model.safetensors.index.json")),
Some(FileFormat::Safetensors),
"an index is read as the shards it names"
);
assert_eq!(
FileFormat::from_path(Path::new("song.MID")),
Some(FileFormat::Midi)
);
assert_eq!(
FileFormat::from_path(Path::new("karaoke.kar")),
Some(FileFormat::Midi)
);
assert_eq!(
FileFormat::from_path(Path::new("dump.vcd")),
Some(FileFormat::Vcd)
);
for name in ["lib.sdf", "lib.SD"] {
assert_eq!(
FileFormat::from_path(Path::new(name)),
Some(FileFormat::Sdf),
"{name}"
);
}
assert_eq!(
FileFormat::from_path(Path::new("config.json")),
Some(FileFormat::Json)
);
for name in [
"take.wav",
"take.BWF",
"mix.rf64",
"loop.aif",
"loop.aiff",
"x.aifc",
] {
assert_eq!(
FileFormat::from_path(Path::new(name)),
Some(FileFormat::Audio),
"{name}"
);
}
}
}
#[cfg(test)]
mod format_tests {
use super::{FileFormat, FormatChoice, ReadMode, RemoteRead, Stored, Summary};
#[test]
fn every_format_is_listed() {
fn listed(f: FileFormat) -> bool {
match f {
FileFormat::Parquet
| FileFormat::Csv
| FileFormat::Tsv
| FileFormat::Psv
| FileFormat::Json
| FileFormat::Jsonl
| FileFormat::Arrow
| FileFormat::Avro
| FileFormat::Orc
| FileFormat::Excel
| FileFormat::Safetensors
| FileFormat::Gguf
| FileFormat::Nmea
| FileFormat::Gpx
| FileFormat::Audio
| FileFormat::Midi
| FileFormat::Sqlite
| FileFormat::Vcd
| FileFormat::Fix
| FileFormat::Sdf
| FileFormat::Numpy
| FileFormat::Elf
| FileFormat::Ulog
| FileFormat::Dataflash
| FileFormat::Candump
| FileFormat::Text
| FileFormat::Journal => FileFormat::ALL.contains(&f),
}
}
for format in FileFormat::ALL {
assert!(listed(format), "{format:?}");
assert_eq!(FileFormat::from_name(format.name()), Some(format));
}
assert_eq!(
FileFormat::ALL.map(|f| f.name()),
[
"parquet",
"csv",
"tsv",
"psv",
"json",
"jsonl",
"arrow",
"avro",
"orc",
"excel",
"safetensors",
"gguf",
"nmea",
"gpx",
"audio",
"midi",
"sqlite",
"vcd",
"fix",
"sdf",
"numpy",
"elf",
"ulog",
"dataflash",
"candump",
"text",
"journal"
]
);
}
#[test]
fn read_mode_by_format_and_storage() {
use ReadMode::*;
let plain = |f: FileFormat| f.read_mode(Stored::Plain);
assert_eq!(plain(FileFormat::Parquet), Some(Lazy));
assert_eq!(plain(FileFormat::Csv), Some(Lazy));
assert_eq!(plain(FileFormat::Arrow), Some(Lazy));
assert_eq!(plain(FileFormat::Audio), Some(Lazy));
assert_eq!(plain(FileFormat::Sqlite), Some(Lazy));
assert_eq!(plain(FileFormat::Nmea), Some(Converted));
assert_eq!(plain(FileFormat::Gpx), Some(Converted));
for f in [
FileFormat::Json,
FileFormat::Jsonl,
FileFormat::Avro,
FileFormat::Orc,
FileFormat::Excel,
FileFormat::Safetensors,
FileFormat::Gguf,
FileFormat::Midi,
] {
assert_eq!(plain(f), Some(InMemory), "{}", f.name());
}
for f in FileFormat::ALL {
assert!(plain(f).is_some(), "every format opens: {}", f.name());
}
assert_eq!(FileFormat::Arrow.read_mode(Stored::Stream), Some(Converted));
assert_eq!(FileFormat::Parquet.read_mode(Stored::Stream), None);
let compressed = |f: FileFormat, in_memory| f.read_mode(Stored::Compressed { in_memory });
for f in [FileFormat::Csv, FileFormat::Tsv, FileFormat::Psv] {
assert_eq!(compressed(f, false), Some(Decompressed));
assert_eq!(compressed(f, true), Some(InMemory));
}
assert_eq!(compressed(FileFormat::Nmea, true), Some(Converted));
assert_eq!(compressed(FileFormat::Parquet, false), None);
assert_eq!(compressed(FileFormat::Json, false), None);
assert_eq!(compressed(FileFormat::Sqlite, false), None);
let spec = FormatChoice::Spec("acme.l2feed".into());
assert_eq!(spec.read_mode(Stored::Plain), Some(Lazy));
assert_eq!(
spec.read_mode(Stored::Compressed { in_memory: true }),
Some(Decompressed)
);
assert_eq!(spec.bucket_object(Stored::Plain), RemoteRead::Downloaded);
let model = |f: FileFormat| matches!(f, FileFormat::Safetensors | FileFormat::Gguf);
for f in FileFormat::ALL {
let in_place = f.bucket_object(Stored::Plain) == RemoteRead::InPlace;
assert_eq!(
in_place,
matches!(f, FileFormat::Parquet | FileFormat::Arrow) || model(f),
"{}",
f.name()
);
assert_eq!(
f.bucket_object(Stored::Compressed { in_memory: false }),
RemoteRead::Downloaded
);
assert_eq!(
f.http_file() == RemoteRead::InPlace,
model(f),
"{}",
f.name()
);
}
assert_eq!(spec.http_file(), RemoteRead::Downloaded);
let prefixes: Vec<_> = FileFormat::ALL
.into_iter()
.filter(|f| f.reads_bucket_prefix())
.collect();
assert_eq!(
prefixes,
[
FileFormat::Parquet,
FileFormat::Csv,
FileFormat::Jsonl,
FileFormat::Arrow,
FileFormat::Safetensors,
FileFormat::Gguf
]
);
assert_eq!(
FileFormat::Arrow.bucket_object(Stored::Stream),
RemoteRead::Downloaded
);
assert_eq!(
FileFormat::Arrow.bucket_prefix(Stored::Stream),
Some(RemoteRead::Downloaded)
);
}
#[test]
fn the_docs_tab_table_agrees_with_the_descriptors() {
let page = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/user-guide/dataset-info.md");
let text = std::fs::read_to_string(&page).expect("the dataset-info page");
let header = "| Format | Tab | Lists inside the file |";
let start = text.find(header).expect("the table of tabs");
let mut seen: Vec<FileFormat> = Vec::new();
for line in text[start..]
.lines()
.skip(2)
.take_while(|l| l.starts_with('|'))
{
let cells: Vec<&str> = line.trim_matches('|').split(" | ").map(str::trim).collect();
let [formats, tab, tables] = cells[..] else {
panic!("three cells: {line}");
};
for title in formats.split(", ") {
let title = title.trim_matches('*');
let format = FileFormat::ALL
.into_iter()
.find(|f| f.title().eq_ignore_ascii_case(title))
.unwrap_or_else(|| panic!("{title} is a format's title"));
seen.push(format);
let said = match format.descriptor().summary {
Summary::Tab(tab) => format!("**{tab}**"),
Summary::None(why) => {
assert!(text.contains(why), "{title}: the page says why: {why}");
"none".to_string()
}
};
assert_eq!(tab, said, "{title}: Tab");
let listed = format
.descriptor()
.tables
.as_ref()
.map_or("no", |_| "tables");
assert_eq!(tables, listed, "{title}: Lists inside the file");
}
}
for f in FileFormat::ALL {
assert!(seen.contains(&f), "{} has a row", f.title());
}
}
}