Skip to main content

datui_cli/
formats.rs

1//! The formats datui reads, one [`Descriptor`] each.
2//!
3//! A descriptor holds what is true of a format without a reader to ask: its name, the
4//! extensions that say it, how a file of it is read locally and remotely, whether it
5//! holds tables and which flag picks one, and what the loading screen says while it is
6//! converted. Everything that asks one of these questions asks here, so `--format`'s
7//! help, the docs' format table, the home screen and the open cannot disagree.
8//!
9//! What needs a reader (the bytes that say a format, the scan, the Info tab, Copy as
10//! Python, the export default) is `datui_lib::formats::readers`, keyed by the same
11//! [`FileFormat`]. Its docs list where a format is still named by the app because it
12//! changes what the app does: Parquet's partitions, Arrow's streams, SQLite's tables.
13//!
14//! Adding a format: a variant, its descriptor below, and its line in
15//! [`FileFormat::descriptor`] and [`FileFormat::ALL`]. The match is exhaustive, so a
16//! variant without a descriptor does not compile; `every_format_is_listed` catches a
17//! variant left out of `ALL`.
18
19use std::path::Path;
20
21/// A format datui reads, as `--format` names it.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum FileFormat {
24    Parquet,
25    Csv,
26    Tsv,
27    Psv,
28    Json,
29    Jsonl,
30    Arrow,
31    Avro,
32    Orc,
33    Excel,
34    Safetensors,
35    Gguf,
36    Nmea,
37    Gpx,
38    Audio,
39    Midi,
40    Sqlite,
41    Vcd,
42    Fix,
43    Sdf,
44    Numpy,
45    Elf,
46    Ulog,
47    Dataflash,
48    Candump,
49    Text,
50    Journal,
51}
52
53/// What datui knows of a format without reading a file of it.
54#[derive(Debug)]
55pub struct Descriptor {
56    /// The name `--format` takes and the home screen counts (`12 parquet`). Lowercase
57    /// and singular, one per format rather than per extension.
58    pub name: &'static str,
59    /// What the format is called in a sentence: `SQLite`, `Arrow IPC`.
60    pub title: &'static str,
61    /// The extensions that say it, lowercase and without the dot. The first is the one
62    /// a glob over a directory of it names.
63    pub extensions: &'static [&'static str],
64    /// Whole-name endings that say it before the extension does:
65    /// `model.safetensors.index.json` is SafeTensors, read as the shards it names.
66    pub name_endings: &'static [&'static str],
67    /// How a file of it on disk, as its extension says, is read.
68    pub read: ReadMode,
69    /// How a compressed file of it is read.
70    pub compressed: Compressed,
71    /// How an Arrow IPC stream of it is read, for the one format that has streams.
72    pub stream: Option<ReadMode>,
73    /// How one HTTP(S) file of it is read.
74    pub http: RemoteRead,
75    /// How one object of it in a bucket is read.
76    pub bucket_object: RemoteRead,
77    /// How a bucket prefix or glob of it is read as one table, or `None` when it is
78    /// browsed into and opened an object at a time.
79    pub bucket_prefix: Option<RemoteRead>,
80    /// Whether many files of it are read as one table.
81    pub many_files: bool,
82    /// How a line of it is read, for text read a line at a time: what `--follow` reads
83    /// as it grows.
84    pub lines: Option<Lines>,
85    /// The tables a file of it can hold, and how one is picked. The home screen lists
86    /// them as places inside the file (`shop.db/orders`, `run.npz/weights`) that
87    /// recents record and `--table` names.
88    pub tables: Option<Tables>,
89    /// What the Info panel shows of a file of it besides the Schema tab.
90    pub summary: Summary,
91    /// Whether a file of it declares its columns' types, which the Schema tab then
92    /// calls known rather than inferred.
93    pub declares_types: bool,
94    /// What the loading screen and the footer say while a file of it is read into
95    /// files of its own before it is scanned.
96    pub conversion: Option<Conversion>,
97}
98
99/// How a line of a line-oriented text format is read.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum Lines {
102    /// Split into columns by this separator, when `--delimiter` does not say.
103    Delimited(u8),
104    /// One JSON object.
105    Json,
106    /// The line itself, blank or not.
107    Text,
108}
109
110/// How a compressed file of a format is read.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum Compressed {
113    /// It is not: the open refuses it.
114    Refused,
115    /// Decompressed once, to a file or with `[file_loading] decompress_in_memory` into memory.
116    Decompressed,
117    /// Decompressed as it is read into the files it is converted to.
118    ReadThrough,
119}
120
121/// What the Info panel shows of a file of a format besides its Schema tab.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Summary {
124    /// A tab of its own, so named (`SQLite`, `Model`), which the format's reader fills
125    /// from what the open read.
126    Tab(&'static str),
127    /// No tab, and why: the Schema tab is all the file says.
128    None(&'static str),
129}
130
131/// Why a text format has no tab of its own.
132const TEXT_ONLY: Summary = Summary::None("text holds its rows and nothing else");
133
134/// The tables in a file of a format.
135#[derive(Debug)]
136pub struct Tables {
137    /// The table a file of several opens when none is named, as the home screen says
138    /// it (`its symbols`, `its first table`): Enter there opens it and → lists them
139    /// all. `None`: Enter lists them.
140    pub opens: Option<&'static str>,
141    /// Whether the home screen lists them by name rather than in the file's order.
142    pub by_name: bool,
143    /// What the flag takes for this format, for its help: `a table or view by name`.
144    pub help: &'static str,
145}
146
147/// What the open says while a file is read into files of its own.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct Conversion {
150    /// The loading screen's line: `Reading FIX log`.
151    pub label: &'static str,
152    /// The footer's, shorter: `Reading FIX log...`.
153    pub status: &'static str,
154}
155
156/// The fields most formats share, spread into each descriptor: a plain file read
157/// whole into memory, downloaded when remote, one file at a time, holding one table.
158const BASE: Descriptor = Descriptor {
159    name: "",
160    title: "",
161    extensions: &[],
162    name_endings: &[],
163    read: ReadMode::InMemory,
164    compressed: Compressed::Refused,
165    stream: None,
166    http: RemoteRead::Downloaded,
167    bucket_object: RemoteRead::Downloaded,
168    bucket_prefix: None,
169    many_files: false,
170    lines: None,
171    tables: None,
172    summary: TEXT_ONLY,
173    declares_types: false,
174    conversion: None,
175};
176
177/// A delimited text format: scanned in place, decompressed once when compressed, and
178/// followed as it grows.
179const DELIMITED: Descriptor = Descriptor {
180    read: ReadMode::Lazy,
181    compressed: Compressed::Decompressed,
182    lines: Some(Lines::Delimited(b',')),
183    ..BASE
184};
185
186/// A text format that cannot be scanned where it is: read once, through its
187/// compression, into files of its own.
188const READ_INTO: Descriptor = Descriptor {
189    read: ReadMode::Converted,
190    compressed: Compressed::ReadThrough,
191    ..BASE
192};
193
194/// A model file's tensor list: one row per tensor, from the header, which a remote
195/// file serves by range.
196const MODEL: Descriptor = Descriptor {
197    http: RemoteRead::InPlace,
198    bucket_object: RemoteRead::InPlace,
199    bucket_prefix: Some(RemoteRead::InPlace),
200    many_files: true,
201    ..BASE
202};
203
204const PARQUET: Descriptor = Descriptor {
205    name: "parquet",
206    title: "Parquet",
207    extensions: &["parquet"],
208    read: ReadMode::Lazy,
209    // Polars reads an object by its footer.
210    bucket_object: RemoteRead::InPlace,
211    bucket_prefix: Some(RemoteRead::InPlace),
212    many_files: true,
213    declares_types: true,
214    summary: Summary::Tab("Parquet"),
215    ..BASE
216};
217
218const CSV: Descriptor = Descriptor {
219    name: "csv",
220    title: "CSV",
221    extensions: &["csv"],
222    bucket_prefix: Some(RemoteRead::InPlace),
223    many_files: true,
224    ..DELIMITED
225};
226
227// TSV and PSV have a single-file reader and no multi-path one.
228const TSV: Descriptor = Descriptor {
229    name: "tsv",
230    title: "TSV",
231    extensions: &["tsv"],
232    lines: Some(Lines::Delimited(b'\t')),
233    ..DELIMITED
234};
235
236const PSV: Descriptor = Descriptor {
237    name: "psv",
238    title: "PSV",
239    extensions: &["psv"],
240    lines: Some(Lines::Delimited(b'|')),
241    ..DELIMITED
242};
243
244const JSON: Descriptor = Descriptor {
245    name: "json",
246    title: "JSON",
247    extensions: &["json"],
248    many_files: true,
249    ..BASE
250};
251
252const JSONL: Descriptor = Descriptor {
253    name: "jsonl",
254    title: "NDJSON",
255    extensions: &["jsonl", "ndjson"],
256    bucket_prefix: Some(RemoteRead::InPlace),
257    many_files: true,
258    lines: Some(Lines::Json),
259    ..BASE
260};
261
262const ARROW: Descriptor = Descriptor {
263    name: "arrow",
264    title: "Arrow IPC",
265    extensions: &["arrow", "arrows", "ipc", "feather"],
266    read: ReadMode::Lazy,
267    // A stream has no footer: it is converted to an IPC file, and downloaded first.
268    stream: Some(ReadMode::Converted),
269    bucket_object: RemoteRead::InPlace,
270    bucket_prefix: Some(RemoteRead::InPlace),
271    many_files: true,
272    declares_types: true,
273    summary: Summary::Tab("Arrow"),
274    ..BASE
275};
276
277const AVRO: Descriptor = Descriptor {
278    name: "avro",
279    title: "Avro",
280    extensions: &["avro"],
281    many_files: true,
282    declares_types: true,
283    summary: Summary::Tab("Avro"),
284    ..BASE
285};
286
287const ORC: Descriptor = Descriptor {
288    name: "orc",
289    title: "ORC",
290    extensions: &["orc"],
291    many_files: true,
292    declares_types: true,
293    summary: Summary::Tab("ORC"),
294    ..BASE
295};
296
297// A workbook is sheets rather than rows, with nothing to concatenate.
298const EXCEL: Descriptor = Descriptor {
299    name: "excel",
300    title: "Excel",
301    extensions: &["xls", "xlsx", "xlsm", "xlsb"],
302    tables: Some(Tables {
303        opens: Some("its first table"),
304        by_name: false,
305        help: "a worksheet by name, or by 0-based index when no worksheet is so named",
306    }),
307    summary: Summary::Tab("Excel"),
308    ..BASE
309};
310
311const SAFETENSORS: Descriptor = Descriptor {
312    name: "safetensors",
313    title: "SafeTensors",
314    extensions: &["safetensors"],
315    name_endings: &[".safetensors.index.json"],
316    summary: Summary::Tab("Model"),
317    ..MODEL
318};
319
320const GGUF: Descriptor = Descriptor {
321    name: "gguf",
322    title: "GGUF",
323    extensions: &["gguf"],
324    summary: Summary::Tab("Model"),
325    ..MODEL
326};
327
328const NMEA: Descriptor = Descriptor {
329    name: "nmea",
330    title: "NMEA",
331    extensions: &["nmea"],
332    many_files: true,
333    tables: Some(Tables {
334        opens: Some("its fixes"),
335        by_name: false,
336        help: "fixes (default), GGA, RMC, VTG, GSA, GSV, GLL, ZDA or sentences",
337    }),
338    conversion: Some(Conversion {
339        label: "Reading NMEA log",
340        status: "Reading NMEA...",
341    }),
342    summary: Summary::Tab("GPS"),
343    ..READ_INTO
344};
345
346const GPX: Descriptor = Descriptor {
347    name: "gpx",
348    title: "GPX",
349    extensions: &["gpx"],
350    many_files: true,
351    conversion: Some(Conversion {
352        label: "Reading GPX file",
353        status: "Reading GPX...",
354    }),
355    summary: Summary::Tab("GPS"),
356    ..READ_INTO
357};
358
359// Recordings, each with its own channels and rate, not parts of one table. Frames are
360// read from the file where they are shown.
361const AUDIO: Descriptor = Descriptor {
362    name: "audio",
363    title: "audio",
364    extensions: &["wav", "wave", "bwf", "rf64", "aif", "aiff", "aifc"],
365    read: ReadMode::Lazy,
366    summary: Summary::Tab("Audio"),
367    ..BASE
368};
369
370// Decoded whole, and refused over 64 MiB.
371const MIDI: Descriptor = Descriptor {
372    name: "midi",
373    title: "MIDI",
374    extensions: &["mid", "midi", "smf", "kar", "rmi"],
375    many_files: true,
376    summary: Summary::Tab("MIDI"),
377    ..BASE
378};
379
380// Read in place, a page at a time, with sort and filters run in SQLite.
381const SQLITE: Descriptor = Descriptor {
382    name: "sqlite",
383    title: "SQLite",
384    extensions: &["db", "db3", "sqlite", "sqlite3"],
385    read: ReadMode::Lazy,
386    tables: Some(Tables {
387        opens: None,
388        by_name: true,
389        help: "a table or view by name",
390    }),
391    summary: Summary::Tab("SQLite"),
392    ..BASE
393};
394
395const VCD: Descriptor = Descriptor {
396    name: "vcd",
397    title: "VCD",
398    extensions: &["vcd"],
399    conversion: Some(Conversion {
400        label: "Reading value change dump",
401        status: "Reading VCD...",
402    }),
403    summary: Summary::Tab("VCD"),
404    ..READ_INTO
405};
406
407// No extension of its own: known by its first bytes.
408const FIX: Descriptor = Descriptor {
409    name: "fix",
410    title: "FIX",
411    conversion: Some(Conversion {
412        label: "Reading FIX log",
413        status: "Reading FIX log...",
414    }),
415    summary: Summary::Tab("FIX"),
416    ..READ_INTO
417};
418
419const SDF: Descriptor = Descriptor {
420    name: "sdf",
421    title: "SDF",
422    extensions: &["sdf", "sd"],
423    conversion: Some(Conversion {
424        label: "Reading SDF records",
425        status: "Reading SDF...",
426    }),
427    summary: Summary::Tab("SDF"),
428    ..READ_INTO
429};
430
431// Decoded from a map of the file where it is shown; a compressed member of an archive
432// is decompressed once to a file first.
433const NUMPY: Descriptor = Descriptor {
434    name: "numpy",
435    title: "NumPy",
436    extensions: &["npy", "npz"],
437    read: ReadMode::Lazy,
438    tables: Some(Tables {
439        opens: None,
440        by_name: false,
441        help: "an array of an archive (.npz) by name",
442    }),
443    conversion: Some(Conversion {
444        label: "Decompressing NumPy array",
445        status: "Decompressing...",
446    }),
447    summary: Summary::Tab("NumPy"),
448    ..BASE
449};
450
451// The symbol table is read into memory.
452const ELF: Descriptor = Descriptor {
453    name: "elf",
454    title: "ELF",
455    extensions: &["elf", "axf"],
456    tables: Some(Tables {
457        opens: Some("its symbols"),
458        by_name: false,
459        help: "symbols (default) or sections",
460    }),
461    summary: Summary::Tab("ELF"),
462    ..BASE
463};
464
465// A flight log is indexed in one pass, and each table decoded from a map of the file
466// where it is shown.
467const ULOG: Descriptor = Descriptor {
468    name: "ulog",
469    title: "ULog",
470    extensions: &["ulg"],
471    read: ReadMode::Lazy,
472    tables: Some(Tables {
473        opens: None,
474        by_name: false,
475        help: "a topic",
476    }),
477    summary: Summary::Tab("ULog"),
478    ..BASE
479};
480
481// `.bin`, which says nothing: known by its first bytes.
482const DATAFLASH: Descriptor = Descriptor {
483    name: "dataflash",
484    title: "DataFlash",
485    read: ReadMode::Lazy,
486    tables: Some(Tables {
487        opens: None,
488        by_name: false,
489        help: "a message type",
490    }),
491    summary: Summary::Tab("DataFlash"),
492    ..BASE
493};
494
495// Known by its lines.
496const CANDUMP: Descriptor = Descriptor {
497    name: "candump",
498    title: "candump",
499    read: ReadMode::Lazy,
500    tables: Some(Tables {
501        opens: None,
502        by_name: false,
503        help: "frames (default), signals, or a message a dictionary names",
504    }),
505    summary: Summary::Tab("CAN"),
506    ..BASE
507};
508
509// Text read as it stands: a row per line. Indexed in one pass and read from a map of
510// the file where it is shown; what nothing else claims.
511const TEXT: Descriptor = Descriptor {
512    name: "text",
513    title: "text",
514    extensions: &["log", "txt"],
515    read: ReadMode::Lazy,
516    compressed: Compressed::Decompressed,
517    many_files: true,
518    lines: Some(Lines::Text),
519    ..BASE
520};
521
522// `journalctl -o json`: NDJSON known by its first record's keys, with time, level
523// and readable messages derived.
524const JOURNAL: Descriptor = Descriptor {
525    name: "journal",
526    title: "systemd journal",
527    many_files: true,
528    lines: Some(Lines::Json),
529    summary: Summary::Tab("Journal"),
530    ..BASE
531};
532
533impl FileFormat {
534    /// Every format, for the places that have to consider all of them, in the order
535    /// `--format`'s help lists them.
536    ///
537    /// Written out, and so able to fall behind the enum. `every_format_is_listed`
538    /// matches a variant exhaustively, so adding one stops that test compiling. What a
539    /// format missing here would cost is bounded: `from_name` answers `None` for it,
540    /// and every caller reads `None` as "not Parquet", which leaves counts off a
541    /// directory rather than giving it another format's.
542    pub const ALL: [Self; 27] = [
543        Self::Parquet,
544        Self::Csv,
545        Self::Tsv,
546        Self::Psv,
547        Self::Json,
548        Self::Jsonl,
549        Self::Arrow,
550        Self::Avro,
551        Self::Orc,
552        Self::Excel,
553        Self::Safetensors,
554        Self::Gguf,
555        Self::Nmea,
556        Self::Gpx,
557        Self::Audio,
558        Self::Midi,
559        Self::Sqlite,
560        Self::Vcd,
561        Self::Fix,
562        Self::Sdf,
563        Self::Numpy,
564        Self::Elf,
565        Self::Ulog,
566        Self::Dataflash,
567        Self::Candump,
568        Self::Text,
569        Self::Journal,
570    ];
571
572    /// What text with nothing else to say is read as: a pipe, a followed file, a
573    /// compressed file whose format is not named, when nothing in it says more.
574    pub const TEXT: Self = Self::Text;
575
576    /// The format's descriptor.
577    pub const fn descriptor(self) -> &'static Descriptor {
578        match self {
579            Self::Parquet => &PARQUET,
580            Self::Csv => &CSV,
581            Self::Tsv => &TSV,
582            Self::Psv => &PSV,
583            Self::Json => &JSON,
584            Self::Jsonl => &JSONL,
585            Self::Arrow => &ARROW,
586            Self::Avro => &AVRO,
587            Self::Orc => &ORC,
588            Self::Excel => &EXCEL,
589            Self::Safetensors => &SAFETENSORS,
590            Self::Gguf => &GGUF,
591            Self::Nmea => &NMEA,
592            Self::Gpx => &GPX,
593            Self::Audio => &AUDIO,
594            Self::Midi => &MIDI,
595            Self::Sqlite => &SQLITE,
596            Self::Vcd => &VCD,
597            Self::Fix => &FIX,
598            Self::Sdf => &SDF,
599            Self::Numpy => &NUMPY,
600            Self::Elf => &ELF,
601            Self::Ulog => &ULOG,
602            Self::Dataflash => &DATAFLASH,
603            Self::Candump => &CANDUMP,
604            Self::Text => &TEXT,
605            Self::Journal => &JOURNAL,
606        }
607    }
608
609    /// The format `path`'s name says: a name ending a descriptor lists
610    /// (`model.safetensors.index.json`) first, then its extension. `None` for a name that
611    /// says none.
612    pub fn from_path(path: &Path) -> Option<Self> {
613        Self::from_name_ending(path).or_else(|| {
614            path.extension()
615                .and_then(|e| e.to_str())
616                .and_then(Self::from_extension)
617        })
618    }
619
620    /// The format a whole-name ending says (`model.safetensors.index.json`), before
621    /// the extension does.
622    pub fn from_name_ending(path: &Path) -> Option<Self> {
623        let name = path.file_name()?.to_str()?.to_ascii_lowercase();
624        Self::ALL.into_iter().find(|f| {
625            f.descriptor()
626                .name_endings
627                .iter()
628                .any(|end| name.ends_with(end))
629        })
630    }
631
632    /// The format's name, as a row on the home screen says it: `12 parquet`, `3 csv`.
633    ///
634    /// The dataset cache stores it (`Holds`), so renaming one makes the records already
635    /// on disk unreadable.
636    pub fn name(self) -> &'static str {
637        self.descriptor().name
638    }
639
640    /// What the format is called in a sentence.
641    pub fn title(self) -> &'static str {
642        self.descriptor().title
643    }
644
645    /// The format a [`FileFormat::name`] names, for a name that was stored rather than
646    /// carried. The inverse of that method, and the only way back: a name is not an
647    /// extension, so `from_extension` cannot read one.
648    pub fn from_name(name: &str) -> Option<Self> {
649        Self::ALL.into_iter().find(|f| f.name() == name)
650    }
651
652    /// Whether many files of this format can be read as one table.
653    ///
654    /// Asked before a directory is offered as a dataset, so the home screen cannot
655    /// promise an open the reader has no route for.
656    pub fn reads_many_files(self) -> bool {
657        self.descriptor().many_files
658    }
659
660    /// Whether a file of this format can hold several tables, each with a path inside
661    /// it (`shop.db/orders`, `run.npz/weights`) that the home screen lists like a
662    /// directory's files and `--table` names.
663    pub fn holds_tables(self) -> bool {
664        self.descriptor().tables.is_some()
665    }
666
667    /// Whether a file of this format holding several tables opens one when none is
668    /// named. See [`Tables::opens`].
669    pub fn opens_one_table(self) -> bool {
670        self.descriptor()
671            .tables
672            .as_ref()
673            .is_some_and(|t| t.opens.is_some())
674    }
675
676    /// The name of the format's tab of the Info panel, when it has one.
677    pub fn summary_tab(self) -> Option<&'static str> {
678        match self.descriptor().summary {
679            Summary::Tab(tab) => Some(tab),
680            Summary::None(_) => None,
681        }
682    }
683
684    /// Whether `--table` picks one of the tables of a file of this format.
685    pub fn takes_table(self) -> bool {
686        self.descriptor().tables.is_some()
687    }
688
689    /// Whether a file of this format is read into files of its own before it is
690    /// scanned: a GPS log, VCD dump, FIX log or SDF file.
691    pub fn reads_into(self) -> bool {
692        self.descriptor().compressed == Compressed::ReadThrough
693    }
694
695    /// How a file of this format is read when it is opened, as `stored` on disk.
696    ///
697    /// The one answer to "does this read only what it shows": the format table in
698    /// `docs/formats/index.md`, the home screen's marker and the Info panel's `Read:`
699    /// line all ask here. `None` when a file stored that way does not open: a compressed
700    /// Parquet file, or an IPC stream of anything but Arrow.
701    pub fn read_mode(self, stored: Stored) -> Option<ReadMode> {
702        let d = self.descriptor();
703        match stored {
704            Stored::Plain => Some(d.read),
705            Stored::Stream => d.stream,
706            Stored::Compressed { in_memory } => match d.compressed {
707                Compressed::Refused => None,
708                Compressed::ReadThrough => Some(ReadMode::Converted),
709                Compressed::Decompressed if in_memory => Some(ReadMode::InMemory),
710                Compressed::Decompressed => Some(ReadMode::Decompressed),
711            },
712        }
713    }
714
715    /// How one object of this format in a bucket (S3, GCS, Azure), as `stored`, is
716    /// read: in place with ranged reads, or downloaded first and then read as
717    /// [`Self::read_mode`] says. Only a plain file is read in place.
718    pub fn bucket_object(self, stored: Stored) -> RemoteRead {
719        match stored {
720            Stored::Plain => self.descriptor().bucket_object,
721            _ => RemoteRead::Downloaded,
722        }
723    }
724
725    /// How one HTTP(S) file of this format is read. A server that sends no ranges gets
726    /// the download question.
727    pub fn http_file(self) -> RemoteRead {
728        self.descriptor().http
729    }
730
731    /// How a bucket prefix or glob of files of this format, as `stored`, is read as
732    /// one table: in place, downloaded first, or `None` when it is not (browsed into
733    /// and opened an object at a time). A prefix of streams is converted as it
734    /// downloads.
735    pub fn bucket_prefix(self, stored: Stored) -> Option<RemoteRead> {
736        let d = self.descriptor();
737        match stored {
738            Stored::Plain => d.bucket_prefix,
739            Stored::Stream => d.stream.map(|_| RemoteRead::Downloaded),
740            Stored::Compressed { .. } => None,
741        }
742    }
743
744    /// Whether a bucket prefix or glob of this format is scanned in place as one table.
745    /// See [`Self::bucket_prefix`].
746    pub fn reads_bucket_prefix(self) -> bool {
747        self.bucket_prefix(Stored::Plain) == Some(RemoteRead::InPlace)
748    }
749
750    /// The column separator a delimited format is read with when `--delimiter` is not
751    /// given. `None` for the formats that are not delimited text.
752    pub fn separator(self) -> Option<u8> {
753        match self.descriptor().lines {
754            Some(Lines::Delimited(separator)) => Some(separator),
755            _ => None,
756        }
757    }
758
759    /// Whether `--follow` reads a file of this format as it grows: text read a line
760    /// at a time.
761    pub fn follows(self) -> bool {
762        self.descriptor().lines.is_some()
763    }
764
765    /// Whether a file of this format is read a line a row, as it stands.
766    pub fn is_lines(self) -> bool {
767        self.descriptor().lines == Some(Lines::Text)
768    }
769
770    /// Whether a compressed file of this format is decompressed once, to a file or
771    /// into memory, before it is read: delimited text and lines.
772    pub fn decompressed_once(self) -> bool {
773        self.descriptor().compressed == Compressed::Decompressed
774    }
775
776    /// What the open says while a file of this format is read into files of its own.
777    /// A format with nothing of its own to say says it converts.
778    pub fn conversion(self) -> Conversion {
779        self.descriptor().conversion.unwrap_or(Conversion {
780            label: "Converting",
781            status: "Converting...",
782        })
783    }
784
785    /// The format an extension says (`parquet`, `csv`).
786    ///
787    /// The one place an extension becomes a format. Everything that asks whether a name
788    /// is data — the home screen, the search, `~` path input, the CLI and the cloud
789    /// listings — asks here, so no route can offer a file another route cannot open.
790    ///
791    /// `.txt` and `.log` say text, read a line a row. A directory ranks text below
792    /// every other format, so a README beside Parquet files does not decide what the
793    /// directory is. A tabular `.txt` opens with `--format csv`.
794    pub fn from_extension(ext: &str) -> Option<Self> {
795        let ext = ext.to_ascii_lowercase();
796        Self::ALL
797            .into_iter()
798            .find(|f| f.descriptor().extensions.contains(&ext.as_str()))
799    }
800}
801
802/// `--format`'s help, listing every format by name.
803pub fn format_help() -> String {
804    let names: Vec<&str> = FileFormat::ALL.iter().map(|f| f.name()).collect();
805    format!(
806        "File format, when the extension does not say: {}; or a format spec: its name (`datui formats` lists them), its file (a path with a / or ending .toml), or its http(s), s3, gs or az URL (at most 1 MiB)",
807        names.join(", ")
808    )
809}
810
811/// `--table`'s help: what it takes for each format whose tables it picks.
812pub fn table_help() -> String {
813    let mut help = String::from("Table to open from a file that holds several.");
814    for format in FileFormat::ALL {
815        if let Some(tables) = &format.descriptor().tables {
816            help.push_str(&format!(" {}: {}.", format.title(), tables.help));
817        }
818    }
819    help.push_str(" Hugging Face cache and DatasetDict directories: a split (default train)");
820    help
821}
822
823/// Why `--table` was refused for a file of `format` (`None`: not known), which holds
824/// one table: the formats whose tables it picks. The reader names the file before it.
825pub fn one_table(format: Option<FileFormat>) -> String {
826    let what = format.map_or("This file".to_string(), |f| format!("A {} file", f.name()));
827    let titles: Vec<&str> = FileFormat::ALL
828        .into_iter()
829        .filter(|f| f.takes_table())
830        .map(FileFormat::title)
831        .collect();
832    format!(
833        "{what} holds one table. --table picks one from {} files, or a Hugging Face dataset's split.",
834        titles.join(", ")
835    )
836}
837
838/// How an open reads a file. See [`FileFormat::read_mode`].
839#[derive(Debug, Clone, Copy, PartialEq, Eq)]
840pub enum ReadMode {
841    /// Scanned where it is: only the rows shown, and what a query needs, are read.
842    Lazy,
843    /// Decompressed whole into a temporary file of the same format, which is then
844    /// scanned lazily.
845    Decompressed,
846    /// Read through whole into a temporary Arrow IPC file, which is then scanned lazily.
847    Converted,
848    /// Read whole into memory before the table appears.
849    InMemory,
850}
851
852impl ReadMode {
853    /// The words for it, as the docs' table and the Info panel say them.
854    pub fn label(self) -> &'static str {
855        match self {
856            Self::Lazy => "lazy scan",
857            Self::Decompressed => "decompressed copy",
858            Self::Converted => "converted to Arrow",
859            Self::InMemory => "in memory",
860        }
861    }
862
863    /// The word a home screen row carries for it, short beside a name: `decompresses`,
864    /// `converts`, `in memory`; `None` for a lazy read, which most are.
865    pub fn marker(self) -> Option<&'static str> {
866        match self {
867            Self::Lazy => None,
868            Self::Decompressed => Some("decompresses"),
869            Self::Converted => Some("converts"),
870            Self::InMemory => Some("in memory"),
871        }
872    }
873}
874
875/// How a file sits on disk, where that changes how it is read.
876#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
877pub enum Stored {
878    /// As its format's extension says.
879    #[default]
880    Plain,
881    /// Under gzip, zstd, bzip2 or xz. `in_memory` is `[file_loading] decompress_in_memory`.
882    Compressed { in_memory: bool },
883    /// An Arrow IPC stream rather than an IPC file: no footer to find the rows by.
884    Stream,
885}
886
887/// How a remote object is read.
888#[derive(Debug, Clone, Copy, PartialEq, Eq)]
889pub enum RemoteRead {
890    /// With ranged reads, where it is.
891    InPlace,
892    /// Downloaded whole to a temporary file first.
893    Downloaded,
894}
895
896impl RemoteRead {
897    /// The words for it, as the docs' table says them.
898    pub fn label(self) -> &'static str {
899        match self {
900            Self::InPlace => "in place",
901            Self::Downloaded => "downloaded",
902        }
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909
910    /// An extension or a name says one format, or the first to list it would always
911    /// win and the other never be found by it.
912    #[test]
913    fn names_and_extensions_say_one_format() {
914        let mut seen: Vec<&str> = Vec::new();
915        for format in FileFormat::ALL {
916            let d = format.descriptor();
917            assert!(!d.title.is_empty(), "{format:?}");
918            for ext in d.extensions {
919                assert!(!seen.contains(ext), ".{ext} names two formats");
920                assert_eq!(*ext, ext.to_ascii_lowercase());
921                seen.push(ext);
922                assert_eq!(FileFormat::from_extension(ext), Some(format));
923            }
924        }
925        let names: Vec<&str> = FileFormat::ALL.map(FileFormat::name).to_vec();
926        for name in &names {
927            assert_eq!(names.iter().filter(|n| *n == name).count(), 1, "{name}");
928        }
929    }
930
931    /// `--format` lists every format; `--table` names every format whose tables it
932    /// picks, and refusing it names them too.
933    #[test]
934    fn help_and_refusals_come_from_the_descriptors() {
935        let format = format_help();
936        let table = table_help();
937        let refused = one_table(Some(FileFormat::Csv));
938        for f in FileFormat::ALL {
939            assert!(format.contains(f.name()), "{}", f.name());
940            let picked = format!("{}: ", f.title());
941            assert_eq!(table.contains(&picked), f.takes_table(), "{}", f.name());
942            if f.takes_table() {
943                assert!(refused.contains(f.title()), "{refused}");
944            }
945        }
946        assert!(
947            refused.starts_with("A csv file holds one table"),
948            "{refused}"
949        );
950        assert!(one_table(None).starts_with("This file holds one table"));
951        assert!(FileFormat::Excel.takes_table(), "a sheet is a table");
952        // A spec named in help would have to ship; none does.
953        assert!(!format.contains("acme"), "{format}");
954    }
955
956    /// Every format read into files of its own says so in words of its own: none falls
957    /// back to another's.
958    #[test]
959    fn conversions_say_what_they_read() {
960        let mut seen: Vec<Conversion> = Vec::new();
961        for f in FileFormat::ALL.into_iter().filter(|f| f.reads_into()) {
962            let c = f.descriptor().conversion.expect("a conversion's words");
963            assert!(c.status.ends_with("..."), "{}", c.status);
964            assert!(
965                !seen
966                    .iter()
967                    .any(|s| s.label == c.label || s.status == c.status),
968                "{c:?}"
969            );
970            seen.push(c);
971        }
972    }
973}