Skip to main content

datui_lib/formats/
command.rs

1//! Reading a file with a spec, and the `datui formats` command.
2
3use super::*;
4
5/// The first `reach` bytes of `path`, through its decompressor when it has one.
6pub fn head_of(
7    path: &Path,
8    compression: Option<crate::CompressionFormat>,
9    reach: u64,
10) -> Option<Vec<u8>> {
11    use std::io::Read;
12    let file = std::fs::File::open(path).ok()?;
13    let reader: Box<dyn Read> = match compression {
14        None => Box::new(file),
15        Some(crate::CompressionFormat::Gzip) => Box::new(flate2::read::GzDecoder::new(file)),
16        Some(crate::CompressionFormat::Zstd) => Box::new(zstd::Decoder::new(file).ok()?),
17        Some(crate::CompressionFormat::Bzip2) => Box::new(bzip2::read::BzDecoder::new(file)),
18        Some(crate::CompressionFormat::Xz) => Box::new(xz2::read::XzDecoder::new(file)),
19    };
20    let mut head = Vec::new();
21    reader
22        .take(reach.min(MAX_MATCH_READ))
23        .read_to_end(&mut head)
24        .ok()?;
25    Some(head)
26}
27
28/// A file read through a spec, as the open carries it to the dataset.
29pub struct Read {
30    pub spec: Arc<Spec>,
31    pub by: Chosen,
32    /// The other specs that matched as well as `spec`, by the same rule.
33    pub also: Vec<String>,
34    /// Warnings from the read: trailing bytes, a short count.
35    pub notes: Vec<String>,
36    pub header: HeaderValues,
37    pub records: Arc<dyn SpecRecords>,
38}
39
40impl std::fmt::Debug for Read {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("Read")
43            .field("spec", &self.spec.name)
44            .field("by", &self.by)
45            .field("also", &self.also)
46            .field("rows", &self.records.rows())
47            .finish()
48    }
49}
50
51/// What a request for a format says, besides the path.
52#[derive(Debug, Clone, Default)]
53pub struct Asked {
54    /// `--format FILE`.
55    pub spec_file: Option<PathBuf>,
56    /// `--format NAME`, or the spec picked in the view.
57    pub spec_name: Option<String>,
58    /// The spec `spec_file` names, read already: fetched, when it is remote.
59    pub spec: Option<Arc<Spec>>,
60    /// `--table NAME`: one variant of the spec's records, read alone; any other
61    /// reader takes the name as its own table.
62    pub variant: Option<String>,
63    /// A built-in format from `--format`, which no spec overrides.
64    pub builtin: bool,
65    pub compression: Option<crate::CompressionFormat>,
66    /// Several files are read as one: only a delimited spec reads them.
67    pub text_only: bool,
68}
69
70/// Where a spec was chosen from, carried to a decompressed copy's read.
71#[derive(Clone)]
72pub struct Choice {
73    pub spec: Arc<Spec>,
74    pub by: Chosen,
75    pub also: Vec<String>,
76}
77
78/// What [`route`] decided about one local path.
79pub enum Route {
80    /// Not a spec's: the path opens as it does without specs.
81    Elsewhere,
82    Read(Box<Read>),
83    /// Compressed: decompress it, then read the copy with `choice.spec`.
84    Decompress(Choice),
85    /// A delimited spec's: read with the CSV reader, in the spec's dialect.
86    Delimited(Choice),
87}
88
89/// The keys a delimited spec takes: its own, and the dialect the option registry
90/// gives a spec key (`[csv]`'s keys and the layout flags'), so a spec reads as a
91/// `[csv]` block.
92pub(crate) fn delimited_spec_keys() -> Vec<&'static str> {
93    use datui_cli::settings::{OPEN, SETTINGS};
94    let mut keys = vec![
95        "name",
96        "description",
97        "documentation",
98        "kind",
99        "match",
100        "metadata_line",
101        "columns",
102    ];
103    keys.extend(SETTINGS.iter().filter_map(|s| s.spec));
104    keys.extend(OPEN.iter().filter_map(|o| o.spec));
105    keys
106}
107
108/// Which specs may read `path` when nothing names one, or `None` when none may. What
109/// the name already says is read as it says, compressed or not: a spec of records takes
110/// only a name that says no format datui reads, and a delimited spec also one that says
111/// delimited text.
112pub(crate) fn unnamed_may(
113    path: &Path,
114    is_dir: bool,
115    text_only: bool,
116) -> Option<impl Fn(&Spec) -> bool> {
117    let said = (!is_dir)
118        .then(|| crate::home::discover::data_format(path))
119        .flatten()
120        // Text by its name (`.log`, `.txt`) says no more than no name does.
121        .filter(|f| !f.is_lines());
122    let parquet_key =
123        crate::home::discover::is_parquet_key(&crate::home::discover::directory_and_name(path));
124    let records_may = said.is_none() && !parquet_key && !text_only;
125    let text_may =
126        !is_dir && !parquet_key && said.is_none_or(|f| crate::FileFormat::separator(f).is_some());
127    (records_may || text_may).then_some(move |s: &Spec| {
128        if s.is_delimited() {
129            text_may
130        } else {
131            records_may
132        }
133    })
134}
135
136/// The first `reach` bytes of `path` for comparing specs' magic, or `None` when its
137/// bytes say a format datui reads already: a file with no extension may be Parquet,
138/// Arrow, Avro or ORC by its bytes, which it stays.
139pub(crate) fn spec_head(
140    path: &Path,
141    compression: Option<crate::CompressionFormat>,
142    reach: u64,
143) -> Option<Vec<u8>> {
144    if compression.is_none() && crate::home::discover::sniff_format(path).is_some() {
145        return None;
146    }
147    head_of(path, compression, reach)
148}
149
150/// Whether, and with which spec, `path` is read. In order: `--format FILE`, then
151/// `--format NAME`, then a glob, then magic. A file whose name or bytes say it is a
152/// format datui reads already keeps opening that way.
153pub fn route(path: &Path, asked: &Asked, registry: &Registry) -> Result<Route, String> {
154    let compression = asked.compression.or_else(|| {
155        path.is_file()
156            .then(|| crate::CompressionFormat::from_extension(path))
157            .flatten()
158    });
159    let named = path.file_name().map_or_else(
160        || path.display().to_string(),
161        |n| n.to_string_lossy().into_owned(),
162    );
163    let explicit = if let Some(spec) = &asked.spec {
164        Some(Choice {
165            spec: spec.clone(),
166            by: Chosen::SpecFile,
167            also: Vec::new(),
168        })
169    } else if let Some(file) = &asked.spec_file {
170        if crate::cloud::source::is_remote_url(file) {
171            return Err(format!(
172                "{}: a remote spec is fetched by the open, and was not",
173                file.display()
174            ));
175        }
176        let spec = Spec::load(file).map_err(|e| e.to_string())?;
177        Some(Choice {
178            spec: Arc::new(spec),
179            by: Chosen::SpecFile,
180            also: Vec::new(),
181        })
182    } else if let Some(name) = &asked.spec_name {
183        let spec = registry.get(name).ok_or_else(|| {
184            format!("no format named {name} on the search path; `datui formats` lists them")
185        })?;
186        Some(Choice {
187            spec: spec.clone(),
188            by: Chosen::Named,
189            also: Vec::new(),
190        })
191    } else {
192        None
193    };
194    if asked.text_only
195        && let Some(choice) = &explicit
196        && !choice.spec.is_delimited()
197    {
198        return Err(format!(
199            "{} reads one file, or one directory of column files",
200            choice.spec.name
201        ));
202    }
203    let choice = match explicit {
204        Some(choice) => choice,
205        None => {
206            if asked.builtin || registry.is_empty() {
207                return Ok(Route::Elsewhere);
208            }
209            let is_dir = path.is_dir();
210            let Some(wanted) = unnamed_may(path, is_dir, asked.text_only) else {
211                return Ok(Route::Elsewhere);
212            };
213            // A glob names the file as it is stored uncompressed: `day.l2.zst` is an `*.l2`.
214            let inner = match compression {
215                Some(_) => path.with_extension(""),
216                None => path.to_path_buf(),
217            };
218            let matched = registry.matching_among(&inner, is_dir, wanted, |reach| {
219                spec_head(path, compression, reach)
220            });
221            let Some(matched) = matched else {
222                return Ok(Route::Elsewhere);
223            };
224            let mut specs = matched.specs.into_iter();
225            let spec = specs.next().expect("a match has a spec");
226            Choice {
227                spec,
228                by: matched.by,
229                also: specs.map(|s| s.name.clone()).collect(),
230            }
231        }
232    };
233    // The CSV reader reads a delimited spec's files, compressed or not.
234    if choice.spec.is_delimited() {
235        return Ok(Route::Delimited(choice));
236    }
237    let choice = match &asked.variant {
238        Some(variant) => Choice {
239            spec: Arc::new(choice.spec.with_variant(variant)?),
240            ..choice
241        },
242        None => choice,
243    };
244    if compression.is_some() && path.is_file() {
245        return Ok(Route::Decompress(choice));
246    }
247    read(path, &named, choice).map(|r| Route::Read(Box::new(r)))
248}
249
250/// Read `path` with the spec `choice` holds, naming it `named` in what it says.
251pub fn read(path: &Path, named: &str, choice: Choice) -> Result<Read, String> {
252    let opened = choice.spec.open(path, named)?;
253    Ok(Read {
254        spec: choice.spec,
255        by: choice.by,
256        also: choice.also,
257        notes: opened.notes,
258        header: opened.header,
259        records: opened.records,
260    })
261}
262
263impl Read {
264    /// The dataset's notes about the read: which format, why, what else matched, the
265    /// header's values, and the warnings.
266    pub fn notes(&self) -> Vec<crate::notes::Note> {
267        let note = |summary: String, scope: String| crate::notes::Note {
268            summary,
269            scope,
270            read_as_text: None,
271            passed_over: None,
272        };
273        let from = self
274            .spec
275            .path
276            .as_ref()
277            .map_or_else(|| "the spec".to_string(), |p| p.display().to_string());
278        let mut notes = vec![note(
279            format!(
280                "read as {}, {}",
281                self.spec.name,
282                chosen_words(&self.spec, self.by)
283            ),
284            format!("from {from}"),
285        )];
286        if !self.also.is_empty() {
287            notes.push(note(
288                format!(
289                    "{} also {} this file; press b to pick another",
290                    self.also.join(", "),
291                    if self.also.len() == 1 {
292                        "matches"
293                    } else {
294                        "match"
295                    }
296                ),
297                format!("by {}", self.by.words()),
298            ));
299        }
300        if !self.header.values.is_empty() {
301            let said: Vec<String> = self
302                .header
303                .values
304                .iter()
305                .map(|(name, value)| format!("{name} = {value}"))
306                .collect();
307            notes.push(note(
308                format!("header: {}", said.join(", ")),
309                "from the file's header".to_string(),
310            ));
311        }
312        for warning in &self.notes {
313            notes.push(note(
314                warning.clone(),
315                "from the file's length and the spec".to_string(),
316            ));
317        }
318        notes
319    }
320}
321
322/// A spec's match conditions as the command line prints them: its chips, or
323/// `no match` when only a choice (`--format`, b, B) picks it.
324pub(crate) fn match_words(spec: &Spec) -> String {
325    let chips = spec.match_chips();
326    if chips.is_empty() {
327        FORMAT_ONLY.to_string()
328    } else {
329        chips_plain(&chips)
330    }
331}
332
333/// What a spec with no `match` says in place of its conditions.
334pub const FORMAT_ONLY: &str = "no match";
335
336/// `datui formats`, or `datui formats check SPEC [FILE]`: what to print, and the exit
337/// code (non-zero when the check finds an error).
338pub fn command(
339    action: Option<&crate::cli::FormatsAction>,
340    args: &crate::cli::Args,
341    config: &crate::config::AppConfig,
342) -> (String, i32) {
343    crate::limits::set(config.limits);
344    let path = search_path_for(config);
345    let registry = Registry::load(&path);
346    match action {
347        None => (registry.listing(&path), 0),
348        Some(crate::cli::FormatsAction::Check { spec, file }) => {
349            let options = crate::OpenOptions::from_args_and_config(args, config);
350            match check(spec, file.as_deref(), &registry, &options) {
351                Ok(text) => (text, 0),
352                Err(text) => (text, 1),
353            }
354        }
355    }
356}
357
358/// Rows `formats check` prints from a file.
359const CHECK_ROWS: usize = 10;
360
361/// Check the spec `named` (a file, or a name on the search path) and, given `file`,
362/// read its first rows.
363pub(crate) fn check(
364    named: &str,
365    file: Option<&Path>,
366    registry: &Registry,
367    options: &crate::OpenOptions,
368) -> Result<String, String> {
369    let as_file = Path::new(named);
370    if let Some(dict) = fix_dict_named(named, registry)? {
371        return check_fix(&dict, file);
372    }
373    if let Some(dbc) = dbc_named(named, registry)? {
374        return check_dbc(&dbc, file);
375    }
376    let spec = if as_file.is_file() {
377        Arc::new(Spec::load(as_file).map_err(|e| format!("error: {e}\n"))?)
378    } else if let Some(spec) = registry.get(named) {
379        spec.clone()
380    } else {
381        let mut said = format!("error: no spec file or format named {named}\n");
382        if let Some(e) = registry.errors.iter().find(|e| {
383            e.path
384                .as_ref()
385                .is_some_and(|p| p.file_stem() == as_file.file_stem())
386        }) {
387            said.push_str(&format!("error: {e}\n"));
388        }
389        return Err(said);
390    };
391    let mut out = format!("{}: ok\n", spec.name);
392    if let Some(from) = &spec.path {
393        out.push_str(&format!("  from {}\n", from.display()));
394    }
395    out.push_str(&format!("  matches {}\n", match_words(&spec)));
396    if spec.is_delimited() {
397        return crate::formats::delimited_spec::check(&spec, file, CHECK_ROWS, options)
398            .map(|rest| out.clone() + &rest)
399            .map_err(|rest| out.clone() + &rest);
400    }
401    let named_fields = spec
402        .records
403        .fields
404        .iter()
405        .filter(|f| f.name.is_some())
406        .count();
407    out.push_str(&format!("  {named_fields} record fields"));
408    if let Some(width) = given_width(&spec.records.fields) {
409        let size = match spec.records.size {
410            Some(Amount::Given(size)) => size,
411            _ => width,
412        };
413        if spec.layout == Layout::Rows
414            && spec
415                .records
416                .size
417                .as_ref()
418                .is_none_or(|s| matches!(s, Amount::Given(_)))
419        {
420            out.push_str(&format!(", {size} bytes a record"));
421        }
422    }
423    out.push('\n');
424    let Some(file) = file else {
425        return Ok(out);
426    };
427    let compression = crate::CompressionFormat::from_extension(file).filter(|_| file.is_file());
428    let shown = file.file_name().map_or_else(
429        || file.display().to_string(),
430        |n| n.to_string_lossy().into_owned(),
431    );
432    let choice = Choice {
433        spec: spec.clone(),
434        by: Chosen::SpecFile,
435        also: Vec::new(),
436    };
437    // A compressed file is read from a copy, as the open reads it.
438    let copy;
439    let readable = match compression {
440        None => file,
441        Some(compression) => {
442            copy = decompressed_copy(file, compression)
443                .map_err(|e| format!("{out}error: {shown}: {e}\n"))?;
444            copy.path()
445        }
446    };
447    let read = read(readable, &shown, choice).map_err(|e| format!("{out}error: {e}\n"))?;
448    for note in &read.notes {
449        out.push_str(&format!("warning: {note}\n"));
450    }
451    if !read.header.values.is_empty() {
452        let said: Vec<String> = read
453            .header
454            .values
455            .iter()
456            .map(|(name, value)| format!("{name} = {value}"))
457            .collect();
458        out.push_str(&format!("header: {}\n", said.join(", ")));
459    }
460    out.push_str(&format!("{} records\n", read.records.rows()));
461    let df = read
462        .records
463        .collect(CHECK_ROWS)
464        .map_err(|e| format!("{out}error: {e}\n"))?;
465    out.push_str(&text_table(&df));
466    Ok(out)
467}
468
469/// The FIX dictionary `named` names: a dictionary file, or one on the search path.
470fn fix_dict_named(
471    named: &str,
472    registry: &Registry,
473) -> Result<Option<Arc<crate::formats::fix::dict::Dictionary>>, String> {
474    let as_file = Path::new(named);
475    if as_file.is_file() {
476        return match crate::formats::fix::dict::Dictionary::load(as_file) {
477            Ok(dict) => Ok(dict.map(Arc::new)),
478            Err(e) => Err(format!("error: {e}\n")),
479        };
480    }
481    Ok(registry.fix_dict(named).cloned())
482}
483
484/// The DBC file `named` names: a `.dbc` file, a `kind = "dbc"` TOML file, or one on the
485/// search path by its name.
486fn dbc_named(
487    named: &str,
488    registry: &Registry,
489) -> Result<Option<Arc<crate::formats::dbc::Dbc>>, String> {
490    let as_file = Path::new(named);
491    if as_file.is_file() {
492        // Any other file would parse as an empty DBC: only these two kinds are asked.
493        let dbc_like = as_file
494            .extension()
495            .is_some_and(|e| e.eq_ignore_ascii_case("dbc") || e.eq_ignore_ascii_case("toml"));
496        if !dbc_like {
497            return Ok(None);
498        }
499        return match crate::formats::dbc::load(as_file) {
500            Ok(dbc) => Ok(dbc.map(Arc::new)),
501            Err(e) => Err(format!("error: {e}\n")),
502        };
503    }
504    Ok(registry
505        .dbc
506        .iter()
507        .find(|found| found.dbc.name == named)
508        .map(|found| found.dbc.clone()))
509}
510
511/// `formats check` of a DBC file: its messages and signals, what it passed over and
512/// the interface it applies to; with `file`, a candump log, how many of its frames it
513/// names and which messages.
514fn check_dbc(dbc: &Arc<crate::formats::dbc::Dbc>, file: Option<&Path>) -> Result<String, String> {
515    use std::io::Read;
516    let mut out = format!("{}: ok\n", dbc.name);
517    if let Some(from) = &dbc.path {
518        out.push_str(&format!("  from {}\n", from.display()));
519    }
520    if let Some(interface) = &dbc.interface {
521        out.push_str(&format!("  matches interface {interface}\n"));
522    }
523    let signals: usize = dbc.messages.iter().map(|m| m.signals.len()).sum();
524    out.push_str(&format!(
525        "  {}, {}\n",
526        crate::formats::text_formats::count(dbc.messages.len() as u64, "message", "messages"),
527        crate::formats::text_formats::count(signals as u64, "signal", "signals"),
528    ));
529    for note in &dbc.notes {
530        out.push_str(&format!("warning: {note}\n"));
531    }
532    let Some(file) = file else {
533        return Ok(out);
534    };
535    let failed =
536        |out: &str, e: &dyn std::fmt::Display| format!("{out}error: {}: {e}\n", file.display());
537    let read = std::sync::atomic::AtomicU64::new(0);
538    let mut bytes = Vec::new();
539    crate::formats::text_formats::open_reader(file, &crate::OpenOptions::default(), &read)
540        .and_then(|mut reader| reader.read_to_end(&mut bytes).map_err(Into::into))
541        .map_err(|e| failed(&out, &e))?;
542    let index = crate::formats::candump::index(&bytes).map_err(|e| failed(&out, &e))?;
543    let layers = crate::formats::candump::Layers {
544        dbcs: vec![dbc.clone()],
545    };
546    let listing = crate::formats::candump::Listing::resolve(&index, layers);
547    let frames = index.keys.len();
548    out.push_str(&format!(
549        "{}, {} of them named by {}\n",
550        crate::formats::text_formats::count(frames as u64, "frame", "frames"),
551        frames - listing.unknown,
552        dbc.name
553    ));
554    let named: Vec<String> = listing
555        .messages
556        .iter()
557        .map(|(name, (_, rows))| format!("{name} ({})", rows.len()))
558        .collect();
559    if !named.is_empty() {
560        out.push_str(&format!("messages in the log: {}\n", named.join(", ")));
561    }
562    Ok(out)
563}
564
565/// `formats check` of a FIX dictionary: what it names and matches; with `file`, how
566/// many of the log's messages it applies to and the tags it names there.
567fn check_fix(
568    dict: &Arc<crate::formats::fix::dict::Dictionary>,
569    file: Option<&Path>,
570) -> Result<String, String> {
571    use std::io::Read;
572    let mut out = format!("{}: ok\n", dict.name);
573    if let Some(from) = &dict.path {
574        out.push_str(&format!("  from {}\n", from.display()));
575    }
576    let summary = dict.matcher.summary();
577    if !summary.is_empty() {
578        out.push_str(&format!("  matches {summary}\n"));
579    }
580    let enums = dict.tags.values().filter(|t| !t.enums.is_empty()).count();
581    out.push_str(&format!("  {} tags, {enums} with enums\n", dict.tags.len()));
582    let Some(file) = file else {
583        return Ok(out);
584    };
585    let read = std::sync::atomic::AtomicU64::new(0);
586    let mut reader =
587        crate::formats::text_formats::open_reader(file, &crate::OpenOptions::default(), &read)
588            .map_err(|e| format!("{out}error: {}: {e}\n", file.display()))?;
589    let mut log =
590        crate::formats::fix::FixReader::new(crate::formats::fix::dict::Layers::new(vec![
591            dict.clone(),
592        ]));
593    let mut chunk = vec![0u8; 1 << 16];
594    loop {
595        let n = reader
596            .read(&mut chunk)
597            .map_err(|e| format!("{out}error: {}: {e}\n", file.display()))?;
598        if n == 0 {
599            break;
600        }
601        log.push(&chunk[..n]);
602        let _ = log.take_batch();
603    }
604    let _ = log.finish();
605    let stats = log.stats();
606    out.push_str(&format!(
607        "{} messages, {} of them matched by {}\n",
608        stats.messages,
609        stats.applied.get(1).copied().unwrap_or(0),
610        dict.name
611    ));
612    let named: Vec<String> = log
613        .tag_names()
614        .into_iter()
615        .filter(|(_, _, by)| *by == 1)
616        .map(|(tag, name, _)| format!("{tag} {name}"))
617        .collect();
618    if !named.is_empty() {
619        out.push_str(&format!("names in the log: {}\n", named.join(", ")));
620    }
621    Ok(out)
622}
623
624/// `df` as plain text: a row of names, then a row per record, columns aligned.
625pub(crate) fn text_table(df: &polars::prelude::DataFrame) -> String {
626    let mut rows: Vec<Vec<String>> = vec![
627        df.get_column_names()
628            .iter()
629            .map(|n| n.to_string())
630            .collect(),
631    ];
632    for i in 0..df.height() {
633        rows.push(
634            df.columns()
635                .iter()
636                .map(|c| match c.get(i) {
637                    Ok(polars::prelude::AnyValue::String(s)) => s.to_string(),
638                    Ok(polars::prelude::AnyValue::StringOwned(s)) => s.to_string(),
639                    Ok(v) => v.to_string(),
640                    Err(_) => String::new(),
641                })
642                .collect(),
643        );
644    }
645    let widths: Vec<usize> = (0..rows[0].len())
646        .map(|c| rows.iter().map(|r| r[c].chars().count()).max().unwrap_or(0))
647        .collect();
648    let mut out = String::new();
649    for row in rows {
650        let cells: Vec<String> = row
651            .iter()
652            .zip(&widths)
653            .map(|(cell, width)| format!("{cell:<width$}"))
654            .collect();
655        out.push_str(cells.join("  ").trim_end());
656        out.push('\n');
657    }
658    out
659}
660
661/// `path` decompressed into a temporary file.
662fn decompressed_copy(
663    path: &Path,
664    compression: crate::CompressionFormat,
665) -> std::io::Result<tempfile::NamedTempFile> {
666    use std::io::Read as _;
667    let file = std::fs::File::open(path)?;
668    let mut reader: Box<dyn std::io::Read> = match compression {
669        crate::CompressionFormat::Gzip => Box::new(flate2::read::GzDecoder::new(file)),
670        crate::CompressionFormat::Zstd => Box::new(zstd::Decoder::new(file)?),
671        crate::CompressionFormat::Bzip2 => Box::new(bzip2::read::BzDecoder::new(file)),
672        crate::CompressionFormat::Xz => Box::new(xz2::read::XzDecoder::new(file)),
673    };
674    let mut copy = tempfile::NamedTempFile::new()?;
675    std::io::copy(&mut reader.by_ref(), copy.as_file_mut())?;
676    Ok(copy)
677}