Skip to main content

shape_runtime/stdlib/
csv_module.rs

1//! Native `csv` module for CSV parsing and serialization.
2//!
3//! Exports: csv.parse, csv.parse_records, csv.stringify, csv.stringify_records,
4//!          csv.read_file, csv.is_valid
5
6use crate::module_exports::{ModuleContext, ModuleExports, ModuleFunction, ModuleParam};
7use shape_value::ValueWord;
8use std::sync::Arc;
9
10/// Create the `csv` module with CSV parsing and serialization functions.
11pub fn create_csv_module() -> ModuleExports {
12    let mut module = ModuleExports::new("csv");
13    module.description = "CSV parsing and serialization".to_string();
14
15    // csv.parse(text: string) -> Array<Array<string>>
16    module.add_function_with_schema(
17        "parse",
18        |args: &[ValueWord], _ctx: &ModuleContext| {
19            let text = args
20                .first()
21                .and_then(|a| a.as_str())
22                .ok_or_else(|| "csv.parse() requires a string argument".to_string())?;
23
24            let mut reader = csv::ReaderBuilder::new()
25                .has_headers(false)
26                .from_reader(text.as_bytes());
27
28            let mut rows: Vec<ValueWord> = Vec::new();
29            for result in reader.records() {
30                let record =
31                    result.map_err(|e| format!("csv.parse() failed: {}", e))?;
32                let row: Vec<ValueWord> = record
33                    .iter()
34                    .map(|field| ValueWord::from_string(Arc::new(field.to_string())))
35                    .collect();
36                rows.push(ValueWord::from_array(Arc::new(row)));
37            }
38
39            Ok(ValueWord::from_array(Arc::new(rows)))
40        },
41        ModuleFunction {
42            description: "Parse CSV text into an array of rows (each row is an array of strings)"
43                .to_string(),
44            params: vec![ModuleParam {
45                name: "text".to_string(),
46                type_name: "string".to_string(),
47                required: true,
48                description: "CSV text to parse".to_string(),
49                ..Default::default()
50            }],
51            return_type: Some("Array<Array<string>>".to_string()),
52        },
53    );
54
55    // csv.parse_records(text: string) -> Array<HashMap<string, string>>
56    module.add_function_with_schema(
57        "parse_records",
58        |args: &[ValueWord], _ctx: &ModuleContext| {
59            let text = args
60                .first()
61                .and_then(|a| a.as_str())
62                .ok_or_else(|| "csv.parse_records() requires a string argument".to_string())?;
63
64            let mut reader = csv::ReaderBuilder::new()
65                .has_headers(true)
66                .from_reader(text.as_bytes());
67
68            let headers: Vec<String> = reader
69                .headers()
70                .map_err(|e| format!("csv.parse_records() failed to read headers: {}", e))?
71                .iter()
72                .map(|h| h.to_string())
73                .collect();
74
75            let mut records: Vec<ValueWord> = Vec::new();
76            for result in reader.records() {
77                let record =
78                    result.map_err(|e| format!("csv.parse_records() failed: {}", e))?;
79                let mut keys = Vec::with_capacity(headers.len());
80                let mut values = Vec::with_capacity(headers.len());
81                for (i, field) in record.iter().enumerate() {
82                    if i < headers.len() {
83                        keys.push(ValueWord::from_string(Arc::new(headers[i].clone())));
84                        values.push(ValueWord::from_string(Arc::new(field.to_string())));
85                    }
86                }
87                records.push(ValueWord::from_hashmap_pairs(keys, values));
88            }
89
90            Ok(ValueWord::from_array(Arc::new(records)))
91        },
92        ModuleFunction {
93            description:
94                "Parse CSV text using the header row as keys, returning an array of hashmaps"
95                    .to_string(),
96            params: vec![ModuleParam {
97                name: "text".to_string(),
98                type_name: "string".to_string(),
99                required: true,
100                description: "CSV text to parse (first row is headers)".to_string(),
101                ..Default::default()
102            }],
103            return_type: Some("Array<HashMap<string, string>>".to_string()),
104        },
105    );
106
107    // csv.stringify(data: Array<Array<string>>, delimiter?: string) -> string
108    module.add_function_with_schema(
109        "stringify",
110        |args: &[ValueWord], _ctx: &ModuleContext| {
111            let data = args
112                .first()
113                .and_then(|a| a.as_any_array())
114                .ok_or_else(|| {
115                    "csv.stringify() requires an Array<Array<string>> argument".to_string()
116                })?
117                .to_generic();
118
119            let delimiter = args
120                .get(1)
121                .and_then(|a| a.as_str())
122                .and_then(|s| s.as_bytes().first().copied())
123                .unwrap_or(b',');
124
125            let mut writer = csv::WriterBuilder::new()
126                .delimiter(delimiter)
127                .from_writer(Vec::new());
128
129            for row_val in data.iter() {
130                let row_arr = row_val.as_any_array().ok_or_else(|| {
131                    "csv.stringify() each row must be an Array<string>".to_string()
132                })?;
133                let row = row_arr.to_generic();
134                let fields: Vec<String> = row
135                    .iter()
136                    .map(|f| {
137                        f.as_str()
138                            .map(|s| s.to_string())
139                            .unwrap_or_else(|| f.to_string())
140                    })
141                    .collect();
142                writer
143                    .write_record(&fields)
144                    .map_err(|e| format!("csv.stringify() failed: {}", e))?;
145            }
146
147            let bytes = writer
148                .into_inner()
149                .map_err(|e| format!("csv.stringify() failed to flush: {}", e))?;
150            let output =
151                String::from_utf8(bytes).map_err(|e| format!("csv.stringify() UTF-8 error: {}", e))?;
152
153            Ok(ValueWord::from_string(Arc::new(output)))
154        },
155        ModuleFunction {
156            description: "Convert an array of rows to a CSV string".to_string(),
157            params: vec![
158                ModuleParam {
159                    name: "data".to_string(),
160                    type_name: "Array<Array<string>>".to_string(),
161                    required: true,
162                    description: "Array of rows, each row is an array of field strings".to_string(),
163                    ..Default::default()
164                },
165                ModuleParam {
166                    name: "delimiter".to_string(),
167                    type_name: "string".to_string(),
168                    required: false,
169                    description: "Field delimiter character (default: comma)".to_string(),
170                    default_snippet: Some("\",\"".to_string()),
171                    ..Default::default()
172                },
173            ],
174            return_type: Some("string".to_string()),
175        },
176    );
177
178    // csv.stringify_records(data: Array<HashMap<string, string>>, headers?: Array<string>) -> string
179    module.add_function_with_schema(
180        "stringify_records",
181        |args: &[ValueWord], _ctx: &ModuleContext| {
182            let data = args
183                .first()
184                .and_then(|a| a.as_any_array())
185                .ok_or_else(|| {
186                    "csv.stringify_records() requires an Array<HashMap<string, string>> argument"
187                        .to_string()
188                })?
189                .to_generic();
190
191            // Determine headers: explicit argument or from first record's keys.
192            let explicit_headers: Option<Vec<String>> = args.get(1).and_then(|a| {
193                let arr = a.as_any_array()?.to_generic();
194                let headers: Vec<String> = arr
195                    .iter()
196                    .filter_map(|h| h.as_str().map(|s| s.to_string()))
197                    .collect();
198                if headers.is_empty() {
199                    None
200                } else {
201                    Some(headers)
202                }
203            });
204
205            let headers = if let Some(h) = explicit_headers {
206                h
207            } else if let Some(first) = data.first() {
208                let (keys, _, _) = first.as_hashmap().ok_or_else(|| {
209                    "csv.stringify_records() each element must be a HashMap".to_string()
210                })?;
211                keys.iter()
212                    .filter_map(|k| k.as_str().map(|s| s.to_string()))
213                    .collect()
214            } else {
215                return Ok(ValueWord::from_string(Arc::new(String::new())));
216            };
217
218            let mut writer = csv::WriterBuilder::new().from_writer(Vec::new());
219
220            // Write header row
221            writer
222                .write_record(&headers)
223                .map_err(|e| format!("csv.stringify_records() failed: {}", e))?;
224
225            // Write data rows
226            for record_val in data.iter() {
227                let (keys, values, _) = record_val.as_hashmap().ok_or_else(|| {
228                    "csv.stringify_records() each element must be a HashMap".to_string()
229                })?;
230
231                let mut row = Vec::with_capacity(headers.len());
232                for header in &headers {
233                    // Find the value for this header key
234                    let mut found = String::new();
235                    for (i, k) in keys.iter().enumerate() {
236                        if let Some(key_str) = k.as_str() {
237                            if key_str == header {
238                                found = values[i]
239                                    .as_str()
240                                    .map(|s| s.to_string())
241                                    .unwrap_or_else(|| values[i].to_string());
242                                break;
243                            }
244                        }
245                    }
246                    row.push(found);
247                }
248
249                writer
250                    .write_record(&row)
251                    .map_err(|e| format!("csv.stringify_records() failed: {}", e))?;
252            }
253
254            let bytes = writer
255                .into_inner()
256                .map_err(|e| format!("csv.stringify_records() failed to flush: {}", e))?;
257            let output = String::from_utf8(bytes)
258                .map_err(|e| format!("csv.stringify_records() UTF-8 error: {}", e))?;
259
260            Ok(ValueWord::from_string(Arc::new(output)))
261        },
262        ModuleFunction {
263            description: "Convert an array of hashmaps to a CSV string with headers".to_string(),
264            params: vec![
265                ModuleParam {
266                    name: "data".to_string(),
267                    type_name: "Array<HashMap<string, string>>".to_string(),
268                    required: true,
269                    description: "Array of records (hashmaps with string keys and values)"
270                        .to_string(),
271                    ..Default::default()
272                },
273                ModuleParam {
274                    name: "headers".to_string(),
275                    type_name: "Array<string>".to_string(),
276                    required: false,
277                    description:
278                        "Explicit header order (default: keys from first record)".to_string(),
279                    ..Default::default()
280                },
281            ],
282            return_type: Some("string".to_string()),
283        },
284    );
285
286    // csv.read_file(path: string) -> Result<Array<Array<string>>>
287    module.add_function_with_schema(
288        "read_file",
289        |args: &[ValueWord], _ctx: &ModuleContext| {
290            let path = args
291                .first()
292                .and_then(|a| a.as_str())
293                .ok_or_else(|| "csv.read_file() requires a path string".to_string())?;
294
295            let text = std::fs::read_to_string(path)
296                .map_err(|e| format!("csv.read_file() failed to read '{}': {}", path, e))?;
297
298            let mut reader = csv::ReaderBuilder::new()
299                .has_headers(false)
300                .from_reader(text.as_bytes());
301
302            let mut rows: Vec<ValueWord> = Vec::new();
303            for result in reader.records() {
304                let record =
305                    result.map_err(|e| format!("csv.read_file() parse error: {}", e))?;
306                let row: Vec<ValueWord> = record
307                    .iter()
308                    .map(|field| ValueWord::from_string(Arc::new(field.to_string())))
309                    .collect();
310                rows.push(ValueWord::from_array(Arc::new(row)));
311            }
312
313            Ok(ValueWord::from_ok(ValueWord::from_array(Arc::new(rows))))
314        },
315        ModuleFunction {
316            description: "Read and parse a CSV file into an array of rows".to_string(),
317            params: vec![ModuleParam {
318                name: "path".to_string(),
319                type_name: "string".to_string(),
320                required: true,
321                description: "Path to the CSV file".to_string(),
322                ..Default::default()
323            }],
324            return_type: Some("Result<Array<Array<string>>>".to_string()),
325        },
326    );
327
328    // csv.is_valid(text: string) -> bool
329    module.add_function_with_schema(
330        "is_valid",
331        |args: &[ValueWord], _ctx: &ModuleContext| {
332            let text = args
333                .first()
334                .and_then(|a| a.as_str())
335                .ok_or_else(|| "csv.is_valid() requires a string argument".to_string())?;
336
337            let mut reader = csv::ReaderBuilder::new()
338                .has_headers(false)
339                .from_reader(text.as_bytes());
340
341            let valid = reader.records().all(|r| r.is_ok());
342            Ok(ValueWord::from_bool(valid))
343        },
344        ModuleFunction {
345            description: "Check if a string is valid CSV".to_string(),
346            params: vec![ModuleParam {
347                name: "text".to_string(),
348                type_name: "string".to_string(),
349                required: true,
350                description: "String to validate as CSV".to_string(),
351                ..Default::default()
352            }],
353            return_type: Some("bool".to_string()),
354        },
355    );
356
357    module
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
365        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
366        crate::module_exports::ModuleContext {
367            schemas: registry,
368            invoke_callable: None,
369            raw_invoker: None,
370            function_hashes: None,
371            vm_state: None,
372            granted_permissions: None,
373            scope_constraints: None,
374            set_pending_resume: None,
375            set_pending_frame_resume: None,
376        }
377    }
378
379    #[test]
380    fn test_csv_module_creation() {
381        let module = create_csv_module();
382        assert_eq!(module.name, "csv");
383        assert!(module.has_export("parse"));
384        assert!(module.has_export("parse_records"));
385        assert!(module.has_export("stringify"));
386        assert!(module.has_export("stringify_records"));
387        assert!(module.has_export("read_file"));
388        assert!(module.has_export("is_valid"));
389    }
390
391    #[test]
392    fn test_csv_parse_simple() {
393        let module = create_csv_module();
394        let parse_fn = module.get_export("parse").unwrap();
395        let ctx = test_ctx();
396        let input = ValueWord::from_string(Arc::new("a,b,c\n1,2,3\n4,5,6".to_string()));
397        let result = parse_fn(&[input], &ctx).unwrap();
398        let rows = result.as_any_array().expect("should be array").to_generic();
399        assert_eq!(rows.len(), 3);
400        // First row
401        let row0 = rows[0].as_any_array().expect("row should be array").to_generic();
402        assert_eq!(row0.len(), 3);
403        assert_eq!(row0[0].as_str(), Some("a"));
404        assert_eq!(row0[1].as_str(), Some("b"));
405        assert_eq!(row0[2].as_str(), Some("c"));
406        // Second row
407        let row1 = rows[1].as_any_array().expect("row should be array").to_generic();
408        assert_eq!(row1[0].as_str(), Some("1"));
409        assert_eq!(row1[1].as_str(), Some("2"));
410        assert_eq!(row1[2].as_str(), Some("3"));
411    }
412
413    #[test]
414    fn test_csv_parse_quoted_fields() {
415        let module = create_csv_module();
416        let parse_fn = module.get_export("parse").unwrap();
417        let ctx = test_ctx();
418        let input =
419            ValueWord::from_string(Arc::new("\"hello, world\",\"foo\"\"bar\"\n".to_string()));
420        let result = parse_fn(&[input], &ctx).unwrap();
421        let rows = result.as_any_array().expect("should be array").to_generic();
422        assert_eq!(rows.len(), 1);
423        let row0 = rows[0].as_any_array().expect("row should be array").to_generic();
424        assert_eq!(row0[0].as_str(), Some("hello, world"));
425        assert_eq!(row0[1].as_str(), Some("foo\"bar"));
426    }
427
428    #[test]
429    fn test_csv_parse_empty() {
430        let module = create_csv_module();
431        let parse_fn = module.get_export("parse").unwrap();
432        let ctx = test_ctx();
433        let input = ValueWord::from_string(Arc::new("".to_string()));
434        let result = parse_fn(&[input], &ctx).unwrap();
435        let rows = result.as_any_array().expect("should be array").to_generic();
436        assert_eq!(rows.len(), 0);
437    }
438
439    #[test]
440    fn test_csv_parse_requires_string() {
441        let module = create_csv_module();
442        let parse_fn = module.get_export("parse").unwrap();
443        let ctx = test_ctx();
444        let result = parse_fn(&[ValueWord::from_f64(42.0)], &ctx);
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn test_csv_parse_records() {
450        let module = create_csv_module();
451        let parse_fn = module.get_export("parse_records").unwrap();
452        let ctx = test_ctx();
453        let input = ValueWord::from_string(Arc::new("name,age\nAlice,30\nBob,25".to_string()));
454        let result = parse_fn(&[input], &ctx).unwrap();
455        let records = result.as_any_array().expect("should be array").to_generic();
456        assert_eq!(records.len(), 2);
457
458        // First record: {name: "Alice", age: "30"}
459        let (keys0, values0, _) = records[0].as_hashmap().expect("should be hashmap");
460        assert_eq!(keys0.len(), 2);
461        // Find "name" key
462        let mut found_name = false;
463        let mut found_age = false;
464        for (i, k) in keys0.iter().enumerate() {
465            if k.as_str() == Some("name") {
466                assert_eq!(values0[i].as_str(), Some("Alice"));
467                found_name = true;
468            }
469            if k.as_str() == Some("age") {
470                assert_eq!(values0[i].as_str(), Some("30"));
471                found_age = true;
472            }
473        }
474        assert!(found_name, "should have 'name' key");
475        assert!(found_age, "should have 'age' key");
476    }
477
478    #[test]
479    fn test_csv_parse_records_requires_string() {
480        let module = create_csv_module();
481        let parse_fn = module.get_export("parse_records").unwrap();
482        let ctx = test_ctx();
483        let result = parse_fn(&[ValueWord::from_f64(42.0)], &ctx);
484        assert!(result.is_err());
485    }
486
487    #[test]
488    fn test_csv_stringify_simple() {
489        let module = create_csv_module();
490        let stringify_fn = module.get_export("stringify").unwrap();
491        let ctx = test_ctx();
492        let data = ValueWord::from_array(Arc::new(vec![
493            ValueWord::from_array(Arc::new(vec![
494                ValueWord::from_string(Arc::new("a".to_string())),
495                ValueWord::from_string(Arc::new("b".to_string())),
496            ])),
497            ValueWord::from_array(Arc::new(vec![
498                ValueWord::from_string(Arc::new("1".to_string())),
499                ValueWord::from_string(Arc::new("2".to_string())),
500            ])),
501        ]));
502        let result = stringify_fn(&[data], &ctx).unwrap();
503        let output = result.as_str().expect("should be string");
504        assert_eq!(output, "a,b\n1,2\n");
505    }
506
507    #[test]
508    fn test_csv_stringify_with_delimiter() {
509        let module = create_csv_module();
510        let stringify_fn = module.get_export("stringify").unwrap();
511        let ctx = test_ctx();
512        let data = ValueWord::from_array(Arc::new(vec![ValueWord::from_array(Arc::new(vec![
513            ValueWord::from_string(Arc::new("a".to_string())),
514            ValueWord::from_string(Arc::new("b".to_string())),
515        ]))]));
516        let delimiter = ValueWord::from_string(Arc::new("\t".to_string()));
517        let result = stringify_fn(&[data, delimiter], &ctx).unwrap();
518        let output = result.as_str().expect("should be string");
519        assert_eq!(output, "a\tb\n");
520    }
521
522    #[test]
523    fn test_csv_stringify_requires_array() {
524        let module = create_csv_module();
525        let stringify_fn = module.get_export("stringify").unwrap();
526        let ctx = test_ctx();
527        let result = stringify_fn(&[ValueWord::from_f64(42.0)], &ctx);
528        assert!(result.is_err());
529    }
530
531    #[test]
532    fn test_csv_stringify_records() {
533        let module = create_csv_module();
534        let stringify_fn = module.get_export("stringify_records").unwrap();
535        let ctx = test_ctx();
536        let record1 = ValueWord::from_hashmap_pairs(
537            vec![
538                ValueWord::from_string(Arc::new("name".to_string())),
539                ValueWord::from_string(Arc::new("age".to_string())),
540            ],
541            vec![
542                ValueWord::from_string(Arc::new("Alice".to_string())),
543                ValueWord::from_string(Arc::new("30".to_string())),
544            ],
545        );
546        let record2 = ValueWord::from_hashmap_pairs(
547            vec![
548                ValueWord::from_string(Arc::new("name".to_string())),
549                ValueWord::from_string(Arc::new("age".to_string())),
550            ],
551            vec![
552                ValueWord::from_string(Arc::new("Bob".to_string())),
553                ValueWord::from_string(Arc::new("25".to_string())),
554            ],
555        );
556        let data = ValueWord::from_array(Arc::new(vec![record1, record2]));
557        let result = stringify_fn(&[data], &ctx).unwrap();
558        let output = result.as_str().expect("should be string");
559        // Should contain header row and two data rows
560        let lines: Vec<&str> = output.trim().lines().collect();
561        assert_eq!(lines.len(), 3);
562        // Header should be name,age (order from first record's keys)
563        assert!(lines[0].contains("name"));
564        assert!(lines[0].contains("age"));
565    }
566
567    #[test]
568    fn test_csv_stringify_records_with_explicit_headers() {
569        let module = create_csv_module();
570        let stringify_fn = module.get_export("stringify_records").unwrap();
571        let ctx = test_ctx();
572        let record = ValueWord::from_hashmap_pairs(
573            vec![
574                ValueWord::from_string(Arc::new("name".to_string())),
575                ValueWord::from_string(Arc::new("age".to_string())),
576            ],
577            vec![
578                ValueWord::from_string(Arc::new("Alice".to_string())),
579                ValueWord::from_string(Arc::new("30".to_string())),
580            ],
581        );
582        let data = ValueWord::from_array(Arc::new(vec![record]));
583        let headers = ValueWord::from_array(Arc::new(vec![
584            ValueWord::from_string(Arc::new("age".to_string())),
585            ValueWord::from_string(Arc::new("name".to_string())),
586        ]));
587        let result = stringify_fn(&[data, headers], &ctx).unwrap();
588        let output = result.as_str().expect("should be string");
589        let lines: Vec<&str> = output.trim().lines().collect();
590        assert_eq!(lines[0], "age,name");
591        assert_eq!(lines[1], "30,Alice");
592    }
593
594    #[test]
595    fn test_csv_stringify_records_empty() {
596        let module = create_csv_module();
597        let stringify_fn = module.get_export("stringify_records").unwrap();
598        let ctx = test_ctx();
599        let data = ValueWord::from_array(Arc::new(vec![]));
600        let result = stringify_fn(&[data], &ctx).unwrap();
601        let output = result.as_str().expect("should be string");
602        assert_eq!(output, "");
603    }
604
605    #[test]
606    fn test_csv_is_valid_true() {
607        let module = create_csv_module();
608        let is_valid_fn = module.get_export("is_valid").unwrap();
609        let ctx = test_ctx();
610        let input = ValueWord::from_string(Arc::new("a,b,c\n1,2,3".to_string()));
611        let result = is_valid_fn(&[input], &ctx).unwrap();
612        assert_eq!(result.as_bool(), Some(true));
613    }
614
615    #[test]
616    fn test_csv_is_valid_empty() {
617        let module = create_csv_module();
618        let is_valid_fn = module.get_export("is_valid").unwrap();
619        let ctx = test_ctx();
620        let input = ValueWord::from_string(Arc::new("".to_string()));
621        let result = is_valid_fn(&[input], &ctx).unwrap();
622        assert_eq!(result.as_bool(), Some(true));
623    }
624
625    #[test]
626    fn test_csv_is_valid_requires_string() {
627        let module = create_csv_module();
628        let is_valid_fn = module.get_export("is_valid").unwrap();
629        let ctx = test_ctx();
630        let result = is_valid_fn(&[ValueWord::from_f64(42.0)], &ctx);
631        assert!(result.is_err());
632    }
633
634    #[test]
635    fn test_csv_read_file() {
636        let module = create_csv_module();
637        let read_fn = module.get_export("read_file").unwrap();
638        let ctx = test_ctx();
639
640        let dir = tempfile::tempdir().unwrap();
641        let path = dir.path().join("test.csv");
642        std::fs::write(&path, "a,b\n1,2\n3,4").unwrap();
643
644        let result = read_fn(
645            &[ValueWord::from_string(Arc::new(
646                path.to_str().unwrap().to_string(),
647            ))],
648            &ctx,
649        )
650        .unwrap();
651        let inner = result.as_ok_inner().expect("should be Ok");
652        let rows = inner.as_any_array().expect("should be array").to_generic();
653        assert_eq!(rows.len(), 3);
654        let row0 = rows[0].as_any_array().expect("row should be array").to_generic();
655        assert_eq!(row0[0].as_str(), Some("a"));
656        assert_eq!(row0[1].as_str(), Some("b"));
657    }
658
659    #[test]
660    fn test_csv_read_file_nonexistent() {
661        let module = create_csv_module();
662        let read_fn = module.get_export("read_file").unwrap();
663        let ctx = test_ctx();
664        let result = read_fn(
665            &[ValueWord::from_string(Arc::new(
666                "/nonexistent/path/file.csv".to_string(),
667            ))],
668            &ctx,
669        );
670        assert!(result.is_err());
671    }
672
673    #[test]
674    fn test_csv_read_file_requires_string() {
675        let module = create_csv_module();
676        let read_fn = module.get_export("read_file").unwrap();
677        let ctx = test_ctx();
678        let result = read_fn(&[ValueWord::from_f64(42.0)], &ctx);
679        assert!(result.is_err());
680    }
681
682    #[test]
683    fn test_csv_schemas() {
684        let module = create_csv_module();
685
686        let parse_schema = module.get_schema("parse").unwrap();
687        assert_eq!(parse_schema.params.len(), 1);
688        assert_eq!(parse_schema.params[0].name, "text");
689        assert!(parse_schema.params[0].required);
690        assert_eq!(
691            parse_schema.return_type.as_deref(),
692            Some("Array<Array<string>>")
693        );
694
695        let parse_records_schema = module.get_schema("parse_records").unwrap();
696        assert_eq!(parse_records_schema.params.len(), 1);
697        assert_eq!(
698            parse_records_schema.return_type.as_deref(),
699            Some("Array<HashMap<string, string>>")
700        );
701
702        let stringify_schema = module.get_schema("stringify").unwrap();
703        assert_eq!(stringify_schema.params.len(), 2);
704        assert!(stringify_schema.params[0].required);
705        assert!(!stringify_schema.params[1].required);
706        assert_eq!(stringify_schema.return_type.as_deref(), Some("string"));
707
708        let stringify_records_schema = module.get_schema("stringify_records").unwrap();
709        assert_eq!(stringify_records_schema.params.len(), 2);
710        assert!(stringify_records_schema.params[0].required);
711        assert!(!stringify_records_schema.params[1].required);
712
713        let read_file_schema = module.get_schema("read_file").unwrap();
714        assert_eq!(read_file_schema.params.len(), 1);
715        assert_eq!(
716            read_file_schema.return_type.as_deref(),
717            Some("Result<Array<Array<string>>>")
718        );
719
720        let is_valid_schema = module.get_schema("is_valid").unwrap();
721        assert_eq!(is_valid_schema.params.len(), 1);
722        assert_eq!(is_valid_schema.return_type.as_deref(), Some("bool"));
723    }
724
725    #[test]
726    fn test_csv_roundtrip() {
727        let module = create_csv_module();
728        let parse_fn = module.get_export("parse").unwrap();
729        let stringify_fn = module.get_export("stringify").unwrap();
730        let ctx = test_ctx();
731
732        let csv_text = "name,age,city\nAlice,30,NYC\nBob,25,LA\n";
733        let parsed = parse_fn(
734            &[ValueWord::from_string(Arc::new(csv_text.to_string()))],
735            &ctx,
736        )
737        .unwrap();
738
739        let re_stringified = stringify_fn(&[parsed], &ctx).unwrap();
740        let output = re_stringified.as_str().expect("should be string");
741        assert_eq!(output, csv_text);
742    }
743
744    #[test]
745    fn test_csv_records_roundtrip() {
746        let module = create_csv_module();
747        let parse_records_fn = module.get_export("parse_records").unwrap();
748        let stringify_records_fn = module.get_export("stringify_records").unwrap();
749        let ctx = test_ctx();
750
751        let csv_text = "name,age\nAlice,30\nBob,25\n";
752        let parsed = parse_records_fn(
753            &[ValueWord::from_string(Arc::new(csv_text.to_string()))],
754            &ctx,
755        )
756        .unwrap();
757
758        let headers = ValueWord::from_array(Arc::new(vec![
759            ValueWord::from_string(Arc::new("name".to_string())),
760            ValueWord::from_string(Arc::new("age".to_string())),
761        ]));
762        let re_stringified = stringify_records_fn(&[parsed, headers], &ctx).unwrap();
763        let output = re_stringified.as_str().expect("should be string");
764        assert_eq!(output, csv_text);
765    }
766}