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