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 let _provider_lock = runmat_filesystem::provider_override_lock();
569 futures::executor::block_on(super::csvwrite_builtin(filename, data, rest))
570 }
571
572 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
573 #[test]
574 fn csvwrite_descriptor_signatures_cover_core_forms() {
575 let labels: Vec<&str> = CSVWRITE_DESCRIPTOR
576 .signatures
577 .iter()
578 .map(|sig| sig.label)
579 .collect();
580 assert!(labels.contains(&"bytesWritten = csvwrite(filename, M)"));
581 assert!(labels.contains(&"bytesWritten = csvwrite(filename, M, row, col)"));
582 }
583
584 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
585
586 fn temp_path(ext: &str) -> PathBuf {
587 let millis = unix_timestamp_ms();
588 let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
589 let mut path = std::env::temp_dir();
590 path.push(format!(
591 "runmat_csvwrite_{}_{}_{}.{}",
592 std::process::id(),
593 millis,
594 unique,
595 ext
596 ));
597 path
598 }
599
600 fn line_ending() -> &'static str {
601 default_line_ending()
602 }
603
604 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
605 #[test]
606 fn csvwrite_writes_basic_matrix() {
607 let path = temp_path("csv");
608 let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
609 let filename = path.to_string_lossy().into_owned();
610
611 csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
612 .expect("csvwrite");
613
614 let contents = fs::read_to_string(&path).expect("read contents");
615 assert_eq!(contents, format!("1,2,3{le}4,5,6{le}", le = line_ending()));
616 let _ = fs::remove_file(path);
617 }
618
619 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
620 #[test]
621 fn csvwrite_honours_offsets() {
622 let path = temp_path("csv");
623 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
624 let filename = path.to_string_lossy().into_owned();
625
626 csvwrite_builtin(
627 Value::from(filename),
628 Value::Tensor(tensor),
629 vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(2))],
630 )
631 .expect("csvwrite");
632
633 let contents = fs::read_to_string(&path).expect("read contents");
634 assert_eq!(
635 contents,
636 format!("{le},,1,3{le},,2,4{le}", le = line_ending())
637 );
638 let _ = fs::remove_file(path);
639 }
640
641 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
642 #[test]
643 fn csvwrite_handles_gpu_tensors() {
644 test_support::with_test_provider(|provider| {
645 let path = temp_path("csv");
646 let tensor = Tensor::new(vec![0.5, 1.5], vec![1, 2]).unwrap();
647 let view = HostTensorView {
648 data: &tensor.data,
649 shape: &tensor.shape,
650 };
651 let handle = provider.upload(&view).expect("upload");
652 let filename = path.to_string_lossy().into_owned();
653
654 csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
655 .expect("csvwrite");
656
657 let contents = fs::read_to_string(&path).expect("read contents");
658 assert_eq!(contents, format!("0.5,1.5{le}", le = line_ending()));
659 let _ = fs::remove_file(path);
660 });
661 }
662
663 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
664 #[test]
665 fn csvwrite_formats_with_short_g_precision() {
666 let path = temp_path("csv");
667 let values =
668 Tensor::new(vec![12.3456, 1_234_567.0, 0.000123456, -0.0], vec![1, 4]).unwrap();
669 let filename = path.to_string_lossy().into_owned();
670
671 csvwrite_builtin(Value::from(filename), Value::Tensor(values), Vec::new())
672 .expect("csvwrite");
673
674 let contents = fs::read_to_string(&path).expect("read contents");
675 assert_eq!(
676 contents,
677 format!("12.346,1.2346e+06,0.00012346,0{le}", le = line_ending())
678 );
679 let _ = fs::remove_file(path);
680 }
681
682 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
683 #[test]
684 fn csvwrite_rejects_negative_offsets() {
685 let path = temp_path("csv");
686 let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
687 let filename = path.to_string_lossy().into_owned();
688 let err = csvwrite_builtin(
689 Value::from(filename),
690 Value::Tensor(tensor),
691 vec![Value::Num(-1.0), Value::Num(0.0)],
692 )
693 .expect_err("negative offsets should be rejected");
694 let message = err.message().to_string();
695 assert!(
696 message.contains("row offset"),
697 "unexpected error message: {message}"
698 );
699 }
700
701 #[cfg(feature = "wgpu")]
702 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
703 #[test]
704 fn csvwrite_handles_wgpu_provider_gather() {
705 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
706 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
707 );
708 let Some(provider) = runmat_accelerate_api::provider() else {
709 panic!("wgpu provider not registered");
710 };
711
712 let path = temp_path("csv");
713 let tensor = Tensor::new(vec![2.0, 4.0], vec![1, 2]).unwrap();
714 let view = HostTensorView {
715 data: &tensor.data,
716 shape: &tensor.shape,
717 };
718 let handle = provider.upload(&view).expect("upload");
719 let filename = path.to_string_lossy().into_owned();
720
721 csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
722 .expect("csvwrite");
723
724 let contents = fs::read_to_string(&path).expect("read contents");
725 assert_eq!(contents, format!("2,4{le}", le = line_ending()));
726 let _ = fs::remove_file(path);
727 }
728
729 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
730 #[test]
731 fn csvwrite_expands_home_directory() {
732 let Some(mut home) = fs_helpers::home_directory() else {
733 return;
735 };
736 let filename = format!(
737 "runmat_csvwrite_home_{}_{}.csv",
738 std::process::id(),
739 NEXT_ID.fetch_add(1, Ordering::Relaxed)
740 );
741 home.push(&filename);
742
743 let tilde_path = format!("~/{}", filename);
744 let tensor = Tensor::new(vec![42.0], vec![1, 1]).unwrap();
745
746 csvwrite_builtin(Value::from(tilde_path), Value::Tensor(tensor), Vec::new())
747 .expect("csvwrite");
748
749 let contents = fs::read_to_string(&home).expect("read contents");
750 assert_eq!(contents, format!("42{le}", le = line_ending()));
751 let _ = fs::remove_file(home);
752 }
753
754 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
755 #[test]
756 fn csvwrite_rejects_non_numeric_inputs() {
757 let path = temp_path("csv");
758 let filename = path.to_string_lossy().into_owned();
759 let err = csvwrite_builtin(
760 Value::from(filename),
761 Value::String("abc".into()),
762 Vec::new(),
763 )
764 .expect_err("csvwrite should fail");
765 let message = err.message().to_string();
766 assert!(
767 message.contains("csvwrite"),
768 "unexpected error message: {message}"
769 );
770 }
771
772 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
773 #[test]
774 fn csvwrite_accepts_logical_arrays() {
775 let path = temp_path("csv");
776 let logical = LogicalArray::new(vec![1, 0, 1, 0], vec![2, 2]).unwrap();
777 let filename = path.to_string_lossy().into_owned();
778
779 csvwrite_builtin(
780 Value::from(filename),
781 Value::LogicalArray(logical),
782 Vec::new(),
783 )
784 .expect("csvwrite");
785
786 let contents = fs::read_to_string(&path).expect("read contents");
787 assert_eq!(contents, format!("1,1{le}0,0{le}", le = line_ending()));
788 let _ = fs::remove_file(path);
789 }
790}