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