Skip to main content

sheeter_engine/
lib.rs

1//! Sheeter is a data file conversion library and CLI tool.
2//!
3//! This crate provides functions to convert between CSV and JSON formats.
4//! It is designed to be small, fast, and portable, suitable for use on
5//! low-end devices such as Android phones. The core idea is simple: take
6//! data in one format, understand its structure, and emit it in another
7//! format without unnecessary overhead.
8//!
9//! # Examples
10//!
11//! Convert CSV to JSON:
12//!
13//! ```
14//! use sheeter_engine::csv_to_json;
15//!
16//! let csv = "name,age\nAlice,30\nBob,25";
17//! let json = csv_to_json(csv).unwrap();
18//! assert_eq!(json, r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#);
19//! ```
20//!
21//! Convert JSON to CSV:
22//!
23//! ```
24//! use sheeter_engine::json_to_csv;
25//!
26//! let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
27//! let csv = json_to_csv(json).unwrap();
28//! assert_eq!(csv, "name,age\nAlice,30\nBob,25\n");
29//! ```
30
31pub mod canonical;
32pub mod detect;
33pub mod filter;
34pub mod inspect;
35pub mod json_tools;
36pub mod ndjson;
37pub mod parquet_io;
38pub mod query;
39pub mod sort;
40pub mod transform;
41pub mod view;
42pub mod xlsx_tools;
43
44pub use canonical::{to_ods_workbook, CanonicalValue};
45pub use detect::{detect_content, detect_extension};
46pub use filter::Filter;
47pub use inspect::inspect;
48pub use ndjson::{stream_delimited_to_ndjson, stream_ndjson_to_delimited, stream_ndjson_to_ndjson};
49pub use parquet_io::from_parquet;
50pub use parquet_io::to_parquet;
51pub use sort::{external_sort_delimited, SortSpec};
52pub use transform::Transforms;
53
54use std::io::Cursor;
55
56/// Converts a delimited string into a JSON array of objects.
57///
58/// This is the shared engine behind `csv_to_json` and `tsv_to_json`. The first
59/// line is read as the header row; every subsequent line becomes a JSON object
60/// mapping header names to string cell values. Duplicate header names are
61/// rejected because they would silently overwrite data.
62///
63/// # Errors
64///
65/// Returns an error on malformed input, duplicate headers, or JSON
66/// serialization failure.
67///
68/// # Examples
69///
70/// ```
71/// use sheeter_engine::csv_to_json;
72///
73/// let csv = "name,age\nAlice,30";
74/// assert_eq!(
75///     csv_to_json(csv).unwrap(),
76///     r#"[{"name":"Alice","age":"30"}]"#
77/// );
78/// ```
79fn delimited_to_json(input: &str, delim: u8) -> Result<String, Box<dyn std::error::Error>> {
80    let input = strip_bom(input);
81    let mut rdr = csv::ReaderBuilder::new()
82        .delimiter(delim)
83        .from_reader(Cursor::new(input));
84    let headers = rdr.headers()?.clone();
85    let mut seen = std::collections::HashSet::new();
86    for header in headers.iter() {
87        if !seen.insert(header) {
88            return Err(format!("duplicate header: {header}").into());
89        }
90    }
91    let mut records = Vec::new();
92    for result in rdr.records() {
93        let record = result?;
94        let mut map = serde_json::Map::new();
95        for (header, value) in headers.iter().zip(record.iter()) {
96            map.insert(
97                header.to_string(),
98                serde_json::Value::String(value.to_string()),
99            );
100        }
101        records.push(serde_json::Value::Object(map));
102    }
103    Ok(serde_json::to_string(&records)?)
104}
105
106/// Converts a JSON array of objects into a delimited string.
107///
108/// This is the shared engine behind `json_to_csv` and `json_to_tsv`. The header
109/// row is the union of all keys across all objects, in first-appearance order.
110/// Missing keys become empty cells. Strings are written as-is; numbers and
111/// booleans use their JSON literal forms; null becomes an empty cell. Nested
112/// objects and arrays cannot be represented losslessly in flat output and cause
113/// an error.
114///
115/// # Errors
116///
117/// Returns an error if the JSON is not an array of objects, contains nested
118/// values, or if writing fails.
119///
120/// # Examples
121///
122/// ```
123/// use sheeter_engine::json_to_csv;
124///
125/// let json = r#"[{"a":1},{"a":2,"b":3}]"#;
126/// assert_eq!(json_to_csv(json).unwrap(), "a,b\n1,\n2,3\n");
127/// ```
128fn json_to_delimited(input: &str, delim: u8) -> Result<String, Box<dyn std::error::Error>> {
129    let values: Vec<serde_json::Value> = serde_json::from_str(input)?;
130    if values.is_empty() {
131        return Ok(String::new());
132    }
133    let mut headers: Vec<String> = Vec::new();
134    for value in &values {
135        let obj = value.as_object().ok_or("JSON array must contain objects")?;
136        for key in obj.keys() {
137            if !headers.contains(key) {
138                headers.push(key.clone());
139            }
140        }
141    }
142    let mut wtr = csv::WriterBuilder::new()
143        .delimiter(delim)
144        .from_writer(vec![]);
145    wtr.write_record(&headers)?;
146    for value in values {
147        let obj = value.as_object().ok_or("JSON array must contain objects")?;
148        let mut record: Vec<String> = Vec::with_capacity(headers.len());
149        for h in &headers {
150            let cell = match obj.get(h) {
151                Some(serde_json::Value::String(s)) => s.clone(),
152                Some(serde_json::Value::Number(n)) => n.to_string(),
153                Some(serde_json::Value::Bool(b)) => b.to_string(),
154                Some(serde_json::Value::Null) => String::new(),
155                Some(serde_json::Value::Object(_)) | Some(serde_json::Value::Array(_)) => {
156                    return Err(format!(
157                        "nested value at key {h} cannot be represented in flat output"
158                    )
159                    .into());
160                }
161                None => String::new(),
162            };
163            record.push(cell);
164        }
165        wtr.write_record(&record)?;
166    }
167    let data = wtr.into_inner()?;
168    Ok(String::from_utf8(data)?)
169}
170
171/// Converts a CSV string into a JSON array of objects.
172///
173/// CSV (Comma-Separated Values) is a simple tabular format. This function reads
174/// the first row as the header and each subsequent row as a record, producing a
175/// JSON array of objects. All values are emitted as JSON strings.
176///
177/// # Errors
178///
179/// Returns an error on malformed CSV, duplicate headers, or JSON failure.
180///
181/// # Examples
182///
183/// ```
184/// use sheeter_engine::csv_to_json;
185///
186/// let csv = "name,age\nAlice,30\nBob,25";
187/// let json = csv_to_json(csv).unwrap();
188/// assert_eq!(json, r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#);
189/// ```
190pub fn csv_to_json(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
191    delimited_to_json(csv, b',')
192}
193
194/// Converts a TSV string into a JSON array of objects.
195///
196/// TSV (Tab-Separated Values) is the same tabular model as CSV but uses a tab
197/// character as the field separator. This is useful for data exported from
198/// spreadsheet tools that prefer tabs over commas.
199///
200/// # Errors
201///
202/// Returns an error on malformed TSV, duplicate headers, or JSON failure.
203///
204/// # Examples
205///
206/// ```
207/// use sheeter_engine::tsv_to_json;
208///
209/// let tsv = "name\tage\nAlice\t30";
210/// let json = tsv_to_json(tsv).unwrap();
211/// assert_eq!(json, r#"[{"name":"Alice","age":"30"}]"#);
212/// ```
213pub fn tsv_to_json(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
214    delimited_to_json(tsv, b'\t')
215}
216
217/// Converts a JSON array of objects into a CSV string.
218///
219/// The header row is the union of all keys across all objects. Missing keys
220/// become empty cells. Nested objects or arrays cause an error to prevent
221/// silent corruption of round-trips.
222///
223/// # Errors
224///
225/// Returns an error if the input is not an array of objects, contains nested
226/// values, or if writing fails.
227///
228/// # Examples
229///
230/// ```
231/// use sheeter_engine::json_to_csv;
232///
233/// let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
234/// assert_eq!(json_to_csv(json).unwrap(), "name,age\nAlice,30\nBob,25\n");
235/// ```
236pub fn json_to_csv(json: &str) -> Result<String, Box<dyn std::error::Error>> {
237    json_to_delimited(json, b',')
238}
239
240/// Converts a JSON array of objects into a TSV string.
241///
242/// Same semantics as `json_to_csv`, but fields are separated by a tab
243/// character. The output uses `\n` line endings.
244///
245/// # Errors
246///
247/// Returns an error if the input is not an array of objects, contains nested
248/// values, or if writing fails.
249///
250/// # Examples
251///
252/// ```
253/// use sheeter_engine::json_to_tsv;
254///
255/// let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
256/// assert_eq!(json_to_tsv(json).unwrap(), "name\tage\nAlice\t30\nBob\t25\n");
257/// ```
258pub fn json_to_tsv(json: &str) -> Result<String, Box<dyn std::error::Error>> {
259    json_to_delimited(json, b'\t')
260}
261
262/// Removes the UTF-8 BOM (`\u{feff}`) from the start of a string if present.
263///
264/// Some tools (notably Excel) prepend a BOM to CSV/TSV exports. Without
265/// stripping, the first header name would include the invisible BOM character
266/// and any downstream lookup by name would silently fail. This explicit
267/// preprocessing guarantees clean headers regardless of parser behavior.
268fn strip_bom(input: &str) -> &str {
269    input.strip_prefix('\u{feff}').unwrap_or(input)
270}
271
272/// Converts a YAML sequence of mappings into a JSON array of objects.
273///
274/// Non-string YAML keys (e.g. `1:`) are converted to their string form
275/// (`"1"`), matching the convention of every mainstream YAML-to-JSON
276/// converter. Collisions are not detected; if the source YAML contains both
277/// `1:` and `"1":`, one value wins.
278///
279/// This function parses YAML with a strict schema to avoid the "Norway problem":
280/// `yes`, `no`, `on`, `off`, `y`, `n` are treated as strings under YAML 1.2
281/// semantics. Nested sequences or mappings cause an error because they cannot
282/// round-trip losslessly through flat CSV/TSV.
283///
284/// # Errors
285///
286/// Returns an error if the YAML is not a sequence of mappings, contains nested
287/// values, or if JSON serialization fails.
288///
289/// # Examples
290///
291/// ```
292/// use sheeter_engine::yaml_to_json;
293///
294/// let yaml = "- name: Alice\n  age: '30'\n- name: Bob\n  age: '25'\n";
295/// let json = yaml_to_json(yaml).unwrap();
296/// assert_eq!(json, r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#);
297/// ```
298pub fn yaml_to_json(yaml: &str) -> Result<String, Box<dyn std::error::Error>> {
299    let opts = serde_saphyr::options! {
300        strict_booleans: true,
301        ignore_binary_tag_for_string: true,
302    };
303    let values: Vec<serde_json::Value> = serde_saphyr::from_str_with_options(yaml, opts)?;
304    for value in &values {
305        let obj = value
306            .as_object()
307            .ok_or("YAML sequence must contain mappings")?;
308        for (key, val) in obj {
309            if val.is_object() || val.is_array() {
310                return Err(format!(
311                    "nested YAML value at key {key} cannot be represented in flat output"
312                )
313                .into());
314            }
315        }
316    }
317    Ok(serde_json::to_string(&values)?)
318}
319
320/// Converts a JSON array of objects into a YAML sequence of mappings.
321///
322/// This is the inverse of `yaml_to_json`. The output is a YAML 1.2 document
323/// where each object becomes a mapping in a sequence. Key order is preserved.
324/// Nested objects or arrays cause an error for the same reason as CSV: they
325/// cannot round-trip losslessly through a flat format.
326///
327/// # Errors
328///
329/// Returns an error if the JSON is not an array of objects, contains nested
330/// values, or if YAML serialization fails.
331///
332/// # Examples
333///
334/// ```
335/// use sheeter_engine::json_to_yaml;
336///
337/// let json = r#"[{"name":"Alice","age":"30"}]"#;
338/// let yaml = json_to_yaml(json).unwrap();
339/// assert!(yaml.contains("name: Alice"));
340/// ```
341pub fn json_to_yaml(json: &str) -> Result<String, Box<dyn std::error::Error>> {
342    let values: Vec<serde_json::Value> = serde_json::from_str(json)?;
343    for value in &values {
344        let obj = value.as_object().ok_or("JSON array must contain objects")?;
345        for (key, val) in obj {
346            if val.is_object() || val.is_array() {
347                return Err(format!(
348                    "nested JSON value at key {key} cannot be represented in flat YAML"
349                )
350                .into());
351            }
352        }
353    }
354    Ok(serde_saphyr::to_string(&values)?)
355}
356
357/// Converts CSV to YAML by composing `csv_to_json` and `json_to_yaml`.
358///
359/// # Examples
360///
361/// ```
362/// use sheeter_engine::csv_to_yaml;
363///
364/// let csv = "name,age\nAlice,30";
365/// let yaml = csv_to_yaml(csv).unwrap();
366/// assert!(yaml.contains("name: Alice"));
367/// ```
368pub fn csv_to_yaml(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
369    json_to_yaml(&csv_to_json(csv)?)
370}
371
372/// Converts TSV to YAML by composing `tsv_to_json` and `json_to_yaml`.
373///
374/// # Examples
375///
376/// ```
377/// use sheeter_engine::tsv_to_yaml;
378///
379/// let tsv = "name\tage\nAlice\t30";
380/// let yaml = tsv_to_yaml(tsv).unwrap();
381/// assert!(yaml.contains("name: Alice"));
382/// ```
383pub fn tsv_to_yaml(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
384    json_to_yaml(&tsv_to_json(tsv)?)
385}
386
387/// Converts YAML to CSV by composing `yaml_to_json` and `json_to_csv`.
388///
389/// # Examples
390///
391/// ```
392/// use sheeter_engine::yaml_to_csv;
393///
394/// let yaml = "- name: Alice\n  age: '30'\n";
395/// let csv = yaml_to_csv(yaml).unwrap();
396/// assert_eq!(csv, "name,age\nAlice,30\n");
397/// ```
398pub fn yaml_to_csv(yaml: &str) -> Result<String, Box<dyn std::error::Error>> {
399    json_to_csv(&yaml_to_json(yaml)?)
400}
401
402/// Converts YAML to TSV by composing `yaml_to_json` and `json_to_tsv`.
403///
404/// # Examples
405///
406/// ```
407/// use sheeter_engine::yaml_to_tsv;
408///
409/// let yaml = "- name: Alice\n  age: '30'\n";
410/// let tsv = yaml_to_tsv(yaml).unwrap();
411/// assert_eq!(tsv, "name\tage\nAlice\t30\n");
412/// ```
413pub fn yaml_to_tsv(yaml: &str) -> Result<String, Box<dyn std::error::Error>> {
414    json_to_tsv(&yaml_to_json(yaml)?)
415}
416
417/// Converts CSV to TSV by composing `csv_to_json` and `json_to_tsv`.
418///
419/// # Examples
420///
421/// ```
422/// use sheeter_engine::csv_to_tsv;
423///
424/// let csv = "name,age\nAlice,30";
425/// let tsv = csv_to_tsv(csv).unwrap();
426/// assert_eq!(tsv, "name\tage\nAlice\t30\n");
427/// ```
428pub fn csv_to_tsv(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
429    json_to_tsv(&csv_to_json(csv)?)
430}
431
432/// Converts TSV to CSV by composing `tsv_to_json` and `json_to_csv`.
433///
434/// # Examples
435///
436/// ```
437/// use sheeter_engine::tsv_to_csv;
438///
439/// let tsv = "name\tage\nAlice\t30";
440/// let csv = tsv_to_csv(tsv).unwrap();
441/// assert_eq!(csv, "name,age\nAlice,30\n");
442/// ```
443pub fn tsv_to_csv(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
444    json_to_csv(&tsv_to_json(tsv)?)
445}
446
447/// Converts an XML document to a JSON representation.
448///
449/// Attributes become `@name` keys, text becomes `#text`, and repeated
450/// sibling elements become arrays. The output has exactly one top-level key:
451/// the root element name.
452///
453/// # Errors
454/// Returns an error if the XML is malformed or empty.
455///
456/// # Examples
457/// ```
458/// use sheeter_engine::xml_to_json;
459/// let json = xml_to_json("<r><a>1</a><a>2</a></r>").unwrap();
460/// assert_eq!(json, r#"{"r":{"a":["1","2"]}}"#);
461/// ```
462pub fn xml_to_json(xml: &str) -> Result<String, Box<dyn std::error::Error>> {
463    CanonicalValue::from_xml(xml)?.to_json()
464}
465
466/// Converts a JSON value to XML.
467///
468/// Tabular JSON (array of objects) is wrapped in `<root><item>...`. Other
469/// JSON shapes are wrapped in `<root>` unless they are a single-key object,
470/// which becomes the root element directly. Keys starting with `@` become
471/// attributes and `#text` becomes text content.
472///
473/// # Errors
474/// Returns an error if the JSON is malformed.
475///
476/// # Examples
477/// ```
478/// use sheeter_engine::json_to_xml;
479/// let xml = json_to_xml(r#"[{"a":"1"}]"#).unwrap();
480/// assert!(xml.contains("<root>"));
481/// ```
482pub fn json_to_xml(json: &str) -> Result<String, Box<dyn std::error::Error>> {
483    CanonicalValue::from_json(json)?.to_xml()
484}
485
486/// Converts CSV to XML via the canonical hub.
487///
488/// # Examples
489/// ```
490/// use sheeter_engine::csv_to_xml;
491/// let xml = csv_to_xml("a\n1").unwrap();
492/// assert!(xml.contains("<item>"));
493/// ```
494pub fn csv_to_xml(input: &str) -> Result<String, Box<dyn std::error::Error>> {
495    CanonicalValue::from_csv(input)?.to_xml()
496}
497
498/// Converts TSV to XML via the canonical hub.
499///
500/// # Examples
501/// ```
502/// use sheeter_engine::tsv_to_xml;
503/// let xml = tsv_to_xml("a\tb\n1\t2").unwrap();
504/// assert!(xml.contains("<item>"));
505/// ```
506pub fn tsv_to_xml(input: &str) -> Result<String, Box<dyn std::error::Error>> {
507    CanonicalValue::from_tsv(input)?.to_xml()
508}
509
510/// Converts YAML to XML via the canonical hub.
511///
512/// # Examples
513/// ```
514/// use sheeter_engine::yaml_to_xml;
515/// let xml = yaml_to_xml("- a: 1\n").unwrap();
516/// assert!(xml.contains("<item>"));
517/// ```
518pub fn yaml_to_xml(input: &str) -> Result<String, Box<dyn std::error::Error>> {
519    CanonicalValue::from_yaml(input)?.to_xml()
520}
521
522/// Converts XML to CSV, extracting the tabular array if present.
523///
524/// Expects the XML to follow `<root><item><col>value</col></item></root>`
525/// (or any single-level root whose single child key holds the rows). Errors
526/// if the structure is not tabular.
527///
528/// # Errors
529/// Returns an error if the XML is malformed or not tabular.
530///
531/// # Examples
532/// ```
533/// use sheeter_engine::xml_to_csv;
534/// let csv = xml_to_csv("<root><item><a>1</a></item></root>").unwrap();
535/// assert_eq!(csv, "a\n1\n");
536/// ```
537pub fn xml_to_csv(xml: &str) -> Result<String, Box<dyn std::error::Error>> {
538    CanonicalValue::from_xml(xml)?.to_csv()
539}
540
541/// Converts XML to TSV, extracting the tabular array if present.
542///
543/// # Examples
544/// ```
545/// use sheeter_engine::xml_to_tsv;
546/// let tsv = xml_to_tsv("<root><item><a>1</a></item></root>").unwrap();
547/// assert_eq!(tsv, "a\n1\n");
548/// ```
549pub fn xml_to_tsv(xml: &str) -> Result<String, Box<dyn std::error::Error>> {
550    CanonicalValue::from_xml(xml)?.to_tsv()
551}
552
553/// Converts XML to YAML, extracting the tabular array if present.
554///
555/// # Examples
556/// ```
557/// use sheeter_engine::xml_to_yaml;
558/// let yaml = xml_to_yaml("<root><item><a>1</a></item></root>").unwrap();
559/// assert!(yaml.contains("a:"));
560/// ```
561pub fn xml_to_yaml(xml: &str) -> Result<String, Box<dyn std::error::Error>> {
562    CanonicalValue::from_xml(xml)?.to_yaml()
563}
564
565/// Converts a TOML document to a JSON value.
566///
567/// TOML datetimes are stringified (`2024-01-15T10:30:00Z` becomes the string
568/// `"2024-01-15T10:30:00Z"`), matching the behavior of YAML. Nested tables
569/// and arrays of tables map directly to JSON objects and arrays.
570///
571/// # Errors
572/// Returns an error if the TOML is malformed.
573///
574/// # Examples
575/// ```
576/// use sheeter_engine::toml_to_json;
577/// let json = toml_to_json("a = 1\n").unwrap();
578/// assert_eq!(json, r#"{"a":1}"#);
579/// ```
580pub fn toml_to_json(toml: &str) -> Result<String, Box<dyn std::error::Error>> {
581    CanonicalValue::from_toml(toml)?.to_json()
582}
583
584/// Converts a TOML document to CSV, extracting the first array of tables.
585///
586/// Flat TOML documents (all scalar values) become a single-row CSV. Documents
587/// with an array of tables at any depth use that array as the row source.
588///
589/// # Errors
590/// Returns an error if the TOML is malformed or has no tabular structure.
591///
592/// # Examples
593/// ```
594/// use sheeter_engine::toml_to_csv;
595/// assert_eq!(toml_to_csv("a = 1\n").unwrap(), "a\n1\n");
596/// ```
597pub fn toml_to_csv(toml: &str) -> Result<String, Box<dyn std::error::Error>> {
598    CanonicalValue::from_toml(toml)?.to_csv()
599}
600
601/// Converts a TOML document to TSV.
602///
603/// # Errors
604/// Returns an error if the TOML is malformed or has no tabular structure.
605///
606/// # Examples
607/// ```
608/// use sheeter_engine::toml_to_tsv;
609/// assert_eq!(toml_to_tsv("a = 1\n").unwrap(), "a\n1\n");
610/// ```
611pub fn toml_to_tsv(toml: &str) -> Result<String, Box<dyn std::error::Error>> {
612    CanonicalValue::from_toml(toml)?.to_tsv()
613}
614
615/// Converts a TOML document to YAML.
616///
617/// # Errors
618/// Returns an error if the TOML is malformed.
619///
620/// # Examples
621/// ```
622/// use sheeter_engine::toml_to_yaml;
623/// let yaml = toml_to_yaml("a = 1\n").unwrap();
624/// assert!(yaml.contains("a:"));
625/// ```
626pub fn toml_to_yaml(toml: &str) -> Result<String, Box<dyn std::error::Error>> {
627    CanonicalValue::from_toml(toml)?.to_yaml()
628}
629
630/// Converts a TOML document to XML.
631///
632/// # Errors
633/// Returns an error if the TOML is malformed.
634///
635/// # Examples
636/// ```
637/// use sheeter_engine::toml_to_xml;
638/// let xml = toml_to_xml("a = 1\n").unwrap();
639/// assert!(xml.contains("<a>"));
640/// ```
641pub fn toml_to_xml(toml: &str) -> Result<String, Box<dyn std::error::Error>> {
642    CanonicalValue::from_toml(toml)?.to_xml()
643}
644
645/// Converts a JSON value to TOML.
646///
647/// JSON arrays at the root are wrapped in `{item: [...]}` because TOML
648/// requires a table at the document root. Null values are skipped, since
649/// TOML has no null type; JSON arrays containing null error out.
650///
651/// # Errors
652/// Returns an error if the JSON is malformed or contains values TOML cannot
653/// represent (null inside arrays, integers outside i64 range).
654///
655/// # Examples
656/// ```
657/// use sheeter_engine::json_to_toml;
658/// let toml = json_to_toml(r#"{"a":1}"#).unwrap();
659/// assert!(toml.contains("a = 1"));
660/// ```
661pub fn json_to_toml(json: &str) -> Result<String, Box<dyn std::error::Error>> {
662    CanonicalValue::from_json(json)?.to_toml()
663}
664
665/// Converts CSV to TOML via the canonical hub.
666///
667/// The resulting TOML document is `[[item]]` array-of-tables, one item per
668/// CSV row.
669///
670/// # Errors
671/// Returns an error if the CSV is malformed.
672///
673/// # Examples
674/// ```
675/// use sheeter_engine::csv_to_toml;
676/// let toml = csv_to_toml("a\n1\n").unwrap();
677/// assert!(toml.contains("[[item]]"));
678/// ```
679pub fn csv_to_toml(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
680    CanonicalValue::from_csv(csv)?.to_toml()
681}
682
683/// Converts TSV to TOML via the canonical hub.
684///
685/// # Errors
686/// Returns an error if the TSV is malformed.
687///
688/// # Examples
689/// ```
690/// use sheeter_engine::tsv_to_toml;
691/// let toml = tsv_to_toml("a\tb\n1\t2\n").unwrap();
692/// assert!(toml.contains("[[item]]"));
693/// ```
694pub fn tsv_to_toml(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
695    CanonicalValue::from_tsv(tsv)?.to_toml()
696}
697
698/// Converts YAML to TOML via the canonical hub.
699///
700/// # Errors
701/// Returns an error if the YAML is malformed or cannot be represented in TOML.
702///
703/// # Examples
704/// ```
705/// use sheeter_engine::yaml_to_toml;
706/// let toml = yaml_to_toml("- a: 1\n").unwrap();
707/// assert!(toml.contains("[[item]]"));
708/// ```
709pub fn yaml_to_toml(yaml: &str) -> Result<String, Box<dyn std::error::Error>> {
710    CanonicalValue::from_yaml(yaml)?.to_toml()
711}
712
713/// Converts XML to TOML via the canonical hub.
714///
715/// Attribute markers (`@name`) and text markers (`#text`) are preserved as
716/// quoted TOML keys, allowing round-trip back to XML.
717///
718/// # Errors
719/// Returns an error if the XML is malformed or cannot be represented in TOML.
720///
721/// # Examples
722/// ```
723/// use sheeter_engine::xml_to_toml;
724/// let toml = xml_to_toml("<r><a>1</a></r>").unwrap();
725/// assert!(toml.contains("a = \"1\""));
726/// ```
727pub fn xml_to_toml(xml: &str) -> Result<String, Box<dyn std::error::Error>> {
728    CanonicalValue::from_xml(xml)?.to_toml()
729}
730
731/// Supported data formats.
732///
733/// Each variant corresponds to a reader/writer pair on
734/// [`CanonicalValue`](crate::CanonicalValue).
735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
736#[non_exhaustive]
737pub enum Format {
738    Csv,
739    Tsv,
740    Json,
741    Yaml,
742    Xml,
743    Toml,
744    Xlsx,
745    Sql,
746    Ndjson,
747    Ods,
748    Parquet,
749}
750
751/// SQL dialect used when parsing input. The writer emits ANSI-compatible
752/// `INSERT` statements that SQLite, PostgreSQL, and MySQL all accept.
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
754#[non_exhaustive]
755pub enum SqlDialect {
756    #[default]
757    Generic,
758    PostgreSql,
759    Sqlite,
760    MySql,
761}
762
763impl std::str::FromStr for SqlDialect {
764    type Err = String;
765    fn from_str(s: &str) -> Result<Self, Self::Err> {
766        match s.to_ascii_lowercase().as_str() {
767            "generic" | "ansi" => Ok(Self::Generic),
768            "postgres" | "postgresql" | "pg" => Ok(Self::PostgreSql),
769            "sqlite" => Ok(Self::Sqlite),
770            "mysql" | "mariadb" => Ok(Self::MySql),
771            other => Err(format!(
772                "unknown SQL dialect {other:?}; expected generic, postgresql, sqlite, or mysql"
773            )),
774        }
775    }
776}
777
778/// One output document: `(sheet_name, bytes)`. Returned by
779/// [`convert_workbook`] because multi-sheet inputs can produce more than one
780/// output.
781pub type OutputDoc = (String, Vec<u8>);
782
783impl Format {
784    /// Every format the Sheeter engine can read and write.
785    pub const ALL: [Format; 11] = [
786        Format::Csv,
787        Format::Tsv,
788        Format::Json,
789        Format::Yaml,
790        Format::Xml,
791        Format::Toml,
792        Format::Xlsx,
793        Format::Sql,
794        Format::Ndjson,
795        Format::Ods,
796        Format::Parquet,
797    ];
798}
799
800/// Converts `input` from one format to another via the canonical hub.
801///
802/// If `from` equals `to`, the input is returned unchanged. That preserves
803/// BOM markers, formatting, and other byte-level details that a round-trip
804/// through the hub would normalize away.
805///
806/// # Errors
807///
808/// Returns an error if the input cannot be parsed as `from` or serialized
809/// as `to`, or if the document cannot be represented in the target format
810/// (for example, nested values when converting to CSV).
811///
812/// # Examples
813///
814/// ```
815/// use sheeter_engine::{convert, Format};
816///
817/// let json = convert(Format::Csv, Format::Json, "a\n1\n").unwrap();
818/// assert_eq!(json, r#"[{"a":"1"}]"#);
819/// ```
820pub fn convert(
821    from: Format,
822    to: Format,
823    input: &str,
824) -> Result<String, Box<dyn std::error::Error>> {
825    if from == to {
826        return Ok(input.to_string());
827    }
828    if from == Format::Xlsx || to == Format::Xlsx {
829        return Err("XLSX is binary; use convert_workbook instead".into());
830    }
831    if from == Format::Ods || to == Format::Ods {
832        return Err("ODS is binary; use convert_workbook instead".into());
833    }
834    if from == Format::Parquet || to == Format::Parquet {
835        return Err("Parquet is binary; use convert_workbook instead".into());
836    }
837    let hub = match from {
838        Format::Csv => CanonicalValue::from_csv(input)?,
839        Format::Tsv => CanonicalValue::from_tsv(input)?,
840        Format::Json => CanonicalValue::from_json(input)?,
841        Format::Yaml => CanonicalValue::from_yaml(input)?,
842        Format::Xml => CanonicalValue::from_xml(input)?,
843        Format::Toml => CanonicalValue::from_toml(input)?,
844        Format::Sql => {
845            let mut sheets = CanonicalValue::from_sql_workbook(input, SqlDialect::Generic)?;
846            if sheets.len() != 1 {
847                return Err("multi-table SQL requires convert_workbook".into());
848            }
849            sheets.remove(0).1
850        }
851        Format::Ndjson => CanonicalValue::from_ndjson(input)?,
852        Format::Ods => unreachable!(),
853        Format::Xlsx => unreachable!(),
854        Format::Parquet => unreachable!(),
855    };
856    match to {
857        Format::Csv => hub.to_csv(),
858        Format::Tsv => hub.to_tsv(),
859        Format::Json => hub.to_json(),
860        Format::Yaml => hub.to_yaml(),
861        Format::Xml => hub.to_xml(),
862        Format::Toml => hub.to_toml(),
863        Format::Sql => hub.to_sql_with_table("data"),
864        Format::Ndjson => hub.to_ndjson(),
865
866        Format::Ods => unreachable!(),
867        Format::Xlsx => unreachable!(),
868        Format::Parquet => unreachable!(),
869    }
870}
871
872/// Converts a byte buffer between formats, supporting multi-sheet workbooks.
873///
874/// Returns one entry per output document. For XLSX inputs with N sheets and
875/// CSV/TSV outputs, the result has N entries. For JSON/YAML/XML/TOML/XLSX
876/// outputs, the result has exactly one entry containing all sheets merged
877/// into a single document.
878///
879/// The returned string is the sheet name (from XLSX) or `"Sheet1"` for
880/// single-document inputs. Callers decide how to map these names to file
881/// paths.
882///
883/// # Errors
884///
885/// Returns an error if the input cannot be parsed or the output cannot be
886/// represented in the target format.
887///
888/// # Examples
889///
890/// ```
891/// use sheeter_engine::{convert_workbook, Format};
892///
893/// let out = convert_workbook(Format::Csv, Format::Json, b"a\n1\n").unwrap();
894/// assert_eq!(out.len(), 1);
895/// assert_eq!(out[0].0, "Sheet1");
896/// ```
897pub fn convert_workbook(
898    from: Format,
899    to: Format,
900    input: &[u8],
901) -> Result<Vec<OutputDoc>, Box<dyn std::error::Error>> {
902    convert_workbook_with_options(from, to, input, SqlDialect::Generic, None)
903}
904
905/// Like [`convert_workbook`], but lets the caller pick the SQL dialect and
906/// the table name used when writing SQL output.
907pub fn convert_workbook_with_options(
908    from: Format,
909    to: Format,
910    input: &[u8],
911    dialect: SqlDialect,
912    table_name: Option<&str>,
913) -> Result<Vec<OutputDoc>, Box<dyn std::error::Error>> {
914    if from == to {
915        return match from {
916            Format::Xlsx | Format::Ods | Format::Parquet => {
917                Ok(vec![("Sheet1".to_string(), input.to_vec())])
918            }
919            _ => {
920                let text = std::str::from_utf8(input)?;
921                Ok(vec![("Sheet1".to_string(), text.as_bytes().to_vec())])
922            }
923        };
924    }
925
926    let sheets: Vec<(String, CanonicalValue)> = match from {
927        Format::Xlsx => CanonicalValue::from_xlsx_workbook(input)?,
928        Format::Parquet => vec![("Sheet1".to_string(), crate::from_parquet(input)?)],
929        Format::Ods => {
930            let v = CanonicalValue::from_ods(input)?;
931            match v {
932                CanonicalValue::Object(o) => o,
933                other => vec![("Sheet1".to_string(), other)],
934            }
935        }
936        Format::Sql => {
937            let text = std::str::from_utf8(input)?;
938            CanonicalValue::from_sql_workbook(text, dialect)?
939        }
940        Format::Ndjson => {
941            let text = std::str::from_utf8(input)?;
942            vec![("Sheet1".to_string(), CanonicalValue::from_ndjson(text)?)]
943        }
944        _ => {
945            let text = std::str::from_utf8(input)?;
946            let hub = match from {
947                Format::Csv => CanonicalValue::from_csv(text)?,
948                Format::Tsv => CanonicalValue::from_tsv(text)?,
949                Format::Json => CanonicalValue::from_json(text)?,
950                Format::Yaml => CanonicalValue::from_yaml(text)?,
951                Format::Xml => CanonicalValue::from_xml(text)?,
952                Format::Toml => CanonicalValue::from_toml(text)?,
953                Format::Sql | Format::Ndjson | Format::Xlsx | Format::Ods | Format::Parquet => {
954                    unreachable!()
955                }
956            };
957            vec![("Sheet1".to_string(), hub)]
958        }
959    };
960
961    if to == Format::Sql {
962        let text = if sheets.len() == 1 {
963            let table = table_name.unwrap_or("data");
964            sheets[0].1.to_sql_with_table(table)?
965        } else {
966            CanonicalValue::to_sql_workbook(&sheets)?
967        };
968        return Ok(vec![("Sheet1".to_string(), text.into_bytes())]);
969    }
970
971    if to == Format::Ods {
972        let bytes = to_ods_workbook(&sheets)?;
973        return Ok(vec![("Sheet1".to_string(), bytes)]);
974    }
975
976    if to == Format::Ndjson {
977        let text = if sheets.len() == 1 {
978            sheets[0].1.to_ndjson()?
979        } else {
980            let merged = CanonicalValue::Object(sheets);
981            merged.to_ndjson()?
982        };
983        return Ok(vec![("Sheet1".to_string(), text.into_bytes())]);
984    }
985
986    if to == Format::Xlsx {
987        let bytes = CanonicalValue::to_xlsx_workbook(&sheets)?;
988        return Ok(vec![("Sheet1".to_string(), bytes)]);
989    }
990
991    if to == Format::Parquet {
992        if sheets.len() != 1 {
993            return Err("Parquet output supports exactly one table".into());
994        }
995        let bytes = crate::parquet_io::to_parquet(&sheets[0].1)?;
996        return Ok(vec![("Sheet1".to_string(), bytes)]);
997    }
998
999    if sheets.len() == 1 {
1000        let text = write_single(to, &sheets[0].1)?;
1001        return Ok(vec![(sheets[0].0.clone(), text.into_bytes())]);
1002    }
1003
1004    match to {
1005        Format::Json | Format::Yaml | Format::Xml | Format::Toml => {
1006            let merged = CanonicalValue::Object(sheets);
1007            Ok(vec![(
1008                "Sheet1".to_string(),
1009                write_single(to, &merged)?.into_bytes(),
1010            )])
1011        }
1012        Format::Csv | Format::Tsv => {
1013            let mut out = Vec::with_capacity(sheets.len());
1014            for (name, value) in &sheets {
1015                out.push((name.clone(), write_single(to, value)?.into_bytes()));
1016            }
1017            Ok(out)
1018        }
1019        Format::Sql => unreachable!(),
1020        Format::Ndjson => unreachable!(),
1021        Format::Ods => unreachable!(),
1022        Format::Xlsx => unreachable!(),
1023        Format::Parquet => unreachable!(),
1024    }
1025}
1026
1027fn write_single(
1028    format: Format,
1029    value: &CanonicalValue,
1030) -> Result<String, Box<dyn std::error::Error>> {
1031    match format {
1032        Format::Csv => value.to_csv(),
1033        Format::Tsv => value.to_tsv(),
1034        Format::Json => value.to_json(),
1035        Format::Yaml => value.to_yaml(),
1036        Format::Xml => value.to_xml(),
1037        Format::Toml => value.to_toml(),
1038        Format::Sql => value.to_sql_with_table("data"),
1039        Format::Ndjson => value.to_ndjson(),
1040        Format::Ods => Err("ODS must go through to_ods_workbook".into()),
1041        Format::Xlsx => Err("XLSX must go through to_xlsx_workbook".into()),
1042        Format::Parquet => Err("Parquet must go through parquet_io::to_parquet".into()),
1043    }
1044}
1045
1046/// Converts CSV or TSV input to CSV or TSV output using constant memory.
1047///
1048/// Reads row by row and writes row by row, so peak memory is bounded by the
1049/// largest single row, not by file size. Applies filter, select, rename, and
1050/// limit transforms on the fly. Sorting and other buffering transforms are
1051/// not supported here.
1052///
1053/// # Errors
1054/// Returns an error if the input is malformed, if a transform fails, or if
1055/// the output writer fails.
1056///
1057/// # Examples
1058///
1059/// ```
1060/// use sheeter_engine::{stream_delimited, Transforms};
1061///
1062/// let input = b"a\n1\n2\n3\n";
1063/// let mut output = Vec::new();
1064/// stream_delimited(input.as_slice(), b',', &mut output, b',', &Transforms::default()).unwrap();
1065/// assert_eq!(output, b"a\n1\n2\n3\n");
1066/// ```
1067pub fn stream_delimited(
1068    reader: impl std::io::Read,
1069    from_delim: u8,
1070    writer: impl std::io::Write,
1071    to_delim: u8,
1072    transforms: &Transforms,
1073) -> Result<(), Box<dyn std::error::Error>> {
1074    let mut rdr = csv::ReaderBuilder::new()
1075        .delimiter(from_delim)
1076        .from_reader(reader);
1077    let raw_headers: Vec<String> = rdr.headers()?.iter().map(|s| s.to_string()).collect();
1078
1079    let mut renamed_headers = raw_headers.clone();
1080    for (from, to) in &transforms.rename {
1081        for h in renamed_headers.iter_mut() {
1082            if h == from {
1083                *h = to.clone();
1084                break;
1085            }
1086        }
1087    }
1088    let final_headers: Vec<String> = match &transforms.select {
1089        Some(sel) => sel.clone(),
1090        None => renamed_headers,
1091    };
1092
1093    let mut wtr = csv::WriterBuilder::new()
1094        .delimiter(to_delim)
1095        .from_writer(writer);
1096    wtr.write_record(&final_headers)?;
1097
1098    let limit = transforms.limit.unwrap_or(usize::MAX);
1099    let mut emitted = 0usize;
1100    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1101    let dedup_cols = transforms.dedup.as_ref().cloned();
1102    for result in rdr.records() {
1103        if emitted >= limit {
1104            break;
1105        }
1106        let record = result?;
1107        let mut row: Vec<(String, CanonicalValue)> = Vec::with_capacity(raw_headers.len());
1108        for (i, h) in raw_headers.iter().enumerate() {
1109            let v = record.get(i).unwrap_or("").to_string();
1110            row.push((h.clone(), CanonicalValue::String(v)));
1111        }
1112        if let Some(new_row) = transforms.apply_to_row(row) {
1113            if let Some(cols) = &dedup_cols {
1114                let key: String = if cols.is_empty() {
1115                    new_row
1116                        .iter()
1117                        .map(|(_, v)| match v {
1118                            CanonicalValue::String(s) => s.clone(),
1119                            _ => String::new(),
1120                        })
1121                        .collect::<Vec<_>>()
1122                        .join("\x1f")
1123                } else {
1124                    cols.iter()
1125                        .map(|c| {
1126                            new_row
1127                                .iter()
1128                                .find(|(k, _)| k == c)
1129                                .map(|(_, v)| match v {
1130                                    CanonicalValue::String(s) => s.clone(),
1131                                    _ => String::new(),
1132                                })
1133                                .unwrap_or_default()
1134                        })
1135                        .collect::<Vec<_>>()
1136                        .join("\x1f")
1137                };
1138                if !seen.insert(key) {
1139                    continue;
1140                }
1141            }
1142            let values: Vec<String> = new_row
1143                .iter()
1144                .map(|(_, v)| match v {
1145                    CanonicalValue::Null => String::new(),
1146                    CanonicalValue::String(s) => s.clone(),
1147                    CanonicalValue::Number(n) => n.to_string(),
1148                    CanonicalValue::Bool(b) => b.to_string(),
1149                    _ => String::new(),
1150                })
1151                .collect();
1152            wtr.write_record(&values)?;
1153            emitted += 1;
1154        }
1155    }
1156    wtr.flush()?;
1157    Ok(())
1158}