1use crate::formats::{Chosen, Spec};
12use polars::prelude::*;
13use std::io::BufRead;
14use std::sync::Arc;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct HeaderRows {
19 pub name: Vec<usize>,
21 pub unit: Option<usize>,
23}
24
25impl HeaderRows {
26 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#[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 pub types: Vec<(String, crate::column_types::ColumnType)>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Metadata {
59 pub raw: String,
61 pub title: Option<String>,
63 pub pairs: Vec<(String, String)>,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct HeadFacts {
71 pub units: Vec<(String, String)>,
73 pub metadata: Option<Metadata>,
74}
75
76#[derive(Debug, Clone)]
78pub struct DelimitedRead {
79 pub spec: Arc<Spec>,
80 pub by: Chosen,
81 pub also: Vec<String>,
83 pub units: Vec<(String, String)>,
85 pub metadata: Option<Metadata>,
86 pub facts_from: Option<String>,
88}
89
90impl DelimitedRead {
91 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 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 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
158pub const MAX_HEAD_LINE: usize = 1000;
161
162impl Delimited {
163 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 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 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 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 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 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 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 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
334pub 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 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
385pub 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
475fn 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 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
538pub 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
589fn 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 #[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 #[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}