Skip to main content

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::io::Write;
10use std::path::{Path, PathBuf};
11
12use runmat_builtins::{
13    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
14    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
15    Tensor, Value,
16};
17use runmat_filesystem::OpenOptions;
18use runmat_macros::runtime_builtin;
19
20use crate::builtins::common::fs::expand_user_path;
21use crate::builtins::common::spec::{
22    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
23    ReductionNaN, ResidencyPolicy, ShapeRequirements,
24};
25use crate::builtins::common::tensor;
26use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
27
28const BUILTIN_NAME: &str = "csvwrite";
29
30const CSVWRITE_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "bytesWritten",
32    ty: BuiltinParamType::NumericScalar,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Number of bytes written to the output file.",
36}];
37const CSVWRITE_INPUTS_FILENAME_DATA: [BuiltinParamDescriptor; 2] = [
38    BuiltinParamDescriptor {
39        name: "filename",
40        ty: BuiltinParamType::StringScalar,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "CSV output path.",
44    },
45    BuiltinParamDescriptor {
46        name: "M",
47        ty: BuiltinParamType::Any,
48        arity: BuiltinParamArity::Required,
49        default: None,
50        description: "Numeric/logical matrix data to write.",
51    },
52];
53const CSVWRITE_INPUTS_FILENAME_DATA_ROW_COL: [BuiltinParamDescriptor; 4] = [
54    BuiltinParamDescriptor {
55        name: "filename",
56        ty: BuiltinParamType::StringScalar,
57        arity: BuiltinParamArity::Required,
58        default: None,
59        description: "CSV output path.",
60    },
61    BuiltinParamDescriptor {
62        name: "M",
63        ty: BuiltinParamType::Any,
64        arity: BuiltinParamArity::Required,
65        default: None,
66        description: "Numeric/logical matrix data to write.",
67    },
68    BuiltinParamDescriptor {
69        name: "row",
70        ty: BuiltinParamType::IntegerScalar,
71        arity: BuiltinParamArity::Required,
72        default: None,
73        description: "Zero-based row offset before writing values.",
74    },
75    BuiltinParamDescriptor {
76        name: "col",
77        ty: BuiltinParamType::IntegerScalar,
78        arity: BuiltinParamArity::Required,
79        default: None,
80        description: "Zero-based column offset before writing values.",
81    },
82];
83const CSVWRITE_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
84    BuiltinSignatureDescriptor {
85        label: "bytesWritten = csvwrite(filename, M)",
86        inputs: &CSVWRITE_INPUTS_FILENAME_DATA,
87        outputs: &CSVWRITE_OUTPUT,
88    },
89    BuiltinSignatureDescriptor {
90        label: "bytesWritten = csvwrite(filename, M, row, col)",
91        inputs: &CSVWRITE_INPUTS_FILENAME_DATA_ROW_COL,
92        outputs: &CSVWRITE_OUTPUT,
93    },
94];
95const CSVWRITE_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
96    code: "RM.CSVWRITE.FILENAME",
97    identifier: None,
98    when: "Filename argument is not a scalar string/char vector.",
99    message: "csvwrite: invalid filename input",
100};
101const CSVWRITE_ERROR_FILENAME_EMPTY: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
102    code: "RM.CSVWRITE.FILENAME_EMPTY",
103    identifier: None,
104    when: "Filename resolves to an empty string.",
105    message: "csvwrite: filename must not be empty",
106};
107const CSVWRITE_ERROR_OFFSETS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
108    code: "RM.CSVWRITE.OFFSETS",
109    identifier: None,
110    when: "Offset arguments are missing, malformed, or out of bounds.",
111    message: "csvwrite: invalid row/column offsets",
112};
113const CSVWRITE_ERROR_DATA_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
114    code: "RM.CSVWRITE.DATA_SHAPE",
115    identifier: None,
116    when: "Input data is not a 2-D matrix.",
117    message: "csvwrite: input must be 2-D",
118};
119const CSVWRITE_ERROR_DATA_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
120    code: "RM.CSVWRITE.DATA_INPUT",
121    identifier: None,
122    when: "Input data cannot be converted to a numeric/logical tensor.",
123    message: "csvwrite: input must be numeric or logical",
124};
125const CSVWRITE_ERROR_IO_OPEN: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
126    code: "RM.CSVWRITE.IO_OPEN",
127    identifier: None,
128    when: "Output file cannot be opened.",
129    message: "csvwrite: unable to open file for writing",
130};
131const CSVWRITE_ERROR_IO_WRITE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
132    code: "RM.CSVWRITE.IO_WRITE",
133    identifier: None,
134    when: "Output file write/flush fails.",
135    message: "csvwrite: write failed",
136};
137const CSVWRITE_ERRORS: [BuiltinErrorDescriptor; 7] = [
138    CSVWRITE_ERROR_FILENAME,
139    CSVWRITE_ERROR_FILENAME_EMPTY,
140    CSVWRITE_ERROR_OFFSETS,
141    CSVWRITE_ERROR_DATA_INPUT,
142    CSVWRITE_ERROR_DATA_SHAPE,
143    CSVWRITE_ERROR_IO_OPEN,
144    CSVWRITE_ERROR_IO_WRITE,
145];
146pub const CSVWRITE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
147    signatures: &CSVWRITE_SIGNATURES,
148    output_mode: BuiltinOutputMode::Fixed,
149    completion_policy: BuiltinCompletionPolicy::Public,
150    errors: &CSVWRITE_ERRORS,
151};
152
153#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::csvwrite")]
154pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
155    name: "csvwrite",
156    op_kind: GpuOpKind::Custom("io-csvwrite"),
157    supported_precisions: &[],
158    broadcast: BroadcastSemantics::None,
159    provider_hooks: &[],
160    constant_strategy: ConstantStrategy::InlineLiteral,
161    residency: ResidencyPolicy::GatherImmediately,
162    nan_mode: ReductionNaN::Include,
163    two_pass_threshold: None,
164    workgroup_size: None,
165    accepts_nan_mode: false,
166    notes: "Runs entirely on the host; gpuArray inputs are gathered before serialisation.",
167};
168
169#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::csvwrite")]
170pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
171    name: "csvwrite",
172    shape: ShapeRequirements::Any,
173    constant_strategy: ConstantStrategy::InlineLiteral,
174    elementwise: None,
175    reduction: None,
176    emits_nan: false,
177    notes: "Not eligible for fusion; performs host-side file I/O.",
178};
179
180fn csvwrite_error_with(
181    error: &'static BuiltinErrorDescriptor,
182    message: impl Into<String>,
183) -> RuntimeError {
184    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
185    if let Some(identifier) = error.identifier {
186        builder = builder.with_identifier(identifier);
187    }
188    builder.build()
189}
190
191fn csvwrite_error_with_source<E>(
192    error: &'static BuiltinErrorDescriptor,
193    message: impl Into<String>,
194    source: E,
195) -> RuntimeError
196where
197    E: std::error::Error + Send + Sync + 'static,
198{
199    let mut builder = build_runtime_error(message)
200        .with_builtin(BUILTIN_NAME)
201        .with_source(source);
202    if let Some(identifier) = error.identifier {
203        builder = builder.with_identifier(identifier);
204    }
205    builder.build()
206}
207
208fn map_control_flow(err: RuntimeError) -> RuntimeError {
209    let identifier = err.identifier().map(|value| value.to_string());
210    let message = err.message().to_string();
211    let mut builder = build_runtime_error(message)
212        .with_builtin(BUILTIN_NAME)
213        .with_source(err);
214    if let Some(identifier) = identifier {
215        builder = builder.with_identifier(identifier);
216    }
217    builder.build()
218}
219
220#[runtime_builtin(
221    name = "csvwrite",
222    category = "io/tabular",
223    summary = "Write numeric matrices to CSV files.",
224    keywords = "csvwrite,csv,write,row offset,column offset",
225    accel = "cpu",
226    type_resolver(crate::builtins::io::type_resolvers::num_type),
227    descriptor(crate::builtins::io::tabular::csvwrite::CSVWRITE_DESCRIPTOR),
228    builtin_path = "crate::builtins::io::tabular::csvwrite"
229)]
230async fn csvwrite_builtin(
231    filename: Value,
232    data: Value,
233    rest: Vec<Value>,
234) -> crate::BuiltinResult<Value> {
235    let filename_value = gather_if_needed_async(&filename)
236        .await
237        .map_err(map_control_flow)?;
238    let path = resolve_path(&filename_value)?;
239
240    let mut gathered_offsets = Vec::with_capacity(rest.len());
241    for value in &rest {
242        gathered_offsets.push(
243            gather_if_needed_async(value)
244                .await
245                .map_err(map_control_flow)?,
246        );
247    }
248    let (row_offset, col_offset) = parse_offsets(&gathered_offsets)?;
249
250    let gathered_data = gather_if_needed_async(&data)
251        .await
252        .map_err(map_control_flow)?;
253    let tensor = tensor::value_into_tensor_for("csvwrite", gathered_data).map_err(|msg| {
254        csvwrite_error_with(&CSVWRITE_ERROR_DATA_INPUT, format!("csvwrite: {msg}"))
255    })?;
256    ensure_matrix_shape(&tensor)?;
257
258    let bytes = write_csv(&path, &tensor, row_offset, col_offset).await?;
259    Ok(Value::Num(bytes as f64))
260}
261
262fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
263    let raw = match value {
264        Value::String(s) => s.clone(),
265        Value::CharArray(ca) if ca.rows == 1 => ca.data.iter().collect(),
266        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].clone(),
267        _ => Err(csvwrite_error_with(
268            &CSVWRITE_ERROR_FILENAME,
269            "csvwrite: filename must be a string scalar or character vector",
270        ))?,
271    };
272
273    if raw.trim().is_empty() {
274        return Err(csvwrite_error_with(
275            &CSVWRITE_ERROR_FILENAME_EMPTY,
276            CSVWRITE_ERROR_FILENAME_EMPTY.message,
277        ));
278    }
279
280    let expanded = expand_user_path(&raw, BUILTIN_NAME)
281        .map_err(|msg| csvwrite_error_with(&CSVWRITE_ERROR_FILENAME, msg))?;
282    Ok(Path::new(&expanded).to_path_buf())
283}
284
285fn parse_offsets(args: &[Value]) -> BuiltinResult<(usize, usize)> {
286    match args.len() {
287        0 => Ok((0, 0)),
288        2 => {
289            let row = parse_offset(&args[0], "row offset")?;
290            let col = parse_offset(&args[1], "column offset")?;
291            Ok((row, col))
292        }
293        _ => Err(csvwrite_error_with(
294            &CSVWRITE_ERROR_OFFSETS,
295            "csvwrite: offsets must be provided as two numeric arguments (row, column)",
296        )),
297    }
298}
299
300fn parse_offset(value: &Value, context: &str) -> BuiltinResult<usize> {
301    match value {
302        Value::Int(i) => {
303            let raw = i.to_i64();
304            if raw < 0 {
305                return Err(csvwrite_error_with(
306                    &CSVWRITE_ERROR_OFFSETS,
307                    format!("csvwrite: {context} must be >= 0"),
308                ));
309            }
310            Ok(raw as usize)
311        }
312        Value::Num(n) => coerce_offset_from_float(*n, context),
313        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
314        Value::Tensor(t) => {
315            if t.data.len() != 1 {
316                return Err(csvwrite_error_with(
317                    &CSVWRITE_ERROR_OFFSETS,
318                    format!(
319                        "csvwrite: {context} must be a scalar, got {} elements",
320                        t.data.len()
321                    ),
322                ));
323            }
324            coerce_offset_from_float(t.data[0], context)
325        }
326        Value::LogicalArray(logical) => {
327            if logical.data.len() != 1 {
328                return Err(csvwrite_error_with(
329                    &CSVWRITE_ERROR_OFFSETS,
330                    format!(
331                        "csvwrite: {context} must be a scalar, got {} elements",
332                        logical.data.len()
333                    ),
334                ));
335            }
336            Ok(if logical.data[0] != 0 { 1 } else { 0 })
337        }
338        other => Err(csvwrite_error_with(
339            &CSVWRITE_ERROR_OFFSETS,
340            format!("csvwrite: {context} must be numeric, got {:?}", other),
341        )),
342    }
343}
344
345fn coerce_offset_from_float(value: f64, context: &str) -> BuiltinResult<usize> {
346    if !value.is_finite() {
347        return Err(csvwrite_error_with(
348            &CSVWRITE_ERROR_OFFSETS,
349            format!("csvwrite: {context} must be finite"),
350        ));
351    }
352    let rounded = value.round();
353    if (rounded - value).abs() > 1e-9 {
354        return Err(csvwrite_error_with(
355            &CSVWRITE_ERROR_OFFSETS,
356            format!("csvwrite: {context} must be an integer"),
357        ));
358    }
359    if rounded < 0.0 {
360        return Err(csvwrite_error_with(
361            &CSVWRITE_ERROR_OFFSETS,
362            format!("csvwrite: {context} must be >= 0"),
363        ));
364    }
365    Ok(rounded as usize)
366}
367
368fn ensure_matrix_shape(tensor: &Tensor) -> BuiltinResult<()> {
369    if tensor.shape.len() <= 2 {
370        return Ok(());
371    }
372    if tensor.shape[2..].iter().all(|&dim| dim == 1) {
373        return Ok(());
374    }
375    Err(csvwrite_error_with(
376        &CSVWRITE_ERROR_DATA_SHAPE,
377        "csvwrite: input must be 2-D; reshape before writing",
378    ))
379}
380
381async fn write_csv(
382    path: &Path,
383    tensor: &Tensor,
384    row_offset: usize,
385    col_offset: usize,
386) -> BuiltinResult<usize> {
387    let mut options = OpenOptions::new();
388    options.create(true).write(true).truncate(true);
389    let mut file = options.open_async(path).await.map_err(|err| {
390        csvwrite_error_with_source(
391            &CSVWRITE_ERROR_IO_OPEN,
392            format!(
393                "csvwrite: unable to open \"{}\" for writing ({err})",
394                path.display()
395            ),
396            err,
397        )
398    })?;
399
400    let line_ending = default_line_ending();
401    let rows = tensor.rows();
402    let cols = tensor.cols();
403
404    let mut bytes_written = 0usize;
405
406    for _ in 0..row_offset {
407        file.write_all(line_ending.as_bytes()).map_err(|err| {
408            csvwrite_error_with_source(
409                &CSVWRITE_ERROR_IO_WRITE,
410                format!("csvwrite: failed to write line ending ({err})"),
411                err,
412            )
413        })?;
414        bytes_written += line_ending.len();
415    }
416
417    if rows == 0 || cols == 0 {
418        file.flush_async().await.map_err(|err| {
419            csvwrite_error_with_source(
420                &CSVWRITE_ERROR_IO_WRITE,
421                format!("csvwrite: failed to flush output ({err})"),
422                err,
423            )
424        })?;
425        return Ok(bytes_written);
426    }
427
428    for row in 0..rows {
429        let mut fields = Vec::with_capacity(col_offset + cols);
430        for _ in 0..col_offset {
431            fields.push(String::new());
432        }
433        for col in 0..cols {
434            let idx = row + col * rows;
435            let value = tensor.data[idx];
436            fields.push(format_numeric(value));
437        }
438        let line = fields.join(",");
439        if !line.is_empty() {
440            file.write_all(line.as_bytes()).map_err(|err| {
441                csvwrite_error_with_source(
442                    &CSVWRITE_ERROR_IO_WRITE,
443                    format!("csvwrite: failed to write value ({err})"),
444                    err,
445                )
446            })?;
447            bytes_written += line.len();
448        }
449        file.write_all(line_ending.as_bytes()).map_err(|err| {
450            csvwrite_error_with_source(
451                &CSVWRITE_ERROR_IO_WRITE,
452                format!("csvwrite: failed to write line ending ({err})"),
453                err,
454            )
455        })?;
456        bytes_written += line_ending.len();
457    }
458
459    file.flush_async().await.map_err(|err| {
460        csvwrite_error_with_source(
461            &CSVWRITE_ERROR_IO_WRITE,
462            format!("csvwrite: failed to flush output ({err})"),
463            err,
464        )
465    })?;
466
467    Ok(bytes_written)
468}
469
470fn default_line_ending() -> &'static str {
471    if cfg!(windows) {
472        "\r\n"
473    } else {
474        "\n"
475    }
476}
477
478fn format_numeric(value: f64) -> String {
479    if value.is_nan() {
480        return "NaN".to_string();
481    }
482    if value.is_infinite() {
483        return if value.is_sign_negative() {
484            "-Inf".to_string()
485        } else {
486            "Inf".to_string()
487        };
488    }
489    if value == 0.0 {
490        return "0".to_string();
491    }
492
493    let precision: i32 = 5;
494    let abs = value.abs();
495    let exp10 = abs.log10().floor() as i32;
496    let use_scientific = exp10 < -4 || exp10 >= precision;
497
498    let raw = if use_scientific {
499        let digits_after = (precision - 1).max(0) as usize;
500        format!("{:.*e}", digits_after, value)
501    } else {
502        let decimals = (precision - 1 - exp10).max(0) as usize;
503        format!("{:.*}", decimals, value)
504    };
505
506    let mut trimmed = trim_trailing_zeros(raw);
507    if trimmed == "-0" {
508        trimmed = "0".to_string();
509    }
510    trimmed
511}
512
513fn trim_trailing_zeros(mut value: String) -> String {
514    if let Some(exp_pos) = value.find(['e', 'E']) {
515        let exponent = value.split_off(exp_pos);
516        while value.ends_with('0') {
517            value.pop();
518        }
519        if value.ends_with('.') {
520            value.pop();
521        }
522        value.push_str(&normalize_exponent(&exponent));
523        value
524    } else {
525        if value.contains('.') {
526            while value.ends_with('0') {
527                value.pop();
528            }
529            if value.ends_with('.') {
530                value.pop();
531            }
532        }
533        if value.is_empty() {
534            "0".to_string()
535        } else {
536            value
537        }
538    }
539}
540
541fn normalize_exponent(exponent: &str) -> String {
542    if exponent.len() <= 1 {
543        return exponent.to_string();
544    }
545    let mut chars = exponent.chars();
546    let marker = chars.next().unwrap();
547    let rest: String = chars.collect();
548    match rest.parse::<i32>() {
549        Ok(parsed) => format!("{}{:+03}", marker, parsed),
550        Err(_) => exponent.to_string(),
551    }
552}
553
554#[cfg(test)]
555pub(crate) mod tests {
556    use super::*;
557    use runmat_time::unix_timestamp_ms;
558    use std::fs;
559    use std::sync::atomic::{AtomicU64, Ordering};
560
561    use runmat_accelerate_api::HostTensorView;
562    use runmat_builtins::{IntValue, LogicalArray};
563
564    use crate::builtins::common::fs as fs_helpers;
565    use crate::builtins::common::test_support;
566
567    fn csvwrite_builtin(filename: Value, data: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
568        futures::executor::block_on(super::csvwrite_builtin(filename, data, rest))
569    }
570
571    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
572    #[test]
573    fn csvwrite_descriptor_signatures_cover_core_forms() {
574        let labels: Vec<&str> = CSVWRITE_DESCRIPTOR
575            .signatures
576            .iter()
577            .map(|sig| sig.label)
578            .collect();
579        assert!(labels.contains(&"bytesWritten = csvwrite(filename, M)"));
580        assert!(labels.contains(&"bytesWritten = csvwrite(filename, M, row, col)"));
581    }
582
583    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
584
585    fn temp_path(ext: &str) -> PathBuf {
586        let millis = unix_timestamp_ms();
587        let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
588        let mut path = std::env::temp_dir();
589        path.push(format!(
590            "runmat_csvwrite_{}_{}_{}.{}",
591            std::process::id(),
592            millis,
593            unique,
594            ext
595        ));
596        path
597    }
598
599    fn line_ending() -> &'static str {
600        default_line_ending()
601    }
602
603    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
604    #[test]
605    fn csvwrite_writes_basic_matrix() {
606        let path = temp_path("csv");
607        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
608        let filename = path.to_string_lossy().into_owned();
609
610        csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
611            .expect("csvwrite");
612
613        let contents = fs::read_to_string(&path).expect("read contents");
614        assert_eq!(contents, format!("1,2,3{le}4,5,6{le}", le = line_ending()));
615        let _ = fs::remove_file(path);
616    }
617
618    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
619    #[test]
620    fn csvwrite_honours_offsets() {
621        let path = temp_path("csv");
622        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
623        let filename = path.to_string_lossy().into_owned();
624
625        csvwrite_builtin(
626            Value::from(filename),
627            Value::Tensor(tensor),
628            vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(2))],
629        )
630        .expect("csvwrite");
631
632        let contents = fs::read_to_string(&path).expect("read contents");
633        assert_eq!(
634            contents,
635            format!("{le},,1,3{le},,2,4{le}", le = line_ending())
636        );
637        let _ = fs::remove_file(path);
638    }
639
640    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
641    #[test]
642    fn csvwrite_handles_gpu_tensors() {
643        test_support::with_test_provider(|provider| {
644            let path = temp_path("csv");
645            let tensor = Tensor::new(vec![0.5, 1.5], vec![1, 2]).unwrap();
646            let view = HostTensorView {
647                data: &tensor.data,
648                shape: &tensor.shape,
649            };
650            let handle = provider.upload(&view).expect("upload");
651            let filename = path.to_string_lossy().into_owned();
652
653            csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
654                .expect("csvwrite");
655
656            let contents = fs::read_to_string(&path).expect("read contents");
657            assert_eq!(contents, format!("0.5,1.5{le}", le = line_ending()));
658            let _ = fs::remove_file(path);
659        });
660    }
661
662    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
663    #[test]
664    fn csvwrite_formats_with_short_g_precision() {
665        let path = temp_path("csv");
666        let values =
667            Tensor::new(vec![12.3456, 1_234_567.0, 0.000123456, -0.0], vec![1, 4]).unwrap();
668        let filename = path.to_string_lossy().into_owned();
669
670        csvwrite_builtin(Value::from(filename), Value::Tensor(values), Vec::new())
671            .expect("csvwrite");
672
673        let contents = fs::read_to_string(&path).expect("read contents");
674        assert_eq!(
675            contents,
676            format!("12.346,1.2346e+06,0.00012346,0{le}", le = line_ending())
677        );
678        let _ = fs::remove_file(path);
679    }
680
681    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
682    #[test]
683    fn csvwrite_rejects_negative_offsets() {
684        let path = temp_path("csv");
685        let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
686        let filename = path.to_string_lossy().into_owned();
687        let err = csvwrite_builtin(
688            Value::from(filename),
689            Value::Tensor(tensor),
690            vec![Value::Num(-1.0), Value::Num(0.0)],
691        )
692        .expect_err("negative offsets should be rejected");
693        let message = err.message().to_string();
694        assert!(
695            message.contains("row offset"),
696            "unexpected error message: {message}"
697        );
698    }
699
700    #[cfg(feature = "wgpu")]
701    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
702    #[test]
703    fn csvwrite_handles_wgpu_provider_gather() {
704        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
705            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
706        );
707        let Some(provider) = runmat_accelerate_api::provider() else {
708            panic!("wgpu provider not registered");
709        };
710
711        let path = temp_path("csv");
712        let tensor = Tensor::new(vec![2.0, 4.0], vec![1, 2]).unwrap();
713        let view = HostTensorView {
714            data: &tensor.data,
715            shape: &tensor.shape,
716        };
717        let handle = provider.upload(&view).expect("upload");
718        let filename = path.to_string_lossy().into_owned();
719
720        csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
721            .expect("csvwrite");
722
723        let contents = fs::read_to_string(&path).expect("read contents");
724        assert_eq!(contents, format!("2,4{le}", le = line_ending()));
725        let _ = fs::remove_file(path);
726    }
727
728    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
729    #[test]
730    fn csvwrite_expands_home_directory() {
731        let Some(mut home) = fs_helpers::home_directory() else {
732            // Skip when home directory cannot be determined.
733            return;
734        };
735        let filename = format!(
736            "runmat_csvwrite_home_{}_{}.csv",
737            std::process::id(),
738            NEXT_ID.fetch_add(1, Ordering::Relaxed)
739        );
740        home.push(&filename);
741
742        let tilde_path = format!("~/{}", filename);
743        let tensor = Tensor::new(vec![42.0], vec![1, 1]).unwrap();
744
745        csvwrite_builtin(Value::from(tilde_path), Value::Tensor(tensor), Vec::new())
746            .expect("csvwrite");
747
748        let contents = fs::read_to_string(&home).expect("read contents");
749        assert_eq!(contents, format!("42{le}", le = line_ending()));
750        let _ = fs::remove_file(home);
751    }
752
753    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
754    #[test]
755    fn csvwrite_rejects_non_numeric_inputs() {
756        let path = temp_path("csv");
757        let filename = path.to_string_lossy().into_owned();
758        let err = csvwrite_builtin(
759            Value::from(filename),
760            Value::String("abc".into()),
761            Vec::new(),
762        )
763        .expect_err("csvwrite should fail");
764        let message = err.message().to_string();
765        assert!(
766            message.contains("csvwrite"),
767            "unexpected error message: {message}"
768        );
769    }
770
771    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
772    #[test]
773    fn csvwrite_accepts_logical_arrays() {
774        let path = temp_path("csv");
775        let logical = LogicalArray::new(vec![1, 0, 1, 0], vec![2, 2]).unwrap();
776        let filename = path.to_string_lossy().into_owned();
777
778        csvwrite_builtin(
779            Value::from(filename),
780            Value::LogicalArray(logical),
781            Vec::new(),
782        )
783        .expect("csvwrite");
784
785        let contents = fs::read_to_string(&path).expect("read contents");
786        assert_eq!(contents, format!("1,1{le}0,0{le}", le = line_ending()));
787        let _ = fs::remove_file(path);
788    }
789}