Skip to main content

runmat_runtime/builtins/io/filetext/
filewrite.rs

1//! MATLAB-compatible `filewrite` builtin for RunMat.
2
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use runmat_builtins::{CharArray, IntValue, LogicalArray, StringArray, Tensor, Value};
7use runmat_macros::runtime_builtin;
8
9use crate::builtins::common::spec::{
10    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
11    ReductionNaN, ResidencyPolicy, ShapeRequirements,
12};
13use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
14use runmat_filesystem::OpenOptions;
15
16#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::filetext::filewrite")]
17pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
18    name: "filewrite",
19    op_kind: GpuOpKind::Custom("io-file-write"),
20    supported_precisions: &[],
21    broadcast: BroadcastSemantics::None,
22    provider_hooks: &[],
23    constant_strategy: ConstantStrategy::InlineLiteral,
24    residency: ResidencyPolicy::GatherImmediately,
25    nan_mode: ReductionNaN::Include,
26    two_pass_threshold: None,
27    workgroup_size: None,
28    accepts_nan_mode: false,
29    notes: "Performs synchronous host file I/O; GPU providers do not participate.",
30};
31
32#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::filetext::filewrite")]
33pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
34    name: "filewrite",
35    shape: ShapeRequirements::Any,
36    constant_strategy: ConstantStrategy::InlineLiteral,
37    elementwise: None,
38    reduction: None,
39    emits_nan: false,
40    notes: "Standalone host-side operation; never fused with other kernels.",
41};
42
43const BUILTIN_NAME: &str = "filewrite";
44
45fn filewrite_error(message: impl Into<String>) -> RuntimeError {
46    build_runtime_error(message)
47        .with_builtin(BUILTIN_NAME)
48        .build()
49}
50
51fn map_control_flow(err: RuntimeError) -> RuntimeError {
52    let identifier = err.identifier().map(str::to_string);
53    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
54        .with_builtin(BUILTIN_NAME)
55        .with_source(err);
56    if let Some(identifier) = identifier {
57        builder = builder.with_identifier(identifier);
58    }
59    builder.build()
60}
61
62fn map_control_flow_with_context(err: RuntimeError, context: &str) -> RuntimeError {
63    let identifier = err.identifier().map(str::to_string);
64    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {context}: {}", err.message()))
65        .with_builtin(BUILTIN_NAME)
66        .with_source(err);
67    if let Some(identifier) = identifier {
68        builder = builder.with_identifier(identifier);
69    }
70    builder.build()
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum FileEncoding {
75    Auto,
76    Utf8,
77    Ascii,
78    Latin1,
79    Raw,
80}
81
82impl FileEncoding {
83    fn from_label(label: &str) -> Option<Self> {
84        match label.trim().to_ascii_lowercase().as_str() {
85            "auto" | "default" | "system" | "native" => Some(FileEncoding::Auto),
86            "utf-8" | "utf8" | "utf_8" | "unicode" => Some(FileEncoding::Utf8),
87            "ascii" | "us-ascii" | "us_ascii" | "usascii" => Some(FileEncoding::Ascii),
88            "latin1" | "latin-1" | "latin_1" | "iso-8859-1" | "iso8859-1" | "iso88591" => {
89                Some(FileEncoding::Latin1)
90            }
91            "raw" | "bytes" | "byte" | "binary" => Some(FileEncoding::Raw),
92            _ => None,
93        }
94    }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum WriteMode {
99    Overwrite,
100    Append,
101}
102
103impl WriteMode {
104    fn from_label(label: &str) -> Option<Self> {
105        match label.trim().to_ascii_lowercase().as_str() {
106            "overwrite" | "replace" | "truncate" => Some(WriteMode::Overwrite),
107            "append" | "add" => Some(WriteMode::Append),
108            _ => None,
109        }
110    }
111}
112
113#[derive(Debug, Clone, Copy)]
114struct FilewriteOptions {
115    encoding: FileEncoding,
116    write_mode: WriteMode,
117}
118
119impl Default for FilewriteOptions {
120    fn default() -> Self {
121        Self {
122            encoding: FileEncoding::Auto,
123            write_mode: WriteMode::Overwrite,
124        }
125    }
126}
127
128#[runtime_builtin(
129    name = "filewrite",
130    category = "io/filetext",
131    summary = "Write text or raw bytes to a file.",
132    keywords = "filewrite,io,write file,text file,append,encoding",
133    accel = "cpu",
134    type_resolver(crate::builtins::io::type_resolvers::filewrite_type),
135    builtin_path = "crate::builtins::io::filetext::filewrite"
136)]
137async fn filewrite_builtin(
138    path: Value,
139    data: Value,
140    rest: Vec<Value>,
141) -> crate::BuiltinResult<Value> {
142    let path = gather_if_needed_async(&path)
143        .await
144        .map_err(map_control_flow)?;
145    let data = gather_if_needed_async(&data)
146        .await
147        .map_err(map_control_flow)?;
148    let rest = gather_values(&rest).await?;
149    let options = parse_options(&rest)?;
150    let resolved = resolve_path(&path)?;
151    let payload = prepare_payload(&data, options.encoding)?;
152    let written = write_bytes(&resolved, &payload, options.write_mode)?;
153    Ok(Value::Num(written as f64))
154}
155
156async fn gather_values(values: &[Value]) -> BuiltinResult<Vec<Value>> {
157    let mut out = Vec::with_capacity(values.len());
158    for value in values {
159        out.push(
160            gather_if_needed_async(value)
161                .await
162                .map_err(map_control_flow)?,
163        );
164    }
165    Ok(out)
166}
167
168fn parse_options(args: &[Value]) -> BuiltinResult<FilewriteOptions> {
169    if args.is_empty() {
170        return Ok(FilewriteOptions::default());
171    }
172
173    let mut options = FilewriteOptions::default();
174    let mut idx = 0usize;
175    let mut encoding_specified = false;
176    let mut write_mode_specified = false;
177
178    if !args.is_empty() && !is_keyword(&args[0])? {
179        match encoding_from_value(&args[0]) {
180            Ok(enc) => {
181                options.encoding = enc;
182                encoding_specified = true;
183                idx = 1;
184            }
185            Err(err) => {
186                if args.len() == 1 {
187                    return Err(err);
188                }
189            }
190        }
191    }
192
193    if !(args.len() - idx).is_multiple_of(2) {
194        return Err(filewrite_error(
195            "filewrite: expected keyword/value argument pairs",
196        ));
197    }
198
199    while idx < args.len() {
200        let key = keyword_name(&args[idx])?;
201        let value = &args[idx + 1];
202        if key.eq_ignore_ascii_case("encoding") {
203            if encoding_specified {
204                return Err(filewrite_error("filewrite: duplicate 'Encoding' argument"));
205            }
206            options.encoding = encoding_from_value(value)?;
207            encoding_specified = true;
208        } else if key.eq_ignore_ascii_case("writemode") {
209            if write_mode_specified {
210                return Err(filewrite_error("filewrite: duplicate 'WriteMode' argument"));
211            }
212            options.write_mode = write_mode_from_value(value)?;
213            write_mode_specified = true;
214        } else {
215            return Err(filewrite_error(format!(
216                "filewrite: unrecognised option '{}'",
217                key
218            )));
219        }
220        idx += 2;
221    }
222
223    Ok(options)
224}
225
226fn is_keyword(value: &Value) -> BuiltinResult<bool> {
227    let text = keyword_name(value)?;
228    Ok(text.eq_ignore_ascii_case("encoding") || text.eq_ignore_ascii_case("writemode"))
229}
230
231fn keyword_name(value: &Value) -> BuiltinResult<String> {
232    match value {
233        Value::String(s) => Ok(s.clone()),
234        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
235        Value::CharArray(_) => Err(filewrite_error(
236            "filewrite: keyword names must be 1-by-N character vectors",
237        )),
238        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
239        Value::StringArray(_) => Err(filewrite_error(
240            "filewrite: keyword inputs must be scalar string arrays",
241        )),
242        other => Err(filewrite_error(format!(
243            "filewrite: expected keyword as string scalar or character vector, got {other:?}"
244        ))),
245    }
246}
247
248fn encoding_from_value(value: &Value) -> BuiltinResult<FileEncoding> {
249    let label = keyword_name(value)?;
250    match FileEncoding::from_label(&label) {
251        Some(enc) => Ok(enc),
252        None if label.trim().is_empty() => Err(filewrite_error(
253            "filewrite: encoding name must not be empty",
254        )),
255        None => Err(filewrite_error(format!(
256            "filewrite: unsupported encoding '{}'",
257            label
258        ))),
259    }
260}
261
262fn write_mode_from_value(value: &Value) -> BuiltinResult<WriteMode> {
263    let label = keyword_name(value)?;
264    match WriteMode::from_label(&label) {
265        Some(mode) => Ok(mode),
266        None if label.trim().is_empty() => {
267            Err(filewrite_error("filewrite: write mode must not be empty"))
268        }
269        None => Err(filewrite_error(format!(
270            "filewrite: unsupported write mode '{}'; use 'overwrite' or 'append'",
271            label
272        ))),
273    }
274}
275
276fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
277    match value {
278        Value::String(s) => normalize_path(s),
279        Value::CharArray(ca) if ca.rows == 1 => {
280            let path: String = ca.data.iter().collect();
281            normalize_path(&path)
282        }
283        Value::CharArray(_) => Err(filewrite_error(
284            "filewrite: filename must be a 1-by-N character vector",
285        )),
286        Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
287        Value::StringArray(_) => Err(filewrite_error(
288            "filewrite: string array filename inputs must be scalar",
289        )),
290        other => Err(filewrite_error(format!(
291            "filewrite: expected filename as string scalar or character vector, got {other:?}"
292        ))),
293    }
294}
295
296fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
297    if raw.is_empty() {
298        return Err(filewrite_error("filewrite: filename must not be empty"));
299    }
300    Ok(Path::new(raw).to_path_buf())
301}
302
303enum Payload {
304    Text(Vec<char>),
305    Bytes(Vec<u8>),
306}
307
308fn prepare_payload(data: &Value, encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
309    let payload = extract_payload(data)?;
310    match payload {
311        Payload::Text(chars) => encode_text(chars, encoding),
312        Payload::Bytes(bytes) => encode_bytes(bytes, encoding),
313    }
314}
315
316fn extract_payload(data: &Value) -> BuiltinResult<Payload> {
317    match data {
318        Value::CharArray(ca) => Ok(Payload::Text(char_array_to_text(ca))),
319        Value::String(s) => Ok(Payload::Text(s.chars().collect())),
320        Value::StringArray(sa) => Ok(Payload::Text(string_array_to_text(sa))),
321        Value::Num(n) => {
322            let byte = float_to_byte(*n)
323                .map_err(|err| map_control_flow_with_context(err, "filewrite: numeric value"))?;
324            Ok(Payload::Bytes(vec![byte]))
325        }
326        Value::Int(i) => {
327            let byte = int_value_to_byte(i)
328                .map_err(|err| map_control_flow_with_context(err, "filewrite: integer value"))?;
329            Ok(Payload::Bytes(vec![byte]))
330        }
331        Value::Bool(flag) => Ok(Payload::Bytes(vec![if *flag { 1 } else { 0 }])),
332        Value::Tensor(t) => Ok(Payload::Bytes(tensor_to_bytes(t)?)),
333        Value::LogicalArray(la) => Ok(Payload::Bytes(logical_to_bytes(la))),
334        Value::Cell(_) => Err(filewrite_error(
335            "filewrite: cell arrays are not supported inputs",
336        )),
337        Value::GpuTensor(_) => Err(filewrite_error(
338            "filewrite: internal error: GPU tensor should be gathered",
339        )),
340        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(filewrite_error(
341            "filewrite: complex data must be converted to text before writing",
342        )),
343        other => Err(filewrite_error(format!(
344            "filewrite: unsupported data type {other:?}; expected text or uint8-compatible array"
345        ))),
346    }
347}
348
349fn char_array_to_text(ca: &CharArray) -> Vec<char> {
350    if ca.rows <= 1 {
351        return ca.data.clone();
352    }
353    let mut out = Vec::with_capacity(ca.rows * (ca.cols + 1));
354    for row in 0..ca.rows {
355        for col in 0..ca.cols {
356            out.push(ca.data[row * ca.cols + col]);
357        }
358        if row + 1 < ca.rows {
359            out.push('\n');
360        }
361    }
362    out
363}
364
365fn string_array_to_text(sa: &StringArray) -> Vec<char> {
366    if sa.data.is_empty() {
367        return Vec::new();
368    }
369    let mut combined = String::new();
370    for (idx, entry) in sa.data.iter().enumerate() {
371        combined.push_str(entry);
372        if idx + 1 < sa.data.len() {
373            combined.push('\n');
374        }
375    }
376    combined.chars().collect()
377}
378
379fn tensor_to_bytes(tensor: &Tensor) -> BuiltinResult<Vec<u8>> {
380    let mut out = Vec::with_capacity(tensor.data.len());
381    for (idx, value) in tensor.data.iter().enumerate() {
382        match float_to_byte(*value) {
383            Ok(byte) => out.push(byte),
384            Err(msg) => {
385                return Err(filewrite_error(format!(
386                    "filewrite: numeric element {} {msg}",
387                    idx
388                )));
389            }
390        }
391    }
392    Ok(out)
393}
394
395fn logical_to_bytes(array: &LogicalArray) -> Vec<u8> {
396    array
397        .data
398        .iter()
399        .map(|value| if *value != 0 { 1 } else { 0 })
400        .collect()
401}
402
403fn float_to_byte(value: f64) -> BuiltinResult<u8> {
404    if !value.is_finite() {
405        return Err(filewrite_error(format!(
406            "value {value} is not finite; cannot write as raw byte"
407        )));
408    }
409    let rounded = value.round();
410    if (value - rounded).abs() > 1e-9 {
411        return Err(filewrite_error(format!(
412            "value {value} is not an integer in the range 0..255"
413        )));
414    }
415    let int = rounded as i128;
416    if !(0..=255).contains(&int) {
417        return Err(filewrite_error(format!(
418            "value {value} is not in the range 0..255"
419        )));
420    }
421    Ok(int as u8)
422}
423
424fn int_value_to_byte(value: &IntValue) -> BuiltinResult<u8> {
425    match value {
426        IntValue::I8(v) => signed_to_byte(*v as i64),
427        IntValue::I16(v) => signed_to_byte(*v as i64),
428        IntValue::I32(v) => signed_to_byte(*v as i64),
429        IntValue::I64(v) => signed_to_byte(*v),
430        IntValue::U8(v) => Ok(*v),
431        IntValue::U16(v) => unsigned_to_byte(*v as u64),
432        IntValue::U32(v) => unsigned_to_byte(*v as u64),
433        IntValue::U64(v) => unsigned_to_byte(*v),
434    }
435}
436
437fn signed_to_byte(value: i64) -> BuiltinResult<u8> {
438    if !(0..=255).contains(&value) {
439        return Err(filewrite_error(format!(
440            "value {value} is not in the range 0..255"
441        )));
442    }
443    Ok(value as u8)
444}
445
446fn unsigned_to_byte(value: u64) -> BuiltinResult<u8> {
447    if value > 255 {
448        return Err(filewrite_error(format!(
449            "value {value} is not in the range 0..255"
450        )));
451    }
452    Ok(value as u8)
453}
454
455fn encode_text(chars: Vec<char>, encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
456    match encoding {
457        FileEncoding::Auto | FileEncoding::Utf8 => {
458            Ok(chars.iter().collect::<String>().into_bytes())
459        }
460        FileEncoding::Ascii => encode_ascii_chars(&chars),
461        FileEncoding::Latin1 | FileEncoding::Raw => encode_latin_chars(&chars, encoding),
462    }
463}
464
465fn encode_ascii_chars(chars: &[char]) -> BuiltinResult<Vec<u8>> {
466    let mut out = Vec::with_capacity(chars.len());
467    for &ch in chars {
468        if ch as u32 > 0x7F {
469            return Err(filewrite_error(format!(
470                "filewrite: character '{}' (U+{:04X}) cannot be encoded as ASCII",
471                ch, ch as u32
472            )));
473        }
474        out.push(ch as u8);
475    }
476    Ok(out)
477}
478
479fn encode_latin_chars(chars: &[char], encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
480    let mut out = Vec::with_capacity(chars.len());
481    for &ch in chars {
482        if ch as u32 > 0xFF {
483            return Err(filewrite_error(format!(
484                "filewrite: character '{}' (U+{:04X}) cannot be encoded as {}",
485                ch,
486                ch as u32,
487                match encoding {
488                    FileEncoding::Latin1 => "Latin-1",
489                    FileEncoding::Raw => "raw bytes",
490                    _ => unreachable!(),
491                }
492            )));
493        }
494        out.push(ch as u8);
495    }
496    Ok(out)
497}
498
499fn encode_bytes(bytes: Vec<u8>, encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
500    if matches!(encoding, FileEncoding::Ascii) {
501        for (idx, byte) in bytes.iter().enumerate() {
502            if *byte > 0x7F {
503                return Err(filewrite_error(format!(
504                    "filewrite: byte 0x{byte:02X} at index {idx} cannot be encoded as ASCII"
505                )));
506            }
507        }
508    }
509    Ok(bytes)
510}
511
512fn write_bytes(path: &Path, payload: &[u8], mode: WriteMode) -> BuiltinResult<usize> {
513    let mut options = OpenOptions::new();
514    options.create(true);
515    match mode {
516        WriteMode::Overwrite => {
517            options.write(true).truncate(true);
518        }
519        WriteMode::Append => {
520            options.write(true).append(true);
521        }
522    }
523
524    let mut file = options.open(path).map_err(|err| {
525        build_runtime_error(format!(
526            "filewrite: unable to open '{}': {}",
527            path.display(),
528            err
529        ))
530        .with_builtin(BUILTIN_NAME)
531        .with_source(err)
532        .build()
533    })?;
534
535    file.write_all(payload).map_err(|err| {
536        build_runtime_error(format!(
537            "filewrite: unable to write to '{}': {}",
538            path.display(),
539            err
540        ))
541        .with_builtin(BUILTIN_NAME)
542        .with_source(err)
543        .build()
544    })?;
545
546    file.flush().map_err(|err| {
547        build_runtime_error(format!(
548            "filewrite: unable to flush '{}': {}",
549            path.display(),
550            err
551        ))
552        .with_builtin(BUILTIN_NAME)
553        .with_source(err)
554        .build()
555    })?;
556
557    Ok(payload.len())
558}
559
560#[cfg(test)]
561pub(crate) mod tests {
562    use super::*;
563    use crate::builtins::common::test_support;
564    use crate::RuntimeError;
565    use runmat_time::unix_timestamp_ms;
566    use std::io::Read;
567
568    fn unwrap_error_message(err: RuntimeError) -> String {
569        err.message().to_string()
570    }
571
572    fn run_filewrite(path: Value, data: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
573        futures::executor::block_on(filewrite_builtin(path, data, rest))
574    }
575
576    fn unique_path(prefix: &str) -> PathBuf {
577        let millis = unix_timestamp_ms();
578        let mut path = std::env::temp_dir();
579        path.push(format!("runmat_{prefix}_{}_{}", std::process::id(), millis));
580        path
581    }
582
583    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
584    #[test]
585    fn filewrite_writes_text_content() {
586        let path = unique_path("filewrite_text");
587        let contents = "RunMat filewrite\nLine two\n";
588
589        let result = run_filewrite(
590            Value::from(path.to_string_lossy().to_string()),
591            Value::from(contents),
592            Vec::new(),
593        )
594        .expect("filewrite");
595
596        match result {
597            Value::Num(n) => assert_eq!(n as usize, contents.len()),
598            other => panic!("expected numeric byte count, got {other:?}"),
599        }
600
601        let written = test_support::fs::read_to_string(&path).expect("read filewrite output");
602        assert_eq!(written, contents);
603
604        let _ = test_support::fs::remove_file(&path);
605    }
606
607    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
608    #[test]
609    fn filewrite_appends_when_requested() {
610        let path = unique_path("filewrite_append");
611        test_support::fs::write(&path, "first\n").expect("write baseline");
612
613        run_filewrite(
614            Value::from(path.to_string_lossy().to_string()),
615            Value::from("second\n"),
616            vec![Value::from("WriteMode"), Value::from("append")],
617        )
618        .expect("filewrite append");
619
620        let written = test_support::fs::read_to_string(&path).expect("read appended file");
621        assert_eq!(written, "first\nsecond\n");
622
623        let _ = test_support::fs::remove_file(&path);
624    }
625
626    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
627    #[test]
628    fn filewrite_errors_on_invalid_ascii() {
629        let path = unique_path("filewrite_ascii_error");
630        let err = unwrap_error_message(
631            run_filewrite(
632                Value::from(path.to_string_lossy().to_string()),
633                Value::from("café"),
634                vec![Value::from("Encoding"), Value::from("ascii")],
635            )
636            .unwrap_err(),
637        );
638        assert!(
639            err.contains("cannot be encoded as ASCII"),
640            "unexpected error message: {err}"
641        );
642        assert!(!path.exists());
643    }
644
645    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
646    #[test]
647    fn filewrite_writes_raw_bytes_from_tensor() {
648        let path = unique_path("filewrite_raw_bytes");
649        let tensor = Tensor::new(vec![0.0, 127.0, 255.0], vec![3, 1]).expect("tensor");
650        run_filewrite(
651            Value::from(path.to_string_lossy().to_string()),
652            Value::Tensor(tensor),
653            vec![Value::from("Encoding"), Value::from("raw")],
654        )
655        .expect("filewrite raw");
656
657        let mut bytes = Vec::new();
658        runmat_filesystem::File::open(&path)
659            .expect("open raw file")
660            .read_to_end(&mut bytes)
661            .expect("read raw file");
662        assert_eq!(bytes, vec![0u8, 127u8, 255u8]);
663
664        let _ = test_support::fs::remove_file(&path);
665    }
666
667    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
668    #[test]
669    fn filewrite_numeric_scalar_writes_byte() {
670        let path = unique_path("filewrite_numeric_scalar");
671        run_filewrite(
672            Value::from(path.to_string_lossy().to_string()),
673            Value::Num(65.0),
674            Vec::new(),
675        )
676        .expect("filewrite numeric scalar");
677
678        let bytes = test_support::fs::read(&path).expect("read numeric scalar file");
679        assert_eq!(bytes, vec![65u8]);
680
681        let _ = test_support::fs::remove_file(&path);
682    }
683
684    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
685    #[test]
686    fn filewrite_bool_scalar_writes_byte() {
687        let path = unique_path("filewrite_bool_scalar");
688        run_filewrite(
689            Value::from(path.to_string_lossy().to_string()),
690            Value::Bool(true),
691            Vec::new(),
692        )
693        .expect("filewrite bool scalar");
694
695        let bytes = test_support::fs::read(&path).expect("read bool scalar file");
696        assert_eq!(bytes, vec![1u8]);
697
698        let _ = test_support::fs::remove_file(&path);
699    }
700
701    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
702    #[test]
703    fn filewrite_writes_logical_array_bytes() {
704        let path = unique_path("filewrite_logical_array");
705        let logical = LogicalArray::new(vec![0, 1, 2], vec![3]).expect("logical array");
706        run_filewrite(
707            Value::from(path.to_string_lossy().to_string()),
708            Value::LogicalArray(logical),
709            Vec::new(),
710        )
711        .expect("filewrite logical array");
712
713        let bytes = test_support::fs::read(&path).expect("read logical array file");
714        assert_eq!(bytes, vec![0u8, 1u8, 1u8]);
715
716        let _ = test_support::fs::remove_file(&path);
717    }
718
719    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
720    #[test]
721    fn filewrite_errors_on_numeric_out_of_range() {
722        let path = unique_path("filewrite_out_of_range");
723        let err = unwrap_error_message(
724            run_filewrite(
725                Value::from(path.to_string_lossy().to_string()),
726                Value::Num(300.0),
727                Vec::new(),
728            )
729            .unwrap_err(),
730        );
731        assert!(
732            err.contains("range 0..255"),
733            "unexpected error message: {err}"
734        );
735        assert!(!path.exists());
736    }
737
738    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
739    #[test]
740    fn filewrite_errors_on_non_integer_numeric() {
741        let path = unique_path("filewrite_non_integer");
742        let err = unwrap_error_message(
743            run_filewrite(
744                Value::from(path.to_string_lossy().to_string()),
745                Value::Num(std::f64::consts::PI),
746                Vec::new(),
747            )
748            .unwrap_err(),
749        );
750        assert!(
751            err.contains("not an integer"),
752            "unexpected error message: {err}"
753        );
754        assert!(!path.exists());
755    }
756
757    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
758    #[test]
759    fn filewrite_rejects_ascii_numeric_bytes_above_range() {
760        let path = unique_path("filewrite_ascii_bytes");
761        let tensor = Tensor::new(vec![255.0], vec![1, 1]).expect("tensor");
762        let err = unwrap_error_message(
763            run_filewrite(
764                Value::from(path.to_string_lossy().to_string()),
765                Value::Tensor(tensor),
766                vec![Value::from("Encoding"), Value::from("ascii")],
767            )
768            .unwrap_err(),
769        );
770        assert!(
771            err.contains("cannot be encoded as ASCII"),
772            "unexpected error message: {err}"
773        );
774        assert!(!path.exists());
775    }
776
777    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
778    #[test]
779    fn filewrite_positional_encoding_argument() {
780        let path = unique_path("filewrite_positional_encoding");
781        run_filewrite(
782            Value::from(path.to_string_lossy().to_string()),
783            Value::from("Espa\u{00F1}a"),
784            vec![Value::from("latin1")],
785        )
786        .expect("filewrite positional encoding");
787
788        let bytes = test_support::fs::read(&path).expect("read latin1 file");
789        assert_eq!(bytes, b"Espa\xF1a");
790
791        let _ = test_support::fs::remove_file(&path);
792    }
793
794    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
795    #[test]
796    fn filewrite_utf8_encoding_allows_arbitrary_bytes() {
797        let path = unique_path("filewrite_utf8_numeric");
798        let tensor = Tensor::new(vec![0.0, 255.0], vec![2, 1]).expect("tensor");
799        run_filewrite(
800            Value::from(path.to_string_lossy().to_string()),
801            Value::Tensor(tensor),
802            vec![Value::from("Encoding"), Value::from("utf-8")],
803        )
804        .expect("filewrite utf8 numeric");
805
806        let bytes = test_support::fs::read(&path).expect("read utf8 numeric file");
807        assert_eq!(bytes, vec![0u8, 255u8]);
808
809        let _ = test_support::fs::remove_file(&path);
810    }
811
812    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
813    #[test]
814    fn filewrite_rejects_unknown_option() {
815        let path = unique_path("filewrite_unknown_option");
816        let err = unwrap_error_message(
817            run_filewrite(
818                Value::from(path.to_string_lossy().to_string()),
819                Value::from("data"),
820                vec![Value::from("Mode"), Value::from("append")],
821            )
822            .unwrap_err(),
823        );
824        assert!(
825            err.contains("unrecognised option"),
826            "unexpected error message: {err}"
827        );
828        assert!(!path.exists());
829    }
830
831    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
832    #[test]
833    fn filewrite_rejects_duplicate_encoding() {
834        let path = unique_path("filewrite_duplicate_encoding");
835        let err = unwrap_error_message(
836            run_filewrite(
837                Value::from(path.to_string_lossy().to_string()),
838                Value::from("data"),
839                vec![
840                    Value::from("utf-8"),
841                    Value::from("Encoding"),
842                    Value::from("ascii"),
843                ],
844            )
845            .unwrap_err(),
846        );
847        assert!(
848            err.contains("duplicate 'Encoding'"),
849            "unexpected error message: {err}"
850        );
851        assert!(!path.exists());
852    }
853
854    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
855    #[test]
856    fn filewrite_rejects_duplicate_writemode() {
857        let path = unique_path("filewrite_duplicate_writemode");
858        let err = unwrap_error_message(
859            run_filewrite(
860                Value::from(path.to_string_lossy().to_string()),
861                Value::from("data"),
862                vec![
863                    Value::from("WriteMode"),
864                    Value::from("append"),
865                    Value::from("WriteMode"),
866                    Value::from("overwrite"),
867                ],
868            )
869            .unwrap_err(),
870        );
871        assert!(
872            err.contains("duplicate 'WriteMode'"),
873            "unexpected error message: {err}"
874        );
875        assert!(!path.exists());
876    }
877
878    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
879    #[test]
880    fn filewrite_rejects_invalid_writemode_value() {
881        let path = unique_path("filewrite_invalid_writemode");
882        let err = unwrap_error_message(
883            run_filewrite(
884                Value::from(path.to_string_lossy().to_string()),
885                Value::from("data"),
886                vec![Value::from("WriteMode"), Value::from("invalid")],
887            )
888            .unwrap_err(),
889        );
890        assert!(
891            err.contains("unsupported write mode"),
892            "unexpected error message: {err}"
893        );
894        assert!(!path.exists());
895    }
896
897    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
898    #[test]
899    fn filewrite_rejects_invalid_encoding_value() {
900        let path = unique_path("filewrite_invalid_encoding");
901        let err = unwrap_error_message(
902            run_filewrite(
903                Value::from(path.to_string_lossy().to_string()),
904                Value::from("data"),
905                vec![Value::from("Encoding"), Value::from("utf-32")],
906            )
907            .unwrap_err(),
908        );
909        assert!(
910            err.contains("unsupported encoding"),
911            "unexpected error message: {err}"
912        );
913        assert!(!path.exists());
914    }
915
916    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
917    #[test]
918    fn filewrite_accepts_char_array_filename() {
919        let path = unique_path("filewrite_char_path");
920        let path_str = path.to_string_lossy();
921        let chars: Vec<char> = path_str.chars().collect();
922        let char_array = CharArray::new(chars, 1, path_str.len()).expect("char array path");
923
924        run_filewrite(
925            Value::CharArray(char_array),
926            Value::from("hello"),
927            Vec::new(),
928        )
929        .expect("filewrite char path");
930
931        let written = test_support::fs::read_to_string(&path).expect("read char path file");
932        assert_eq!(written, "hello");
933
934        let _ = test_support::fs::remove_file(&path);
935    }
936
937    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
938    #[test]
939    fn filewrite_string_array_stores_newlines() {
940        let path = unique_path("filewrite_string_array");
941        let array = StringArray::new(vec!["a".into(), "b".into(), "c".into()], vec![3, 1])
942            .expect("string array");
943        run_filewrite(
944            Value::from(path.to_string_lossy().to_string()),
945            Value::StringArray(array),
946            Vec::new(),
947        )
948        .expect("filewrite string array");
949
950        let written = test_support::fs::read_to_string(&path).expect("read string array file");
951        assert_eq!(written, "a\nb\nc");
952
953        let _ = test_support::fs::remove_file(&path);
954    }
955}