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}