Skip to main content

datui_lib/formats/readers/
mod.rs

1//! The readers: what datui does with a file of each format.
2//!
3//! A format's descriptor ([`crate::FileFormat::descriptor`], in datui-cli) says what is
4//! true of it without a file. Its `Reader` holds the code: signature bytes, the scan,
5//! conversion to files of its own, listed tables, the home preview, Copy as Python's
6//! call, the default export format, and Info tab facts (from the scan, or `Reader::facts`
7//! reading a footer). Readers live beside their parsers (`crate::formats::sqlite::READER`); those
8//! Polars reads are in `polars`. `of` maps every format exhaustively, so a missing
9//! reader does not compile. Adding a format: variant and descriptor in datui-cli, parser
10//! and reader in a module, a line in `of`.
11//!
12//! Outside those, a format is named only where it changes app behavior:
13//!
14//! - Parquet: hive partitions and footers (directories and globs as one scan, part files
15//!   by directory name, footer row counts, object-store directories read as Parquet only).
16//! - Arrow IPC: streams are converted to a file first (`Scan::Streams`,
17//!   `Conversion::Streams`); Hugging Face caches and DatasetDicts, a split at a time.
18//! - JSON: Hugging Face metadata and model configs are not data left out.
19//! - SafeTensors and GGUF: a directory of weights is the model; remote models read by
20//!   headers (`crate::cloud::remote_model`).
21//! - CSV: the reader delimited specs read through.
22//! - Text, and unnamed CSV, TSV, JSON and NDJSON: told apart by [`crate::formats::lines::guess`],
23//!   not `sniff`; a text name still has its bytes asked ([`FileFormat::TEXT`]); followed
24//!   lines are counted by the watcher.
25//! - Audio: a full quality run checks the signal (`crate::formats::audio::recording`).
26
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::sync::atomic::AtomicU64;
30
31use color_eyre::Result;
32use color_eyre::eyre::eyre;
33
34use crate::export::export_modal::ExportFormat;
35use crate::formats::members::Table;
36use crate::formats::segments::Converted;
37use crate::formats::text_formats::Detail;
38use crate::loading::scan::Scan;
39use crate::loading::unfinished::Writer;
40use crate::{FileFormat, OpenOptions, ReadReport};
41
42pub(crate) mod csv;
43pub(crate) mod facts;
44pub mod hive;
45pub(crate) mod polars;
46#[cfg(test)]
47mod tests;
48
49/// What a reader made of a file: its frame, and what the read did to its rows.
50#[derive(Default)]
51pub(crate) struct Read {
52    pub(crate) lf: ::polars::prelude::LazyFrame,
53    /// What the read did to the rows, as Python method calls: names trimmed, text
54    /// columns typed.
55    pub(crate) python: Vec<String>,
56    /// What the read of several files has to say of them: files passed over, columns
57    /// not every file has.
58    pub(crate) notes: Vec<crate::notes::Note>,
59    /// Each column's unit, from the first of several files read through a spec that
60    /// has the column; `None` when the first file's header said them all.
61    pub(crate) units: Option<Vec<(String, String)>>,
62    /// The columns the read gave a type, and the frame before it did.
63    pub(crate) typing: Typing,
64    /// The decompressed copy the frame scans, removed when the last holder lets go.
65    pub(crate) temp: Option<Arc<csv::Decompressed>>,
66}
67
68impl From<::polars::prelude::LazyFrame> for Read {
69    fn from(lf: ::polars::prelude::LazyFrame) -> Self {
70        Self {
71            lf,
72            ..Default::default()
73        }
74    }
75}
76
77impl Read {
78    /// The read's typing: its notes go with the read's, its columns are counted later.
79    pub(crate) fn typed(mut self, mut typing: Typing) -> Self {
80        self.notes.append(&mut typing.notes);
81        self.typing = typing;
82        self
83    }
84}
85
86/// The columns a read gave a type, the frame before it did, and its notes.
87#[derive(Clone, Default)]
88pub struct Typing {
89    pub(crate) source: Option<::polars::prelude::LazyFrame>,
90    pub(crate) typed: Vec<crate::formats::column_types::Typed>,
91    pub(crate) notes: Vec<crate::notes::Note>,
92    /// The columns the scan read as text, by the names it read them under, for Copy
93    /// as Python's `schema_overrides`.
94    pub(crate) text: Vec<String>,
95}
96
97impl std::fmt::Debug for Typing {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("Typing")
100            .field("typed", &self.typed)
101            .field("notes", &self.notes)
102            .finish_non_exhaustive()
103    }
104}
105
106/// Lists the tables of a file of a format.
107pub(crate) type ListTables = fn(&Path) -> Result<Vec<Table>>;
108
109/// What the Info panel's worker reads of one local file of a format besides its size,
110/// where the open left it to Polars: a Parquet footer.
111pub(crate) type FactsFn = fn(&Path) -> Result<FormatFacts>;
112
113/// A format's facts read. See [`Reader::facts`].
114#[derive(Clone, Copy)]
115pub(crate) struct Facts {
116    pub read: FactsFn,
117    /// Whether it reads a footer that gives the Schema tab's Compression column, which
118    /// keeps its room while the read is out.
119    pub footer: bool,
120}
121
122/// What a `FactsFn` read: the format's tab of the Info panel, and the footer the
123/// Schema tab's Compression column is drawn from.
124#[derive(Debug, Clone, Default)]
125pub struct FormatFacts {
126    pub detail: Option<Arc<Detail>>,
127    pub footer: Option<crate::formats::parquet_footer::Footer>,
128}
129
130/// The columns one table of a file of a format opens with, for the home screen's
131/// preview: the one named, or the one the file opens when none is.
132pub(crate) type TableSchema =
133    fn(&Path, Option<&str>) -> Option<crate::home::discover::SchemaPreview>;
134
135/// What a scan is given: the files to open as `format`, one unless the format reads
136/// many as one table, and where to report what it found besides the frame.
137pub(crate) struct ScanIn<'a> {
138    pub format: FileFormat,
139    pub paths: &'a [PathBuf],
140    pub options: &'a OpenOptions,
141    pub report: &'a mut ReadReport,
142    pub formats: &'a crate::formats::Registry,
143}
144
145impl ScanIn<'_> {
146    /// The file a format read one file at a time opens.
147    pub fn path(&self) -> &Path {
148        &self.paths[0]
149    }
150}
151
152/// Opens files of a format: the frame, or what the load turns into one first.
153pub(crate) type ScanFn = fn(ScanIn<'_>) -> Result<Scan>;
154
155/// What a conversion is given: the files to read into files of their own, named
156/// `display` to the user, written through `writer`, counting the bytes read in `read`.
157pub(crate) struct ConvertIn<'a> {
158    pub files: &'a [PathBuf],
159    pub display: &'a Path,
160    pub format: FileFormat,
161    pub options: &'a OpenOptions,
162    pub formats: &'a crate::formats::Registry,
163    pub writer: &'a Writer,
164    pub read: &'a AtomicU64,
165}
166
167/// What a conversion wrote, and what the file said besides its rows.
168pub(crate) type ConvertOut = Result<(Converted, Option<Arc<Detail>>)>;
169
170/// Reads files of a format into files of their own, which the dataset scans.
171pub(crate) type ConvertFn = fn(&ConvertIn<'_>) -> ConvertOut;
172
173/// What a scan of a prefix or glob in an object store is given: the URL as the user
174/// named it, and as Polars lists it.
175#[cfg(feature = "cloud")]
176pub(crate) struct BucketIn<'a> {
177    pub url: &'a str,
178    pub path: ::polars::prelude::PlRefPath,
179    pub cloud: ::polars::io::cloud::CloudOptions,
180    pub glob: bool,
181    pub options: &'a OpenOptions,
182    pub format: FileFormat,
183}
184
185/// Scans a prefix or glob of a format in an object store as one table, in place.
186#[cfg(feature = "cloud")]
187pub(crate) type BucketScan = fn(BucketIn<'_>) -> Result<::polars::prelude::LazyFrame>;
188
189/// The code behind one format.
190pub(crate) struct Reader {
191    /// Opens a file of it, or several of a format that reads many as one table.
192    pub scan: ScanFn,
193    /// Scans a prefix of it in an object store in place, for a format other than
194    /// Parquet whose descriptor says a prefix is ([`FileFormat::reads_bucket_prefix`]).
195    /// Parquet's own scan has hive partitioning, and a prefix of model files is read by
196    /// its headers before a scan.
197    #[cfg(feature = "cloud")]
198    pub bucket_scan: Option<BucketScan>,
199    /// Reads a file of it into files of its own: a format the scan answers with
200    /// [`Scan::ReadInto`], or an archive's compressed member ([`Scan::Unpack`]).
201    pub convert: Option<ConvertFn>,
202    /// The bytes at the start of a file that say it is this format, if any do.
203    pub signatures: &'static [Signature],
204    /// The formats a file of this one is named as: a file whose name says one of them
205    /// is still asked its first bytes for this (journal JSON in a `.json` file).
206    pub refines: &'static [FileFormat],
207    /// The tables a file of it lists on the home screen, read cheaply: a database's
208    /// schema, an archive's directory. Only for a format whose descriptor says it holds
209    /// tables that are listed.
210    pub tables: Option<ListTables>,
211    /// What the Info panel reads of one local file of it when it first opens, for a
212    /// format whose scan leaves what the file says besides its rows to Polars. Its
213    /// tab is offered from the open on, and filled when the read lands.
214    pub facts: Option<Facts>,
215    /// The columns of one of its tables, read cheaply for the home screen's preview,
216    /// where a table's columns are not in its listing.
217    pub table_schema: Option<TableSchema>,
218    /// Whether a file named as it whose first bytes do not say it cannot open: a `.db`
219    /// file that is not SQLite. The home screen lists such a file as no data.
220    pub bytes_decide: bool,
221    /// How the home screen's preview reads a file of it before it is opened, where its
222    /// first rows are cheap.
223    pub preview: Option<Preview>,
224    /// How Copy as Python reads it with Polars, where Polars does.
225    pub python: Option<crate::export::python_script::Python>,
226    /// What a view of it is exported as unless the user picks: the format itself where
227    /// datui writes it.
228    pub export: Option<ExportFormat>,
229}
230
231/// How the home screen's preview reads a file's first rows.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub(crate) enum Preview {
234    /// A scan from the start, of a file no larger than the preview reads.
235    Scan,
236    /// Its first row group, of a file whose groups are no larger.
237    RowGroup,
238}
239
240/// The reader of a format nothing is written for: no tables, no export default.
241pub(crate) const BASE: Reader = Reader {
242    scan: |input| {
243        Err(eyre!(
244            "datui has no reader for {} files.",
245            input.format.name()
246        ))
247    },
248    #[cfg(feature = "cloud")]
249    bucket_scan: None,
250    convert: None,
251    signatures: &[],
252    refines: &[],
253    tables: None,
254    facts: None,
255    table_schema: None,
256    bytes_decide: false,
257    preview: None,
258    python: None,
259    export: None,
260};
261
262/// Opens `input`'s files as their format's reader does. An error opening one file
263/// names it ([`crate::error_display::FileError`]); one of several names its own.
264pub(crate) fn scan(input: ScanIn<'_>) -> Result<Scan> {
265    let one = (input.paths.len() == 1).then(|| input.paths[0].clone());
266    (of(input.format).scan)(input).map_err(|e| match one {
267        Some(path) => crate::error_display::in_file(&path, e),
268        None => e,
269    })
270}
271
272/// The reader of `format`.
273pub(crate) fn of(format: FileFormat) -> &'static Reader {
274    match format {
275        FileFormat::Parquet => &polars::PARQUET,
276        FileFormat::Csv => &polars::CSV,
277        FileFormat::Tsv => &polars::TSV,
278        FileFormat::Psv => &polars::PSV,
279        FileFormat::Json => &polars::JSON,
280        FileFormat::Jsonl => &polars::JSONL,
281        FileFormat::Arrow => &polars::ARROW,
282        FileFormat::Avro => &polars::AVRO,
283        FileFormat::Orc => &polars::ORC,
284        FileFormat::Excel => &polars::EXCEL,
285        FileFormat::Safetensors => &crate::formats::model_files::SAFETENSORS,
286        FileFormat::Gguf => &crate::formats::model_files::GGUF,
287        FileFormat::Nmea => &crate::formats::gps::NMEA,
288        FileFormat::Gpx => &crate::formats::gps::GPX,
289        FileFormat::Audio => &crate::formats::audio::READER,
290        FileFormat::Midi => &crate::formats::midi::READER,
291        FileFormat::Sqlite => &crate::formats::sqlite::READER,
292        FileFormat::Vcd => &crate::formats::vcd::READER,
293        FileFormat::Fix => &crate::formats::fix::READER,
294        FileFormat::Sdf => &crate::formats::sdf::READER,
295        FileFormat::Numpy => &crate::formats::numpy::READER,
296        FileFormat::Elf => &crate::formats::elf::READER,
297        FileFormat::Ulog => &crate::formats::ulog::READER,
298        FileFormat::Dataflash => &crate::formats::dataflash::READER,
299        FileFormat::Candump => &crate::formats::candump::READER,
300        FileFormat::Text => &crate::formats::lines::READER,
301        FileFormat::Journal => &crate::formats::journal::READER,
302    }
303}
304
305/// Bytes read from the start of a file to tell its format by.
306pub const HEAD: usize = 4096;
307
308/// The bytes at the start of a file that say a format.
309pub(crate) struct Signature {
310    /// Whether `head`, a file's first bytes ([`HEAD`] of them, or the whole of a shorter
311    /// file), say the format. `file` is the file they came from, where there is one:
312    /// for bytes another format shares (an NPZ archive is a zip file) or that are
313    /// confirmed further in (Parquet's footer).
314    pub says: fn(&[u8], Option<&Path>) -> bool,
315    /// How the bytes say it, which orders the signatures: magic numbers are asked
316    /// first, then structure read from lengths, then text.
317    pub kind: Kind,
318    /// Where the bytes are believed.
319    pub trusted: Trusted,
320}
321
322/// How a signature says its format. See [`Signature::kind`].
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub(crate) enum Kind {
325    Magic,
326    Structure,
327    Text,
328}
329
330/// Where a signature is believed. A weak one (`ORC`, three letters a text file may
331/// start with) is believed in fewer places than a strong one.
332#[derive(Debug, Clone, Copy)]
333pub(crate) struct Trusted {
334    /// Data piped in, which has no name.
335    pub pipe: bool,
336    /// A file opened whose name says no format.
337    pub open: Unnamed,
338    /// A file a directory listing looks inside ([`crate::home::discover::worth_sniffing`]).
339    /// Never ELF, which would list every executable, nor text, which is a parse.
340    pub listing: bool,
341    /// The bytes say a file of several tables, each a place inside it
342    /// (`shop.db/orders`), whatever the file is called.
343    pub tables: bool,
344}
345
346/// Which files whose names say no format a signature is believed for.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub(crate) enum Unnamed {
349    Never,
350    /// Any: `.bin`, text (`.log`, `.txt`), or none.
351    Any,
352    /// Only a file with no extension at all, such as a part file.
353    NoExtension,
354}
355
356/// Believed everywhere, but not as a file of tables.
357pub(crate) const EVERYWHERE: Trusted = Trusted {
358    pipe: true,
359    open: Unnamed::Any,
360    listing: true,
361    tables: false,
362};
363
364/// Where a file's first bytes are asked to say its format.
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub(crate) enum Asked {
367    /// Data piped in.
368    Pipe,
369    /// A file being opened whose name says no format; `extension` is whether it has
370    /// one at all.
371    Open { extension: bool },
372    /// A file a directory listing looks inside.
373    Listing,
374    /// Whether a file of any name holds several tables.
375    Tables,
376}
377
378impl Asked {
379    fn believes(self, format: FileFormat, trusted: Trusted) -> bool {
380        match self {
381            Asked::Pipe => trusted.pipe,
382            Asked::Open { extension } => match trusted.open {
383                Unnamed::Never => false,
384                Unnamed::Any => true,
385                Unnamed::NoExtension => !extension,
386            },
387            Asked::Listing => trusted.listing,
388            Asked::Tables => trusted.tables && format.holds_tables(),
389        }
390    }
391}
392
393/// The format `head`, the first bytes of `file` (none for a pipe), says, as believed
394/// where it is `asked`, of the formats `among` admits: every format's signatures,
395/// magic numbers first. Text no signature claims is told apart by [`crate::formats::lines::guess`]
396/// instead (JSON, NDJSON, CSV, TSV or lines), since those formats have no signatures.
397pub(crate) fn sniff(
398    head: &[u8],
399    file: Option<&Path>,
400    asked: Asked,
401    among: impl Fn(FileFormat) -> bool,
402) -> Option<FileFormat> {
403    [Kind::Magic, Kind::Structure, Kind::Text]
404        .into_iter()
405        .find_map(|kind| {
406            FileFormat::ALL.into_iter().find(|&format| {
407                among(format)
408                    && of(format).signatures.iter().any(|sig| {
409                        sig.kind == kind
410                            && asked.believes(format, sig.trusted)
411                            && (sig.says)(head, file)
412                    })
413            })
414        })
415}
416
417/// The format a file named as `named` is by its first bytes, when they say one that
418/// refines it ([`Reader::refines`]): journal JSON in a `.json` file.
419pub(crate) fn refined(path: &Path, named: FileFormat) -> Option<FileFormat> {
420    if !FileFormat::ALL
421        .into_iter()
422        .any(|f| of(f).refines.contains(&named))
423    {
424        return None;
425    }
426    let head = head_of(path)?;
427    sniff(&head, Some(path), Asked::Open { extension: true }, |f| {
428        of(f).refines.contains(&named)
429    })
430}
431
432/// [`sniff`] for the file at `path`, by its first [`HEAD`] bytes.
433pub(crate) fn sniff_file(path: &Path, asked: Asked) -> Option<FileFormat> {
434    let head = head_of(path)?;
435    sniff(&head, Some(path), asked, |_| true)
436}
437
438/// The format of a local file being opened whose name says none, by its first bytes;
439/// `compression` is `--compression`. A format read through its compression (a GPS log,
440/// a VCD dump) is named under it (`track.nmea.gz`) and known by its bytes inside it.
441pub(crate) fn sniff_open(
442    path: &Path,
443    compression: Option<crate::CompressionFormat>,
444) -> Option<FileFormat> {
445    let read_through = |f: FileFormat| f.reads_into();
446    let asked = Asked::Open {
447        extension: path.extension().is_some(),
448    };
449    let is_file = path.is_file();
450    if is_file
451        && let Some(found) =
452            head_of(path).and_then(|head| sniff(&head, Some(path), asked, |_| true))
453    {
454        return Some(found);
455    }
456    let compression = compression.or_else(|| crate::CompressionFormat::from_extension(path))?;
457    if let Some(named) = path
458        .file_stem()
459        .and_then(|stem| FileFormat::from_path(Path::new(stem)))
460        .filter(|f| read_through(*f))
461    {
462        return Some(named);
463    }
464    if !is_file {
465        return None;
466    }
467    let head = crate::formats::head_of(path, Some(compression), HEAD as u64)?;
468    sniff(&head, Some(path), asked, read_through)
469}
470
471/// The first [`HEAD`] bytes of the file at `path`, or all of a shorter one.
472pub(crate) fn head_of(path: &Path) -> Option<Vec<u8>> {
473    use std::io::Read;
474    let mut head = Vec::with_capacity(HEAD);
475    std::fs::File::open(path)
476        .ok()?
477        .take(HEAD as u64)
478        .read_to_end(&mut head)
479        .ok()?;
480    Some(head)
481}
482
483/// The scan of a format read into files of its own before it is scanned
484/// (`Step::Convert`), as a compressed CSV is.
485pub(crate) fn read_into(input: ScanIn<'_>) -> Result<Scan> {
486    Ok(Scan::ReadInto {
487        files: input.paths.to_vec(),
488        format: input.format,
489    })
490}
491
492/// Read `input`'s files into files of their own as their format's reader does, an
493/// error named by the file the user opened.
494pub(crate) fn convert(input: &ConvertIn<'_>) -> ConvertOut {
495    match of(input.format).convert {
496        Some(convert) => convert(input),
497        None => Err(eyre!(
498            "{} files are not read into files of their own.",
499            input.format.name()
500        )),
501    }
502    .map_err(|e| crate::error_display::in_file(input.display, e))
503}
504
505/// Why several files of a format that reads one at a time are refused.
506pub(crate) fn many_files_refused() -> String {
507    let (many, one): (Vec<FileFormat>, Vec<FileFormat>) = FileFormat::ALL
508        .into_iter()
509        .partition(|f| f.reads_many_files());
510    let names = |formats: &[FileFormat]| {
511        formats
512            .iter()
513            .map(|f| f.title())
514            .collect::<Vec<_>>()
515            .join(", ")
516    };
517    format!(
518        "Unsupported file type for multiple files: {} files are read as one table; open {} files one at a time.",
519        names(&many),
520        names(&one)
521    )
522}
523
524/// The format a view read as `format` is exported as by default.
525pub(crate) fn export_default(format: FileFormat) -> Option<ExportFormat> {
526    of(format).export
527}
528
529/// Bad input opened as the load opens it, for each reader's error tests.
530#[cfg(test)]
531pub(crate) mod bad_input {
532    use std::path::Path;
533    use std::sync::Arc;
534    use std::sync::atomic::{AtomicBool, AtomicU64};
535
536    use super::{ConvertIn, ScanIn};
537    use crate::loading::scan::Scan;
538    use crate::{FileFormat, OpenOptions, ReadReport};
539
540    /// What the user is told opening `bytes`, written to a file called `name` in
541    /// `dir`, as `format`, through the scan, a conversion and the first rows. `None`
542    /// when it opens.
543    pub(crate) fn opening(
544        dir: &Path,
545        name: &str,
546        bytes: &[u8],
547        format: FileFormat,
548        options: &OpenOptions,
549    ) -> Option<String> {
550        let path = dir.join(name);
551        std::fs::write(&path, bytes).unwrap();
552        let said =
553            |e: color_eyre::Report| crate::error_display::user_message_from_report(&e, Some(&path));
554        let formats = crate::formats::Registry::of(Vec::new());
555        let mut report = ReadReport::default();
556        let paths = [path.clone()];
557        let scan = super::scan(ScanIn {
558            format,
559            paths: &paths,
560            options,
561            report: &mut report,
562            formats: &formats,
563        });
564        // The files a conversion wrote, kept until the rows are read.
565        let mut written = Vec::new();
566        let lf = match scan {
567            Err(e) => return Some(said(e)),
568            Ok(Scan::Frame(lf)) => *lf,
569            Ok(Scan::ReadInto { files, format }) => {
570                let unfinished = crate::loading::unfinished::Unfinished::default();
571                let writer = unfinished.writer(Arc::new(AtomicBool::new(false)));
572                let read = AtomicU64::new(0);
573                match super::convert(&ConvertIn {
574                    files: &files,
575                    display: &path,
576                    format,
577                    options,
578                    formats: &formats,
579                    writer: &writer,
580                    read: &read,
581                }) {
582                    Err(e) => return Some(said(e)),
583                    Ok((converted, _)) => {
584                        written = converted.files;
585                        converted.lf
586                    }
587                }
588            }
589            Ok(_) => return None,
590        };
591        let rows = lf.limit(100).collect();
592        drop(written);
593        rows.err().map(|e| said(color_eyre::Report::new(e)))
594    }
595
596    /// `message` is a reader error's shape: the file named in quotes first, then
597    /// the line and column when it is at one place (`"spec.toml":3:7: `), its first
598    /// line a sentence ended with a full stop, nothing Rust prints.
599    pub(crate) fn assert_shape(message: &str, path: &Path) {
600        let quoted = format!("\"{}\":", path.display());
601        assert!(message.starts_with(&quoted), "names the file: {message}");
602        let after = &message[quoted.len()..];
603        let what = match after.strip_prefix(' ') {
604            Some(what) => what,
605            None => {
606                // `3:7: `: a line and a column, one-based.
607                let mut parts = after.splitn(3, ':');
608                let (line, column) = (parts.next().unwrap(), parts.next().unwrap_or_default());
609                for n in [line, column] {
610                    assert!(
611                        n.parse::<usize>().is_ok_and(|n| n > 0),
612                        "a place in the file: {message}"
613                    );
614                }
615                parts
616                    .next()
617                    .and_then(|what| what.strip_prefix(' '))
618                    .unwrap_or_else(|| panic!("a place, then a space: {message}"))
619            }
620        };
621        let first = message.lines().next().unwrap_or_default();
622        assert!(first.ends_with('.'), "ends with a full stop: {message}");
623        assert!(
624            crate::error_display::starts_with_a_key(what)
625                || what.chars().next().is_some_and(|c| !c.is_lowercase()),
626            "sentence case: {message}"
627        );
628        assert_eq!(
629            what.matches(path.to_string_lossy().as_ref()).count(),
630            0,
631            "named once: {message}"
632        );
633        for rust in [
634            "Some(",
635            "None",
636            "Error {",
637            "Kind(",
638            "PolarsError",
639            "ComputeError",
640            "os error",
641        ] {
642            assert!(!message.contains(rust), "no Rust ({rust}): {message}");
643        }
644    }
645
646    /// Each of `bad`, a file name and its bytes, fails to open as `format` with an
647    /// error of the one shape; `says` is a word each message holds.
648    pub(crate) fn each_names_its_file(format: FileFormat, bad: &[(&str, &[u8], &str)]) {
649        let dir = tempfile::tempdir().unwrap();
650        for (name, bytes, says) in bad {
651            let message = opening(dir.path(), name, bytes, format, &OpenOptions::default())
652                .unwrap_or_else(|| panic!("{name} opens"));
653            eprintln!("{message}");
654            assert_shape(&message, &dir.path().join(name));
655            assert!(message.contains(says), "{name}: {message}");
656        }
657    }
658}