Skip to main content

datui_lib/
delimited_spec.rs

1//! Format specs of `kind = "delimited"`: the reading options for a family of CSV-like
2//! files, with header rows that have roles (names, units), a metadata line, and a few
3//! derived columns.
4//!
5//! A spec is parsed in [`crate::formats`], beside the binary specs, and matched by the
6//! same `match`. Reading it is the CSV reader's: [`Delimited::apply`] sets the
7//! dialect options a matched file is opened with, and [`Delimited::derive`] adds the
8//! derived columns to the frame. The only lines read apart from the scan are the
9//! header lines and the metadata line ([`Delimited::facts`]).
10
11use crate::formats::{Chosen, Spec};
12use polars::prelude::*;
13use std::io::BufRead;
14use std::sync::Arc;
15
16/// What the lines before the data hold, by role.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct HeaderRows {
19    /// The 1-based lines whose pieces, joined, name the columns.
20    pub name: Vec<usize>,
21    /// The line that gives each column's unit.
22    pub unit: Option<usize>,
23}
24
25impl HeaderRows {
26    /// The last header line: the data starts after it.
27    pub fn last(&self) -> usize {
28        self.name
29            .iter()
30            .copied()
31            .chain(self.unit)
32            .max()
33            .unwrap_or(0)
34    }
35}
36
37pub use crate::column_types::{Derived, DerivedKind};
38
39/// A delimited spec's reading options. Each one left out keeps what the command line
40/// or the config says.
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct Delimited {
43    pub delimiter: Option<u8>,
44    pub comment_char: Option<String>,
45    pub skip_initial_space: Option<bool>,
46    pub header_rows: Option<HeaderRows>,
47    pub header_join: Option<String>,
48    pub metadata_line: Option<usize>,
49    pub null_values: Vec<String>,
50    pub skip_lines: Option<usize>,
51    pub columns: Vec<Derived>,
52    /// Columns of the file read as a declared type, by name.
53    pub types: Vec<(String, crate::column_types::ColumnType)>,
54}
55
56/// The `key="value"` line at the top of a file.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Metadata {
59    /// The line as it is in the file, without its comment prefix and line break.
60    pub raw: String,
61    /// The leading item with no `=`, such as `device_info` in `#device_info, a="1"`.
62    pub title: Option<String>,
63    /// Key and value, in the order the line has them. Empty when the line does not
64    /// parse, and then `raw` is shown.
65    pub pairs: Vec<(String, String)>,
66}
67
68/// What a file's header lines say besides its column names.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct HeadFacts {
71    /// Each column's unit, by the name the column is shown with.
72    pub units: Vec<(String, String)>,
73    pub metadata: Option<Metadata>,
74}
75
76/// What a read through a delimited spec found, as the open carries it to the dataset.
77#[derive(Debug, Clone)]
78pub struct DelimitedRead {
79    pub spec: Arc<Spec>,
80    pub by: Chosen,
81    /// The other specs that matched as well as `spec`, by the same rule.
82    pub also: Vec<String>,
83    /// Each column's unit, by the name the column is shown with.
84    pub units: Vec<(String, String)>,
85    pub metadata: Option<Metadata>,
86    /// The file the header lines were read from, when more than one file was read.
87    pub facts_from: Option<String>,
88}
89
90impl DelimitedRead {
91    /// The read before its header lines are read: the spec and why.
92    pub fn chosen(spec: Arc<Spec>, by: Chosen, also: Vec<String>) -> Self {
93        Self {
94            spec,
95            by,
96            also,
97            units: Vec::new(),
98            metadata: None,
99            facts_from: None,
100        }
101    }
102
103    pub fn delimited(&self) -> &Delimited {
104        self.spec
105            .delimited
106            .as_deref()
107            .expect("a delimited read has a delimited spec")
108    }
109
110    /// The unit of the column shown as `column`.
111    pub fn unit_of(&self, column: &str) -> Option<&str> {
112        self.units
113            .iter()
114            .find(|(name, _)| name == column)
115            .map(|(_, unit)| unit.as_str())
116    }
117
118    /// The dataset's notes about the read: which spec read it and why, and what else
119    /// matched.
120    pub fn notes(&self) -> Vec<crate::notes::Note> {
121        let note = |summary: String, scope: String| crate::notes::Note {
122            summary,
123            scope,
124            read_as_text: None,
125            passed_over: None,
126        };
127        let from = self
128            .spec
129            .path
130            .as_ref()
131            .map_or_else(|| "the spec".to_string(), |p| p.display().to_string());
132        let mut notes = vec![note(
133            format!(
134                "read as {}, {}",
135                self.spec.name,
136                crate::formats::chosen_words(&self.spec, self.by)
137            ),
138            format!("from {from}"),
139        )];
140        if !self.also.is_empty() {
141            notes.push(note(
142                format!(
143                    "{} also {} this file",
144                    self.also.join(", "),
145                    if self.also.len() == 1 {
146                        "matches"
147                    } else {
148                        "match"
149                    }
150                ),
151                format!("by {}", self.by.words()),
152            ));
153        }
154        notes
155    }
156}
157
158/// The most lines [`Delimited::facts`] reads: the header lines and the metadata line
159/// sit at the top of a file.
160pub const MAX_HEAD_LINE: usize = 1000;
161
162impl Delimited {
163    /// `options` with the spec's dialect: each option the spec gives replaces what the
164    /// config said, but not a flag typed on the command line (#651), and the file is
165    /// read as CSV unless its name says TSV or PSV.
166    pub fn apply(&self, options: &mut crate::OpenOptions) {
167        let typed = options.typed_dialect;
168        if let Some(d) = self.delimiter.filter(|_| !typed.delimiter) {
169            options.delimiter = Some(d);
170        }
171        if let Some(c) = self.comment_char.as_ref().filter(|_| !typed.comment_char) {
172            options.comment_char = Some(c.clone());
173        }
174        if let Some(s) = self
175            .skip_initial_space
176            .filter(|_| !typed.skip_initial_space)
177        {
178            options.skip_initial_space = s;
179        }
180        if let Some(rows) = self.header_rows.as_ref().filter(|_| !typed.header_rows) {
181            options.header_rows = rows.name.clone();
182            // The unit line is a header line too: the data starts after the last one.
183            options.skip_lines = Some(options.skip_lines.unwrap_or(0).max(rows.last()));
184        }
185        if let Some(join) = &self.header_join {
186            options.header_join = join.clone();
187        }
188        if let Some(n) = self.skip_lines.filter(|_| !typed.skip_lines) {
189            options.skip_lines = Some(options.skip_lines.unwrap_or(0).max(n));
190        }
191        if !self.null_values.is_empty() {
192            // Applied again to a read again's options, so each value once.
193            let values = options.null_values.get_or_insert_with(Vec::new);
194            for value in &self.null_values {
195                if !values.contains(value) {
196                    values.push(value.clone());
197                }
198            }
199        }
200        if options
201            .format
202            .is_none_or(|f| crate::FileFormat::separator(f).is_none())
203        {
204            options.format = Some(crate::FileFormat::Csv);
205        }
206    }
207
208    /// The lines [`Self::facts`] reads, 1-based.
209    pub fn head_lines(&self) -> Vec<usize> {
210        let mut lines: Vec<usize> = self
211            .header_rows
212            .iter()
213            .flat_map(|rows| rows.name.iter().copied().chain(rows.unit))
214            .chain(self.metadata_line)
215            .collect();
216        lines.sort_unstable();
217        lines.dedup();
218        lines
219    }
220
221    /// The units and the metadata from the top of the text `source` holds, split on
222    /// `separator`. Only the lines the spec names are read.
223    pub fn facts(
224        &self,
225        source: impl BufRead,
226        separator: u8,
227        join: &str,
228    ) -> color_eyre::Result<HeadFacts> {
229        let wanted = self.head_lines();
230        if wanted.is_empty() {
231            return Ok(HeadFacts::default());
232        }
233        let lines = crate::csv_dialect::named_lines(source, &wanted)?;
234        Ok(self.facts_of(&wanted, &lines, separator, join))
235    }
236
237    /// [`Self::facts`] from `lines`, the lines `wanted` names as
238    /// [`crate::csv_dialect::named_lines`] read them; a line not among them is blank.
239    pub fn facts_of(
240        &self,
241        wanted: &[usize],
242        lines: &[Vec<u8>],
243        separator: u8,
244        join: &str,
245    ) -> HeadFacts {
246        let line = |n: usize| -> &[u8] {
247            wanted
248                .iter()
249                .position(|&w| w == n)
250                .map_or(&[][..], |i| lines[i].as_slice())
251        };
252        let comment = self.comment_char.as_deref();
253        let mut units = Vec::new();
254        if let Some(rows) = &self.header_rows
255            && let Some(unit) = rows.unit
256        {
257            let mut pieces: Vec<Vec<String>> = Vec::new();
258            for &row in &rows.name {
259                for (i, field) in
260                    crate::csv_dialect::header_fields(line(row), row, separator, comment)
261                        .into_iter()
262                        .enumerate()
263                {
264                    if pieces.len() <= i {
265                        pieces.resize_with(i + 1, Vec::new);
266                    }
267                    if !field.is_empty() {
268                        pieces[i].push(field);
269                    }
270                }
271            }
272            let names: Vec<String> = pieces.into_iter().map(|p| p.join(join)).collect();
273            let raw: Vec<PlSmallStr> = (1..=names.len())
274                .map(|i| format!("column_{i}").into())
275                .collect();
276            let shown = crate::csv_dialect::shown_names(&raw, Some(&names));
277            let unit_fields =
278                crate::csv_dialect::header_fields(line(unit), unit, separator, comment);
279            for (name, unit) in shown.into_iter().zip(unit_fields) {
280                // A derived column of the same name takes the column's place, and the
281                // unit was the text's.
282                if !unit.is_empty() && !self.columns.iter().any(|d| d.name == name) {
283                    units.push((name, unit));
284                }
285            }
286        }
287        let metadata = self.metadata_line.map(|n| {
288            let mut text = line(n);
289            if n == 1 {
290                text = text.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(text);
291            }
292            let text = String::from_utf8_lossy(text);
293            let text = text.trim_end_matches(['\n', '\r']);
294            let text = comment.and_then(|c| text.strip_prefix(c)).unwrap_or(text);
295            parse_metadata(text)
296        });
297        HeadFacts { units, metadata }
298    }
299
300    /// `lf` with the derived columns, each before the first column it is made from.
301    /// Lazy: nothing is read.
302    pub fn derive(&self, mut lf: LazyFrame) -> PolarsResult<LazyFrame> {
303        if self.columns.is_empty() {
304            return Ok(lf);
305        }
306        let schema = lf.collect_schema()?;
307        let mut order: Vec<PlSmallStr> = schema.iter_names().cloned().collect();
308        let mut exprs = Vec::with_capacity(self.columns.len());
309        for derived in &self.columns {
310            for from in &derived.from {
311                if !schema.contains(from) {
312                    polars_bail!(
313                        ColumnNotFound: "\"{}\" is made from \"{from}\", which the file has no column of",
314                        derived.name
315                    );
316                }
317            }
318            let name = PlSmallStr::from(derived.name.as_str());
319            if !order.contains(&name) {
320                let at = order
321                    .iter()
322                    .position(|c| c.as_str() == derived.from[0])
323                    .unwrap_or(order.len());
324                order.insert(at, name.clone());
325            }
326            exprs.push(derived.expr().alias(name));
327        }
328        Ok(lf
329            .with_columns(exprs)
330            .select(order.into_iter().map(col).collect::<Vec<_>>()))
331    }
332}
333
334/// `read` with the units and metadata from the header lines of the first of
335/// `paths`, read in `options`' dialect.
336pub fn read_facts(
337    read: &DelimitedRead,
338    paths: &[std::path::PathBuf],
339    options: &crate::OpenOptions,
340) -> color_eyre::Result<DelimitedRead> {
341    let separator = options.separator_or(
342        options
343            .format
344            .and_then(crate::FileFormat::separator)
345            .unwrap_or(b','),
346    );
347    let facts_of = |file: &std::path::Path| -> color_eyre::Result<HeadFacts> {
348        let compression = options
349            .compression
350            .or_else(|| crate::CompressionFormat::from_extension(file));
351        let source = crate::widgets::datatable::DataTableState::text_source(file, compression)
352            .map_err(|e| crate::error_display::in_file(file, e.into()))?;
353        read.delimited()
354            .facts(source, separator, &options.header_join)
355            .map_err(|e| crate::error_display::in_file(file, e))
356    };
357    // From the first file with a header: of several, one with nothing in it is
358    // passed over by the read too.
359    let mut found = None;
360    for file in paths {
361        match facts_of(file) {
362            Err(e) if paths.len() > 1 && crate::csv_dialect::is_blank_file(&e) => continue,
363            facts => {
364                found = Some((file, facts?));
365                break;
366            }
367        }
368    }
369    let Some((file, HeadFacts { units, metadata })) = found else {
370        return Ok(read.clone());
371    };
372    Ok(DelimitedRead {
373        units,
374        metadata,
375        facts_from: (paths.len() > 1).then(|| {
376            file.file_name().map_or_else(
377                || file.display().to_string(),
378                |n| n.to_string_lossy().into_owned(),
379            )
380        }),
381        ..read.clone()
382    })
383}
384
385/// What `datui formats check` prints for a delimited spec and, given `file`, the
386/// file's metadata, units and first `rows` rows as read with `base`, the command
387/// line's options, whose typed dialect flags win over the spec's.
388pub fn check(
389    spec: &Arc<Spec>,
390    file: Option<&std::path::Path>,
391    rows: usize,
392    base: &crate::OpenOptions,
393) -> Result<String, String> {
394    let delimited = spec
395        .delimited
396        .as_deref()
397        .ok_or_else(|| format!("error: {} is not a delimited spec\n", spec.name))?;
398    let mut out = String::new();
399    out.push_str(&format!("  delimited: {}\n", delimited.summary()));
400    for derived in &delimited.columns {
401        out.push_str(&format!(
402            "  {} = {} from {}\n",
403            derived.name,
404            derived.kind.name(),
405            derived.from.join(", ")
406        ));
407    }
408    for (name, ty) in &delimited.types {
409        let format = ty
410            .format
411            .as_ref()
412            .map_or_else(String::new, |f| format!(", format {f:?}"));
413        out.push_str(&format!("  {name}: {}{format}\n", ty.name()));
414    }
415    let typed = typed_summary(base);
416    if !typed.is_empty() {
417        out.push_str(&format!("  command line, over the spec: {typed}\n"));
418    }
419    let Some(file) = file else {
420        return Ok(out);
421    };
422    let fail = |out: &str, e: &color_eyre::Report| {
423        let said = crate::error_display::user_message_from_report(e, Some(file));
424        format!("{out}error: {said}\n")
425    };
426    let mut options = crate::OpenOptions {
427        format: crate::FileFormat::from_path(file),
428        ..base.clone()
429    };
430    delimited.apply(&mut options);
431    let chosen = DelimitedRead::chosen(spec.clone(), Chosen::SpecFile, Vec::new());
432    options.delimited = Some(Arc::new(chosen.clone()));
433    let read = read_facts(&chosen, &[file.to_path_buf()], &options).map_err(|e| fail(&out, &e))?;
434    if let Some(metadata) = &read.metadata {
435        if metadata.pairs.is_empty() {
436            out.push_str(&format!(
437                "metadata (not key=value pairs): {}\n",
438                metadata.raw
439            ));
440        } else {
441            let pairs: Vec<String> = metadata
442                .pairs
443                .iter()
444                .map(|(k, v)| format!("{k} = {v}"))
445                .collect();
446            let title = metadata
447                .title
448                .as_ref()
449                .map_or_else(String::new, |t| format!("{t}: "));
450            out.push_str(&format!("metadata: {title}{}\n", pairs.join(", ")));
451        }
452    }
453    if !read.units.is_empty() {
454        let units: Vec<String> = read
455            .units
456            .iter()
457            .map(|(column, unit)| format!("{column} = {unit}"))
458            .collect();
459        out.push_str(&format!("units: {}\n", units.join(", ")));
460    }
461    let separator = options.separator_or(b',');
462    let state =
463        crate::widgets::datatable::DataTableState::from_delimited(file, separator, &options)
464            .map_err(|e| fail(&out, &crate::error_display::in_file(file, e)))?;
465    let df = state
466        .lf()
467        .clone()
468        .limit(rows as IdxSize)
469        .collect()
470        .map_err(|e| fail(&out, &crate::error_display::in_file(file, e.into())))?;
471    out.push_str(&crate::formats::text_table(&df));
472    Ok(out)
473}
474
475/// The dialect flags typed on the command line, in a line: `delimiter ','`.
476fn typed_summary(options: &crate::OpenOptions) -> String {
477    let typed = options.typed_dialect;
478    let mut said = Vec::new();
479    if let Some(d) = options.delimiter.filter(|_| typed.delimiter) {
480        said.push(format!("delimiter {:?}", d as char));
481    }
482    if let Some(c) = options.comment_char.as_ref().filter(|_| typed.comment_char) {
483        said.push(format!("comment {c:?}"));
484    }
485    if typed.skip_initial_space {
486        said.push(format!("skip initial space {}", options.skip_initial_space));
487    }
488    if typed.header_rows {
489        let rows: Vec<String> = options.header_rows.iter().map(usize::to_string).collect();
490        said.push(format!("header rows {}", rows.join(",")));
491    }
492    if let Some(n) = options.skip_lines.filter(|_| typed.skip_lines) {
493        said.push(format!("skip lines {n}"));
494    }
495    said.join(", ")
496}
497
498impl Delimited {
499    /// The options the spec sets, in a line: `header line 3, unit line 2, ...`.
500    pub fn summary(&self) -> String {
501        let lines = |rows: &[usize]| {
502            let rows: Vec<String> = rows.iter().map(usize::to_string).collect();
503            rows.join(" + ")
504        };
505        let mut said = Vec::new();
506        if let Some(d) = self.delimiter {
507            said.push(format!("delimiter {:?}", d as char));
508        }
509        if let Some(rows) = &self.header_rows {
510            said.push(format!("names on line {}", lines(&rows.name)));
511            if let Some(unit) = rows.unit {
512                said.push(format!("units on line {unit}"));
513            }
514        }
515        if let Some(n) = self.metadata_line {
516            said.push(format!("metadata on line {n}"));
517        }
518        if let Some(c) = &self.comment_char {
519            said.push(format!("comments start {c:?}"));
520        }
521        if self.skip_initial_space == Some(true) {
522            said.push("skip initial space".to_string());
523        }
524        if let Some(n) = self.skip_lines {
525            said.push(format!("skip {n} lines"));
526        }
527        if !self.null_values.is_empty() {
528            said.push(format!("null {}", self.null_values.join(", ")));
529        }
530        if said.is_empty() {
531            "CSV with a header line".to_string()
532        } else {
533            said.join(", ")
534        }
535    }
536}
537
538/// `name, key="value", key=value`: the items separated by commas outside quotes. A
539/// first item with no `=` is the title. A line with anything else, or with no pairs,
540/// is kept raw only.
541pub fn parse_metadata(line: &str) -> Metadata {
542    let raw = line.trim().to_string();
543    let mut items = Vec::new();
544    let mut item = String::new();
545    let mut quoted = false;
546    for c in line.chars() {
547        match c {
548            '"' => {
549                quoted = !quoted;
550                item.push(c);
551            }
552            ',' if !quoted => items.push(std::mem::take(&mut item)),
553            c => item.push(c),
554        }
555    }
556    items.push(item);
557    let unparsed = |raw: String| Metadata {
558        raw,
559        title: None,
560        pairs: Vec::new(),
561    };
562    if quoted {
563        return unparsed(raw);
564    }
565    let mut title = None;
566    let mut pairs = Vec::new();
567    for (i, item) in items.iter().map(|s| s.trim()).enumerate() {
568        if item.is_empty() {
569            continue;
570        }
571        match item.split_once('=') {
572            Some((key, value)) => {
573                let key = key.trim();
574                if key.is_empty() || key.contains('"') {
575                    return unparsed(raw);
576                }
577                pairs.push((key.to_string(), unquote(value.trim())));
578            }
579            None if i == 0 && !item.contains('"') => title = Some(item.to_string()),
580            None => return unparsed(raw),
581        }
582    }
583    if pairs.is_empty() {
584        return unparsed(raw);
585    }
586    Metadata { raw, title, pairs }
587}
588
589/// `"a ""b"""` is `a "b"`; a value with no quotes around it is itself.
590fn unquote(value: &str) -> String {
591    match value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
592        Some(inner) => inner.replace("\"\"", "\""),
593        None => value.to_string(),
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn a_metadata_line_parses_into_pairs_and_a_title() {
603        let m = parse_metadata(r#"device_info, log_version="1.03", model="X, Y", serial=123,"#);
604        assert_eq!(m.title.as_deref(), Some("device_info"));
605        assert_eq!(
606            m.pairs,
607            [
608                ("log_version".to_string(), "1.03".to_string()),
609                ("model".to_string(), "X, Y".to_string()),
610                ("serial".to_string(), "123".to_string()),
611            ]
612        );
613    }
614
615    #[test]
616    fn a_line_that_does_not_parse_is_kept_raw() {
617        for line in [
618            "just some words",
619            "a=1, stray",
620            r#"a="open"#,
621            "=1",
622            "title only",
623        ] {
624            let m = parse_metadata(line);
625            assert!(m.pairs.is_empty(), "{line}: {m:?}");
626            assert_eq!(m.raw, line.trim());
627        }
628    }
629
630    fn spec() -> Delimited {
631        Delimited {
632            comment_char: Some("#".into()),
633            header_rows: Some(HeaderRows {
634                name: vec![3],
635                unit: Some(2),
636            }),
637            metadata_line: Some(1),
638            ..Default::default()
639        }
640    }
641
642    #[test]
643    fn units_follow_the_shown_names_and_the_metadata_line_is_read() {
644        let text = "#device_info, version=\"2\"\n#yyyy-mm-dd, hh:mm, volts,\n  Date, Time,  Volts, Volts,\n";
645        let HeadFacts { units, metadata } = spec().facts(text.as_bytes(), b',', " ").unwrap();
646        assert_eq!(
647            units,
648            [
649                ("Date".to_string(), "yyyy-mm-dd".to_string()),
650                ("Time".to_string(), "hh:mm".to_string()),
651                ("Volts".to_string(), "volts".to_string()),
652            ]
653        );
654        let metadata = metadata.unwrap();
655        assert_eq!(metadata.title.as_deref(), Some("device_info"));
656        assert_eq!(metadata.pairs, [("version".to_string(), "2".to_string())]);
657    }
658
659    #[test]
660    fn a_derived_column_that_takes_a_name_does_not_take_its_unit() {
661        let text = "#m=1\nyyyy-mm-dd, hh:mm\nDate, Time\n";
662        let spec = Delimited {
663            columns: vec![Derived {
664                name: "Time".into(),
665                from: vec!["Date".into(), "Time".into()],
666                kind: DerivedKind::Datetime,
667                format: None,
668            }],
669            ..spec()
670        };
671        let facts = spec.facts(text.as_bytes(), b',', " ").unwrap();
672        assert_eq!(
673            facts.units,
674            [("Date".to_string(), "yyyy-mm-dd".to_string())]
675        );
676    }
677
678    #[test]
679    fn check_names_the_file_and_why_it_could_not_be_read() {
680        let dir = tempfile::tempdir().unwrap();
681        let short = dir.path().join("short.csv");
682        std::fs::write(&short, "a,b\n1,2\n").unwrap();
683        let text = "name = \"a.log\"\nkind = \"delimited\"\nheader_rows = 3";
684        let spec = Arc::new(Spec::parse(text, None).unwrap());
685        for (file, said) in [
686            (
687                short,
688                "short.csv\": Header line 3 is past the end of the file.",
689            ),
690            (
691                dir.path().join("none.csv"),
692                "none.csv\": File or directory not found.",
693            ),
694        ] {
695            let e = check(&spec, Some(&file), 5, &crate::OpenOptions::default()).unwrap_err();
696            assert!(e.contains(said), "{e}");
697        }
698    }
699
700    /// A flag typed on the command line keeps its value; the rest come from the spec
701    /// (#651).
702    #[test]
703    fn apply_leaves_typed_flags() {
704        let mut options = crate::OpenOptions {
705            delimiter: Some(b','),
706            comment_char: Some("%".into()),
707            typed_dialect: crate::TypedDialect {
708                delimiter: true,
709                ..Default::default()
710            },
711            ..Default::default()
712        };
713        let spec = Delimited {
714            delimiter: Some(b';'),
715            ..spec()
716        };
717        spec.apply(&mut options);
718        assert_eq!(options.delimiter, Some(b','), "typed");
719        assert_eq!(options.comment_char.as_deref(), Some("#"), "not typed");
720    }
721
722    #[test]
723    fn apply_sets_the_dialect_and_skips_every_header_line() {
724        let mut options = crate::OpenOptions::default();
725        let spec = Delimited {
726            header_rows: Some(HeaderRows {
727                name: vec![2],
728                unit: Some(3),
729            }),
730            null_values: vec!["NA".into()],
731            ..spec()
732        };
733        spec.apply(&mut options);
734        assert_eq!(options.header_rows, [2]);
735        assert_eq!(options.skip_lines, Some(3));
736        assert_eq!(options.comment_char.as_deref(), Some("#"));
737        assert_eq!(options.null_values, Some(vec!["NA".to_string()]));
738        assert_eq!(options.format, Some(crate::FileFormat::Csv));
739    }
740
741    #[test]
742    fn a_datetime_from_date_time_and_offset_is_utc() {
743        let df = df!(
744            "d" => [Some("2024-03-05"), None, Some("2024-03-05")],
745            "t" => [Some("14:03:22"), None, Some("23:30:00")],
746            "o" => [Some("-05:00"), None, Some("+0530")],
747            "v" => [1, 2, 3],
748        )
749        .unwrap();
750        let spec = Delimited {
751            columns: vec![Derived {
752                name: "time".into(),
753                from: vec!["d".into(), "t".into(), "o".into()],
754                kind: DerivedKind::Datetime,
755                format: None,
756            }],
757            ..Default::default()
758        };
759        let out = spec.derive(df.lazy()).unwrap().collect().unwrap();
760        assert_eq!(
761            out.get_column_names()
762                .iter()
763                .map(|n| n.as_str())
764                .collect::<Vec<_>>(),
765            ["time", "d", "t", "o", "v"],
766            "before its first source"
767        );
768        let time = out.column("time").unwrap();
769        assert_eq!(
770            time.dtype(),
771            &DataType::Datetime(TimeUnit::Microseconds, Some(TimeZone::UTC))
772        );
773        let shown: Vec<String> = (0..3).map(|i| time.get(i).unwrap().to_string()).collect();
774        assert_eq!(shown[0], "2024-03-05 19:03:22 UTC");
775        assert_eq!(shown[1], "null");
776        assert_eq!(shown[2], "2024-03-05 18:00:00 UTC");
777    }
778
779    /// Every way a delimited spec or the file it reads is refused names the file, in
780    /// the one shape; a spec's problem with its line and column.
781    #[test]
782    fn errors_name_the_file() {
783        use crate::readers::bad_input::assert_shape;
784        let dir = tempfile::tempdir().unwrap();
785        let spec_path = dir.path().join("log.toml");
786        let text = "name = \"a.log\"\nkind = \"delimited\"\nheader_rows = \"three\"\n";
787        let e = Spec::parse(text, Some(&spec_path)).unwrap_err().to_string();
788        eprintln!("{e}");
789        assert_shape(&e, &spec_path);
790        assert!(e.contains(":3:"), "{e}");
791
792        let data = dir.path().join("a.csv");
793        std::fs::write(&data, "a\n1\n").unwrap();
794        let spec = Delimited {
795            columns: vec![Derived {
796                name: "day".into(),
797                from: vec!["date".into()],
798                kind: DerivedKind::Date,
799                format: None,
800            }],
801            ..Default::default()
802        };
803        let lf = LazyCsvReader::new(PlRefPath::new(data.to_string_lossy().as_ref()))
804            .finish()
805            .unwrap();
806        let Err(e) = spec.derive(lf) else {
807            panic!("a missing column is an error");
808        };
809        let e = crate::error_display::user_message_from_report(&e.into(), Some(&data));
810        eprintln!("{e}");
811        assert_shape(&e, &data);
812        assert!(e.contains("\"date\""), "{e}");
813
814        let text = "name = \"a.log\"\nkind = \"delimited\"\nheader_rows = 3";
815        let spec = Arc::new(Spec::parse(text, None).unwrap());
816        let read = DelimitedRead::chosen(spec, Chosen::SpecFile, Vec::new());
817        for file in [data.clone(), dir.path().join("none.csv")] {
818            let e = read_facts(
819                &read,
820                std::slice::from_ref(&file),
821                &crate::OpenOptions::default(),
822            )
823            .unwrap_err();
824            let e = crate::error_display::user_message_from_report(&e, Some(&file));
825            eprintln!("{e}");
826            assert_shape(&e, &file);
827        }
828    }
829
830    #[test]
831    fn a_missing_source_column_is_named() {
832        let df = df!("a" => [1]).unwrap();
833        let spec = Delimited {
834            columns: vec![Derived {
835                name: "day".into(),
836                from: vec!["date".into()],
837                kind: DerivedKind::Date,
838                format: None,
839            }],
840            ..Default::default()
841        };
842        let Err(err) = spec.derive(df.lazy()) else {
843            panic!("a missing column is an error");
844        };
845        let err = err.to_string();
846        assert!(
847            err.contains("\"day\" is made from \"date\", which the file has no column of"),
848            "{err}"
849        );
850    }
851}