buup/transformers/
csv_to_json.rs

1use crate::{Transform, TransformError, TransformerCategory};
2
3/// CSV to JSON transformer
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct CsvToJson;
6
7impl Transform for CsvToJson {
8    fn name(&self) -> &'static str {
9        "CSV to JSON"
10    }
11
12    fn id(&self) -> &'static str {
13        "csvtojson"
14    }
15
16    fn description(&self) -> &'static str {
17        "Converts CSV data to JSON format"
18    }
19
20    fn category(&self) -> TransformerCategory {
21        TransformerCategory::Other
22    }
23
24    fn transform(&self, input: &str) -> Result<String, TransformError> {
25        if input.trim().is_empty() {
26            return Ok("[]".to_string());
27        }
28
29        let mut lines = input.lines().collect::<Vec<_>>();
30        if lines.is_empty() {
31            return Ok("[]".to_string());
32        }
33
34        // Extract header row
35        let header = lines.remove(0);
36        let headers = parse_csv_row(header);
37
38        if headers.is_empty() {
39            return Ok("[]".to_string());
40        }
41
42        // Process data rows
43        let mut json = String::from("[");
44        let mut first_row = true;
45
46        for line in lines {
47            if line.trim().is_empty() {
48                continue;
49            }
50
51            let values = parse_csv_row(line);
52            if values.is_empty() {
53                continue;
54            }
55
56            if !first_row {
57                json.push(',');
58            } else {
59                first_row = false;
60            }
61
62            // Create JSON object for this row
63            json.push_str("\n  {");
64            let mut first_field = true;
65
66            for (i, value) in values.iter().enumerate() {
67                if i >= headers.len() {
68                    break;
69                }
70
71                if !first_field {
72                    json.push(',');
73                } else {
74                    first_field = false;
75                }
76
77                // Escape JSON field name
78                json.push_str(&format!("\n    \"{}\":", escape_json_string(&headers[i])));
79
80                // Handle value based on content
81                if value.trim().is_empty() {
82                    json.push_str("null");
83                } else if value == "true"
84                    || value == "false"
85                    || value == "null"
86                    || value.parse::<f64>().is_ok()
87                {
88                    // Numbers, booleans, and null can be added directly
89                    json.push_str(value);
90                } else {
91                    // String values need to be quoted and escaped
92                    json.push_str(&format!("\"{}\"", escape_json_string(value)));
93                }
94            }
95
96            json.push_str("\n  }");
97        }
98
99        if first_row {
100            // No rows were processed, return an empty array without newlines
101            return Ok("[]".to_string());
102        }
103
104        json.push_str("\n]");
105        Ok(json)
106    }
107
108    fn default_test_input(&self) -> &'static str {
109        "id,name,value\n1,apple,1.5\n2,banana,0.75"
110    }
111}
112
113/// Parses a CSV row into fields, handling quoted values
114fn parse_csv_row(row: &str) -> Vec<String> {
115    let mut fields = Vec::new();
116    let mut current_field = String::new();
117    let mut in_quotes = false;
118    let mut chars = row.chars().peekable();
119
120    while let Some(c) = chars.next() {
121        match c {
122            '"' => {
123                if in_quotes && chars.peek() == Some(&'"') {
124                    // Escaped quote inside quoted field
125                    chars.next(); // Consume the second quote
126                    current_field.push('"');
127                } else {
128                    // Toggle quote mode
129                    in_quotes = !in_quotes;
130                }
131            }
132            ',' if !in_quotes => {
133                // End of field
134                fields.push(current_field);
135                current_field = String::new();
136            }
137            _ => {
138                current_field.push(c);
139            }
140        }
141    }
142
143    // Add the last field
144    fields.push(current_field);
145    fields
146}
147
148/// Escapes special characters in a JSON string
149fn escape_json_string(s: &str) -> String {
150    let mut result = String::with_capacity(s.len() + 2);
151
152    for c in s.chars() {
153        match c {
154            '"' => result.push_str("\\\""),
155            '\\' => result.push_str("\\\\"),
156            '\n' => result.push_str("\\n"),
157            '\r' => result.push_str("\\r"),
158            '\t' => result.push_str("\\t"),
159            '\u{0008}' => result.push_str("\\b"),
160            '\u{000C}' => result.push_str("\\f"),
161            _ if c.is_control() => {
162                result.push_str(&format!("\\u{:04x}", c as u32));
163            }
164            _ => result.push(c),
165        }
166    }
167
168    result
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_csv_to_json_basic() {
177        let transformer = CsvToJson;
178        let input = transformer.default_test_input(); // Use default for a basic check
179        let expected_default = r#"[
180  {
181    "id":1,
182    "name":"apple",
183    "value":1.5
184  },
185  {
186    "id":2,
187    "name":"banana",
188    "value":0.75
189  }
190]"#;
191        assert_eq!(transformer.transform(input).unwrap(), expected_default);
192
193        let input_complex = "name,age,active\nAlice,30,true\nBob,25,false";
194        let expected_complex = r#"[
195  {
196    "name":"Alice",
197    "age":30,
198    "active":true
199  },
200  {
201    "name":"Bob",
202    "age":25,
203    "active":false
204  }
205]"#;
206        assert_eq!(
207            transformer.transform(input_complex).unwrap(),
208            expected_complex
209        );
210    }
211
212    #[test]
213    fn test_csv_to_json_with_quotes() {
214        let transformer = CsvToJson;
215        let input = r#"name,quote
216Alice,"Hello, world"
217Bob,"Quoted ""text"" here""#;
218        let expected = r#"[
219  {
220    "name":"Alice",
221    "quote":"Hello, world"
222  },
223  {
224    "name":"Bob",
225    "quote":"Quoted \"text\" here"
226  }
227]"#;
228        assert_eq!(transformer.transform(input).unwrap(), expected);
229    }
230
231    #[test]
232    fn test_csv_to_json_empty() {
233        let transformer = CsvToJson;
234        assert_eq!(transformer.transform("").unwrap(), "[]");
235    }
236
237    #[test]
238    fn test_csv_to_json_header_only() {
239        let transformer = CsvToJson;
240        assert_eq!(transformer.transform("name,age").unwrap(), "[]");
241    }
242
243    #[test]
244    fn test_csv_to_json_with_null() {
245        let transformer = CsvToJson;
246        let input = "name,age\nAlice,\nBob,25";
247        let expected = r#"[
248  {
249    "name":"Alice",
250    "age":null
251  },
252  {
253    "name":"Bob",
254    "age":25
255  }
256]"#;
257        assert_eq!(transformer.transform(input).unwrap(), expected);
258    }
259}