Skip to main content

spreadsheet_to_json/
options.rs

1use heck::ToSnakeCase;
2use indexmap::IndexMap;
3use serde_json::{json, Error, Value};
4use simple_string_patterns::{SimpleMatch, StripCharacters};
5use to_segments::ToSegments;
6use std::{path::Path, str::FromStr, sync::Arc};
7use enclose_strings::SimpleExtract;
8
9use is_truthy::TruthyRuleSet;
10use crate::key_segment::KeySegment;
11/// default max number of rows in direct single sheet mode without an override via ->max_row_count(max_row_count)
12pub const DEFAULT_MAX_ROWS: usize = 10_000;
13/// default max number of rows multiple sheet preview mode without an override via ->max_row_count(max_row_count)
14pub const DEFAULT_MAX_ROWS_PREVIEW: usize = 1000;
15
16/// How a datetime-bearing cell is rendered. `Full` is the ordinary complete ISO datetime;
17/// the other three each discard progressively more of it. Used both as `RowOptionSet`'s
18/// row-wide default and as `Column`'s per-column override for genuine datetime cells --
19/// see the doc comments on each for how the two combine.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub enum DateTimeMode {
22  #[default]
23  Full, // complete date and time with milliseconds and a trailing Z, e.g.
24        // "2023-06-15T10:17:00.000Z" -- the default, JS-interop-friendly form
25  Simple, // complete date and time, but without milliseconds or a trailing Z, e.g.
26          // "2023-06-15T10:17:00"
27  DateOnly, // date component only, e.g. "2023-06-15"
28  TimeOnly, // time-of-day only, with seconds, e.g. "10:17:00"
29  HmOnly, // time-of-day only, hours and minutes, e.g. "10:17" -- for values better read as
30          // a plain clock time (a start/end time, a recurring daily slot) than a precise
31          // duration down to the second
32}
33
34impl std::fmt::Display for DateTimeMode {
35  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36    let result = match self {
37      Self::Full => "date/time",
38      Self::Simple => "simple date/time",
39      Self::DateOnly => "date only",
40      Self::TimeOnly => "time only",
41      Self::HmOnly => "hours:minutes only",
42    };
43    write!(f, "{}", result)
44  }
45}
46
47/// Row parsing options with nested column options
48#[derive(Debug, Clone, Default)]
49pub struct RowOptionSet {
50  pub columns: Vec<Column>,
51  pub decimal_comma: bool, // always parse as euro number format
52  /// Row-wide default rendering mode for datetime cells (genuine `Data::DateTime`/
53  /// `Data::DateTimeIso` cells, and any string/CSV cell under an explicit
54  /// `Format::Date`/`Format::Time`/`Format::Hm`/`Format::DateTime` override). A column's
55  /// own `Format` override, or its own `datetime_mode` on a `Format::Auto` column, takes
56  /// precedence over this row-wide default; see `Column::datetime_mode`.
57  pub datetime_mode: DateTimeMode,
58  /// When set, any key whose value is JSON `null` is dropped from the row entirely
59  /// (recursively, through nested objects built via `KeySegment::Object`/`Array`/
60  /// `InnerObject` too) rather than being emitted as `"key": null`. Only ever targets
61  /// genuine `Value::Null` -- an empty string ("") is a different, deliberate value and
62  /// is left alone. Off by default: existing output is unchanged unless opted into.
63  pub omit_null_values: bool,
64}
65
66impl RowOptionSet {
67
68  // simple constructor with column keys only
69  pub fn simple(cols: &[Column]) -> Self {
70    RowOptionSet {
71      decimal_comma: false,
72      datetime_mode: DateTimeMode::Full,
73      omit_null_values: false,
74      columns: cols.to_vec()
75    }
76  }
77
78  // lets you set all options
79  pub fn new(cols: &[Column], decimal_comma: bool, datetime_mode: DateTimeMode) -> Self {
80    RowOptionSet {
81      decimal_comma,
82      datetime_mode,
83      omit_null_values: false,
84      columns: cols.to_vec()
85    }
86  }
87
88  pub fn column(&self, index: usize) -> Option<&Column> {
89    self.columns.get(index)
90  }
91
92  pub fn date_mode(&self) -> String {
93    self.datetime_mode.to_string()
94  }
95
96  pub fn decimal_separator(&self) -> String {
97    if self.decimal_comma {
98      ","
99    } else {
100      "."
101    }.to_string()
102  }
103}
104
105/// Core options with nested row options
106#[derive(Debug, Clone, Default)]
107pub struct OptionSet {
108  pub selected: Option<Vec<String>>, // Optional sheet name reference. Will default to index value if not matched
109  pub indices: Vec<u32>, // worksheet index
110  pub path: Option<String>, // path argument. If None, do not attempt to parse
111  pub rows: RowOptionSet,
112  pub jsonl: bool,
113  pub max: Option<u32>,
114  pub omit_header: bool,
115  /// 0-based row index of the header row. `None` means unset -- when `data_row_index` is
116  /// also unset (and headers aren't omitted), the reader runs a best-guess detection
117  /// pass instead of blindly assuming row 0; see `detect::detect_header_and_data_rows`.
118  pub header_row: Option<usize>,
119  /// 0-based row index where actual data begins. `None` (the default) means immediately
120  /// after the header row (or triggers detection, per `header_row`'s doc above). Rows
121  /// strictly between the header row and this one are skipped entirely -- neither
122  /// captured as headers nor as data -- for spreadsheets that leave a note, blank, or
123  /// subtitle row between the header and the first real data row.
124  pub data_row_index: Option<usize>,
125  /// Number of consecutive rows, starting at the header row, that together form the
126  /// header -- e.g. a spreadsheet with a merged "2015"/"2010" year row followed by a
127  /// "Female"/"Male" sub-label row underneath needs a span of 2. Column keys are built
128  /// by forward-filling blanks *within* each header row independently (so a value from a
129  /// merged cell -- which calamine only ever reports in that cell's top-left position,
130  /// leaving the rest of the merge blank -- carries across the columns it visually spans)
131  /// and then joining each column's non-blank values down the span with `_`. Defaults to
132  /// `1` -- a single header row, today's only behavior -- via `effective_header_row_span`
133  /// rather than this field's own zero value, so `OptionSet::default()` (whose derived
134  /// `Default` gives `0`, not `1`) never accidentally changes behavior.
135  pub header_row_span: usize,
136  /// Whether to run best-guess header/data-row detection (see
137  /// `detect::detect_header_and_data_rows`) when both `header_row` and `data_row_index`
138  /// are unset, instead of assuming row 0 is the header. Off (`false`) by default for
139  /// direct library use, so `OptionSet::new(path)` alone always behaves the same simple,
140  /// predictable way it always has -- callers that want detection opt in explicitly via
141  /// `.detect_header()`. Consumers like `spread-cli` that want it as *their own* default
142  /// user experience turn it on unconditionally when building their `OptionSet`.
143  pub detect_header: bool,
144  pub read_mode: ReadMode,
145  pub field_mode: FieldNameMode
146}
147
148impl OptionSet {
149  /// Instantiates a new option set with a path string for file operations.
150  pub fn new(path_str: &str) -> Self {
151    OptionSet {
152        selected: None,
153        indices: vec![0],
154        path: Some(path_str.to_string()),
155        rows: RowOptionSet::default(),
156        jsonl: false,
157        max: None,
158        omit_header: false,
159        header_row: None,
160        data_row_index: None,
161        header_row_span: 1,
162        detect_header: false,
163        read_mode: ReadMode::Sync,
164        field_mode: FieldNameMode::AutoA1,
165    }
166  }
167
168  /// Sets the sheet name for the operation.
169  pub fn sheet_name(mut self, name: &str) -> Self {
170    self.selected = Some(vec![name.to_string()]);
171    self
172  }
173
174  /// Sets the sheet name for the operation.
175  pub fn sheet_names(mut self, names: &[String]) -> Self {
176    self.selected = Some(names.to_vec());
177    self
178  }
179
180  /// Sets the sheet index.
181  pub fn sheet_index(mut self, index: u32) -> Self {
182      self.indices = vec![index];
183      self
184  }
185
186  /// Sets the sheet index.
187  pub fn sheet_indices(mut self, indices: &[u32]) -> Self {
188    self.indices = indices.to_vec();
189    self
190}
191
192  /// Sets JSON Lines mode to true.
193  pub fn json_lines(mut self) -> Self {
194      self.jsonl = true;
195      self
196  }
197
198  /// Sets JSON Lines mode
199  pub fn set_json_lines(mut self, mode: bool) -> Self {
200    self.jsonl = mode;
201    self
202  }
203
204  /// Omits the header when reading.
205  pub fn omit_header(mut self) -> Self {
206      self.omit_header = true;
207      self
208  }
209
210  /// Sets the header row's 0-based row index.
211  pub fn header_row(mut self, row: usize) -> Self {
212      self.header_row = Some(row);
213      self
214  }
215
216  /// Sets the 0-based row index where actual data begins. Rows between the header row
217  /// and this one are skipped entirely, for spreadsheets that leave a note or blank row
218  /// between the header and the first real data row. If set at or before the header row,
219  /// this is ignored and data capture falls back to starting immediately after the
220  /// header row instead.
221  pub fn data_row_index(mut self, row: usize) -> Self {
222      self.data_row_index = Some(row);
223      self
224  }
225
226  /// Sets the number of consecutive rows, starting at the header row, that together
227  /// form the header -- see `header_row_span`'s field doc for what multi-row headers
228  /// this is for.
229  pub fn header_row_span(mut self, span: usize) -> Self {
230      self.header_row_span = span;
231      self
232  }
233
234  /// `header_row_span`, normalized to at least `1` -- the field's own derived-Default
235  /// value is `0`, which isn't a meaningful span (a header can't span zero rows), so
236  /// every consumer reads it through this method rather than the raw field.
237  pub fn effective_header_row_span(&self) -> usize {
238      self.header_row_span.max(1)
239  }
240
241  /// Opts into best-guess header/data-row detection when both `header_row` and
242  /// `data_row_index` are left unset, instead of the library's normal default of
243  /// assuming row 0 is the header. Off by default -- see the `detect_header` field doc.
244  pub fn detect_header(mut self) -> Self {
245      self.detect_header = true;
246      self
247  }
248
249  /// Sets the maximum number of rows to read.
250  pub fn max_row_count(mut self, max: u32) -> Self {
251      self.max = Some(max);
252      self
253  }
254
255  /// Sets the read mode to asynchronous, single sheet mode
256  /// This is for reading long files with 10K+ rows in the target sheet
257  pub fn read_mode_async(mut self) -> Self {
258      self.read_mode = ReadMode::Async;
259      self
260  }
261
262   /// Sets the read mode to direct with multiple sheet output
263   /// This serves to fetch quick a overview of a spreadsheet
264   pub fn read_mode_preview(mut self) -> Self {
265    self.read_mode = ReadMode::PreviewMultiple;
266    self
267}
268
269  /// Sets read mode from a range of common key names
270  /// async, preview or sync (default) with synonyms such as `a`, `p` and `s`
271  /// If the key is unmatched, it will always default to Sync
272  pub fn set_read_mode(mut self, key: &str) -> Self {
273    self.read_mode = ReadMode::from_key(key);
274    self
275  }
276
277  pub fn multimode(&self) -> bool {
278    self.read_mode.is_multimode()
279  }
280
281  pub fn file_name(&self) -> Option<String> {
282    if let Some(path_str) = self.path.clone() {
283      Path::new(&path_str).file_name().map(|f| f.to_string_lossy().to_string())
284    } else {
285      None
286    }
287  }
288
289  /// Override matched and unmatched headers with custom headers.
290  pub fn override_headers(mut self, keys: &[&str]) -> Self {
291    let mut columns: Vec<Column> = Vec::with_capacity(keys.len());
292    for ck in keys {
293        columns.push(Column::new(Some(&ck.to_snake_case())));
294    }
295    self.rows = RowOptionSet::simple(&columns);
296    self
297  }
298
299  /// Override matched and unmatched columns with custom keys and/or formatting options
300  pub fn override_columns(mut self, cols: &[Value]) -> Self {
301    let mut columns: Vec<Column> = Vec::with_capacity(cols.len());
302    for json_value in cols {
303        columns.push(Column::from_json(json_value));
304    }
305    self.rows = RowOptionSet::simple(&columns);
306    self
307  }
308
309  /// Sets the column key naming convention.
310  pub fn field_name_mode(mut self, system: &str, override_header: bool) -> Self {
311      self.field_mode = FieldNameMode::from_key(system, override_header);
312      self
313  }
314
315  pub fn row_mode(&self) -> String {
316    if self.jsonl {
317      "JSON lines"
318    } else {
319      "JSON"
320    }.to_string()
321  }
322
323  pub fn header_mode(&self) -> String {
324    if self.omit_header {
325      "ignore"
326    } else {
327      "capture"
328    }.to_string()
329  }
330
331  /// render option output contextually as JSON
332  pub fn to_json(&self) -> Value {
333    
334    let mut output: IndexMap<String, Value> = IndexMap::new();
335    if let Some(selected) =  self.selected.clone() {
336      let selected = if self.multimode() {
337        json!({
338          "sheets": selected,
339          "indices": self.indices.clone()
340        })
341      } else {
342        json!({
343          "sheet": selected.first().unwrap_or(&"".to_string()),
344          "index": self.indices.first().unwrap_or(&0)
345        })
346      };
347      output.insert("selected".to_string(), selected);
348    }
349    if let Some(fname) = self.file_name() {
350      output.insert("file name".to_string(), fname.into());
351    }
352    if let Some(max_val) = self.max {
353      output.insert("max".to_string(), max_val.into());
354    }
355    output.insert("omit_header".to_string(), self.omit_header.into());
356    output.insert("header_row".to_string(), self.header_row.into());
357    output.insert("data_row_index".to_string(), self.data_row_index.into());
358    output.insert("detect_header".to_string(), self.detect_header.into());
359    output.insert("read_mode".to_string(), self.read_mode.to_string().into());
360    output.insert("jsonl".to_string(), self.jsonl.into());
361    output.insert("decimal_separator".to_string(), self.rows.decimal_separator().into());
362    output.insert("date_mode".to_string(), self.rows.date_mode().into());
363    if !self.columns().is_empty() {
364      let columns: Vec<Value> = self.rows.columns.clone().into_iter().map(|c| c.to_json()).collect();
365      output.insert("columns".to_string(), columns.into());
366    }
367    json!(output)
368  }
369
370  pub fn index_list(&self) -> String {
371    self.indices.clone().into_iter().map(|s| s.to_string()).collect::<Vec<String>>().join(", ")
372  }
373
374  /// render option output contextually as a list of strings
375  /// for use in a terminal or text output
376  pub fn to_lines(&self) -> Vec<String> {
377    let mut lines = vec![];
378    if let Some(s_names) = self.selected.clone() {
379      let plural = if s_names.len() > 1 {
380        "s"
381      } else {
382        ""
383      };
384      lines.push(format!("sheet name{}: {}", plural, s_names.join(",")));
385    } else if !self.indices.is_empty() {
386      lines.push(format!("sheet indices: {}", self.index_list()));
387    }
388    if let Some(fname) = self.file_name() {
389      lines.push(format!("file name: {}", fname));
390    }
391    if self.max.is_some() {
392      let max_val = self.max.unwrap_or(0);
393      if max_val > 0 {
394        lines.push(format!("max rows: {}", max_val));
395      }
396    }
397    lines.extend(vec![
398      format!("mode: {}", self.row_mode()),
399      format!("headers: {}", self.header_mode()),
400      format!("header row: {}", self.header_row.map(|v| v.to_string()).unwrap_or_else(|| if self.detect_header { "auto-detect".to_string() } else { "0 (default)".to_string() })),
401      format!("data row index: {}", self.data_row_index.map(|v| v.to_string()).unwrap_or_else(|| "default (immediately after header)".to_string())),
402      format!("decimal separator: {}", self.rows.decimal_separator()),
403      format!("date mode: {}", self.rows.date_mode()),
404      format!("column style: {}", self.field_mode.to_string())
405    ]);
406
407    if !self.columns().is_empty() {
408      lines.push("columns:".to_string());
409      for col in self.rows.columns.clone() {
410        lines.push(col.to_line());
411      }
412    }
413    lines
414  }
415
416  /// 0-based header row index, resolved *without* auto-detection -- `header_row` if set,
417  /// row 0 otherwise. Detection (see `detect::detect_header_and_data_rows`) only runs
418  /// when both `header_row` and `data_row_index` are unset, and requires sample row data
419  /// this method doesn't have access to; readers check for that case themselves before
420  /// falling back to this method.
421  pub fn header_row_index(&self) -> usize {
422    self.header_row.unwrap_or(0)
423  }
424
425  /// 0-based absolute row index at which data capture may begin, combining `header_row`,
426  /// `data_row_index`, and `omit_header`. `None` when nothing is customized (header row
427  /// 0, no explicit data row) -- meaning no additional gating beyond the ordinary "first
428  /// row after the header" behavior applies, so callers can skip this check entirely
429  /// rather than compute a value that changes nothing.
430  ///
431  /// `data_row_index` is honored literally whenever it's at or after the header row --
432  /// including *equal to* the header row, which is a legitimate (if rare) configuration:
433  /// a CSV with predefined/external headers where no line is actually consumed as a
434  /// header, or a sheet that inherits its column names from elsewhere and has no header
435  /// line of its own. It's only treated as unset (falling back to the default below) when
436  /// it's strictly *before* the header row, which is never meaningful.
437  ///
438  /// The default when `data_row_index` is unset depends on whether a header row is
439  /// actually being consumed: immediately after the header row when one is (the common
440  /// case), or right at the header row itself when `omit_header` is set -- since then no
441  /// row is being consumed for headers in the first place, so there's nothing to skip
442  /// past.
443  pub fn first_data_row_index(&self) -> Option<usize> {
444    if self.header_row.is_none() && self.data_row_index.is_none() && self.effective_header_row_span() <= 1 {
445      return None;
446    }
447    let header_row_index = self.header_row_index();
448    let default_start = if self.omit_header { header_row_index } else { header_row_index + self.effective_header_row_span() };
449    match self.data_row_index {
450      Some(requested) if requested >= header_row_index => Some(requested),
451      _ => Some(default_start),
452    }
453  }
454
455  /// get the maximum of rows to be output synchronously
456  pub fn max_rows(&self) -> usize {
457    if let Some(mr) = self.max {
458      mr as usize
459    } else {
460      match self.read_mode {
461        ReadMode::PreviewMultiple => DEFAULT_MAX_ROWS_PREVIEW,
462        _ => DEFAULT_MAX_ROWS
463      }
464    }
465  }
466
467  /// future development with advanced column options
468  #[allow(dead_code)]
469  pub fn columns(&self) -> Vec<Column> {
470    self.rows.columns.clone()
471  }
472
473  /// cloned read mode
474  pub fn read_mode(&self) -> ReadMode {
475    self.read_mode
476  }
477
478  /// Needs full data set to processed later
479  pub fn is_async(&self) -> bool {
480    self.read_mode.is_async()
481  }
482
483  // Should rows be captured synchronously
484  pub fn capture_rows(&self) -> bool {
485    !matches!(self.read_mode, ReadMode::Async)
486  }
487
488}
489
490
491/// Cell format overrides
492#[derive(Debug, Clone)]
493pub enum Format {
494  Auto, // automatic interpretation
495  Text, // text
496  Integer, // integer only
497  Decimal(u8), // decimal to stated precision
498  Float, // f64 
499  Boolean, // Boolean or  cast to boolean from integers
500  Date, // Interpret as date only
501  DateTime, // Interpret as full datetime
502  DateTimeSimple, // Interpret as full datetime, without milliseconds or a trailing Z
503  Time, // Interpret as time-of-day only, discarding any date component
504  Hm, // Interpret as hours:minutes only, discarding seconds and any date component
505  DateTimeCustom(Arc<str>),
506  Truthy, // interpret common yes/no, y/n, true/false text strings as true/false
507  #[allow(dead_code)]
508  TruthyCustom(TruthyRuleSet), // define custom yes/no values
509  /// Splits a delimited string cell into an array, formatting each piece as the given
510  /// element Format -- e.g. Format::Array(Format::Float, ",") turns "34.8,78.3" into
511  /// [34.8, 78.3]. `Arc<Format>` rather than a bare `Format`, since a bare recursive
512  /// field would give Format infinite size; `Arc` (not `Box`) for the same cheap-clone
513  /// reasoning already applied to the rest of this crate's recursive/repeatedly-cloned
514  /// types (Format is cloned per column during resolution, same as Column's own fields).
515  Array(Arc<Format>, Arc<str>),
516}
517
518impl std::fmt::Display for Format {
519  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520    let result = match self {
521      Self::Auto => "auto".to_string(),
522      Self::Text => "text".to_string(),
523      Self::Integer => "integer".to_string(),
524      Self::Decimal(n) => format!("decimal({})", n),
525      Self::Float => "float".to_string(),
526      Self::Boolean => "boolean".to_string(),
527      Self::Date => "date".to_string(),
528      Self::DateTime => "datetime".to_string(),
529      Self::DateTimeSimple => "datetime_simple".to_string(),
530      Self::Time => "time".to_string(),
531      Self::Hm => "hm".to_string(),
532      Self::DateTimeCustom(fmt) => format!("datetime({})", fmt),
533      Self::Truthy => "truthy".to_string(),
534      Self::TruthyCustom(rules) => {
535        let true_str: Vec<String> = rules.true_options().iter().map(|o| o.pattern().to_string()).collect();
536        let false_str: Vec<String> = rules.false_options().iter().map(|o| o.pattern().to_string()).collect();
537        format!("truthy({},{})", true_str.join("|"), false_str.join("|"))
538      },
539      Self::Array(fmt, sep) => format!("array({},{})", fmt, sep),
540    };
541    write!(f, "{}", result)
542  }
543}
544
545impl FromStr for Format {
546  type Err = Error;
547  fn from_str(key: &str) -> Result<Self, Self::Err> {
548    let is_array = str::contains(key, "[]");
549    let base_key = if is_array {
550      key.to_head("[")
551    } else {
552      key.to_string()
553    };
554    let clean_key = base_key.trim().to_lowercase().strip_non_alphanum();
555
556    let array_splitter = if is_array {
557      let raw = key.to_tail("]").extract_from_parentheses().unwrap_or(",".to_string());
558      // Accept a JS-like quoted separator, e.g. "text[].split(',')" -- strip a matching
559      // pair of quotes if present, otherwise use the extracted content as-is (the plain
560      // "(,)" case, which was never quoted to begin with).
561      let unquoted = raw.extract_from_single_quotes()
562        .or_else(|| raw.extract_from_double_quotes())
563        .unwrap_or(raw);
564      Some(unquoted)
565    } else {
566      None
567    };
568      let fmt = match clean_key.as_str() {
569        "s" | "str" | "string" | "t" | "txt" | "text" => Self::Text,
570        "i" | "int" | "integer" => Self::Integer,
571        "d1" | "dec1" | "decimal1" => Self::Decimal(1),
572        "d2" | "dec2" | "decimal2" => Self::Decimal(2),
573        "d3" | "dec3" | "decimal3" => Self::Decimal(3),
574        "d4" | "dec4" | "decimal4" => Self::Decimal(4),
575        "d5" | "dec5" | "decimal5" => Self::Decimal(5),
576        "d6" | "dec6" | "decimal6" => Self::Decimal(6),
577        "d7" | "dec7" | "decimal7" => Self::Decimal(7),
578        "d8" | "dec8" | "decimal8" => Self::Decimal(8),
579        "fl" | "f" | "float" => Self::Float,
580        "b" | "bool" | "boolean" => Self::Boolean,
581        "da" | "date" => Self::Date,
582        "dt" | "datetime" => Self::DateTime,
583        "ds" | "datetimesimple" => Self::DateTimeSimple,
584        "ti" | "time" => Self::Time,
585        "hm" | "hoursminutes" | "hourmin"=> Self::Hm,
586        "tr" | "truthy" | "true" => Self::Truthy,
587        _ => {
588          if let Some(str) = match_custom_dt(key) {
589            Self::DateTimeCustom(Arc::from(str))
590          } else if let Some((yes, no)) = match_custom_truthy(key) {
591            Self::TruthyCustom(TruthyRuleSet::new().add_true(&yes).add_false(&no))
592          } else {
593            Self::Auto
594          }
595        },
596      };
597      if is_array {
598        let splitter = array_splitter.unwrap_or(",".to_string());
599        Ok(Self::Array(Arc::new(fmt), Arc::from(splitter.as_str())))
600      } else {
601        Ok(fmt)
602      }
603  }
604}
605
606fn match_custom_dt(key: &str) -> Option<String> {
607  let test_str = key.trim();
608  if test_str.starts_with_ci("dt:") {
609    Some(test_str[3..].to_string())
610  } else {
611    None
612  }
613}
614
615fn match_custom_truthy(key: &str) -> Option<(String,String)> {
616  let test_str = key.trim();
617  if let (Some(head), Some(tail)) = test_str.to_head_tail(":") {
618    if tail.len() > 1 && head.len() > 1 && head.starts_with_ci("tr") {
619      if let (Some(yes), Some(no)) = tail.to_head_tail(",") {
620        if !yes.is_empty() && !no.is_empty() {
621          return Some((yes.to_string(), no.to_string()));
622        }
623      }
624    }
625  }
626  // Independent of the colon syntax above, not an alternative reached only when a ':'
627  // happens to be present too -- "true(vrai,faux)" has no ':' at all, so this has to be
628  // its own top-level attempt, not nested inside the colon branch's condition.
629  if test_str.to_head("(").starts_with_ci("tr") {
630    if let Some(inner) = test_str.extract_from_parentheses() {
631      if let (Some(yes), Some(no)) = inner.to_head_tail(",") {
632        if !yes.is_empty() && !no.is_empty() {
633          return Some((yes.to_string(), no.to_string()));
634        }
635      }
636    }
637  }
638  None
639}
640
641impl Format {
642  #[allow(dead_code)]
643  pub fn truthy_custom(yes: &str, no: &str) -> Self {
644    Format::TruthyCustom(TruthyRuleSet::new().add_true(yes).add_false(no))
645  }
646}
647
648/// Reads a column's per-column `DateTimeMode` from JSON, either via an explicit
649/// `"datetime_mode": "date"/"time"/"hm"/"full"` string, or (for backwards compatibility
650/// with configs predating `DateTimeMode`) the boolean keys `"date_only"`/`"time_only"`/
651/// `"hm_only"`, checked in that order of precedence.
652fn datetime_mode_from_json(json: &Value) -> DateTimeMode {
653  if let Some(mode_str) = json.get("datetime_mode").and_then(|v| v.as_str()) {
654    return match mode_str {
655      "date" | "date_only" => DateTimeMode::DateOnly,
656      "time" | "time_only" => DateTimeMode::TimeOnly,
657      "hm" | "hm_only" => DateTimeMode::HmOnly,
658      _ => DateTimeMode::Full,
659    };
660  }
661  if json.get("date_only").and_then(|v| v.as_bool()).unwrap_or(false) {
662    DateTimeMode::DateOnly
663  } else if json.get("time_only").and_then(|v| v.as_bool()).unwrap_or(false) {
664    DateTimeMode::TimeOnly
665  } else if json.get("hm_only").and_then(|v| v.as_bool()).unwrap_or(false) {
666    DateTimeMode::HmOnly
667  } else {
668    DateTimeMode::Full
669  }
670}
671
672#[derive(Debug, Clone)]
673pub struct Column {
674  /// Where a matched cell's value lands in the output row. `Some(KeySegment::Simple(_))`
675  /// is a plain rename, functionally identical to the old `Option<Arc<str>>` this field
676  /// used to be; the other variants describe nested/grouped placement -- see
677  /// `key_segment.rs`.
678  pub key: Option<KeySegment>,
679  /// Natural (auto-detected, snake_cased) key to match this override against, regardless
680  /// of the column's actual position. When None, the column applies positionally instead
681  /// (matched by its index within the configured column list), as before.
682  pub source_key: Option<Arc<str>>,
683  pub format: Format,
684  pub default: Option<Value>,
685  /// Rendering mode applied *only* when this column's own `format` is `Format::Auto` and
686  /// the source cell is already a genuine datetime (`Data::DateTime`/`Data::DateTimeIso`)
687  /// -- it has no effect on strings or numbers in that column, unlike
688  /// `Format::Date`/`Format::Time`/`Format::Hm`/`Format::DateTime`, which force *any*
689  /// cell type through date/time interpretation. Overrides the row-wide
690  /// `RowOptionSet::datetime_mode` default when set to anything other than `Full`.
691  pub datetime_mode: DateTimeMode,
692  pub decimal_comma: bool, // parse as euro number format
693}
694
695impl Column {
696
697  /// build new column with an optional key name only
698  pub fn new(key_opt: Option<&str>) -> Self {
699    Self::from_key_ref_with_format(key_opt, Format::Auto, None, DateTimeMode::Full, false)
700  }
701
702  /// build new column data type override and optional default
703  pub fn new_format(fmt: Format, default: Option<Value>) -> Self {
704    Self::from_key_ref_with_format(None, fmt, default, DateTimeMode::Full, false)
705  }
706
707  /// build a column override matched by its natural (auto-detected) key rather than
708  /// by position, e.g. to rename and/or reformat a single field out of many without
709  /// needing to enumerate every column ahead of it.
710  pub fn from_source_key_with_format(source_key: &str, key_opt: Option<&str>, format: Format, default: Option<Value>, datetime_mode: DateTimeMode, decimal_comma: bool) -> Self {
711    let mut col = Self::from_key_ref_with_format(key_opt, format, default, datetime_mode, decimal_comma);
712    col.source_key = Some(Arc::from(source_key));
713    col
714  }
715
716  /// build new column data type override and optional default
717  pub fn from_json(json: &Value) -> Self {
718    let key_opt = json.get("key").map(|v| v.as_str().unwrap_or(""));
719    let source_key = json.get("source_key").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
720    let fmt = match json.get("format").and_then(|v| v.as_str()) {
721      Some(fmt_str) => {
722        match Format::from_str(fmt_str) {
723          Ok(fmt) => fmt,
724          Err(_) => Format::Auto
725        }
726      },
727      None => Format::Auto
728    };
729    let default = match json.get("default") {
730      Some(def_val) => {
731        match def_val {
732          Value::String(s) => Some(Value::String(s.clone())),
733          Value::Number(n) => Some(Value::Number(n.clone())),
734          Value::Bool(b) => Some(Value::Bool(*b)),
735          _ => None
736        }
737      },
738      None => None
739    };
740    let datetime_mode = datetime_mode_from_json(json);
741    let dec_commas_keys = ["decimal_comma", "dec_comma"];
742    let mut decimal_comma = false;
743
744    for key in &dec_commas_keys {
745      if let Some(euro_val) = json.get(*key) {
746        decimal_comma = euro_val.as_bool().unwrap_or(false);
747        break;
748      }
749    }
750    let mut col = if let Some(src) = source_key {
751      Column::from_source_key_with_format(src, key_opt, fmt, default, datetime_mode, decimal_comma)
752    } else {
753      Column::from_key_ref_with_format(key_opt, fmt, default, datetime_mode, decimal_comma)
754    };
755    // key_opt above only ever handles a plain string (a non-string "key" -- the tagged
756    // object form -- silently became Simple("") through it, since as_str() on an object
757    // returns None). KeySegment::from_json is the real parser for "key": it handles the
758    // plain-string shorthand identically (so this is a harmless no-op re-assignment in
759    // that case) and additionally parses the full Object/Array/InnerObject/PlainArray/
760    // Excluded tree from a tagged JSON object -- the only way a client crate builds a
761    // nested KeySegment through JSON, without writing Rust or touching calamine/csv.
762    if let Some(key_json) = json.get("key") {
763      if let Some(segment) = KeySegment::from_json(key_json) {
764        col.key = Some(segment);
765      }
766    }
767    col
768}
769
770
771  // future development with column options
772  #[allow(dead_code)]
773  pub fn set_format(mut self, fmt: Format) -> Self {
774    self.format = fmt;
775    self
776  }
777
778  #[allow(dead_code)]
779  pub fn set_default(mut self, val: Value) -> Self {
780    self.default = Some(val);
781    self
782  }
783
784  #[allow(dead_code)]
785  pub fn set_datetime_mode(mut self, val: DateTimeMode) -> Self {
786    self.datetime_mode = val;
787    self
788  }
789
790  #[allow(dead_code)]
791  pub fn set_decimal_comma(mut self, val: bool) -> Self {
792    self.decimal_comma = val;
793    self
794  }
795
796  pub fn from_key_ref_with_format(key_opt: Option<&str>, format: Format, default: Option<Value>, datetime_mode: DateTimeMode, decimal_comma: bool) -> Self {
797    let key = key_opt.map(|k_str| KeySegment::Simple(Arc::from(k_str)));
798    Column {
799      key,
800      source_key: None,
801      format,
802      default,
803      datetime_mode,
804      decimal_comma
805    }
806  }
807
808  /// A flat, single-string fallback for contexts that only show one name per column
809  /// (header/metadata listings) -- for a plain rename (`KeySegment::Simple`) this is
810  /// the whole story; for a nested `key`, see `KeySegment`'s own `Display` impl for what
811  /// this collapses to.
812  pub fn key_name(&self) -> String {
813    self.key.as_ref().map(|k| k.to_string()).unwrap_or_default()
814  }
815
816  pub fn source_key_name(&self) -> String {
817    self.source_key.clone().unwrap_or(Arc::from("")).to_string()
818  }
819
820  pub fn to_json(&self) -> Value {
821    json!({
822      "key": self.key_name(),
823      "source_key": self.source_key_name(),
824      "format": self.format.to_string(),
825      "default": self.default,
826      "datetime_mode": self.datetime_mode.to_string(),
827      "decimal_comma": self.decimal_comma
828    })
829  }
830
831  pub fn to_line(&self) -> String {
832    let datetime_mode_str = if self.datetime_mode != DateTimeMode::Full {
833      format!(", {}", self.datetime_mode)
834    } else {
835      "".to_string()
836    };
837    let def_string = if let Some(def_val) = self.default.clone() {
838      format!("default: {}", def_val)
839    } else {
840      "".to_string()
841    };
842    let comma_str = if self.decimal_comma {
843      ", decimal comma"
844    } else {
845      ""
846    };
847    let source_str = if self.source_key.is_some() {
848      format!(", matched from {}", self.source_key_name())
849    } else {
850      "".to_string()
851    };
852    format!(
853      "\tkey {}, format {}{}{}{}{}",
854      self.key_name(),
855      self.format,
856      def_string,
857      datetime_mode_str,
858      comma_str,
859      source_str)
860  }
861
862}
863
864
865/// Match on permitted file types identified by file extensions
866/// Unmatched means do not process
867#[derive(Debug, Clone, Copy)]
868pub enum Extension {
869  Unmatched,
870  Ods,
871  Xlsx,
872  Xlsm,
873  Xlsb,
874  Xls,
875  Csv,
876  Tsv,
877}
878
879impl Extension {
880  pub fn from_path(path:&Path) -> Extension {
881    if let Some(ext) = path.extension() {
882      if let Some(ext_str) = ext.to_str() {
883        let ext_lc = ext_str.to_lowercase();
884        return match  ext_lc.as_str() {
885          "ods" => Extension::Ods,
886          "xlsx" => Extension::Xlsx,
887          // .xlsm (macro-enabled) is the same OOXML container as .xlsx -- calamine's own
888          // open_workbook_auto already routes both through its Xlsx reader (it does its
889          // own extension check on the same path), so there's nothing macro-specific to
890          // handle here; we just need to stop rejecting the extension before it gets there.
891          "xlsm" => Extension::Xlsm,
892          "xlsb" => Extension::Xlsb,
893          "xls" => Extension::Xls,
894          "csv" => Extension::Csv,
895          "tsv" => Extension::Tsv,
896          _ => Extension::Unmatched
897        }
898      }
899    }
900    Extension::Unmatched
901  }
902
903  /// use the Calamine library
904  pub fn use_calamine(&self) -> bool {
905    matches!(self, Self::Ods | Self::Xlsx | Self::Xlsm | Self::Xlsb | Self::Xls)
906  }
907
908  /// added for future development
909  /// Process a simple CSV or TSV
910  #[allow(dead_code)]
911  pub fn use_csv(&self) -> bool {
912    matches!(self, Self::Csv | Self::Tsv)
913  }
914
915}
916
917impl std::fmt::Display for Extension {
918  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
919    let result = match self {
920      Self::Ods => "ods",
921      Self::Xlsx => "xlsx",
922      Self::Xlsm => "xlsm",
923      Self::Xlsb => "xlsb",
924      Self::Xls => "xls",
925      Self::Csv => "csv",
926      Self::Tsv => "tsv",
927      _ => ""
928    };
929    write!(f, "{}", result)
930  }
931}
932
933pub struct PathData<'a> {
934  path: &'a Path,
935  ext: Extension
936}
937
938impl<'a> PathData<'a> {
939  pub fn new(path: &'a Path) -> Self {
940    PathData {
941      path,
942      ext: Extension::from_path(path)
943    }
944  }
945
946  pub fn mode(&self) -> Extension {
947    self.ext
948  }
949
950  pub fn extension(&self) -> String {
951    self.ext.to_string()
952  }
953
954  pub fn ext(&self) -> Extension {
955    self.ext
956  }
957
958  pub fn path(&self) -> &Path {
959    self.path
960  }
961
962  pub fn is_valid(&self) -> bool {
963    !matches!(self.ext, Extension::Unmatched)
964  }
965
966  pub fn use_calamine(&self) -> bool {
967    self.ext.use_calamine()
968  }
969
970  pub fn filename(&self) -> String {
971    if let Some(file_ref) = self.path.file_name() {
972        file_ref.to_string_lossy().to_string()
973    } else {
974        "".to_owned()
975    }
976  }
977}
978
979
980
981#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
982pub enum ReadMode {
983  #[default]
984  Sync,
985  PreviewMultiple,
986  Async
987}
988
989/// either Preview or Async mode
990impl ReadMode {
991
992  pub fn from_key(key: &str) -> Self {
993    let sample = key.to_lowercase().strip_non_alphanum();
994    match sample.as_str() {
995      "async" | "defer" | "deferred" | "a" => ReadMode::Async,
996      "preview" | "p" | "pre" | "multimode" | "multiple" | "previewmultiple" | "previewmulti" | "m" => ReadMode::PreviewMultiple,
997      _ => ReadMode::Sync
998    }
999  }
1000
1001  pub fn is_async(&self) -> bool {
1002    matches!(self, Self::Async)
1003  }
1004
1005  /// not preview or sync mode
1006  pub fn is_multimode(&self) -> bool {
1007    matches!(self, Self::PreviewMultiple)
1008  }
1009}
1010
1011impl std::fmt::Display for ReadMode {
1012  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1013    let result = match self {
1014      Self::Async => "deferred",
1015      Self::PreviewMultiple => "preview",
1016      _ => "direct"
1017    };
1018    write!(f, "{}", result)
1019  }
1020}
1021
1022/// defines the column key naming convention
1023#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1024pub enum FieldNameMode {
1025  #[default]
1026  AutoA1, // will use A1 column keys if headers are unavailable
1027  AutoNumPadded, // will use C01 format if column headers are unavailable
1028  A1, // Defaults to A1 columns unless custom keys are added
1029  NumPadded, // Defaults to C01 format unless custom keys are added
1030}
1031
1032/// either Preview or Async mode
1033impl FieldNameMode {
1034
1035
1036  pub fn from_key(system: &str, override_header: bool) -> Self {
1037    if system.starts_with_ci("a1") {
1038      if override_header {
1039        FieldNameMode::A1
1040      } else {
1041        FieldNameMode::AutoA1
1042      }
1043    } else if system.starts_with_ci("c") || system.starts_with_ci("n") {
1044      if override_header {
1045        FieldNameMode::NumPadded
1046      } else {
1047        FieldNameMode::AutoNumPadded
1048      }
1049    } else {
1050      FieldNameMode::AutoA1
1051    }
1052  }
1053
1054
1055  /// use AQ column field style
1056  pub fn use_a1(&self) -> bool {
1057    matches!(self, Self::AutoA1 | Self::A1)
1058  }
1059
1060  /// use c01 column field style
1061  pub fn use_c01(&self) -> bool {
1062    matches!(self, Self::AutoNumPadded | Self::NumPadded)
1063  }
1064
1065   /// use seqquential a1 or C01 column style unless custom overrides are added
1066   pub fn override_headers(&self) -> bool {
1067    matches!(self, Self::NumPadded | Self::A1)
1068  }
1069
1070  /// use default headers if available unless override by custom headers
1071  pub fn keep_headers(&self) -> bool {
1072    !self.override_headers()
1073  }
1074
1075  /// The always-fallback variant of this style -- A1 letters or C01 numbers regardless
1076  /// of whether real header text is available. Used when no row should ever be treated
1077  /// as a source of header text at all (e.g. `omit_header`), as opposed to the "Auto"
1078  /// variants' normal behavior of falling back only when text happens to be missing.
1079  pub fn forced_fallback(&self) -> Self {
1080    if self.use_c01() {
1081      Self::NumPadded
1082    } else {
1083      Self::A1
1084    }
1085  }
1086}
1087
1088impl std::fmt::Display for FieldNameMode {
1089  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1090    let result = match self {
1091      Self::AutoNumPadded => "C01 auto",
1092      Self::NumPadded => "C01 override",
1093      Self::A1 => "A1 override",
1094      _ => "A1 auto",
1095    };
1096    write!(f, "{}", result)
1097  }
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102  use super::*;
1103
1104  #[test]
1105  fn test_format_mode() {
1106    let custom_boolean = Format::truthy_custom("si", "no");
1107    assert_eq!(custom_boolean.to_string(), "truthy(si,no)");
1108  }
1109
1110  #[test]
1111  fn test_match_truthy_custom() {
1112    let (true_keys, false_keys) = match_custom_truthy("tr:si,no").unwrap();
1113    assert_eq!("si", true_keys);
1114    assert_eq!("no", false_keys);
1115
1116    let (true_keys, false_keys) = match_custom_truthy("true(vrai,faux)").unwrap();
1117    assert_eq!("vrai", true_keys);
1118    assert_eq!("faux", false_keys);
1119  }
1120
1121  fn assert_array_format(parsed: Format, expected_element: &str, expected_separator: &str) {
1122    match parsed {
1123      Format::Array(element_fmt, separator) => {
1124        assert_eq!(element_fmt.to_string(), expected_element);
1125        assert_eq!(separator.as_ref(), expected_separator);
1126      }
1127      other => panic!("expected Format::Array, got {:?}", other),
1128    }
1129  }
1130
1131  #[test]
1132  fn test_format_from_str_parses_the_documented_array_syntax() {
1133    assert_array_format(Format::from_str("string[](|)").unwrap(), "text", "|");
1134    assert_array_format(Format::from_str("int[](|)").unwrap(), "integer", "|");
1135  }
1136
1137  #[test]
1138  fn test_format_from_str_array_accepts_arbitrary_text_between_bracket_and_paren() {
1139    // to_tail("]") + extract_from_parentheses() only care about "]" and the first "(...)"
1140    // pair after it -- whatever text sits between them (".split", nothing at all) is
1141    // never inspected, so "text[].split(,)" parses identically to "text[](,)".
1142    assert_array_format(Format::from_str("text[].split(,)").unwrap(), "text", ",");
1143    assert_array_format(Format::from_str("int[].split(|)").unwrap(), "integer", "|");
1144  }
1145
1146  #[test]
1147  fn test_format_from_str_array_strips_a_js_like_quoted_separator() {
1148    assert_array_format(Format::from_str("text[].split(',')").unwrap(), "text", ",");
1149    assert_array_format(Format::from_str(r#"text[].split(",")"#).unwrap(), "text", ",");
1150    // an unquoted separator still works exactly as before -- quote-stripping is opt-in,
1151    // not a requirement
1152    assert_array_format(Format::from_str("text[](,)").unwrap(), "text", ",");
1153  }
1154
1155  #[test]
1156  fn test_format_from_str_array_defaults_to_comma_with_no_explicit_separator() {
1157    assert_array_format(Format::from_str("int[]").unwrap(), "integer", ",");
1158    assert_array_format(Format::from_str("string[]").unwrap(), "text", ",");
1159  }
1160
1161  #[test]
1162  fn test_format_from_str_array_accepts_the_same_loose_type_synonyms_as_scalars() {
1163    // Same loose matching (case-insensitive, multiple synonyms) already used for plain
1164    // scalar formats, now also recognised ahead of an array suffix.
1165    for (key, expected) in [
1166      ("s[](|)", "text"),
1167      ("str[](|)", "text"),
1168      ("STRING[](|)", "text"),
1169      ("i[](|)", "integer"),
1170      ("INTEGER[](|)", "integer"),
1171      ("fl[](|)", "float"),
1172      ("float[](|)", "float"),
1173      ("d2[](|)", "decimal(2)"),
1174      ("b[](|)", "boolean"),
1175      ("da[](|)", "date"),
1176    ] {
1177      assert_array_format(Format::from_str(key).unwrap(), expected, "|");
1178    }
1179  }
1180
1181  #[test]
1182  fn test_format_from_str_array_accepts_a_multi_character_separator() {
1183    assert_array_format(Format::from_str("string[](::)").unwrap(), "text", "::");
1184  }
1185
1186  #[test]
1187  fn test_format_from_str_array_with_an_unrecognised_element_type_falls_back_to_auto() {
1188    // Matches the existing plain-scalar behavior: an unrecognised type name falls back
1189    // to Format::Auto rather than erroring -- the array wrapper doesn't change that.
1190    assert_array_format(Format::from_str("bogus[](|)").unwrap(), "auto", "|");
1191  }
1192
1193  #[test]
1194  fn test_format_from_str_non_array_syntax_is_unaffected() {
1195    // Regression guard: adding array-suffix parsing must not change plain scalar
1196    // parsing, which has no "[]" in it at all.
1197    assert_eq!(Format::from_str("int").unwrap().to_string(), "integer");
1198    assert_eq!(Format::from_str("string").unwrap().to_string(), "text");
1199    assert_eq!(Format::from_str("d3").unwrap().to_string(), "decimal(3)");
1200  }
1201
1202
1203  #[test]
1204  fn test_first_data_row_index_defaults_to_none_when_both_unset() {
1205    // No additional gating when neither header_row nor data_row_index is set -- callers
1206    // should skip the check entirely rather than compute a value that changes nothing.
1207    let opts = OptionSet::new("x.xlsx");
1208    assert_eq!(opts.first_data_row_index(), None);
1209  }
1210
1211  #[test]
1212  fn test_first_data_row_index_defaults_to_right_after_header_row() {
1213    // header_row=2 (0-based); with no explicit data_row_index, data starts immediately
1214    // after, at 0-based row 3.
1215    let opts = OptionSet::new("x.xlsx").header_row(2);
1216    assert_eq!(opts.first_data_row_index(), Some(3));
1217  }
1218
1219  #[test]
1220  fn test_first_data_row_index_honors_explicit_gap() {
1221    // header_row=2, data_row_index=4 (both 0-based) -- row 3 (the row directly below the
1222    // header) is a gap that gets skipped.
1223    let opts = OptionSet::new("x.xlsx").header_row(2).data_row_index(4);
1224    assert_eq!(opts.first_data_row_index(), Some(4));
1225  }
1226
1227  #[test]
1228  fn test_first_data_row_index_honors_data_row_equal_to_header() {
1229    // data_row_index equal to the header row is a legitimate (if rare) configuration --
1230    // e.g. a CSV with predefined/external headers where no line is actually consumed as
1231    // a header -- so it's honored literally, not silently bumped to header_row + 1.
1232    let opts = OptionSet::new("x.xlsx").header_row(2).data_row_index(2);
1233    assert_eq!(opts.first_data_row_index(), Some(2));
1234  }
1235
1236  #[test]
1237  fn test_first_data_row_index_ignores_data_row_before_header() {
1238    // data_row_index set strictly *before* the header row is nonsensical -- falls back
1239    // to "immediately after the header row" instead of producing zero data rows.
1240    let opts = OptionSet::new("x.xlsx").header_row(2).data_row_index(0);
1241    assert_eq!(opts.first_data_row_index(), Some(3));
1242  }
1243
1244  #[test]
1245  fn test_first_data_row_index_with_only_data_row_set() {
1246    // header_row unset (defaults to 0); data_row_index=1 skips over row 0 (the header)
1247    // even though header_row itself was never explicitly set.
1248    let opts = OptionSet::new("x.xlsx").data_row_index(1);
1249    assert_eq!(opts.first_data_row_index(), Some(1));
1250  }
1251
1252  #[test]
1253  fn test_xlsm_is_recognised_and_routed_through_calamine() {
1254    // Regression: .xlsm (macro-enabled) is the same OOXML container as .xlsx -- calamine's
1255    // own open_workbook_auto already reads both through its Xlsx reader -- but our own
1256    // Extension enum didn't recognise the extension at all, so .xlsm files were rejected
1257    // before calamine ever got a chance to open them.
1258    let ext = Extension::from_path(Path::new("workbook.xlsm"));
1259    assert!(matches!(ext, Extension::Xlsm));
1260    assert!(ext.use_calamine());
1261    assert_eq!(ext.to_string(), "xlsm");
1262  }
1263
1264}