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