runmat_runtime/builtins/io/tabular/
csvwrite.rs

1//! MATLAB-compatible `csvwrite` builtin for RunMat.
2//!
3//! `csvwrite` is an older convenience wrapper that persists numeric matrices to
4//! comma-separated text files. Modern MATLAB code typically prefers
5//! `writematrix`, but many legacy scripts still depend on `csvwrite`'s terse
6//! API and zero-based offset arguments. This implementation mirrors those
7//! semantics while integrating with RunMat's builtin framework.
8
9use std::fs::OpenOptions;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13use runmat_builtins::{Tensor, Value};
14use runmat_macros::runtime_builtin;
15
16use crate::builtins::common::fs::expand_user_path;
17use crate::builtins::common::spec::{
18    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
19    ReductionNaN, ResidencyPolicy, ShapeRequirements,
20};
21use crate::builtins::common::tensor;
22#[cfg(feature = "doc_export")]
23use crate::register_builtin_doc_text;
24use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
25
26#[cfg(feature = "doc_export")]
27pub const DOC_MD: &str = r#"---
28title: "csvwrite"
29category: "io/tabular"
30keywords: ["csvwrite", "csv", "write", "comma-separated values", "numeric export", "row offset", "column offset"]
31summary: "Write numeric matrices to comma-separated text files using MATLAB-compatible offsets."
32references:
33  - https://www.mathworks.com/help/matlab/ref/csvwrite.html
34gpu_support:
35  elementwise: false
36  reduction: false
37  precisions: []
38  broadcasting: "none"
39  notes: "Runs entirely on the CPU. gpuArray inputs are gathered before serialisation."
40fusion:
41  elementwise: false
42  reduction: false
43  max_inputs: 2
44  constants: "inline"
45requires_feature: null
46tested:
47  unit: "builtins::io::tabular::csvwrite::tests"
48  integration:
49    - "builtins::io::tabular::csvwrite::tests::csvwrite_writes_basic_matrix"
50    - "builtins::io::tabular::csvwrite::tests::csvwrite_honours_offsets"
51    - "builtins::io::tabular::csvwrite::tests::csvwrite_handles_gpu_tensors"
52    - "builtins::io::tabular::csvwrite::tests::csvwrite_expands_home_directory"
53    - "builtins::io::tabular::csvwrite::tests::csvwrite_formats_with_short_g_precision"
54    - "builtins::io::tabular::csvwrite::tests::csvwrite_handles_wgpu_provider_gather"
55    - "builtins::io::tabular::csvwrite::tests::csvwrite_rejects_negative_offsets"
56---
57
58# What does the `csvwrite` function do in MATLAB / RunMat?
59`csvwrite(filename, M)` writes a numeric matrix to a comma-separated text file.
60The builtin honours MATLAB's historical zero-based row/column offset arguments so
61that existing scripts continue to behave identically in RunMat.
62
63## How does the `csvwrite` function behave in MATLAB / RunMat?
64- Only real numeric or logical inputs are accepted. Logical values are converted
65  to `0` and `1` before writing. Complex and textual inputs raise descriptive
66  errors.
67- `csvwrite(filename, M, row, col)` starts writing at zero-based row `row` and
68  column `col`, leaving earlier rows blank and earlier columns empty within each
69  row. Offsets must be non-negative integers.
70- Matrices must be 2-D (trailing singleton dimensions are ignored). Column-major
71  ordering is respected when serialising to text.
72- Numbers are emitted using MATLAB-compatible short `g` formatting (`%.5g`). `NaN`, `Inf`,
73  and `-Inf` tokens are written verbatim.
74- Existing files are overwritten. `csvwrite` does not support appending; switch
75  to `writematrix` with `'WriteMode','append'` when the behaviour is required.
76- Paths that begin with `~` expand to the user's home directory before writing.
77
78## `csvwrite` Function GPU Execution Behaviour
79`csvwrite` always executes on the host CPU. When the matrix resides on the GPU,
80RunMat gathers the data through the active acceleration provider before
81serialisation. No provider hooks are required, and the return value reports the
82number of bytes written after the gather completes.
83
84## Examples of using the `csvwrite` function in MATLAB / RunMat
85
86### Writing a numeric matrix to CSV
87```matlab
88A = [1 2 3; 4 5 6];
89csvwrite("scores.csv", A);
90```
91Expected contents of `scores.csv`:
92```matlab
931,2,3
944,5,6
95```
96
97### Starting output after a header row
98```matlab
99fid = fopen("with_header.csv", "w");
100fprintf(fid, "Name,Jan,Feb\nalpha,1,2\nbeta,3,4\n");
101fclose(fid);
102
103csvwrite("with_header.csv", [10 20; 30 40], 1, 0);
104```
105Expected contents of `with_header.csv`:
106```matlab
107Name,Jan,Feb
108
10910,20
11030,40
111```
112
113### Skipping leading columns before data
114```matlab
115B = magic(3);
116csvwrite("offset_columns.csv", B, 0, 2);
117```
118Expected contents of `offset_columns.csv`:
119```matlab
120,,8,1,6
121,,3,5,7
122,,4,9,2
123```
124
125### Exporting logical masks as numeric zeros and ones
126```matlab
127mask = [true false true; false true false];
128csvwrite("mask.csv", mask);
129```
130Expected contents of `mask.csv`:
131```matlab
1321,0,1
1330,1,0
134```
135
136### Writing GPU-resident data without manual gather
137```matlab
138G = gpuArray(single([0.1 0.2 0.3]));
139csvwrite("gpu_values.csv", G);
140```
141Expected behaviour:
142```matlab
143% Data is gathered automatically from the GPU and written to disk.
144```
145
146### Persisting a scalar value for downstream tools
147```matlab
148total = sum(rand(5));
149csvwrite("scalar.csv", total);
150```
151Expected contents of `scalar.csv`:
152```matlab
1532.5731
154```
155
156## GPU residency in RunMat (Do I need `gpuArray`?)
157No additional steps are necessary. `csvwrite` treats GPU arrays as residency
158sinks: data is gathered back to host memory prior to writing. This matches
159MATLAB's behaviour, where file I/O always operates on host-resident values.
160
161## FAQ
162
163### Why must the input be numeric or logical?
164`csvwrite` predates MATLAB's table and string support and only serialises numeric
165values. Provide numeric matrices or logical masks, or switch to `writematrix`
166when you need to mix text and numbers.
167
168### Are row and column offsets zero-based?
169Yes. `row = 1` skips one full line before writing, and `col = 2` inserts two
170empty comma-separated fields at the start of each written row.
171
172### Can I append to an existing CSV with `csvwrite`?
173No. `csvwrite` always overwrites the destination file. Use `writematrix` with
174`'WriteMode','append'` or manipulate the file with lower-level I/O functions.
175
176### How are `NaN` and `Inf` values written?
177They are emitted verbatim as `NaN`, `Inf`, or `-Inf`, matching MATLAB's text
178representation so that downstream tools can parse them consistently.
179
180### What line ending does `csvwrite` use?
181The builtin uses the platform default (`\r\n` on Windows, `\n` elsewhere). Most
182CSV consumers handle either convention transparently.
183
184## See Also
185[csvread](./csvread), [readmatrix](./readmatrix), [writematrix](./writematrix), [fprintf](../filetext/fprintf), [gpuArray](../../acceleration/gpu/gpuArray), [gather](../../acceleration/gpu/gather)
186
187## Source & Feedback
188- The full source code for `csvwrite` lives at: [`crates/runmat-runtime/src/builtins/io/tabular/csvwrite.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/tabular/csvwrite.rs)
189- Found a behavioural difference? [Open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with details and a minimal reproduction.
190"#;
191
192pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
193    name: "csvwrite",
194    op_kind: GpuOpKind::Custom("io-csvwrite"),
195    supported_precisions: &[],
196    broadcast: BroadcastSemantics::None,
197    provider_hooks: &[],
198    constant_strategy: ConstantStrategy::InlineLiteral,
199    residency: ResidencyPolicy::GatherImmediately,
200    nan_mode: ReductionNaN::Include,
201    two_pass_threshold: None,
202    workgroup_size: None,
203    accepts_nan_mode: false,
204    notes: "Runs entirely on the host; gpuArray inputs are gathered before serialisation.",
205};
206
207register_builtin_gpu_spec!(GPU_SPEC);
208
209pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
210    name: "csvwrite",
211    shape: ShapeRequirements::Any,
212    constant_strategy: ConstantStrategy::InlineLiteral,
213    elementwise: None,
214    reduction: None,
215    emits_nan: false,
216    notes: "Not eligible for fusion; performs host-side file I/O.",
217};
218
219register_builtin_fusion_spec!(FUSION_SPEC);
220
221#[cfg(feature = "doc_export")]
222register_builtin_doc_text!("csvwrite", DOC_MD);
223
224#[runtime_builtin(
225    name = "csvwrite",
226    category = "io/tabular",
227    summary = "Write numeric matrices to comma-separated text files using MATLAB-compatible offsets.",
228    keywords = "csvwrite,csv,write,row offset,column offset",
229    accel = "cpu"
230)]
231fn csvwrite_builtin(filename: Value, data: Value, rest: Vec<Value>) -> Result<Value, String> {
232    let filename_value = gather_if_needed(&filename).map_err(|e| format!("csvwrite: {e}"))?;
233    let path = resolve_path(&filename_value)?;
234
235    let (row_offset, col_offset) = parse_offsets(&rest)?;
236
237    let gathered_data = gather_if_needed(&data).map_err(|e| format!("csvwrite: {e}"))?;
238    let tensor = tensor::value_into_tensor_for("csvwrite", gathered_data)?;
239    ensure_matrix_shape(&tensor)?;
240
241    let bytes = write_csv(&path, &tensor, row_offset, col_offset)?;
242    Ok(Value::Num(bytes as f64))
243}
244
245fn resolve_path(value: &Value) -> Result<PathBuf, String> {
246    let raw = match value {
247        Value::String(s) => s.clone(),
248        Value::CharArray(ca) if ca.rows == 1 => ca.data.iter().collect(),
249        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].clone(),
250        _ => {
251            return Err(
252                "csvwrite: filename must be a string scalar or character vector".to_string(),
253            )
254        }
255    };
256
257    if raw.trim().is_empty() {
258        return Err("csvwrite: filename must not be empty".to_string());
259    }
260
261    let expanded = expand_user_path(&raw, "csvwrite").map_err(|e| format!("csvwrite: {e}"))?;
262    Ok(Path::new(&expanded).to_path_buf())
263}
264
265fn parse_offsets(args: &[Value]) -> Result<(usize, usize), String> {
266    match args.len() {
267        0 => Ok((0, 0)),
268        2 => {
269            let row = parse_offset(&args[0], "row offset")?;
270            let col = parse_offset(&args[1], "column offset")?;
271            Ok((row, col))
272        }
273        _ => Err(
274            "csvwrite: offsets must be provided as two numeric arguments (row, column)".to_string(),
275        ),
276    }
277}
278
279fn parse_offset(value: &Value, context: &str) -> Result<usize, String> {
280    match value {
281        Value::Int(i) => {
282            let raw = i.to_i64();
283            if raw < 0 {
284                return Err(format!("csvwrite: {context} must be >= 0"));
285            }
286            Ok(raw as usize)
287        }
288        Value::Num(n) => coerce_offset_from_float(*n, context),
289        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
290        Value::Tensor(t) => {
291            if t.data.len() != 1 {
292                return Err(format!(
293                    "csvwrite: {context} must be a scalar, got {} elements",
294                    t.data.len()
295                ));
296            }
297            coerce_offset_from_float(t.data[0], context)
298        }
299        Value::LogicalArray(logical) => {
300            if logical.data.len() != 1 {
301                return Err(format!(
302                    "csvwrite: {context} must be a scalar, got {} elements",
303                    logical.data.len()
304                ));
305            }
306            Ok(if logical.data[0] != 0 { 1 } else { 0 })
307        }
308        other => Err(format!(
309            "csvwrite: {context} must be numeric, got {:?}",
310            other
311        )),
312    }
313}
314
315fn coerce_offset_from_float(value: f64, context: &str) -> Result<usize, String> {
316    if !value.is_finite() {
317        return Err(format!("csvwrite: {context} must be finite"));
318    }
319    let rounded = value.round();
320    if (rounded - value).abs() > 1e-9 {
321        return Err(format!("csvwrite: {context} must be an integer"));
322    }
323    if rounded < 0.0 {
324        return Err(format!("csvwrite: {context} must be >= 0"));
325    }
326    Ok(rounded as usize)
327}
328
329fn ensure_matrix_shape(tensor: &Tensor) -> Result<(), String> {
330    if tensor.shape.len() <= 2 {
331        return Ok(());
332    }
333    if tensor.shape[2..].iter().all(|&dim| dim == 1) {
334        return Ok(());
335    }
336    Err("csvwrite: input must be 2-D; reshape before writing".to_string())
337}
338
339fn write_csv(
340    path: &Path,
341    tensor: &Tensor,
342    row_offset: usize,
343    col_offset: usize,
344) -> Result<usize, String> {
345    let mut options = OpenOptions::new();
346    options.create(true).write(true).truncate(true);
347    let mut file = options.open(path).map_err(|err| {
348        format!(
349            "csvwrite: unable to open \"{}\" for writing ({err})",
350            path.display()
351        )
352    })?;
353
354    let line_ending = default_line_ending();
355    let rows = tensor.rows();
356    let cols = tensor.cols();
357
358    let mut bytes_written = 0usize;
359
360    for _ in 0..row_offset {
361        file.write_all(line_ending.as_bytes())
362            .map_err(|err| format!("csvwrite: failed to write line ending ({err})"))?;
363        bytes_written += line_ending.len();
364    }
365
366    if rows == 0 || cols == 0 {
367        file.flush()
368            .map_err(|err| format!("csvwrite: failed to flush output ({err})"))?;
369        return Ok(bytes_written);
370    }
371
372    for row in 0..rows {
373        let mut fields = Vec::with_capacity(col_offset + cols);
374        for _ in 0..col_offset {
375            fields.push(String::new());
376        }
377        for col in 0..cols {
378            let idx = row + col * rows;
379            let value = tensor.data[idx];
380            fields.push(format_numeric(value));
381        }
382        let line = fields.join(",");
383        if !line.is_empty() {
384            file.write_all(line.as_bytes())
385                .map_err(|err| format!("csvwrite: failed to write value ({err})"))?;
386            bytes_written += line.len();
387        }
388        file.write_all(line_ending.as_bytes())
389            .map_err(|err| format!("csvwrite: failed to write line ending ({err})"))?;
390        bytes_written += line_ending.len();
391    }
392
393    file.flush()
394        .map_err(|err| format!("csvwrite: failed to flush output ({err})"))?;
395
396    Ok(bytes_written)
397}
398
399fn default_line_ending() -> &'static str {
400    if cfg!(windows) {
401        "\r\n"
402    } else {
403        "\n"
404    }
405}
406
407fn format_numeric(value: f64) -> String {
408    if value.is_nan() {
409        return "NaN".to_string();
410    }
411    if value.is_infinite() {
412        return if value.is_sign_negative() {
413            "-Inf".to_string()
414        } else {
415            "Inf".to_string()
416        };
417    }
418    if value == 0.0 {
419        return "0".to_string();
420    }
421
422    let precision: i32 = 5;
423    let abs = value.abs();
424    let exp10 = abs.log10().floor() as i32;
425    let use_scientific = exp10 < -4 || exp10 >= precision;
426
427    let raw = if use_scientific {
428        let digits_after = (precision - 1).max(0) as usize;
429        format!("{:.*e}", digits_after, value)
430    } else {
431        let decimals = (precision - 1 - exp10).max(0) as usize;
432        format!("{:.*}", decimals, value)
433    };
434
435    let mut trimmed = trim_trailing_zeros(raw);
436    if trimmed == "-0" {
437        trimmed = "0".to_string();
438    }
439    trimmed
440}
441
442fn trim_trailing_zeros(mut value: String) -> String {
443    if let Some(exp_pos) = value.find(['e', 'E']) {
444        let exponent = value.split_off(exp_pos);
445        while value.ends_with('0') {
446            value.pop();
447        }
448        if value.ends_with('.') {
449            value.pop();
450        }
451        value.push_str(&normalize_exponent(&exponent));
452        value
453    } else {
454        if value.contains('.') {
455            while value.ends_with('0') {
456                value.pop();
457            }
458            if value.ends_with('.') {
459                value.pop();
460            }
461        }
462        if value.is_empty() {
463            "0".to_string()
464        } else {
465            value
466        }
467    }
468}
469
470fn normalize_exponent(exponent: &str) -> String {
471    if exponent.len() <= 1 {
472        return exponent.to_string();
473    }
474    let mut chars = exponent.chars();
475    let marker = chars.next().unwrap();
476    let rest: String = chars.collect();
477    match rest.parse::<i32>() {
478        Ok(parsed) => format!("{}{:+03}", marker, parsed),
479        Err(_) => exponent.to_string(),
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use std::fs;
487    use std::sync::atomic::{AtomicU64, Ordering};
488    use std::time::{SystemTime, UNIX_EPOCH};
489
490    use runmat_accelerate_api::HostTensorView;
491    use runmat_builtins::{IntValue, LogicalArray};
492
493    use crate::builtins::common::fs as fs_helpers;
494    use crate::builtins::common::test_support;
495
496    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
497
498    fn temp_path(ext: &str) -> PathBuf {
499        let millis = SystemTime::now()
500            .duration_since(UNIX_EPOCH)
501            .unwrap()
502            .as_millis();
503        let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
504        let mut path = std::env::temp_dir();
505        path.push(format!(
506            "runmat_csvwrite_{}_{}_{}.{}",
507            std::process::id(),
508            millis,
509            unique,
510            ext
511        ));
512        path
513    }
514
515    fn line_ending() -> &'static str {
516        default_line_ending()
517    }
518
519    #[test]
520    fn csvwrite_writes_basic_matrix() {
521        let path = temp_path("csv");
522        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
523        let filename = path.to_string_lossy().into_owned();
524
525        csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
526            .expect("csvwrite");
527
528        let contents = fs::read_to_string(&path).expect("read contents");
529        assert_eq!(contents, format!("1,2,3{le}4,5,6{le}", le = line_ending()));
530        let _ = fs::remove_file(path);
531    }
532
533    #[test]
534    fn csvwrite_honours_offsets() {
535        let path = temp_path("csv");
536        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
537        let filename = path.to_string_lossy().into_owned();
538
539        csvwrite_builtin(
540            Value::from(filename),
541            Value::Tensor(tensor),
542            vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(2))],
543        )
544        .expect("csvwrite");
545
546        let contents = fs::read_to_string(&path).expect("read contents");
547        assert_eq!(
548            contents,
549            format!("{le},,1,3{le},,2,4{le}", le = line_ending())
550        );
551        let _ = fs::remove_file(path);
552    }
553
554    #[test]
555    fn csvwrite_handles_gpu_tensors() {
556        test_support::with_test_provider(|provider| {
557            let path = temp_path("csv");
558            let tensor = Tensor::new(vec![0.5, 1.5], vec![1, 2]).unwrap();
559            let view = HostTensorView {
560                data: &tensor.data,
561                shape: &tensor.shape,
562            };
563            let handle = provider.upload(&view).expect("upload");
564            let filename = path.to_string_lossy().into_owned();
565
566            csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
567                .expect("csvwrite");
568
569            let contents = fs::read_to_string(&path).expect("read contents");
570            assert_eq!(contents, format!("0.5,1.5{le}", le = line_ending()));
571            let _ = fs::remove_file(path);
572        });
573    }
574
575    #[test]
576    fn csvwrite_formats_with_short_g_precision() {
577        let path = temp_path("csv");
578        let values =
579            Tensor::new(vec![12.3456, 1_234_567.0, 0.000123456, -0.0], vec![1, 4]).unwrap();
580        let filename = path.to_string_lossy().into_owned();
581
582        csvwrite_builtin(Value::from(filename), Value::Tensor(values), Vec::new())
583            .expect("csvwrite");
584
585        let contents = fs::read_to_string(&path).expect("read contents");
586        assert_eq!(
587            contents,
588            format!("12.346,1.2346e+06,0.00012346,0{le}", le = line_ending())
589        );
590        let _ = fs::remove_file(path);
591    }
592
593    #[test]
594    fn csvwrite_rejects_negative_offsets() {
595        let path = temp_path("csv");
596        let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
597        let filename = path.to_string_lossy().into_owned();
598        let err = csvwrite_builtin(
599            Value::from(filename),
600            Value::Tensor(tensor),
601            vec![Value::Num(-1.0), Value::Num(0.0)],
602        )
603        .expect_err("negative offsets should be rejected");
604        assert!(
605            err.contains("row offset"),
606            "unexpected error message: {err}"
607        );
608    }
609
610    #[cfg(feature = "wgpu")]
611    #[test]
612    fn csvwrite_handles_wgpu_provider_gather() {
613        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
614            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
615        );
616        let Some(provider) = runmat_accelerate_api::provider() else {
617            panic!("wgpu provider not registered");
618        };
619
620        let path = temp_path("csv");
621        let tensor = Tensor::new(vec![2.0, 4.0], vec![1, 2]).unwrap();
622        let view = HostTensorView {
623            data: &tensor.data,
624            shape: &tensor.shape,
625        };
626        let handle = provider.upload(&view).expect("upload");
627        let filename = path.to_string_lossy().into_owned();
628
629        csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
630            .expect("csvwrite");
631
632        let contents = fs::read_to_string(&path).expect("read contents");
633        assert_eq!(contents, format!("2,4{le}", le = line_ending()));
634        let _ = fs::remove_file(path);
635    }
636
637    #[test]
638    fn csvwrite_expands_home_directory() {
639        let Some(mut home) = fs_helpers::home_directory() else {
640            // Skip when home directory cannot be determined.
641            return;
642        };
643        let filename = format!(
644            "runmat_csvwrite_home_{}_{}.csv",
645            std::process::id(),
646            NEXT_ID.fetch_add(1, Ordering::Relaxed)
647        );
648        home.push(&filename);
649
650        let tilde_path = format!("~/{}", filename);
651        let tensor = Tensor::new(vec![42.0], vec![1, 1]).unwrap();
652
653        csvwrite_builtin(Value::from(tilde_path), Value::Tensor(tensor), Vec::new())
654            .expect("csvwrite");
655
656        let contents = fs::read_to_string(&home).expect("read contents");
657        assert_eq!(contents, format!("42{le}", le = line_ending()));
658        let _ = fs::remove_file(home);
659    }
660
661    #[test]
662    fn csvwrite_rejects_non_numeric_inputs() {
663        let path = temp_path("csv");
664        let filename = path.to_string_lossy().into_owned();
665        let err = csvwrite_builtin(
666            Value::from(filename),
667            Value::String("abc".into()),
668            Vec::new(),
669        )
670        .expect_err("csvwrite should fail");
671        assert!(err.contains("csvwrite"), "unexpected error message: {err}");
672    }
673
674    #[test]
675    fn csvwrite_accepts_logical_arrays() {
676        let path = temp_path("csv");
677        let logical = LogicalArray::new(vec![1, 0, 1, 0], vec![2, 2]).unwrap();
678        let filename = path.to_string_lossy().into_owned();
679
680        csvwrite_builtin(
681            Value::from(filename),
682            Value::LogicalArray(logical),
683            Vec::new(),
684        )
685        .expect("csvwrite");
686
687        let contents = fs::read_to_string(&path).expect("read contents");
688        assert_eq!(contents, format!("1,1{le}0,0{le}", le = line_ending()));
689        let _ = fs::remove_file(path);
690    }
691
692    #[test]
693    #[cfg(feature = "doc_export")]
694    fn doc_examples_present() {
695        let blocks = test_support::doc_examples(DOC_MD);
696        assert!(!blocks.is_empty());
697    }
698}