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