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