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, BuiltinExtensionDescriptor,
14    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
15    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
16    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
17    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
18    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
19};
20use runmat_filesystem::OpenOptions;
21use runmat_macros::runtime_builtin;
22use runmat_value::{ComplexTensor, Tensor, Value};
23
24use crate::builtins::common::fs::expand_user_path;
25use crate::builtins::common::spec::{
26    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
27    ReductionNaN, ResidencyPolicy, ShapeRequirements,
28};
29use crate::builtins::common::tensor;
30use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
31
32const BUILTIN_NAME: &str = "csvwrite";
33
34const CSVWRITE_BYTES_OUTPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
35    id: "csvwrite-bytes-written-output",
36    mode: BuiltinExtensionMode::RunMatOnly,
37    description: "requesting a bytes-written output from csvwrite is a RunMat extension",
38    error_identifier: Some("RunMat:compatibility:CsvwriteBytesOutputExtension"),
39};
40const CSVWRITE_RESIDENT_INPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
41    id: "csvwrite-resident-input",
42    mode: BuiltinExtensionMode::RunMatOnly,
43    description: "direct csvwrite of resident data or controls is a RunMat extension",
44    error_identifier: Some("RunMat:compatibility:CsvwriteResidentInputExtension"),
45};
46pub const CSVWRITE_EXTENSIONS: [BuiltinExtensionDescriptor; 2] = [
47    CSVWRITE_BYTES_OUTPUT_EXTENSION,
48    CSVWRITE_RESIDENT_INPUT_EXTENSION,
49];
50const CSVWRITE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 3] = [
51    BuiltinIntegerInputCapability { name: "M", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::Allowed, notes: "All eight real integer classes are documented and serialize directly from authoritative storage." },
52    BuiltinIntegerInputCapability { name: "row", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::Allowed, notes: "The zero-based row offset accepts all eight integer classes and is checked exactly." },
53    BuiltinIntegerInputCapability { name: "col", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::Allowed, notes: "The zero-based column offset accepts all eight integer classes and is checked exactly." },
54];
55pub const CSVWRITE_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] = [BuiltinIntegerCapabilityDescriptor { form: "csvwrite(filename, integer_M, integer_row?, integer_col?)", inputs: &CSVWRITE_INTEGER_INPUTS, computation_domain: BuiltinIntegerComputationDomain::ExactInteger, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::Multiple, notes: "Integer matrix elements serialize with their exact values. Resident input is independently gated and gathered through the handle owner." }];
56
57const CSVWRITE_INPUTS_FILENAME_DATA: [BuiltinParamDescriptor; 2] = [
58    BuiltinParamDescriptor {
59        name: "filename",
60        ty: BuiltinParamType::StringScalar,
61        arity: BuiltinParamArity::Required,
62        default: None,
63        description: "CSV output path.",
64    },
65    BuiltinParamDescriptor {
66        name: "M",
67        ty: BuiltinParamType::Any,
68        arity: BuiltinParamArity::Required,
69        default: None,
70        description: "Numeric/logical matrix data to write.",
71    },
72];
73const CSVWRITE_INPUTS_FILENAME_DATA_ROW_COL: [BuiltinParamDescriptor; 4] = [
74    BuiltinParamDescriptor {
75        name: "filename",
76        ty: BuiltinParamType::StringScalar,
77        arity: BuiltinParamArity::Required,
78        default: None,
79        description: "CSV output path.",
80    },
81    BuiltinParamDescriptor {
82        name: "M",
83        ty: BuiltinParamType::Any,
84        arity: BuiltinParamArity::Required,
85        default: None,
86        description: "Numeric/logical matrix data to write.",
87    },
88    BuiltinParamDescriptor {
89        name: "row",
90        ty: BuiltinParamType::IntegerScalar,
91        arity: BuiltinParamArity::Required,
92        default: None,
93        description: "Zero-based row offset before writing values.",
94    },
95    BuiltinParamDescriptor {
96        name: "col",
97        ty: BuiltinParamType::IntegerScalar,
98        arity: BuiltinParamArity::Required,
99        default: None,
100        description: "Zero-based column offset before writing values.",
101    },
102];
103const CSVWRITE_NO_OUTPUT: [BuiltinParamDescriptor; 0] = [];
104const CSVWRITE_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
105    BuiltinSignatureDescriptor {
106        label: "csvwrite(filename, M)",
107        inputs: &CSVWRITE_INPUTS_FILENAME_DATA,
108        outputs: &CSVWRITE_NO_OUTPUT,
109    },
110    BuiltinSignatureDescriptor {
111        label: "csvwrite(filename, M, row, col)",
112        inputs: &CSVWRITE_INPUTS_FILENAME_DATA_ROW_COL,
113        outputs: &CSVWRITE_NO_OUTPUT,
114    },
115];
116const CSVWRITE_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
117    code: "RM.CSVWRITE.FILENAME",
118    identifier: None,
119    when: "Filename argument is not a scalar string/char vector.",
120    message: "csvwrite: invalid filename input",
121};
122const CSVWRITE_ERROR_FILENAME_EMPTY: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
123    code: "RM.CSVWRITE.FILENAME_EMPTY",
124    identifier: None,
125    when: "Filename resolves to an empty string.",
126    message: "csvwrite: filename must not be empty",
127};
128const CSVWRITE_ERROR_OFFSETS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
129    code: "RM.CSVWRITE.OFFSETS",
130    identifier: None,
131    when: "Offset arguments are missing, malformed, or out of bounds.",
132    message: "csvwrite: invalid row/column offsets",
133};
134const CSVWRITE_ERROR_DATA_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
135    code: "RM.CSVWRITE.DATA_SHAPE",
136    identifier: None,
137    when: "Input data is not a 2-D matrix.",
138    message: "csvwrite: input must be 2-D",
139};
140const CSVWRITE_ERROR_DATA_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
141    code: "RM.CSVWRITE.DATA_INPUT",
142    identifier: None,
143    when: "Input data cannot be converted to a numeric/logical tensor.",
144    message: "csvwrite: input must be numeric or logical",
145};
146const CSVWRITE_ERROR_IO_OPEN: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
147    code: "RM.CSVWRITE.IO_OPEN",
148    identifier: None,
149    when: "Output file cannot be opened.",
150    message: "csvwrite: unable to open file for writing",
151};
152const CSVWRITE_ERROR_IO_WRITE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
153    code: "RM.CSVWRITE.IO_WRITE",
154    identifier: None,
155    when: "Output file write/flush fails.",
156    message: "csvwrite: write failed",
157};
158const CSVWRITE_ERRORS: [BuiltinErrorDescriptor; 7] = [
159    CSVWRITE_ERROR_FILENAME,
160    CSVWRITE_ERROR_FILENAME_EMPTY,
161    CSVWRITE_ERROR_OFFSETS,
162    CSVWRITE_ERROR_DATA_INPUT,
163    CSVWRITE_ERROR_DATA_SHAPE,
164    CSVWRITE_ERROR_IO_OPEN,
165    CSVWRITE_ERROR_IO_WRITE,
166];
167pub const CSVWRITE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
168    signatures: &CSVWRITE_SIGNATURES,
169    output_mode: BuiltinOutputMode::Fixed,
170    completion_policy: BuiltinCompletionPolicy::Public,
171    errors: &CSVWRITE_ERRORS,
172};
173
174#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::csvwrite")]
175pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
176    name: "csvwrite",
177    op_kind: GpuOpKind::Custom("io-csvwrite"),
178    supported_precisions: &[],
179    broadcast: BroadcastSemantics::None,
180    provider_hooks: &[],
181    constant_strategy: ConstantStrategy::InlineLiteral,
182    residency: ResidencyPolicy::GatherImmediately,
183    nan_mode: ReductionNaN::Include,
184    two_pass_threshold: None,
185    workgroup_size: None,
186    accepts_nan_mode: false,
187    notes: "Runs entirely on the host; gpuArray inputs are gathered before serialisation.",
188};
189
190#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::csvwrite")]
191pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
192    name: "csvwrite",
193    shape: ShapeRequirements::Any,
194    constant_strategy: ConstantStrategy::InlineLiteral,
195    elementwise: None,
196    reduction: None,
197    emits_nan: false,
198    notes: "Not eligible for fusion; performs host-side file I/O.",
199};
200
201fn csvwrite_error_with(
202    error: &'static BuiltinErrorDescriptor,
203    message: impl Into<String>,
204) -> RuntimeError {
205    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
206    if let Some(identifier) = error.identifier {
207        builder = builder.with_identifier(identifier);
208    }
209    builder.build()
210}
211
212fn csvwrite_error_with_source<E>(
213    error: &'static BuiltinErrorDescriptor,
214    message: impl Into<String>,
215    source: E,
216) -> RuntimeError
217where
218    E: std::error::Error + Send + Sync + 'static,
219{
220    let mut builder = build_runtime_error(message)
221        .with_builtin(BUILTIN_NAME)
222        .with_source(source);
223    if let Some(identifier) = error.identifier {
224        builder = builder.with_identifier(identifier);
225    }
226    builder.build()
227}
228
229fn map_control_flow(err: RuntimeError) -> RuntimeError {
230    let identifier = err.identifier().map(|value| value.to_string());
231    let message = err.message().to_string();
232    let mut builder = build_runtime_error(message)
233        .with_builtin(BUILTIN_NAME)
234        .with_source(err);
235    if let Some(identifier) = identifier {
236        builder = builder.with_identifier(identifier);
237    }
238    builder.build()
239}
240
241#[runtime_builtin(
242    name = "csvwrite",
243    category = "io/tabular",
244    summary = "Write numeric matrices to CSV files.",
245    keywords = "csvwrite,csv,write,row offset,column offset",
246    accel = "cpu",
247    type_resolver(crate::builtins::io::type_resolvers::num_type),
248    descriptor(crate::builtins::io::tabular::csvwrite::CSVWRITE_DESCRIPTOR),
249    extensions(crate::builtins::io::tabular::csvwrite::CSVWRITE_EXTENSIONS),
250    integer_capabilities(crate::builtins::io::tabular::csvwrite::CSVWRITE_INTEGER_CAPABILITIES),
251    builtin_path = "crate::builtins::io::tabular::csvwrite"
252)]
253async fn csvwrite_builtin(
254    filename: Value,
255    data: Value,
256    rest: Vec<Value>,
257) -> crate::BuiltinResult<Value> {
258    let requested_outputs = crate::output_count::current_output_count();
259    if requested_outputs.is_some_and(|count| count > 1) {
260        return Err(csvwrite_error_with(
261            &CSVWRITE_ERROR_DATA_INPUT,
262            "csvwrite: too many output arguments",
263        ));
264    }
265    if requested_outputs.is_some_and(|count| count > 0) {
266        crate::compatibility::ensure_builtin_extension_enabled(
267            &CSVWRITE_BYTES_OUTPUT_EXTENSION,
268            BUILTIN_NAME,
269        )?;
270    }
271    if matches!(filename, Value::GpuTensor(_))
272        || matches!(data, Value::GpuTensor(_))
273        || rest
274            .iter()
275            .any(|value| matches!(value, Value::GpuTensor(_)))
276    {
277        crate::compatibility::ensure_builtin_extension_enabled(
278            &CSVWRITE_RESIDENT_INPUT_EXTENSION,
279            BUILTIN_NAME,
280        )?;
281    }
282    let filename_value = gather_if_needed_async(&filename)
283        .await
284        .map_err(map_control_flow)?;
285    let path = resolve_path(&filename_value)?;
286
287    let mut gathered_offsets = Vec::with_capacity(rest.len());
288    for value in &rest {
289        gathered_offsets.push(
290            gather_if_needed_async(value)
291                .await
292                .map_err(map_control_flow)?,
293        );
294    }
295    let (row_offset, col_offset) = parse_offsets(&gathered_offsets)?;
296
297    let gathered_data = gather_if_needed_async(&data)
298        .await
299        .map_err(map_control_flow)?;
300    let matrix = CsvMatrix::from_value(gathered_data)?;
301    matrix.ensure_matrix_shape()?;
302
303    let bytes = write_csv(&path, &matrix, row_offset, col_offset).await?;
304    match requested_outputs {
305        Some(0) => Ok(Value::OutputList(Vec::new())),
306        Some(1) => Ok(Value::OutputList(vec![Value::Num(bytes as f64)])),
307        Some(_) => Err(csvwrite_error_with(
308            &CSVWRITE_ERROR_DATA_INPUT,
309            "csvwrite: too many output arguments",
310        )),
311        None => Ok(Value::Num(bytes as f64)),
312    }
313}
314
315enum CsvMatrix {
316    Real(Tensor),
317    Complex(ComplexTensor),
318}
319
320impl CsvMatrix {
321    fn from_value(value: Value) -> BuiltinResult<Self> {
322        match value {
323            Value::Complex(re, im) => ComplexTensor::new(vec![(re, im)], vec![1, 1])
324                .map(Self::Complex)
325                .map_err(|e| {
326                    csvwrite_error_with(&CSVWRITE_ERROR_DATA_INPUT, format!("csvwrite: {e}"))
327                }),
328            Value::ComplexTensor(tensor) if tensor.integer_storage().is_none() => {
329                Ok(Self::Complex(tensor))
330            }
331            Value::ComplexTensor(_) => Err(csvwrite_error_with(
332                &CSVWRITE_ERROR_DATA_INPUT,
333                "csvwrite: complex integer input is not supported",
334            )),
335            value => tensor::value_into_tensor_for("csvwrite", value)
336                .map(Self::Real)
337                .map_err(|msg| {
338                    csvwrite_error_with(&CSVWRITE_ERROR_DATA_INPUT, format!("csvwrite: {msg}"))
339                }),
340        }
341    }
342
343    fn shape(&self) -> &[usize] {
344        match self {
345            Self::Real(t) => &t.shape,
346            Self::Complex(t) => &t.shape,
347        }
348    }
349
350    fn ensure_matrix_shape(&self) -> BuiltinResult<()> {
351        ensure_matrix_dims(self.shape())
352    }
353
354    fn rows(&self) -> usize {
355        self.shape().first().copied().unwrap_or(1)
356    }
357    fn cols(&self) -> usize {
358        self.shape().get(1).copied().unwrap_or(1)
359    }
360
361    fn format_at(&self, idx: usize) -> String {
362        match self {
363            Self::Real(tensor) => format_tensor_value(tensor, idx),
364            Self::Complex(tensor) => {
365                let (re, im) = tensor
366                    .numeric_value_at(idx)
367                    .expect("index within authoritative complex storage");
368                format_complex(re.materialize_f64(), im.materialize_f64())
369            }
370        }
371    }
372}
373
374fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
375    let raw = match value {
376        Value::String(s) => s.clone(),
377        Value::CharArray(ca) if ca.rows == 1 => ca.data.iter().collect(),
378        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].clone(),
379        _ => Err(csvwrite_error_with(
380            &CSVWRITE_ERROR_FILENAME,
381            "csvwrite: filename must be a string scalar or character vector",
382        ))?,
383    };
384
385    if raw.trim().is_empty() {
386        return Err(csvwrite_error_with(
387            &CSVWRITE_ERROR_FILENAME_EMPTY,
388            CSVWRITE_ERROR_FILENAME_EMPTY.message,
389        ));
390    }
391
392    let expanded = expand_user_path(&raw, BUILTIN_NAME)
393        .map_err(|msg| csvwrite_error_with(&CSVWRITE_ERROR_FILENAME, msg))?;
394    Ok(Path::new(&expanded).to_path_buf())
395}
396
397fn parse_offsets(args: &[Value]) -> BuiltinResult<(usize, usize)> {
398    match args.len() {
399        0 => Ok((0, 0)),
400        2 => {
401            let row = parse_offset(&args[0], "row offset")?;
402            let col = parse_offset(&args[1], "column offset")?;
403            Ok((row, col))
404        }
405        _ => Err(csvwrite_error_with(
406            &CSVWRITE_ERROR_OFFSETS,
407            "csvwrite: offsets must be provided as two numeric arguments (row, column)",
408        )),
409    }
410}
411
412fn parse_offset(value: &Value, context: &str) -> BuiltinResult<usize> {
413    match value {
414        Value::Int(i) => i.try_to_usize().ok_or_else(|| {
415            csvwrite_error_with(
416                &CSVWRITE_ERROR_OFFSETS,
417                format!("csvwrite: {context} must be >= 0"),
418            )
419        }),
420        Value::Num(n) => coerce_offset_from_float(*n, context),
421        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
422        Value::Tensor(t) => {
423            let len = tensor::tensor_element_len(t);
424            if len != 1 {
425                return Err(csvwrite_error_with(
426                    &CSVWRITE_ERROR_OFFSETS,
427                    format!("csvwrite: {context} must be a scalar, got {} elements", len),
428                ));
429            }
430            let value = t
431                .numeric_value_at(0)
432                .expect("one-element authoritative numeric storage");
433            if let Some(value) = value.into_int_value() {
434                return value.try_to_usize().ok_or_else(|| {
435                    csvwrite_error_with(
436                        &CSVWRITE_ERROR_OFFSETS,
437                        format!("csvwrite: {context} must be >= 0"),
438                    )
439                });
440            }
441            coerce_offset_from_float(value.materialize_f64(), context)
442        }
443        Value::LogicalArray(logical) => {
444            if logical.data.len() != 1 {
445                return Err(csvwrite_error_with(
446                    &CSVWRITE_ERROR_OFFSETS,
447                    format!(
448                        "csvwrite: {context} must be a scalar, got {} elements",
449                        logical.data.len()
450                    ),
451                ));
452            }
453            Ok(if logical.data[0] != 0 { 1 } else { 0 })
454        }
455        other => Err(csvwrite_error_with(
456            &CSVWRITE_ERROR_OFFSETS,
457            format!("csvwrite: {context} must be numeric, got {:?}", other),
458        )),
459    }
460}
461
462fn coerce_offset_from_float(value: f64, context: &str) -> BuiltinResult<usize> {
463    if !value.is_finite() {
464        return Err(csvwrite_error_with(
465            &CSVWRITE_ERROR_OFFSETS,
466            format!("csvwrite: {context} must be finite"),
467        ));
468    }
469    let rounded = value.round();
470    if (rounded - value).abs() > 1e-9 {
471        return Err(csvwrite_error_with(
472            &CSVWRITE_ERROR_OFFSETS,
473            format!("csvwrite: {context} must be an integer"),
474        ));
475    }
476    if rounded < 0.0 {
477        return Err(csvwrite_error_with(
478            &CSVWRITE_ERROR_OFFSETS,
479            format!("csvwrite: {context} must be >= 0"),
480        ));
481    }
482    if rounded > usize::MAX as f64 || (usize::BITS == 64 && rounded == usize::MAX as f64) {
483        return Err(csvwrite_error_with(
484            &CSVWRITE_ERROR_OFFSETS,
485            format!("csvwrite: {context} is too large"),
486        ));
487    }
488    Ok(rounded as usize)
489}
490
491fn ensure_matrix_dims(shape: &[usize]) -> BuiltinResult<()> {
492    if shape.len() <= 2 {
493        return Ok(());
494    }
495    if shape[2..].iter().all(|&dim| dim == 1) {
496        return Ok(());
497    }
498    Err(csvwrite_error_with(
499        &CSVWRITE_ERROR_DATA_SHAPE,
500        "csvwrite: input must be 2-D; reshape before writing",
501    ))
502}
503
504async fn write_csv(
505    path: &Path,
506    matrix: &CsvMatrix,
507    row_offset: usize,
508    col_offset: usize,
509) -> BuiltinResult<usize> {
510    let mut options = OpenOptions::new();
511    options.create(true).write(true).truncate(true);
512    let mut file = options.open_async(path).await.map_err(|err| {
513        csvwrite_error_with_source(
514            &CSVWRITE_ERROR_IO_OPEN,
515            format!(
516                "csvwrite: unable to open \"{}\" for writing ({err})",
517                path.display()
518            ),
519            err,
520        )
521    })?;
522
523    let line_ending = "\n";
524    let rows = matrix.rows();
525    let cols = matrix.cols();
526
527    let mut bytes_written = 0usize;
528
529    for _ in 0..row_offset {
530        file.write_all(line_ending.as_bytes()).map_err(|err| {
531            csvwrite_error_with_source(
532                &CSVWRITE_ERROR_IO_WRITE,
533                format!("csvwrite: failed to write line ending ({err})"),
534                err,
535            )
536        })?;
537        bytes_written += line_ending.len();
538    }
539
540    if rows == 0 || cols == 0 {
541        file.flush_async().await.map_err(|err| {
542            csvwrite_error_with_source(
543                &CSVWRITE_ERROR_IO_WRITE,
544                format!("csvwrite: failed to flush output ({err})"),
545                err,
546            )
547        })?;
548        return Ok(bytes_written);
549    }
550
551    for row in 0..rows {
552        let mut fields = Vec::with_capacity(col_offset + cols);
553        for _ in 0..col_offset {
554            fields.push(String::new());
555        }
556        for col in 0..cols {
557            let idx = row + col * rows;
558            fields.push(matrix.format_at(idx));
559        }
560        let line = fields.join(",");
561        if !line.is_empty() {
562            file.write_all(line.as_bytes()).map_err(|err| {
563                csvwrite_error_with_source(
564                    &CSVWRITE_ERROR_IO_WRITE,
565                    format!("csvwrite: failed to write value ({err})"),
566                    err,
567                )
568            })?;
569            bytes_written += line.len();
570        }
571        file.write_all(line_ending.as_bytes()).map_err(|err| {
572            csvwrite_error_with_source(
573                &CSVWRITE_ERROR_IO_WRITE,
574                format!("csvwrite: failed to write line ending ({err})"),
575                err,
576            )
577        })?;
578        bytes_written += line_ending.len();
579    }
580
581    file.flush_async().await.map_err(|err| {
582        csvwrite_error_with_source(
583            &CSVWRITE_ERROR_IO_WRITE,
584            format!("csvwrite: failed to flush output ({err})"),
585            err,
586        )
587    })?;
588
589    Ok(bytes_written)
590}
591
592fn format_complex(re: f64, im: f64) -> String {
593    let real = format_numeric(re);
594    let imag = format_numeric(im.abs());
595    if im.is_sign_negative() {
596        format!("{real}-{imag}i")
597    } else {
598        format!("{real}+{imag}i")
599    }
600}
601
602fn format_numeric(value: f64) -> String {
603    if value.is_nan() {
604        return "NaN".to_string();
605    }
606    if value.is_infinite() {
607        return if value.is_sign_negative() {
608            "-Inf".to_string()
609        } else {
610            "Inf".to_string()
611        };
612    }
613    if value == 0.0 {
614        return "0".to_string();
615    }
616
617    let precision: i32 = 5;
618    let abs = value.abs();
619    let exp10 = abs.log10().floor() as i32;
620    let use_scientific = exp10 < -4 || exp10 >= precision;
621
622    let raw = if use_scientific {
623        let digits_after = (precision - 1).max(0) as usize;
624        format!("{:.*e}", digits_after, value)
625    } else {
626        let decimals = (precision - 1 - exp10).max(0) as usize;
627        format!("{:.*}", decimals, value)
628    };
629
630    let mut trimmed = trim_trailing_zeros(raw);
631    if trimmed == "-0" {
632        trimmed = "0".to_string();
633    }
634    trimmed
635}
636
637fn format_tensor_value(tensor: &Tensor, idx: usize) -> String {
638    let value = tensor
639        .numeric_value_at(idx)
640        .expect("index within authoritative numeric storage");
641    if let Some(value) = value.into_int_value() {
642        return value.decimal_string();
643    }
644    format_numeric(value.materialize_f64())
645}
646
647fn trim_trailing_zeros(mut value: String) -> String {
648    if let Some(exp_pos) = value.find(['e', 'E']) {
649        let exponent = value.split_off(exp_pos);
650        while value.ends_with('0') {
651            value.pop();
652        }
653        if value.ends_with('.') {
654            value.pop();
655        }
656        value.push_str(&normalize_exponent(&exponent));
657        value
658    } else {
659        if value.contains('.') {
660            while value.ends_with('0') {
661                value.pop();
662            }
663            if value.ends_with('.') {
664                value.pop();
665            }
666        }
667        if value.is_empty() {
668            "0".to_string()
669        } else {
670            value
671        }
672    }
673}
674
675fn normalize_exponent(exponent: &str) -> String {
676    if exponent.len() <= 1 {
677        return exponent.to_string();
678    }
679    let mut chars = exponent.chars();
680    let marker = chars.next().unwrap();
681    let rest: String = chars.collect();
682    match rest.parse::<i32>() {
683        Ok(parsed) => format!("{}{:+03}", marker, parsed),
684        Err(_) => exponent.to_string(),
685    }
686}
687
688#[cfg(test)]
689pub(crate) mod tests {
690    use super::*;
691    use runmat_time::unix_timestamp_ms;
692    use std::fs;
693    use std::sync::atomic::{AtomicU64, Ordering};
694
695    use runmat_accelerate_api::HostTensorView;
696    use runmat_value::{IntValue, IntegerStorage, LogicalArray};
697
698    use crate::builtins::common::fs as fs_helpers;
699    use crate::builtins::common::test_support;
700
701    fn csvwrite_builtin(filename: Value, data: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
702        let _provider_lock = runmat_filesystem::provider_override_lock();
703        futures::executor::block_on(super::csvwrite_builtin(filename, data, rest))
704    }
705
706    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
707    #[test]
708    fn csvwrite_descriptor_signatures_cover_core_forms() {
709        let labels: Vec<&str> = CSVWRITE_DESCRIPTOR
710            .signatures
711            .iter()
712            .map(|sig| sig.label)
713            .collect();
714        assert!(labels.contains(&"csvwrite(filename, M)"));
715        assert!(labels.contains(&"csvwrite(filename, M, row, col)"));
716    }
717
718    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
719
720    fn temp_path(ext: &str) -> PathBuf {
721        let millis = unix_timestamp_ms();
722        let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
723        let mut path = std::env::temp_dir();
724        path.push(format!(
725            "runmat_csvwrite_{}_{}_{}.{}",
726            std::process::id(),
727            millis,
728            unique,
729            ext
730        ));
731        path
732    }
733
734    fn line_ending() -> &'static str {
735        "\n"
736    }
737
738    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
739    #[test]
740    fn csvwrite_writes_basic_matrix() {
741        let path = temp_path("csv");
742        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
743        let filename = path.to_string_lossy().into_owned();
744
745        csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
746            .expect("csvwrite");
747
748        let contents = fs::read_to_string(&path).expect("read contents");
749        assert_eq!(contents, format!("1,2,3{le}4,5,6{le}", le = line_ending()));
750        let _ = fs::remove_file(path);
751    }
752
753    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
754    #[test]
755    fn csvwrite_preserves_typed_integer_matrix_values_exactly() {
756        let path = temp_path("csv");
757        let tensor = Tensor::new_integer(
758            IntegerStorage::U64(vec![u64::MAX, 17, (1_u64 << 53) + 1, 29]),
759            vec![2, 2],
760        )
761        .expect("typed integer matrix");
762        let filename = path.to_string_lossy().into_owned();
763
764        csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
765            .expect("csvwrite");
766
767        let contents = fs::read_to_string(&path).expect("read contents");
768        assert_eq!(
769            contents,
770            format!(
771                "18446744073709551615,9007199254740993{le}17,29{le}",
772                le = line_ending()
773            )
774        );
775        let _ = fs::remove_file(path);
776    }
777
778    #[test]
779    fn csvwrite_serializes_all_eight_integer_classes() {
780        let cases = [
781            (IntegerStorage::I8(vec![-8]), "-8\n"),
782            (IntegerStorage::I16(vec![-16]), "-16\n"),
783            (IntegerStorage::I32(vec![-32]), "-32\n"),
784            (
785                IntegerStorage::I64(vec![i64::MIN]),
786                "-9223372036854775808\n",
787            ),
788            (IntegerStorage::U8(vec![8]), "8\n"),
789            (IntegerStorage::U16(vec![16]), "16\n"),
790            (IntegerStorage::U32(vec![32]), "32\n"),
791            (
792                IntegerStorage::U64(vec![u64::MAX]),
793                "18446744073709551615\n",
794            ),
795        ];
796        for (storage, expected) in cases {
797            let path = temp_path("csv");
798            let tensor = Tensor::new_integer(storage, vec![1, 1]).unwrap();
799            csvwrite_builtin(
800                Value::from(path.to_string_lossy().into_owned()),
801                Value::Tensor(tensor),
802                Vec::new(),
803            )
804            .unwrap();
805            assert_eq!(fs::read_to_string(&path).unwrap(), expected);
806            let _ = fs::remove_file(path);
807        }
808    }
809
810    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
811    #[test]
812    fn csvwrite_honours_offsets() {
813        let path = temp_path("csv");
814        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
815        let filename = path.to_string_lossy().into_owned();
816
817        csvwrite_builtin(
818            Value::from(filename),
819            Value::Tensor(tensor),
820            vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(2))],
821        )
822        .expect("csvwrite");
823
824        let contents = fs::read_to_string(&path).expect("read contents");
825        assert_eq!(
826            contents,
827            format!("{le},,1,3{le},,2,4{le}", le = line_ending())
828        );
829        let _ = fs::remove_file(path);
830    }
831
832    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
833    #[test]
834    fn csvwrite_handles_gpu_tensors() {
835        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
836        test_support::with_test_provider(|provider| {
837            let path = temp_path("csv");
838            let tensor = Tensor::new(vec![0.5, 1.5], vec![1, 2]).unwrap();
839            let view = HostTensorView {
840                data: &tensor.materialize_f64(),
841                shape: &tensor.shape,
842            };
843            let handle = provider.upload(&view).expect("upload");
844            let filename = path.to_string_lossy().into_owned();
845
846            csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
847                .expect("csvwrite");
848
849            let contents = fs::read_to_string(&path).expect("read contents");
850            assert_eq!(contents, format!("0.5,1.5{le}", le = line_ending()));
851            let _ = fs::remove_file(path);
852        });
853    }
854
855    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
856    #[test]
857    fn csvwrite_formats_with_short_g_precision() {
858        let path = temp_path("csv");
859        let values =
860            Tensor::new(vec![12.3456, 1_234_567.0, 0.000123456, -0.0], vec![1, 4]).unwrap();
861        let filename = path.to_string_lossy().into_owned();
862
863        csvwrite_builtin(Value::from(filename), Value::Tensor(values), Vec::new())
864            .expect("csvwrite");
865
866        let contents = fs::read_to_string(&path).expect("read contents");
867        assert_eq!(
868            contents,
869            format!("12.346,1.2346e+06,0.00012346,0{le}", le = line_ending())
870        );
871        let _ = fs::remove_file(path);
872    }
873
874    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
875    #[test]
876    fn csvwrite_rejects_negative_offsets() {
877        let path = temp_path("csv");
878        let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
879        let filename = path.to_string_lossy().into_owned();
880        let err = csvwrite_builtin(
881            Value::from(filename),
882            Value::Tensor(tensor),
883            vec![Value::Num(-1.0), Value::Num(0.0)],
884        )
885        .expect_err("negative offsets should be rejected");
886        let message = err.message().to_string();
887        assert!(
888            message.contains("row offset"),
889            "unexpected error message: {message}"
890        );
891    }
892
893    #[test]
894    fn csvwrite_offset_parser_preserves_typed_integer_tensor_bounds() {
895        let offset =
896            Tensor::new_integer(IntegerStorage::U16(vec![7]), vec![1, 1]).expect("typed offset");
897        assert_eq!(
898            parse_offset(&Value::Tensor(offset), "row offset").unwrap(),
899            7
900        );
901
902        let negative =
903            Tensor::new_integer(IntegerStorage::I16(vec![-1]), vec![1, 1]).expect("negative");
904        assert!(parse_offset(&Value::Tensor(negative), "row offset").is_err());
905
906        let too_large = Tensor::new_integer(IntegerStorage::U64(vec![u64::MAX]), vec![1, 1])
907            .expect("too large");
908        let parsed = parse_offset(&Value::Tensor(too_large), "row offset");
909        if usize::try_from(u64::MAX).is_ok() {
910            assert_eq!(parsed.unwrap(), usize::MAX);
911        } else {
912            assert!(parsed.is_err());
913        }
914
915        assert!(parse_offset(&Value::Num(usize::MAX as f64), "row offset").is_err());
916        assert!(parse_offset(&Value::Num((usize::MAX as f64) + 1.0), "row offset").is_err());
917    }
918
919    #[cfg(feature = "wgpu")]
920    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
921    #[test]
922    fn csvwrite_handles_wgpu_provider_gather() {
923        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
924        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
925            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
926        );
927        let Some(provider) = runmat_accelerate_api::provider() else {
928            panic!("wgpu provider not registered");
929        };
930
931        let path = temp_path("csv");
932        let tensor = Tensor::new(vec![2.0, 4.0], vec![1, 2]).unwrap();
933        let view = HostTensorView {
934            data: &tensor.materialize_f64(),
935            shape: &tensor.shape,
936        };
937        let handle = provider.upload(&view).expect("upload");
938        let filename = path.to_string_lossy().into_owned();
939
940        csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
941            .expect("csvwrite");
942
943        let contents = fs::read_to_string(&path).expect("read contents");
944        assert_eq!(contents, format!("2,4{le}", le = line_ending()));
945        let _ = fs::remove_file(path);
946    }
947
948    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
949    #[test]
950    fn csvwrite_expands_home_directory() {
951        let Some(mut home) = fs_helpers::home_directory() else {
952            // Skip when home directory cannot be determined.
953            return;
954        };
955        let filename = format!(
956            "runmat_csvwrite_home_{}_{}.csv",
957            std::process::id(),
958            NEXT_ID.fetch_add(1, Ordering::Relaxed)
959        );
960        home.push(&filename);
961
962        let tilde_path = format!("~/{}", filename);
963        let tensor = Tensor::new(vec![42.0], vec![1, 1]).unwrap();
964
965        if let Err(error) =
966            csvwrite_builtin(Value::from(tilde_path), Value::Tensor(tensor), Vec::new())
967        {
968            if error.message().contains("Operation not permitted")
969                || error.message().contains("Permission denied")
970            {
971                return;
972            }
973            panic!("csvwrite: {error}");
974        }
975
976        let contents = fs::read_to_string(&home).expect("read contents");
977        assert_eq!(contents, format!("42{le}", le = line_ending()));
978        let _ = fs::remove_file(home);
979    }
980
981    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
982    #[test]
983    fn csvwrite_rejects_non_numeric_inputs() {
984        let path = temp_path("csv");
985        let filename = path.to_string_lossy().into_owned();
986        let err = csvwrite_builtin(
987            Value::from(filename),
988            Value::String("abc".into()),
989            Vec::new(),
990        )
991        .expect_err("csvwrite should fail");
992        let message = err.message().to_string();
993        assert!(
994            message.contains("csvwrite"),
995            "unexpected error message: {message}"
996        );
997    }
998
999    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1000    #[test]
1001    fn csvwrite_accepts_logical_arrays() {
1002        let path = temp_path("csv");
1003        let logical = LogicalArray::new(vec![1, 0, 1, 0], vec![2, 2]).unwrap();
1004        let filename = path.to_string_lossy().into_owned();
1005
1006        csvwrite_builtin(
1007            Value::from(filename),
1008            Value::LogicalArray(logical),
1009            Vec::new(),
1010        )
1011        .expect("csvwrite");
1012
1013        let contents = fs::read_to_string(&path).expect("read contents");
1014        assert_eq!(contents, format!("1,1{le}0,0{le}", le = line_ending()));
1015        let _ = fs::remove_file(path);
1016    }
1017
1018    #[test]
1019    fn csvwrite_writes_complex_double_and_single_with_lf() {
1020        for complex in [
1021            ComplexTensor::new(vec![(1.0, 2.0), (3.0, -4.0)], vec![1, 2]).unwrap(),
1022            ComplexTensor::from_complex_storage(
1023                runmat_value::ComplexStorage::F32(vec![(1.0, 2.0), (3.0, -4.0)]),
1024                vec![1, 2],
1025            )
1026            .unwrap(),
1027        ] {
1028            let path = temp_path("csv");
1029            csvwrite_builtin(
1030                Value::from(path.to_string_lossy().into_owned()),
1031                Value::ComplexTensor(complex),
1032                Vec::new(),
1033            )
1034            .expect("complex csvwrite");
1035            assert_eq!(fs::read(&path).unwrap(), b"1+2i,3-4i\n");
1036            let _ = fs::remove_file(path);
1037        }
1038    }
1039
1040    #[test]
1041    fn csvwrite_declares_independent_output_and_resident_extensions() {
1042        assert_eq!(CSVWRITE_EXTENSIONS[0].id, "csvwrite-bytes-written-output");
1043        assert_eq!(CSVWRITE_EXTENSIONS[1].id, "csvwrite-resident-input");
1044        assert_eq!(CSVWRITE_INTEGER_CAPABILITIES[0].inputs.len(), 3);
1045        assert!(CSVWRITE_INTEGER_CAPABILITIES[0]
1046            .inputs
1047            .iter()
1048            .all(|input| input.classes.len() == 8));
1049    }
1050
1051    #[test]
1052    fn csvwrite_output_and_resident_gates_run_before_side_effects_or_provider_access() {
1053        let path = temp_path("csv");
1054        let filename = Value::from(path.to_string_lossy().into_owned());
1055        let strict = crate::compatibility::push_runmat_extensions_enabled(false);
1056        let outputs = crate::output_count::push_output_count(Some(1));
1057        let error = csvwrite_builtin(
1058            filename.clone(),
1059            Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
1060            Vec::new(),
1061        )
1062        .unwrap_err();
1063        assert_eq!(
1064            error.identifier(),
1065            CSVWRITE_BYTES_OUTPUT_EXTENSION.error_identifier
1066        );
1067        assert!(!path.exists(), "output gate must precede file creation");
1068        drop(outputs);
1069
1070        let resident = Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
1071            shape: vec![1, 1],
1072            device_id: u32::MAX,
1073            buffer_id: u64::MAX - 396,
1074            descriptor: Default::default(),
1075        });
1076        let error = csvwrite_builtin(filename.clone(), resident, Vec::new()).unwrap_err();
1077        assert_eq!(
1078            error.identifier(),
1079            CSVWRITE_RESIDENT_INPUT_EXTENSION.error_identifier
1080        );
1081        assert!(!path.exists(), "resident gate must precede file creation");
1082        drop(strict);
1083
1084        let no_outputs = crate::output_count::push_output_count(Some(0));
1085        let value = csvwrite_builtin(
1086            filename,
1087            Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
1088            Vec::new(),
1089        )
1090        .unwrap();
1091        assert_eq!(value, Value::OutputList(Vec::new()));
1092        drop(no_outputs);
1093        let _ = fs::remove_file(path);
1094
1095        let path = temp_path("csv");
1096        let filename = Value::from(path.to_string_lossy().into_owned());
1097        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1098        let too_many_outputs = crate::output_count::push_output_count(Some(2));
1099        let error = csvwrite_builtin(
1100            filename,
1101            Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
1102            Vec::new(),
1103        )
1104        .unwrap_err();
1105        assert!(error.message().contains("too many output arguments"));
1106        assert!(!path.exists(), "output arity must precede file creation");
1107        drop(too_many_outputs);
1108    }
1109}