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