Skip to main content

polydat_core/library/
datafile.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Data file nodes: ordinal-based access to CSV, JSONL, and text files.
5//!
6//! Each node loads the file once at construction time (the filename is
7//! a const parameter), builds an in-memory index, and serves fast
8//! ordinal lookups at cycle time. Ordinals wrap via modulo so every
9//! u64 input is valid.
10//!
11//! The single-source nodes (`csv_row`, `csv_row_count`, `jsonl_row`,
12//! `jsonl_row_count`) use `#[poly_const(setup, from = filename)]`.
13//! The two two-source nodes (`csv_field`, `jsonl_field`) use
14//! multi-source `#[poly_const(setup, from = (filename, column|path))]`
15//! to build the per-ordinal value table at construction time.
16//!
17//! The macro-emitted `new()` is infallible; the setup function panics
18//! with a formatted error on bad file content, and the build closure
19//! carries the panic upward so workload compile surfaces the
20//! diagnostic text. This matches `polydat-nodes`' `regex.rs`
21//! `compile_regex(...).expect("invalid regex")` setup.
22
23#[cfg(test)]
24use crate::ast::{PolydatNode, Value};
25
26// ─── Workload-relative path resolution ─────────────────────────
27//
28// Data-file paths are read at construction time relative to the
29// process CWD. That breaks when a workload is run from anywhere but
30// the directory its relative paths assume. So — mirroring how
31// `extends:` resolves relative targets against the workload's own
32// directory — a compile sets the workload's directory as a base, and a
33// relative path that doesn't resolve against the CWD is retried
34// against it. Absolute paths and CWD-resolvable paths are untouched,
35// so existing workloads are unaffected.
36
37thread_local! {
38    static DATA_BASE_DIR: std::cell::RefCell<Option<std::path::PathBuf>> =
39        const { std::cell::RefCell::new(None) };
40}
41
42/// Set the base directory relative data-file paths resolve against
43/// (the workload's own directory). Returns the previous value so the
44/// caller can restore it — compiles nest. The compile entry points
45/// bracket their (synchronous) body with this; concurrent compiles on
46/// different threads keep independent values.
47pub fn set_data_base_dir(dir: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
48    DATA_BASE_DIR.with(|d| d.replace(dir))
49}
50
51/// Resolve a data-file path. Absolute paths and paths that already
52/// resolve against the CWD are returned verbatim; otherwise, if a
53/// workload base dir is set and `<base>/<path>` exists, that absolute
54/// path is returned. Falls back to the original so a genuine
55/// not-found error names the path the author wrote.
56fn resolve_data_path(filename: &str) -> std::borrow::Cow<'_, str> {
57    let p = std::path::Path::new(filename);
58    if p.is_absolute() || p.exists() {
59        return std::borrow::Cow::Borrowed(filename);
60    }
61    DATA_BASE_DIR.with(|d| {
62        if let Some(base) = d.borrow().as_ref() {
63            let candidate = base.join(filename);
64            if candidate.exists() {
65                return std::borrow::Cow::Owned(candidate.to_string_lossy().into_owned());
66            }
67        }
68        std::borrow::Cow::Borrowed(filename)
69    })
70}
71
72// ─── CSV ───────────────────────────────────────────────────────
73
74/// Setup function for `csv_field`: read the named column from the
75/// CSV file and return one entry per data row. Panics on read or
76/// column-not-found error (workload-compile diagnostic path).
77fn read_csv_column(filename: &str, column: &str) -> Vec<String> {
78    let content = std::fs::read_to_string(resolve_data_path(filename).as_ref())
79        .unwrap_or_else(|e| panic!("csv_field: failed to read '{filename}': {e}"));
80    let records =
81        split_csv_records(&content).unwrap_or_else(|e| panic!("csv_field: '{filename}': {e}"));
82    let mut lines = records.into_iter();
83
84    let header_line = lines
85        .next()
86        .unwrap_or_else(|| panic!("csv_field: '{filename}' is empty"));
87    let headers = split_csv_line(header_line)
88        .unwrap_or_else(|e| panic!("csv_field: '{filename}' header: {e}"));
89
90    let col_idx = if let Ok(idx) = column.parse::<usize>() {
91        idx
92    } else {
93        headers
94            .iter()
95            .position(|h| h.trim() == column)
96            .unwrap_or_else(|| {
97                panic!(
98                    "csv_field: column '{column}' not found in '{filename}'. Available: {}",
99                    headers.join(", ")
100                )
101            })
102    };
103
104    let mut values = Vec::new();
105    for (row, line) in lines.enumerate() {
106        let fields = split_csv_line(line)
107            .unwrap_or_else(|e| panic!("csv_field: '{filename}' row {}: {e}", row + 1));
108        let val = fields.get(col_idx).map_or("", |f| f.trim()).to_string();
109        values.push(val);
110    }
111
112    if values.is_empty() {
113        panic!("csv_field: '{filename}' has no data rows");
114    }
115    values
116}
117
118/// Read a specific column from a CSV row at a given ordinal.
119///
120/// Signature: `csv_field(ordinal: u64) -> (output: Str)`
121/// Const: `filename: Str`, `column: Str` (header name or "0","1",... index)
122///
123/// The file is read and the named column extracted at construction
124/// time. Header row is auto-detected. Ordinal wraps modulo row count.
125/// Fields follow RFC 4180 quoting: a `"`-wrapped field may hold
126/// commas, line breaks, and `""` for a literal quote; the value served
127/// is the unwrapped text.
128#[crate::polydat_node(category = Data)]
129fn csv_field(
130    ordinal: u64,
131    filename: crate::derive_support::Const<&str>,
132    column: crate::derive_support::Const<&str>,
133    #[poly_const(read_csv_column, from = (filename, column))] values: &Vec<String>,
134) -> String {
135    let _ = filename;
136    let _ = column;
137    let idx = ordinal as usize % values.len();
138    values[idx].clone()
139}
140
141/// Read all CSV data rows (skipping the header) as raw records — a
142/// quoted field's line breaks stay inside its row. Panics on read
143/// failure or empty file — workload-compile error path (preserves
144/// the original error-message format).
145fn read_csv_data_rows(filename: &str) -> Vec<String> {
146    let content = std::fs::read_to_string(resolve_data_path(filename).as_ref())
147        .unwrap_or_else(|e| panic!("csv_row: failed to read '{filename}': {e}"));
148    let records =
149        split_csv_records(&content).unwrap_or_else(|e| panic!("csv_row: '{filename}': {e}"));
150    let rows: Vec<String> = records
151        .into_iter()
152        .skip(1) // skip header
153        .filter(|l| !l.trim().is_empty())
154        .map(|l| l.to_string())
155        .collect();
156    if rows.is_empty() {
157        panic!("csv_row: '{filename}' has no data rows");
158    }
159    rows
160}
161
162/// Read a CSV file and return its data-row count (excluding header).
163/// Panics on read failure — workload-compile error path.
164fn read_csv_row_count(filename: &str) -> u64 {
165    let content = std::fs::read_to_string(resolve_data_path(filename).as_ref())
166        .unwrap_or_else(|e| panic!("csv_row_count: failed to read '{filename}': {e}"));
167    let records =
168        split_csv_records(&content).unwrap_or_else(|e| panic!("csv_row_count: '{filename}': {e}"));
169    records
170        .into_iter()
171        .skip(1)
172        .filter(|l| !l.trim().is_empty())
173        .count() as u64
174}
175
176/// Read an entire CSV row at a given ordinal as a comma-separated string.
177///
178/// Signature: `csv_row(ordinal: u64) -> (output: Str)`
179/// Const: `filename: Str`
180///
181/// The row is served as written in the file — quotes intact, and a
182/// quoted field's line breaks kept inside the row.
183///
184/// Construction-time failure (missing file, empty file) panics via
185/// the setup function; the build closure propagates the panic message.
186#[crate::polydat_node(category = Data)]
187fn csv_row(
188    ordinal: u64,
189    filename: crate::derive_support::Const<&str>,
190    #[poly_const(read_csv_data_rows, from = filename)] rows: &Vec<String>,
191) -> String {
192    let idx = ordinal as usize % rows.len();
193    rows[idx].clone()
194}
195
196/// Return the number of data rows in a CSV file (init-time constant).
197///
198/// Signature: `csv_row_count() -> (output: u64)`
199/// Const: `filename: Str`
200///
201/// The count is computed once at setup time; `eval` reads the
202/// cached value.
203#[crate::polydat_node(category = Data)]
204fn csv_row_count(
205    filename: crate::derive_support::Const<&str>,
206    #[poly_const(read_csv_row_count, from = filename)] count: &u64,
207) -> u64 {
208    *count
209}
210
211// ─── JSONL ─────────────────────────────────────────────────────
212
213/// Setup function for `jsonl_field`: read the JSONL file, extract
214/// the named field per line, return one entry per non-empty line.
215/// Panics on read or JSON-parse error (workload-compile diagnostic
216/// path).
217fn read_jsonl_field(filename: &str, path: &str) -> Vec<String> {
218    let content = std::fs::read_to_string(resolve_data_path(filename).as_ref())
219        .unwrap_or_else(|e| panic!("jsonl_field: failed to read '{filename}': {e}"));
220    let mut values = Vec::new();
221    for (line_num, line) in content.lines().enumerate() {
222        let trimmed = line.trim();
223        if trimmed.is_empty() {
224            continue;
225        }
226        let parsed: serde_json::Value = serde_json::from_str(trimmed)
227            .unwrap_or_else(|e| panic!("jsonl_field: parse error at line {}: {e}", line_num + 1));
228        let val = resolve_json_path(&parsed, path);
229        values.push(val);
230    }
231    if values.is_empty() {
232        panic!("jsonl_field: '{filename}' has no lines");
233    }
234    values
235}
236
237/// Read a field from a JSONL line at a given ordinal.
238///
239/// Signature: `jsonl_field(ordinal: u64) -> (output: Str)`
240/// Const: `filename: Str`, `path: Str` (JSON field name or dot path)
241///
242/// Each line of the file is a JSON object. The field is extracted
243/// by name (top-level) or dot-path (nested) at construction time.
244/// Ordinal wraps modulo line count.
245#[crate::polydat_node(category = Data)]
246fn jsonl_field(
247    ordinal: u64,
248    filename: crate::derive_support::Const<&str>,
249    path: crate::derive_support::Const<&str>,
250    #[poly_const(read_jsonl_field, from = (filename, path))] values: &Vec<String>,
251) -> String {
252    let _ = filename;
253    let _ = path;
254    let idx = ordinal as usize % values.len();
255    values[idx].clone()
256}
257
258/// Read all non-empty lines of a JSONL file. Panics on read failure
259/// or empty file (workload-compile error path).
260fn read_jsonl_lines(filename: &str) -> Vec<String> {
261    let content = std::fs::read_to_string(resolve_data_path(filename).as_ref())
262        .unwrap_or_else(|e| panic!("jsonl_row: failed to read '{filename}': {e}"));
263    let rows: Vec<String> = content
264        .lines()
265        .filter(|l| !l.trim().is_empty())
266        .map(|l| l.to_string())
267        .collect();
268    if rows.is_empty() {
269        panic!("jsonl_row: '{filename}' has no lines");
270    }
271    rows
272}
273
274/// Count non-empty lines in a JSONL file. Panics on read failure
275/// (workload-compile error path).
276fn read_jsonl_row_count(filename: &str) -> u64 {
277    let content = std::fs::read_to_string(resolve_data_path(filename).as_ref())
278        .unwrap_or_else(|e| panic!("jsonl_row_count: failed to read '{filename}': {e}"));
279    content.lines().filter(|l| !l.trim().is_empty()).count() as u64
280}
281
282/// Read an entire JSONL line at a given ordinal as a JSON string.
283///
284/// Signature: `jsonl_row(ordinal: u64) -> (output: Str)`
285/// Const: `filename: Str`
286///
287/// Lines are captured at setup time.
288#[crate::polydat_node(category = Data)]
289fn jsonl_row(
290    ordinal: u64,
291    filename: crate::derive_support::Const<&str>,
292    #[poly_const(read_jsonl_lines, from = filename)] rows: &Vec<String>,
293) -> String {
294    let idx = ordinal as usize % rows.len();
295    rows[idx].clone()
296}
297
298/// Return the number of lines in a JSONL file (init-time constant).
299///
300/// Signature: `jsonl_row_count() -> (output: u64)`
301/// Const: `filename: Str`
302#[crate::polydat_node(category = Data)]
303fn jsonl_row_count(
304    filename: crate::derive_support::Const<&str>,
305    #[poly_const(read_jsonl_row_count, from = filename)] count: &u64,
306) -> u64 {
307    *count
308}
309
310// ─── Helpers ───────────────────────────────────────────────────
311
312// RFC 4180 quoting. A field that begins with `"` runs to the next
313// lone `"`; inside it, commas and line breaks are field content and
314// `""` is one literal `"`. Everything else is unchanged from the
315// plain comma split: unquoted fields are taken verbatim (a `"` in the
316// middle of one is just a character), and a record ends at LF with a
317// preceding CR dropped, exactly as `str::lines` did. Callers that
318// serve whole rows (`csv_row`) keep the record's source text, quotes
319// and all; `csv_field` sees the unescaped field.
320
321/// Split CSV content into raw records, honouring quoted line breaks.
322/// Each record is the source slice without its line terminator. An
323/// unterminated quote is reported with the line it opened on.
324fn split_csv_records(content: &str) -> Result<Vec<&str>, String> {
325    let bytes = content.as_bytes();
326    let mut records = Vec::new();
327    let mut start = 0;
328    let mut in_quotes = false;
329    let mut at_field_start = true;
330    let mut line = 1;
331    let mut quote_line = 1;
332    let mut i = 0;
333    while i < bytes.len() {
334        let b = bytes[i];
335        if in_quotes {
336            match b {
337                b'"' if bytes.get(i + 1) == Some(&b'"') => i += 1,
338                b'"' => {
339                    in_quotes = false;
340                    at_field_start = false;
341                }
342                b'\n' => line += 1,
343                _ => {}
344            }
345        } else {
346            match b {
347                b'"' if at_field_start => {
348                    in_quotes = true;
349                    quote_line = line;
350                }
351                b',' => at_field_start = true,
352                b'\n' => {
353                    let end = if i > start && bytes[i - 1] == b'\r' {
354                        i - 1
355                    } else {
356                        i
357                    };
358                    records.push(&content[start..end]);
359                    start = i + 1;
360                    line += 1;
361                    at_field_start = true;
362                }
363                _ => at_field_start = false,
364            }
365        }
366        i += 1;
367    }
368    if in_quotes {
369        return Err(format!(
370            "unterminated quoted field starting at line {quote_line}"
371        ));
372    }
373    if start < bytes.len() {
374        records.push(&content[start..]);
375    }
376    Ok(records)
377}
378
379/// Split one CSV record on commas, respecting quoted fields. Unquoted
380/// fields are borrowed verbatim; quoted fields are unwrapped and
381/// `""` collapsed to `"`.
382fn split_csv_line(line: &str) -> Result<Vec<std::borrow::Cow<'_, str>>, String> {
383    use std::borrow::Cow;
384    let bytes = line.as_bytes();
385    let mut fields = Vec::new();
386    let mut i = 0;
387    loop {
388        if bytes.get(i) == Some(&b'"') {
389            let mut field = String::new();
390            i += 1;
391            let mut seg = i;
392            loop {
393                match bytes.get(i) {
394                    None => return Err("unterminated quoted field".to_string()),
395                    Some(b'"') => {
396                        field.push_str(&line[seg..i]);
397                        if bytes.get(i + 1) == Some(&b'"') {
398                            field.push('"');
399                            i += 2;
400                            seg = i;
401                        } else {
402                            i += 1;
403                            break;
404                        }
405                    }
406                    Some(_) => i += 1,
407                }
408            }
409            fields.push(Cow::Owned(field));
410            match bytes.get(i) {
411                None => return Ok(fields),
412                Some(b',') => i += 1,
413                Some(_) => {
414                    return Err(format!(
415                        "unexpected text after the closing quote of field {}",
416                        fields.len()
417                    ));
418                }
419            }
420        } else {
421            let start = i;
422            while i < bytes.len() && bytes[i] != b',' {
423                i += 1;
424            }
425            fields.push(Cow::Borrowed(&line[start..i]));
426            if i == bytes.len() {
427                return Ok(fields);
428            }
429            i += 1;
430        }
431    }
432}
433
434/// Resolve a dot-separated JSON path. Returns the value as a string.
435fn resolve_json_path(value: &serde_json::Value, path: &str) -> String {
436    let mut current = value;
437    for key in path.split('.') {
438        match current {
439            serde_json::Value::Object(map) => {
440                current = match map.get(key) {
441                    Some(v) => v,
442                    None => return String::new(),
443                };
444            }
445            serde_json::Value::Array(arr) => {
446                if let Ok(idx) = key.parse::<usize>() {
447                    current = match arr.get(idx) {
448                        Some(v) => v,
449                        None => return String::new(),
450                    };
451                } else {
452                    return String::new();
453                }
454            }
455            _ => return String::new(),
456        }
457    }
458    match current {
459        serde_json::Value::String(s) => s.clone(),
460        serde_json::Value::Null => String::new(),
461        other => other.to_string(),
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use std::io::Write;
469
470    fn write_temp_csv(name: &str, content: &str) -> String {
471        let path = std::env::temp_dir().join(name);
472        let mut f = std::fs::File::create(&path).unwrap();
473        f.write_all(content.as_bytes()).unwrap();
474        path.to_str().unwrap().to_string()
475    }
476
477    #[test]
478    fn csv_field_by_name() {
479        let path = write_temp_csv(
480            "test_csv_field.csv",
481            "name,age,city\nalice,30,paris\nbob,25,london\n",
482        );
483        let node = CsvField::new(path, "name".to_string());
484        let mut out = [Value::None];
485        node.eval(&[Value::U64(0)], &mut out);
486        assert_eq!(out[0].to_display_string(), "alice");
487        node.eval(&[Value::U64(1)], &mut out);
488        assert_eq!(out[0].to_display_string(), "bob");
489        // Wrap around
490        node.eval(&[Value::U64(2)], &mut out);
491        assert_eq!(out[0].to_display_string(), "alice");
492    }
493
494    #[test]
495    fn relative_path_resolves_against_data_base_dir() {
496        // A file in a subdirectory, referenced by basename only —
497        // the workload-relative resolution mirrors how `extends:`
498        // resolves relative targets against the workload's directory.
499        let dir = std::env::temp_dir().join("nbrs_datafile_base_test");
500        std::fs::create_dir_all(&dir).unwrap();
501        let file = dir.join("base_rows.jsonl");
502        std::fs::write(&file, "{\"v\": 7}\n").unwrap();
503
504        // No base dir: a bare basename does not resolve to the file.
505        let prev = set_data_base_dir(None);
506        assert_eq!(
507            resolve_data_path("base_rows.jsonl").as_ref(),
508            "base_rows.jsonl"
509        );
510
511        // Base dir set: the basename resolves to the absolute file, and
512        // the reader honours it.
513        set_data_base_dir(Some(dir.clone()));
514        assert_eq!(
515            resolve_data_path("base_rows.jsonl").as_ref(),
516            file.to_string_lossy()
517        );
518        assert_eq!(
519            read_jsonl_field("base_rows.jsonl", "v"),
520            vec!["7".to_string()]
521        );
522
523        // Absolute paths pass through untouched even with a base set.
524        let abs = file.to_string_lossy().into_owned();
525        assert_eq!(resolve_data_path(&abs).as_ref(), abs);
526
527        set_data_base_dir(prev);
528    }
529
530    #[test]
531    fn csv_field_by_index() {
532        let path = write_temp_csv("test_csv_idx.csv", "name,age,city\nalice,30,paris\n");
533        let node = CsvField::new(path, "1".to_string());
534        let mut out = [Value::None];
535        node.eval(&[Value::U64(0)], &mut out);
536        assert_eq!(out[0].to_display_string(), "30");
537    }
538
539    #[test]
540    fn csv_row_returns_full_line() {
541        let path = write_temp_csv("test_csv_row.csv", "a,b,c\n1,2,3\n4,5,6\n");
542        // Macro-emitted: `CsvRow::new(filename: String) -> Self` (panics on
543        // bad file via `read_csv_data_rows`).
544        let node = CsvRow::new(path);
545        let mut out = [Value::None];
546        node.eval(&[Value::U64(0)], &mut out);
547        assert_eq!(out[0].to_display_string(), "1,2,3");
548    }
549
550    #[test]
551    fn csv_row_count_excludes_header() {
552        let path = write_temp_csv("test_csv_count.csv", "h1,h2\na,b\nc,d\ne,f\n");
553        let node = CsvRowCount::new(path);
554        let mut out = [Value::None];
555        node.eval(&[], &mut out);
556        assert_eq!(out[0].as_u64(), 3);
557    }
558
559    // ── RFC 4180 quoting ────────────────────────────────────────
560
561    fn fields(line: &str) -> Vec<String> {
562        split_csv_line(line)
563            .unwrap()
564            .into_iter()
565            .map(|f| f.into_owned())
566            .collect()
567    }
568
569    #[test]
570    fn csv_quoted_field_keeps_embedded_comma() {
571        assert_eq!(
572            fields("\"Shook, Jonathan\",42"),
573            vec!["Shook, Jonathan", "42"]
574        );
575        let path = write_temp_csv(
576            "test_csv_quoted_comma.csv",
577            "name,age\n\"Shook, Jonathan\",42\nbob,25\n",
578        );
579        let node = CsvField::new(path.clone(), "name".to_string());
580        let mut out = [Value::None];
581        node.eval(&[Value::U64(0)], &mut out);
582        assert_eq!(out[0].to_display_string(), "Shook, Jonathan");
583        // The comma inside the quotes does not shift the next column.
584        let node = CsvField::new(path, "age".to_string());
585        node.eval(&[Value::U64(0)], &mut out);
586        assert_eq!(out[0].to_display_string(), "42");
587    }
588
589    #[test]
590    fn csv_quoted_field_collapses_doubled_quote() {
591        assert_eq!(fields("\"say \"\"hi\"\"\",x"), vec!["say \"hi\"", "x"]);
592        assert_eq!(fields("\"\"\"\""), vec!["\""]);
593        let path = write_temp_csv("test_csv_quoted_dq.csv", "q\n\"say \"\"hi\"\"\"\n");
594        let node = CsvField::new(path, "q".to_string());
595        let mut out = [Value::None];
596        node.eval(&[Value::U64(0)], &mut out);
597        assert_eq!(out[0].to_display_string(), "say \"hi\"");
598    }
599
600    #[test]
601    fn csv_quoted_field_keeps_embedded_newline() {
602        // LF and CR LF both stay inside the field; the record count
603        // and the row text follow the quotes, not the line breaks.
604        let path = write_temp_csv(
605            "test_csv_quoted_nl.csv",
606            "id,note\n1,\"line one\nline two\"\n2,\"crlf\r\nhere\"\r\n3,plain\n",
607        );
608        let node = CsvField::new(path.clone(), "note".to_string());
609        let mut out = [Value::None];
610        node.eval(&[Value::U64(0)], &mut out);
611        assert_eq!(out[0].to_display_string(), "line one\nline two");
612        node.eval(&[Value::U64(1)], &mut out);
613        assert_eq!(out[0].to_display_string(), "crlf\r\nhere");
614        node.eval(&[Value::U64(2)], &mut out);
615        assert_eq!(out[0].to_display_string(), "plain");
616
617        let node = CsvRowCount::new(path.clone());
618        node.eval(&[], &mut out);
619        assert_eq!(out[0].as_u64(), 3);
620
621        // csv_row serves the record as written, quotes and break intact.
622        let node = CsvRow::new(path);
623        node.eval(&[Value::U64(0)], &mut out);
624        assert_eq!(out[0].to_display_string(), "1,\"line one\nline two\"");
625        node.eval(&[Value::U64(1)], &mut out);
626        assert_eq!(out[0].to_display_string(), "2,\"crlf\r\nhere\"");
627    }
628
629    #[test]
630    fn csv_empty_quoted_field() {
631        assert_eq!(fields("a,\"\",c"), vec!["a", "", "c"]);
632        assert_eq!(fields("\"\""), vec![""]);
633        assert_eq!(fields("\"\","), vec!["", ""]);
634        let path = write_temp_csv("test_csv_quoted_empty.csv", "a,b,c\n1,\"\",3\n");
635        let node = CsvField::new(path, "b".to_string());
636        let mut out = [Value::None];
637        node.eval(&[Value::U64(0)], &mut out);
638        assert_eq!(out[0].to_display_string(), "");
639    }
640
641    #[test]
642    fn csv_mixed_quoted_and_unquoted_fields() {
643        // Unquoted fields are exactly what the plain split gave: taken
644        // verbatim, whitespace and interior quote characters included.
645        assert_eq!(
646            fields("plain,\"quoted, one\", spaced ,\"\",it\"s,\"last\""),
647            vec!["plain", "quoted, one", " spaced ", "", "it\"s", "last"]
648        );
649        assert_eq!(fields(""), vec![""]);
650        assert_eq!(fields("a,"), vec!["a", ""]);
651        let path = write_temp_csv(
652            "test_csv_quoted_mixed.csv",
653            "a,b,c,d\nplain,\"quoted, one\", spaced ,\"last\"\n",
654        );
655        let mut out = [Value::None];
656        for (col, want) in [
657            ("a", "plain"),
658            ("b", "quoted, one"),
659            ("c", "spaced"),
660            ("d", "last"),
661        ] {
662            let node = CsvField::new(path.clone(), col.to_string());
663            node.eval(&[Value::U64(0)], &mut out);
664            assert_eq!(out[0].to_display_string(), want, "column {col}");
665        }
666    }
667
668    #[test]
669    fn csv_unterminated_quote_is_an_error() {
670        let err = split_csv_records("a,b\n1,\"open\n2,x\n").unwrap_err();
671        assert_eq!(err, "unterminated quoted field starting at line 2");
672        assert_eq!(
673            split_csv_line("\"open").unwrap_err(),
674            "unterminated quoted field"
675        );
676        assert!(
677            split_csv_line("\"a\"b,c")
678                .unwrap_err()
679                .contains("after the closing quote")
680        );
681    }
682
683    #[test]
684    #[should_panic(expected = "csv_field: ")]
685    fn csv_field_unterminated_quote_panics_with_loader_error() {
686        let path = write_temp_csv("test_csv_unterminated.csv", "a,b\n1,\"open\n2,x\n");
687        let _ = CsvField::new(path, "b".to_string());
688    }
689
690    #[test]
691    #[should_panic(expected = "unterminated quoted field starting at line 2")]
692    fn csv_row_count_unterminated_quote_panics_with_loader_error() {
693        let path = write_temp_csv("test_csv_unterminated_count.csv", "a,b\n1,\"open\n2,x\n");
694        let _ = CsvRowCount::new(path);
695    }
696
697    #[test]
698    fn jsonl_field_top_level() {
699        let path = write_temp_csv(
700            "test_jsonl_field.jsonl",
701            "{\"name\":\"alice\",\"age\":30}\n{\"name\":\"bob\",\"age\":25}\n",
702        );
703        let node = JsonlField::new(path, "name".to_string());
704        let mut out = [Value::None];
705        node.eval(&[Value::U64(0)], &mut out);
706        assert_eq!(out[0].to_display_string(), "alice");
707        node.eval(&[Value::U64(1)], &mut out);
708        assert_eq!(out[0].to_display_string(), "bob");
709    }
710
711    #[test]
712    fn jsonl_field_nested_path() {
713        let path = write_temp_csv(
714            "test_jsonl_nested.jsonl",
715            "{\"user\":{\"name\":\"alice\"}}\n{\"user\":{\"name\":\"bob\"}}\n",
716        );
717        let node = JsonlField::new(path, "user.name".to_string());
718        let mut out = [Value::None];
719        node.eval(&[Value::U64(0)], &mut out);
720        assert_eq!(out[0].to_display_string(), "alice");
721    }
722
723    #[test]
724    fn jsonl_row_returns_full_json() {
725        let path = write_temp_csv("test_jsonl_row.jsonl", "{\"a\":1}\n{\"b\":2}\n");
726        let node = JsonlRow::new(path);
727        let mut out = [Value::None];
728        node.eval(&[Value::U64(0)], &mut out);
729        assert!(out[0].to_display_string().contains("\"a\":1"));
730    }
731
732    #[test]
733    fn jsonl_row_count() {
734        let path = write_temp_csv(
735            "test_jsonl_count.jsonl",
736            "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n",
737        );
738        let node = JsonlRowCount::new(path);
739        let mut out = [Value::None];
740        node.eval(&[], &mut out);
741        assert_eq!(out[0].as_u64(), 3);
742    }
743}