Skip to main content

spreadsheet_to_json/
data_set.rs

1use std::{fs::File, io::BufReader};
2use calamine::{Reader, Sheets};
3use heck::ToSnakeCase;
4use indexmap::IndexMap;
5use serde::Serialize;
6use serde_json::{json, Value};
7
8use crate::{OptionSet, PathData, ReadMode};
9
10
11/// Core info about a spreadsheet with extension, matched worksheet name and index an all worksheet keys
12#[derive(Debug, Clone)]
13pub struct WorkbookInfo {
14    pub filename: String,
15    pub extension: String,
16    pub selected: Option<Vec<String>>,
17    pub sheets: Vec<String>,
18}
19
20impl WorkbookInfo {
21    pub fn new(path_data: &PathData, selected: &[String], sheet_refs: &[String]) -> Self {
22        WorkbookInfo {
23            extension: path_data.extension(),
24            filename: path_data.filename(), 
25            selected: Some(selected.to_vec()),
26            sheets: sheet_refs.to_vec(),
27        }
28    }
29
30    pub fn simple(path_data: &PathData) -> Self {
31        let sheet_name = "single";
32        WorkbookInfo {
33            extension: path_data.extension(),
34            filename: path_data.filename(), 
35            selected: None,
36            sheets: vec![sheet_name.to_owned()],
37        }
38    }
39
40    pub fn ext(&self) -> String {
41        self.extension.to_owned()
42    }
43
44    pub fn name(&self) -> String {
45        self.filename.to_owned()
46    }
47
48    pub fn sheet(&self, index: usize) -> (String, usize) {
49      let sheet_name = self.sheets.get(index).unwrap_or(&"single".to_owned()).to_owned();
50      (sheet_name, index)
51    }
52
53    pub fn sheets(&self) -> Vec<String> {
54        self.sheets.clone()
55    }
56}
57
58
59// Result set
60#[derive(Debug, Clone)]
61pub struct ResultSet {
62    pub filename: String,
63    pub extension: String,
64    pub selected: Option<Vec<String>>,
65    pub sheets: Vec<String>,
66    pub keys: Vec<String>,
67    pub num_rows: usize,
68    pub data: SpreadData,
69    pub out_ref: Option<String>,
70    pub opts: OptionSet,
71    /// 0-based index of the header row actually used, resolved at read time -- whether
72    /// from an explicit `OptionSet.header_row` override or auto-detection. `None` when no
73    /// row was captured as a header at all (`--omit-header`, or when auto-detection found
74    /// no confident header in the sample) -- distinct from `opts.header_row`, which only
75    /// ever reflects an explicit override and stays `None` whenever detection ran instead.
76    pub header_row_index: Option<usize>,
77    /// 0-based index of the first data row actually used, resolved at read time -- whether
78    /// from an explicit `OptionSet.data_row_index` override or auto-detection. Always
79    /// concrete: a file always starts reading data *somewhere*, unlike the header row.
80    pub body_start_index: usize,
81}
82
83impl ResultSet {
84
85  /// Instantiate with Core workbook info, header keys, data set and optional output reference.
86  /// `header_row_index`/`body_start_index` are the *resolved* 0-based row indices actually
87  /// used for this read -- see the field docs on `ResultSet` for why these differ from
88  /// `opts.header_row`/`opts.data_row_index`.
89  pub fn new(info: &WorkbookInfo, keys: &[String], data_set: DataSet, opts: &OptionSet, out_ref: Option<&str>, header_row_index: Option<usize>, body_start_index: usize) -> Self {
90    let (num_rows, data) = match data_set {
91      DataSet::WithRows(size, rows) => (size, rows),
92      DataSet::Count(size) => (size, vec![])
93    };
94    ResultSet {
95      extension: info.ext(),
96      filename: info.name(),
97      selected: info.selected.clone(),
98      sheets: info.sheets(),
99      keys: keys.to_vec(),
100      num_rows,
101      data: SpreadData::from_single(data),
102      out_ref: out_ref.map(|s| s.to_string()),
103      opts: opts.to_owned(),
104      header_row_index,
105      body_start_index,
106    }
107  }
108
109  pub fn from_multiple(sheets: &[SheetDataSet], info: &WorkbookInfo, opts: &OptionSet) -> Self {
110    let selected = None;
111    let mut sheet_names = vec![];
112    let filename = info.filename.clone();
113    let extension = info.extension.clone();
114    let mut keys: Vec<String> = vec![];
115    let mut num_rows = 0;
116    for (sheet_index, sheet) in sheets.iter().enumerate() {
117      num_rows += sheet.num_rows;
118      sheet_names.push(sheet.name());
119      if sheet_index == 0 {
120        keys = sheet.keys.clone();
121      }
122    }
123    ResultSet {
124      extension,
125      filename,
126      selected,
127      sheets: sheet_names,
128      keys,
129      num_rows,
130      data: SpreadData::Multiple(sheets.to_vec()),
131      out_ref: None,
132      opts: opts.to_owned(),
133      // A single top-level header/body-start index doesn't represent multiple sheets,
134      // each of which may resolve to a different row -- unlike ResultSet::new (used for
135      // single-sheet reads), per-sheet resolved indices aren't tracked here yet.
136      header_row_index: None,
137      body_start_index: 0,
138    }
139  }
140
141
142  pub fn multimode(&self) -> bool {
143    matches!(self.data, SpreadData::Multiple(_))
144  }
145
146  /// Full result set as JSON with criteria, options and data in synchronous mode
147  pub fn to_json(&self) -> Value {
148    let mut result = json!({
149      "name": self.filename,
150      "extension": self.extension,
151      "selected": self.selected.clone().unwrap_or(vec![]),
152      "sheets": self.sheets,
153      "num_rows": self.num_rows,
154      "fields": self.keys,
155      "multimode": self.multimode(),
156      "data": self.data.to_json(),
157      "opts": self.opts.to_json()
158    });
159    if let Some(out_ref_str) = self.out_ref.clone() {
160      result["outref"] = json!(out_ref_str);
161    }
162    result
163  }
164
165   /// Full result set as CLI-friendly lines
166   pub fn to_output_lines(&self, json_lines: bool) -> Vec<String> {
167    let selected_names = self.selected.clone().unwrap_or(vec![]);
168    let num_selected = selected_names.len();
169    let plural = if num_selected > 1 {
170      "s"
171    } else {
172      ""
173    };
174    let mut lines = vec![
175      format!("name:{}", self.filename),
176      format!("extension: {}", self.extension),
177      
178      format!("sheets: {}", self.sheets.join(", ")),
179    ];
180    if num_selected > 0 {
181      lines.push(format!("selected sheet{}: {}", plural, selected_names.join(", ")));
182    }
183    lines.push(format!("row count: {}", self.num_rows));
184    lines.push(format!("fields: {}", self.keys.join(",")));
185    lines.push(format!("multimode: {}", self.multimode()));
186    for opt_line in self.opts.to_lines() {
187      lines.push(opt_line);
188    }
189    if let Some(out_ref_str) = self.out_ref.clone() {
190      lines.push(format!("output reference: {}", out_ref_str));
191    } else {
192      let has_many_sheets = self.sheets.len() > 1;
193      if !has_many_sheets || !self.multimode() {
194        lines.push("data:".to_owned());
195      }
196      if json_lines {
197        for sheet in &self.data.sheets() {
198          if has_many_sheets {
199            lines.push(format!("Sheet `{}` ({}):", sheet.name(), sheet.num_rows));
200          }
201          for item in &sheet.rows {
202            lines.push(format!("{}", json!(item)));
203          }
204        }
205      } else {
206        if self.multimode() {
207          for sheet in self.data.sheets() {
208            lines.push(format!("Sheet `{}` ({}):", sheet.name(), sheet.num_rows));
209            lines.push(format!("{}", json!(sheet)));
210          }
211        } else {
212          lines.push(format!("{}", self.data.to_json()));
213        }
214      }
215    }
216    lines
217  }
218
219  /// Extract the vector of rows as Index Maps of JSON values
220  /// Good for post-processing results
221  pub fn to_vec(&self) -> Vec<IndexMap<String, Value>> {
222    self.data.first_sheet().clone()
223  }
224  
225  /// JSON object of row arrays only
226  pub fn json_data(&self) -> Value {
227    json!(self.data)
228  }
229
230  /// final output as vector of JSON-serializable array
231  pub fn rows(&self) -> Vec<String> {
232    let sheet = self.data.first_sheet();
233    let mut lines = Vec::with_capacity(sheet.len());
234    for row in &sheet {
235      lines.push(json!(row).to_string());
236    }
237    lines
238  }
239
240}
241
242#[derive(Debug, Clone, Serialize)]
243pub struct SheetDataSet {
244  pub sheet: (String, String),
245  pub num_rows: usize,
246  pub keys: Vec<String>,
247  pub rows: Vec<IndexMap<String, Value>>
248}
249
250impl SheetDataSet {
251
252  
253
254  pub fn new(name: &str, keys: &[String], rows: &[IndexMap<String, Value>], total: usize) -> Self {
255    Self {
256      sheet: (name.to_string(), name.to_snake_case()),
257      keys: keys.to_vec(),
258      rows: rows.to_vec(),
259      num_rows: total
260    }
261  }
262
263  pub fn key(&self) -> String {
264    self.sheet.1.clone()
265  }
266
267  pub fn name(&self) -> String {
268    self.sheet.0.clone()
269  }
270}
271
272#[derive(Debug, Clone, Serialize)]
273pub enum SpreadData {
274   Single(Vec<IndexMap<String, Value>>),
275   Multiple(Vec<SheetDataSet>)
276}
277
278impl SpreadData {
279  pub fn from_single(rows: Vec<IndexMap<String, Value>>) -> Self {
280    SpreadData::Single(rows)
281  }
282
283  pub fn from_multiple(sheet_data: &[SheetDataSet]) -> Self {
284    SpreadData::Multiple(sheet_data.to_owned())
285  }
286
287  pub fn first_sheet(&self) -> Vec<IndexMap<String, Value>> {
288    match self {
289      SpreadData::Single(rows) => rows.to_owned(),
290      SpreadData::Multiple(sheets) => {
291        if let Some(sheet) = sheets.first() {
292          sheet.rows.to_owned()
293        } else {
294          vec![]
295        }
296      }
297    }
298  }
299
300  // Only for preview multiple mode
301  pub fn sheets(&self) -> Vec<SheetDataSet> {
302    match self {
303      SpreadData::Single(_) => vec![],
304      SpreadData::Multiple(sheets) => sheets.to_owned()
305    }
306  }
307
308  pub fn to_json(&self) -> Value {
309    match self {
310      SpreadData::Single(sheet) => json!(sheet),
311      SpreadData::Multiple(sheet_map) => json!(sheet_map)
312    }
313  }
314}
315
316
317#[derive(Debug, Clone, Serialize)]
318pub enum DataSet {
319   WithRows(usize, Vec<IndexMap<String, Value>>),
320   Count(usize) 
321}
322
323impl DataSet {
324  pub fn from_count_and_rows(count: usize, rows: Vec<IndexMap<String, Value>>, opts: &OptionSet) -> Self {
325    match opts.read_mode() {
326      ReadMode::Sync | ReadMode::PreviewMultiple => DataSet::WithRows(count, rows),
327      ReadMode::Async => DataSet::Count(count),
328    }
329  }
330}
331
332
333pub fn to_index_map(row: &[serde_json::Value], headers: &[String]) -> IndexMap<String, Value> {
334    let mut hm: IndexMap<String, serde_json::Value> = IndexMap::new();
335    for (sub_index, hk) in headers.iter().enumerate() {
336        if let Some(cell) = row.get(sub_index) {
337            hm.insert(hk.to_owned(), cell.to_owned());
338        }
339    }
340    hm
341}
342
343pub fn match_sheet_name_and_index(workbook: &mut Sheets<BufReader<File>>, opts: &OptionSet) -> (Vec<String>, Vec<String>, Vec<usize>) {
344  let mut sheet_indices = vec![];
345  let mut selected_names: Vec<String> = vec![];
346  let sheet_names = workbook.worksheets().into_iter().map(|ws| ws.0).collect::<Vec<String>>();
347  if let Some(sheet_keys) = opts.selected.clone() {
348      for sheet_key in sheet_keys {
349          if let Some(sheet_index) = sheet_names.iter().position(|s| s.to_snake_case() == sheet_key.to_snake_case()) {
350              sheet_indices.push(sheet_index);
351              selected_names.push(sheet_names[sheet_index].clone());
352          }
353      }
354  }
355  if sheet_indices.is_empty() && !opts.indices.is_empty() {
356    for s_index in opts.indices.clone() {
357      let sheet_index = s_index as usize;
358      if let Some(sheet_name) = sheet_names.get(sheet_index) {
359          sheet_indices.push(sheet_index);
360          selected_names.push(sheet_name.to_owned());
361      }
362    }
363  }
364  if sheet_indices.is_empty() {
365    sheet_indices = vec![0];
366    if !sheet_names.is_empty() {
367      selected_names.push(sheet_names[0].clone());
368    }
369  }
370  (selected_names, sheet_names, sheet_indices)
371}
372
373#[cfg(test)]
374mod tests {
375  use super::*;
376  use calamine::open_workbook_auto;
377
378  // data/sample-data-2.ods has two worksheets: "Rsults-2" and "results 1"
379  const SAMPLE_PATH: &str = "data/sample-data-2.ods";
380
381  fn opts_selecting(sheet_key: &str) -> OptionSet {
382    OptionSet::new(SAMPLE_PATH).sheet_name(sheet_key)
383  }
384
385  #[test]
386  fn test_sheet_name_matching_is_case_insensitive_and_ignores_punctuation() {
387    let mut workbook = open_workbook_auto(SAMPLE_PATH).unwrap();
388    // exact, uppercase, lowercase, and punctuation/whitespace variants should all match
389    // the same real sheet name "results 1" via snake_case comparison
390    for variant in ["results 1", "RESULTS 1", "Results_1", "results-1", "  results   1  "] {
391      let opts = opts_selecting(variant);
392      let (selected_names, _sheet_names, sheet_indices) = match_sheet_name_and_index(&mut workbook, &opts);
393      assert_eq!(selected_names, vec!["results 1".to_string()], "variant '{}' should match 'results 1'", variant);
394      assert_eq!(sheet_indices, vec![1], "variant '{}' should resolve to index 1", variant);
395    }
396  }
397
398  #[test]
399  fn test_sheet_name_matching_handles_names_with_no_spaces() {
400    // sheet names like "Sheet1", "Sheet2" (no internal separators at all) should still
401    // match themselves case-insensitively
402    let mut workbook = open_workbook_auto(SAMPLE_PATH).unwrap();
403    for variant in ["Rsults-2", "rsults-2", "RSULTS-2", "rsults_2"] {
404      let opts = opts_selecting(variant);
405      let (selected_names, _sheet_names, sheet_indices) = match_sheet_name_and_index(&mut workbook, &opts);
406      assert_eq!(selected_names, vec!["Rsults-2".to_string()], "variant '{}' should match 'Rsults-2'", variant);
407      assert_eq!(sheet_indices, vec![0]);
408    }
409  }
410
411  #[test]
412  fn test_sheet_name_matching_falls_back_to_first_sheet_when_unmatched() {
413    let mut workbook = open_workbook_auto(SAMPLE_PATH).unwrap();
414    let opts = opts_selecting("nonexistent sheet name");
415    let (selected_names, sheet_names, sheet_indices) = match_sheet_name_and_index(&mut workbook, &opts);
416    assert_eq!(sheet_names, vec!["Rsults-2".to_string(), "results 1".to_string()]);
417    // no match found -> falls back to the first sheet, not an error
418    assert_eq!(selected_names, vec!["Rsults-2".to_string()]);
419    assert_eq!(sheet_indices, vec![0]);
420  }
421
422  #[test]
423  fn test_sheet_index_selection_still_works() {
424    let mut workbook = open_workbook_auto(SAMPLE_PATH).unwrap();
425    let opts = OptionSet::new(SAMPLE_PATH).sheet_index(1);
426    let (selected_names, _sheet_names, sheet_indices) = match_sheet_name_and_index(&mut workbook, &opts);
427    assert_eq!(selected_names, vec!["results 1".to_string()]);
428    assert_eq!(sheet_indices, vec![1]);
429  }
430}
431