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