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