1use 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 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}