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
31use std::io::Cursor;
32
33/// Converts a delimited string into a JSON array of objects.
34///
35/// This is the shared engine behind `csv_to_json` and `tsv_to_json`. The first
36/// line is read as the header row; every subsequent line becomes a JSON object
37/// mapping header names to string cell values. Duplicate header names are
38/// rejected because they would silently overwrite data.
39///
40/// # Errors
41///
42/// Returns an error on malformed input, duplicate headers, or JSON
43/// serialization failure.
44///
45/// # Examples
46///
47/// ```
48/// use sheeter_engine::csv_to_json;
49///
50/// let csv = "name,age\nAlice,30";
51/// assert_eq!(
52/// csv_to_json(csv).unwrap(),
53/// r#"[{"name":"Alice","age":"30"}]"#
54/// );
55/// ```
56fn delimited_to_json(input: &str, delim: u8) -> Result<String, Box<dyn std::error::Error>> {
57 let input = strip_bom(input);
58 let mut rdr = csv::ReaderBuilder::new()
59 .delimiter(delim)
60 .from_reader(Cursor::new(input));
61 let headers = rdr.headers()?.clone();
62 let mut seen = std::collections::HashSet::new();
63 for header in headers.iter() {
64 if !seen.insert(header) {
65 return Err(format!("duplicate header: {header}").into());
66 }
67 }
68 let mut records = Vec::new();
69 for result in rdr.records() {
70 let record = result?;
71 let mut map = serde_json::Map::new();
72 for (header, value) in headers.iter().zip(record.iter()) {
73 map.insert(
74 header.to_string(),
75 serde_json::Value::String(value.to_string()),
76 );
77 }
78 records.push(serde_json::Value::Object(map));
79 }
80 Ok(serde_json::to_string(&records)?)
81}
82
83/// Converts a JSON array of objects into a delimited string.
84///
85/// This is the shared engine behind `json_to_csv` and `json_to_tsv`. The header
86/// row is the union of all keys across all objects, in first-appearance order.
87/// Missing keys become empty cells. Strings are written as-is; numbers and
88/// booleans use their JSON literal forms; null becomes an empty cell. Nested
89/// objects and arrays cannot be represented losslessly in flat output and cause
90/// an error.
91///
92/// # Errors
93///
94/// Returns an error if the JSON is not an array of objects, contains nested
95/// values, or if writing fails.
96///
97/// # Examples
98///
99/// ```
100/// use sheeter_engine::json_to_csv;
101///
102/// let json = r#"[{"a":1},{"a":2,"b":3}]"#;
103/// assert_eq!(json_to_csv(json).unwrap(), "a,b\n1,\n2,3\n");
104/// ```
105fn json_to_delimited(input: &str, delim: u8) -> Result<String, Box<dyn std::error::Error>> {
106 let values: Vec<serde_json::Value> = serde_json::from_str(input)?;
107 if values.is_empty() {
108 return Ok(String::new());
109 }
110 let mut headers: Vec<String> = Vec::new();
111 for value in &values {
112 let obj = value.as_object().ok_or("JSON array must contain objects")?;
113 for key in obj.keys() {
114 if !headers.contains(key) {
115 headers.push(key.clone());
116 }
117 }
118 }
119 let mut wtr = csv::WriterBuilder::new()
120 .delimiter(delim)
121 .from_writer(vec![]);
122 wtr.write_record(&headers)?;
123 for value in values {
124 let obj = value.as_object().ok_or("JSON array must contain objects")?;
125 let mut record: Vec<String> = Vec::with_capacity(headers.len());
126 for h in &headers {
127 let cell = match obj.get(h) {
128 Some(serde_json::Value::String(s)) => s.clone(),
129 Some(serde_json::Value::Number(n)) => n.to_string(),
130 Some(serde_json::Value::Bool(b)) => b.to_string(),
131 Some(serde_json::Value::Null) => String::new(),
132 Some(serde_json::Value::Object(_)) | Some(serde_json::Value::Array(_)) => {
133 return Err(format!(
134 "nested value at key {h} cannot be represented in flat output"
135 )
136 .into());
137 }
138 None => String::new(),
139 };
140 record.push(cell);
141 }
142 wtr.write_record(&record)?;
143 }
144 let data = wtr.into_inner()?;
145 Ok(String::from_utf8(data)?)
146}
147
148/// Converts a CSV string into a JSON array of objects.
149///
150/// CSV (Comma-Separated Values) is a simple tabular format. This function reads
151/// the first row as the header and each subsequent row as a record, producing a
152/// JSON array of objects. All values are emitted as JSON strings.
153///
154/// # Errors
155///
156/// Returns an error on malformed CSV, duplicate headers, or JSON failure.
157///
158/// # Examples
159///
160/// ```
161/// use sheeter_engine::csv_to_json;
162///
163/// let csv = "name,age\nAlice,30\nBob,25";
164/// let json = csv_to_json(csv).unwrap();
165/// assert_eq!(json, r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#);
166/// ```
167pub fn csv_to_json(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
168 delimited_to_json(csv, b',')
169}
170
171/// Converts a TSV string into a JSON array of objects.
172///
173/// TSV (Tab-Separated Values) is the same tabular model as CSV but uses a tab
174/// character as the field separator. This is useful for data exported from
175/// spreadsheet tools that prefer tabs over commas.
176///
177/// # Errors
178///
179/// Returns an error on malformed TSV, duplicate headers, or JSON failure.
180///
181/// # Examples
182///
183/// ```
184/// use sheeter_engine::tsv_to_json;
185///
186/// let tsv = "name\tage\nAlice\t30";
187/// let json = tsv_to_json(tsv).unwrap();
188/// assert_eq!(json, r#"[{"name":"Alice","age":"30"}]"#);
189/// ```
190pub fn tsv_to_json(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
191 delimited_to_json(tsv, b'\t')
192}
193
194/// Converts a JSON array of objects into a CSV string.
195///
196/// The header row is the union of all keys across all objects. Missing keys
197/// become empty cells. Nested objects or arrays cause an error to prevent
198/// silent corruption of round-trips.
199///
200/// # Errors
201///
202/// Returns an error if the input is not an array of objects, contains nested
203/// values, or if writing fails.
204///
205/// # Examples
206///
207/// ```
208/// use sheeter_engine::json_to_csv;
209///
210/// let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
211/// assert_eq!(json_to_csv(json).unwrap(), "name,age\nAlice,30\nBob,25\n");
212/// ```
213pub fn json_to_csv(json: &str) -> Result<String, Box<dyn std::error::Error>> {
214 json_to_delimited(json, b',')
215}
216
217/// Converts a JSON array of objects into a TSV string.
218///
219/// Same semantics as `json_to_csv`, but fields are separated by a tab
220/// character. The output uses `\n` line endings.
221///
222/// # Errors
223///
224/// Returns an error if the input is not an array of objects, contains nested
225/// values, or if writing fails.
226///
227/// # Examples
228///
229/// ```
230/// use sheeter_engine::json_to_tsv;
231///
232/// let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
233/// assert_eq!(json_to_tsv(json).unwrap(), "name\tage\nAlice\t30\nBob\t25\n");
234/// ```
235pub fn json_to_tsv(json: &str) -> Result<String, Box<dyn std::error::Error>> {
236 json_to_delimited(json, b'\t')
237}
238
239/// Removes the UTF-8 BOM (`\u{feff}`) from the start of a string if present.
240///
241/// Some tools (notably Excel) prepend a BOM to CSV/TSV exports. Without
242/// stripping, the first header name would include the invisible BOM character
243/// and any downstream lookup by name would silently fail. This explicit
244/// preprocessing guarantees clean headers regardless of parser behavior.
245fn strip_bom(input: &str) -> &str {
246 input.strip_prefix('\u{feff}').unwrap_or(input)
247}
248
249/// Converts a YAML sequence of mappings into a JSON array of objects.
250///
251/// Non-string YAML keys (e.g. `1:`) are converted to their string form
252/// (`"1"`), matching the convention of every mainstream YAML-to-JSON
253/// converter. Collisions are not detected; if the source YAML contains both
254/// `1:` and `"1":`, one value wins.
255///
256/// This function parses YAML with a strict schema to avoid the "Norway problem":
257/// `yes`, `no`, `on`, `off`, `y`, `n` are treated as strings under YAML 1.2
258/// semantics. Nested sequences or mappings cause an error because they cannot
259/// round-trip losslessly through flat CSV/TSV.
260///
261/// # Errors
262///
263/// Returns an error if the YAML is not a sequence of mappings, contains nested
264/// values, or if JSON serialization fails.
265///
266/// # Examples
267///
268/// ```
269/// use sheeter_engine::yaml_to_json;
270///
271/// let yaml = "- name: Alice\n age: '30'\n- name: Bob\n age: '25'\n";
272/// let json = yaml_to_json(yaml).unwrap();
273/// assert_eq!(json, r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#);
274/// ```
275pub fn yaml_to_json(yaml: &str) -> Result<String, Box<dyn std::error::Error>> {
276 let opts = serde_saphyr::options! {
277 strict_booleans: true,
278 ignore_binary_tag_for_string: true,
279 };
280 let values: Vec<serde_json::Value> = serde_saphyr::from_str_with_options(yaml, opts)?;
281 for value in &values {
282 let obj = value
283 .as_object()
284 .ok_or("YAML sequence must contain mappings")?;
285 for (key, val) in obj {
286 if val.is_object() || val.is_array() {
287 return Err(format!(
288 "nested YAML value at key {key} cannot be represented in flat output"
289 )
290 .into());
291 }
292 }
293 }
294 Ok(serde_json::to_string(&values)?)
295}
296
297/// Converts a JSON array of objects into a YAML sequence of mappings.
298///
299/// This is the inverse of `yaml_to_json`. The output is a YAML 1.2 document
300/// where each object becomes a mapping in a sequence. Key order is preserved.
301/// Nested objects or arrays cause an error for the same reason as CSV: they
302/// cannot round-trip losslessly through a flat format.
303///
304/// # Errors
305///
306/// Returns an error if the JSON is not an array of objects, contains nested
307/// values, or if YAML serialization fails.
308///
309/// # Examples
310///
311/// ```
312/// use sheeter_engine::json_to_yaml;
313///
314/// let json = r#"[{"name":"Alice","age":"30"}]"#;
315/// let yaml = json_to_yaml(json).unwrap();
316/// assert!(yaml.contains("name: Alice"));
317/// ```
318pub fn json_to_yaml(json: &str) -> Result<String, Box<dyn std::error::Error>> {
319 let values: Vec<serde_json::Value> = serde_json::from_str(json)?;
320 for value in &values {
321 let obj = value.as_object().ok_or("JSON array must contain objects")?;
322 for (key, val) in obj {
323 if val.is_object() || val.is_array() {
324 return Err(format!(
325 "nested JSON value at key {key} cannot be represented in flat YAML"
326 )
327 .into());
328 }
329 }
330 }
331 Ok(serde_saphyr::to_string(&values)?)
332}
333
334/// Converts CSV to YAML by composing `csv_to_json` and `json_to_yaml`.
335///
336/// # Examples
337///
338/// ```
339/// use sheeter_engine::csv_to_yaml;
340///
341/// let csv = "name,age\nAlice,30";
342/// let yaml = csv_to_yaml(csv).unwrap();
343/// assert!(yaml.contains("name: Alice"));
344/// ```
345pub fn csv_to_yaml(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
346 json_to_yaml(&csv_to_json(csv)?)
347}
348
349/// Converts TSV to YAML by composing `tsv_to_json` and `json_to_yaml`.
350///
351/// # Examples
352///
353/// ```
354/// use sheeter_engine::tsv_to_yaml;
355///
356/// let tsv = "name\tage\nAlice\t30";
357/// let yaml = tsv_to_yaml(tsv).unwrap();
358/// assert!(yaml.contains("name: Alice"));
359/// ```
360pub fn tsv_to_yaml(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
361 json_to_yaml(&tsv_to_json(tsv)?)
362}
363
364/// Converts YAML to CSV by composing `yaml_to_json` and `json_to_csv`.
365///
366/// # Examples
367///
368/// ```
369/// use sheeter_engine::yaml_to_csv;
370///
371/// let yaml = "- name: Alice\n age: '30'\n";
372/// let csv = yaml_to_csv(yaml).unwrap();
373/// assert_eq!(csv, "name,age\nAlice,30\n");
374/// ```
375pub fn yaml_to_csv(yaml: &str) -> Result<String, Box<dyn std::error::Error>> {
376 json_to_csv(&yaml_to_json(yaml)?)
377}
378
379/// Converts YAML to TSV by composing `yaml_to_json` and `json_to_tsv`.
380///
381/// # Examples
382///
383/// ```
384/// use sheeter_engine::yaml_to_tsv;
385///
386/// let yaml = "- name: Alice\n age: '30'\n";
387/// let tsv = yaml_to_tsv(yaml).unwrap();
388/// assert_eq!(tsv, "name\tage\nAlice\t30\n");
389/// ```
390pub fn yaml_to_tsv(yaml: &str) -> Result<String, Box<dyn std::error::Error>> {
391 json_to_tsv(&yaml_to_json(yaml)?)
392}
393
394/// Converts CSV to TSV by composing `csv_to_json` and `json_to_tsv`.
395///
396/// # Examples
397///
398/// ```
399/// use sheeter_engine::csv_to_tsv;
400///
401/// let csv = "name,age\nAlice,30";
402/// let tsv = csv_to_tsv(csv).unwrap();
403/// assert_eq!(tsv, "name\tage\nAlice\t30\n");
404/// ```
405pub fn csv_to_tsv(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
406 json_to_tsv(&csv_to_json(csv)?)
407}
408
409/// Converts TSV to CSV by composing `tsv_to_json` and `json_to_csv`.
410///
411/// # Examples
412///
413/// ```
414/// use sheeter_engine::tsv_to_csv;
415///
416/// let tsv = "name\tage\nAlice\t30";
417/// let csv = tsv_to_csv(tsv).unwrap();
418/// assert_eq!(csv, "name,age\nAlice,30\n");
419/// ```
420pub fn tsv_to_csv(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
421 json_to_csv(&tsv_to_json(tsv)?)
422}