Skip to main content

runmat_runtime/builtins/io/filetext/
fprintf.rs

1//! MATLAB-compatible `fprintf` builtin enabling formatted text output to files and standard streams.
2
3use std::io::Write;
4use std::sync::{Arc, Mutex as StdMutex};
5
6use runmat_builtins::Value;
7use runmat_macros::runtime_builtin;
8
9use crate::builtins::common::format::{
10    decode_escape_sequences, flatten_arguments, format_variadic_with_cursor, ArgCursor,
11};
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::io::filetext::registry::{self, FileInfo};
17use crate::console::{record_console_output, ConsoleStream};
18use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
19use runmat_filesystem::File;
20
21const INVALID_IDENTIFIER_MESSAGE: &str =
22    "fprintf: Invalid file identifier. Use fopen to generate a valid file ID.";
23const MISSING_FORMAT_MESSAGE: &str = "fprintf: missing format string";
24const BUILTIN_NAME: &str = "fprintf";
25
26#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
27pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
28    name: "fprintf",
29    op_kind: GpuOpKind::Custom("io-file-write"),
30    supported_precisions: &[],
31    broadcast: BroadcastSemantics::None,
32    provider_hooks: &[],
33    constant_strategy: ConstantStrategy::InlineLiteral,
34    residency: ResidencyPolicy::GatherImmediately,
35    nan_mode: ReductionNaN::Include,
36    two_pass_threshold: None,
37    workgroup_size: None,
38    accepts_nan_mode: false,
39    notes: "Host-only text I/O. Arguments residing on the GPU are gathered before formatting.",
40};
41
42fn fprintf_error(message: impl Into<String>) -> RuntimeError {
43    build_runtime_error(message)
44        .with_builtin(BUILTIN_NAME)
45        .build()
46}
47
48fn map_control_flow(err: RuntimeError) -> RuntimeError {
49    let message = err.message().to_string();
50    let identifier = err.identifier().map(|value| value.to_string());
51    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {message}"))
52        .with_builtin(BUILTIN_NAME)
53        .with_source(err);
54    if let Some(identifier) = identifier {
55        builder = builder.with_identifier(identifier);
56    }
57    builder.build()
58}
59
60fn map_string_result<T>(result: Result<T, String>) -> BuiltinResult<T> {
61    result.map_err(fprintf_error)
62}
63
64#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
65pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
66    name: "fprintf",
67    shape: ShapeRequirements::Any,
68    constant_strategy: ConstantStrategy::InlineLiteral,
69    elementwise: None,
70    reduction: None,
71    emits_nan: false,
72    notes: "Formatting is a side-effecting sink and never participates in fusion.",
73};
74
75/// Result of evaluating `fprintf`.
76#[derive(Debug)]
77pub struct FprintfEval {
78    bytes_written: usize,
79}
80
81impl FprintfEval {
82    /// Number of bytes emitted by the write.
83    pub fn bytes_written(&self) -> usize {
84        self.bytes_written
85    }
86}
87
88/// Evaluate the `fprintf` builtin without going through the dispatcher.
89pub async fn evaluate(args: &[Value]) -> BuiltinResult<FprintfEval> {
90    if args.is_empty() {
91        return Err(fprintf_error("fprintf: not enough input arguments"));
92    }
93
94    // Gather all arguments to host first
95    let mut all: Vec<Value> = Vec::with_capacity(args.len());
96    for v in args {
97        all.push(gather_value(v).await?);
98    }
99
100    // Locate the first valid formatSpec anywhere in the list
101    let mut fmt_idx: Option<usize> = None;
102    let mut format_string_val: Option<String> = None;
103    for (i, value) in all.iter().enumerate() {
104        // Never interpret a stream label ('stdout'/'stderr') as the format string
105        if match_stream_label(value).is_some() {
106            continue;
107        }
108        if let Some(Value::String(s)) = map_string_result(coerce_to_format_string(value))? {
109            fmt_idx = Some(i);
110            format_string_val = Some(s);
111            break;
112        }
113    }
114    let fmt_idx = fmt_idx.ok_or_else(|| fprintf_error(MISSING_FORMAT_MESSAGE))?;
115    let raw_format = format_string_val.unwrap();
116
117    // Determine output target by scanning only arguments BEFORE the format
118    let mut target_idx: Option<usize> = None;
119    let mut target: OutputTarget = OutputTarget::Stdout;
120    // Prefer explicit stream labels over numeric fids if both appear
121    let mut first_stream: Option<(usize, SpecialStream)> = None;
122    for (i, value) in all.iter().enumerate().take(fmt_idx) {
123        if let Some(stream) = match_stream_label(value) {
124            first_stream = Some((i, stream));
125            break;
126        }
127    }
128    if let Some((idx, stream)) = first_stream {
129        target_idx = Some(idx);
130        target = match stream {
131            SpecialStream::Stdout => OutputTarget::Stdout,
132            SpecialStream::Stderr => OutputTarget::Stderr,
133        };
134    } else {
135        // Try to parse a numeric fid that appears before the format
136        for (i, value) in all.iter().enumerate().take(fmt_idx) {
137            if matches!(value, Value::Num(_) | Value::Int(_) | Value::Tensor(_)) {
138                if let Ok(fid) = parse_fid(value) {
139                    target_idx = Some(i);
140                    target = map_string_result(target_from_fid(fid))?;
141                    break;
142                }
143            }
144        }
145    }
146
147    // Remaining arguments are data, excluding the chosen target and the format
148    let mut data_args: Vec<Value> = Vec::with_capacity(all.len().saturating_sub(1));
149    for (i, v) in all.into_iter().enumerate() {
150        if i == fmt_idx {
151            continue;
152        }
153        if let Some(tidx) = target_idx {
154            if i == tidx {
155                continue;
156            }
157        }
158        data_args.push(v);
159    }
160
161    let format_string =
162        decode_escape_sequences("fprintf", &raw_format).map_err(map_control_flow)?;
163    let flattened_args = flatten_arguments(&data_args, "fprintf")
164        .await
165        .map_err(map_control_flow)?;
166    let rendered = format_with_repetition(&format_string, &flattened_args)?;
167    let bytes = map_string_result(encode_output(&rendered, target.encoding_label()))?;
168    map_string_result(target.write(&bytes))?;
169    Ok(FprintfEval {
170        bytes_written: bytes.len(),
171    })
172}
173
174// kind_of was used for debugging logs; removed to avoid dead code in production builds.
175
176fn try_tensor_char_row_as_string(value: &Value) -> Option<Result<String, String>> {
177    match value {
178        Value::Tensor(t) => {
179            let is_row = (t.shape.len() == 2 && t.shape[0] == 1 && t.data.len() == t.shape[1])
180                || (t.shape.len() == 1 && t.data.len() == t.shape[0]);
181            if is_row {
182                let mut out = String::with_capacity(t.data.len());
183                for &code in &t.data {
184                    if !code.is_finite() {
185                        return Some(Err(
186                            "fprintf: formatSpec must be a character row vector or string scalar"
187                                .to_string(),
188                        ));
189                    }
190                    let v = code as u32;
191                    // Allow full Unicode range; MATLAB chars are UTF-16 but format strings are ASCII-compatible typically
192                    if let Some(ch) = char::from_u32(v) {
193                        out.push(ch);
194                    } else {
195                        return Some(Err(
196                            "fprintf: formatSpec contains invalid character code".to_string()
197                        ));
198                    }
199                }
200                return Some(Ok(out));
201            }
202            None
203        }
204        _ => None,
205    }
206}
207
208fn coerce_to_format_string(value: &Value) -> Result<Option<Value>, String> {
209    match value {
210        Value::String(s) => Ok(Some(Value::String(s.clone()))),
211        Value::StringArray(sa) if sa.data.len() == 1 => Ok(Some(Value::String(sa.data[0].clone()))),
212        Value::CharArray(ca) => {
213            let s: String = ca.data.iter().collect();
214            Ok(Some(Value::String(s)))
215        }
216        Value::Tensor(t) => {
217            // Only accept numeric codepoint vectors of length >= 2 as formatSpec.
218            // This avoids misinterpreting stray 1x1 numerics (e.g., accidental stack values)
219            // as a valid format string.
220            if t.data.len() >= 2 {
221                match try_tensor_char_row_as_string(value) {
222                    Some(Ok(s)) => Ok(Some(Value::String(s))),
223                    Some(Err(e)) => Err(e),
224                    None => Ok(None),
225                }
226            } else {
227                Ok(None)
228            }
229        }
230        _ => Ok(None),
231    }
232}
233
234#[runtime_builtin(
235    name = "fprintf",
236    category = "io/filetext",
237    summary = "Write formatted text to files or standard streams.",
238    keywords = "fprintf,format,printf,io",
239    accel = "cpu",
240    sink = true,
241    suppress_auto_output = true,
242    type_resolver(crate::builtins::io::type_resolvers::fprintf_type),
243    builtin_path = "crate::builtins::io::filetext::fprintf"
244)]
245async fn fprintf_builtin(first: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
246    let mut args = Vec::with_capacity(rest.len() + 1);
247    args.push(first);
248    args.extend(rest);
249    let eval = evaluate(&args).await?;
250    Ok(Value::Num(eval.bytes_written() as f64))
251}
252
253#[derive(Clone, Copy)]
254enum SpecialStream {
255    Stdout,
256    Stderr,
257}
258
259enum OutputTarget {
260    Stdout,
261    Stderr,
262    File {
263        handle: Arc<StdMutex<File>>,
264        encoding: String,
265    },
266}
267
268impl OutputTarget {
269    fn encoding_label(&self) -> Option<&str> {
270        match self {
271            OutputTarget::Stdout | OutputTarget::Stderr => None,
272            OutputTarget::File { encoding, .. } => Some(encoding.as_str()),
273        }
274    }
275
276    fn write(&self, bytes: &[u8]) -> Result<(), String> {
277        match self {
278            OutputTarget::Stdout => {
279                record_console_chunk(ConsoleStream::Stdout, bytes);
280                Ok(())
281            }
282            OutputTarget::Stderr => {
283                record_console_chunk(ConsoleStream::Stderr, bytes);
284                Ok(())
285            }
286            OutputTarget::File { handle, .. } => {
287                let mut guard = handle.lock().map_err(|_| {
288                    "fprintf: failed to lock file handle (poisoned mutex)".to_string()
289                })?;
290                guard
291                    .write_all(bytes)
292                    .map_err(|err| format!("fprintf: failed to write to file ({err})"))
293            }
294        }
295    }
296}
297
298fn record_console_chunk(stream: ConsoleStream, bytes: &[u8]) {
299    if bytes.is_empty() {
300        return;
301    }
302    let text = String::from_utf8_lossy(bytes).to_string();
303    record_console_output(stream, text);
304}
305
306async fn gather_value(value: &Value) -> BuiltinResult<Value> {
307    gather_if_needed_async(value)
308        .await
309        .map_err(map_control_flow)
310}
311
312fn target_from_fid(fid: i32) -> Result<OutputTarget, String> {
313    if fid < 0 {
314        return Err("fprintf: file identifier must be non-negative".to_string());
315    }
316    match fid {
317        0 => Err("fprintf: file identifier 0 (stdin) is not writable".to_string()),
318        1 => Ok(OutputTarget::Stdout),
319        2 => Ok(OutputTarget::Stderr),
320        _ => {
321            let info =
322                registry::info_for(fid).ok_or_else(|| INVALID_IDENTIFIER_MESSAGE.to_string())?;
323            ensure_writable(&info)?;
324            let handle =
325                registry::take_handle(fid).ok_or_else(|| INVALID_IDENTIFIER_MESSAGE.to_string())?;
326            Ok(OutputTarget::File {
327                handle,
328                encoding: info.encoding.clone(),
329            })
330        }
331    }
332}
333
334fn parse_fid(value: &Value) -> Result<i32, String> {
335    let scalar = match value {
336        Value::Num(n) => *n,
337        Value::Int(int) => int.to_f64(),
338        Value::Tensor(t) => {
339            if t.shape == vec![1, 1] && t.data.len() == 1 {
340                t.data[0]
341            } else {
342                return Err("fprintf: file identifier must be numeric".to_string());
343            }
344        }
345        _ => return Err("fprintf: file identifier must be numeric".to_string()),
346    };
347    if !scalar.is_finite() {
348        return Err("fprintf: file identifier must be finite".to_string());
349    }
350    if (scalar.fract().abs()) > f64::EPSILON {
351        return Err("fprintf: file identifier must be an integer".to_string());
352    }
353    Ok(scalar as i32)
354}
355
356fn ensure_writable(info: &FileInfo) -> Result<(), String> {
357    let permission = info.permission.to_ascii_lowercase();
358    if permission.contains('w') || permission.contains('a') || permission.contains('+') {
359        Ok(())
360    } else {
361        Err("fprintf: file is not open for writing".to_string())
362    }
363}
364
365fn match_stream_label(value: &Value) -> Option<SpecialStream> {
366    let candidate = match value {
367        Value::String(s) => s.trim().to_string(),
368        Value::CharArray(ca) if ca.rows == 1 => {
369            ca.data.iter().collect::<String>().trim().to_string()
370        }
371        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].trim().to_string(),
372        _ => return None,
373    };
374    match candidate.to_ascii_lowercase().as_str() {
375        "stdout" => Some(SpecialStream::Stdout),
376        "stderr" => Some(SpecialStream::Stderr),
377        _ => None,
378    }
379}
380
381fn format_with_repetition(format: &str, args: &[Value]) -> BuiltinResult<String> {
382    let mut cursor = ArgCursor::new(args);
383    let mut out = String::new();
384    loop {
385        let step = format_variadic_with_cursor(format, &mut cursor).map_err(remap_format_error)?;
386        out.push_str(&step.output);
387        if step.consumed == 0 {
388            if cursor.remaining() > 0 {
389                return Err(fprintf_error(
390                    "fprintf: formatSpec contains no conversion specifiers but additional arguments were supplied",
391                ));
392            }
393            break;
394        }
395        if cursor.remaining() == 0 {
396            break;
397        }
398    }
399    Ok(out)
400}
401
402fn remap_format_error(err: RuntimeError) -> RuntimeError {
403    let message = err.message().replace("sprintf", "fprintf");
404    let identifier = err.identifier().map(|value| value.to_string());
405    let mut builder = build_runtime_error(message)
406        .with_builtin(BUILTIN_NAME)
407        .with_source(err);
408    if let Some(identifier) = identifier {
409        builder = builder.with_identifier(identifier);
410    }
411    builder.build()
412}
413
414fn encode_output(text: &str, encoding: Option<&str>) -> Result<Vec<u8>, String> {
415    let label = encoding
416        .map(|s| s.trim())
417        .filter(|s| !s.is_empty())
418        .unwrap_or("utf-8");
419    let lower = label.to_ascii_lowercase();
420    let collapsed: String = lower
421        .chars()
422        .filter(|ch| !matches!(ch, '-' | '_' | ' '))
423        .collect();
424    if matches!(
425        collapsed.as_str(),
426        "utf8" | "unicode" | "auto" | "default" | "system"
427    ) {
428        Ok(text.as_bytes().to_vec())
429    } else if matches!(collapsed.as_str(), "ascii" | "usascii" | "ansix341968") {
430        encode_ascii(text)
431    } else if matches!(
432        collapsed.as_str(),
433        "latin1" | "iso88591" | "cp819" | "ibm819"
434    ) {
435        encode_latin1(text, label)
436    } else if matches!(collapsed.as_str(), "windows1252" | "cp1252" | "ansi") {
437        encode_windows_1252(text, label)
438    } else {
439        Ok(text.as_bytes().to_vec())
440    }
441}
442
443fn encode_ascii(text: &str) -> Result<Vec<u8>, String> {
444    let mut bytes = Vec::with_capacity(text.len());
445    for ch in text.chars() {
446        if ch as u32 > 0x7F {
447            return Err(format!(
448                "fprintf: character '{}' (U+{:04X}) cannot be encoded as ASCII",
449                ch, ch as u32
450            ));
451        }
452        bytes.push(ch as u8);
453    }
454    Ok(bytes)
455}
456
457fn encode_latin1(text: &str, label: &str) -> Result<Vec<u8>, String> {
458    let mut bytes = Vec::with_capacity(text.len());
459    for ch in text.chars() {
460        if ch as u32 > 0xFF {
461            return Err(format!(
462                "fprintf: character '{}' (U+{:04X}) cannot be encoded as {}",
463                ch, ch as u32, label
464            ));
465        }
466        bytes.push(ch as u8);
467    }
468    Ok(bytes)
469}
470
471fn encode_windows_1252(text: &str, label: &str) -> Result<Vec<u8>, String> {
472    let mut bytes = Vec::with_capacity(text.len());
473    for ch in text.chars() {
474        if let Some(byte) = windows_1252_byte(ch) {
475            bytes.push(byte);
476        } else {
477            return Err(format!(
478                "fprintf: character '{}' (U+{:04X}) cannot be encoded as {}",
479                ch, ch as u32, label
480            ));
481        }
482    }
483    Ok(bytes)
484}
485
486fn windows_1252_byte(ch: char) -> Option<u8> {
487    let code = ch as u32;
488    if code <= 0x7F {
489        return Some(code as u8);
490    }
491    if (0xA0..=0xFF).contains(&code) {
492        return Some(code as u8);
493    }
494    match code {
495        0x20AC => Some(0x80),
496        0x201A => Some(0x82),
497        0x0192 => Some(0x83),
498        0x201E => Some(0x84),
499        0x2026 => Some(0x85),
500        0x2020 => Some(0x86),
501        0x2021 => Some(0x87),
502        0x02C6 => Some(0x88),
503        0x2030 => Some(0x89),
504        0x0160 => Some(0x8A),
505        0x2039 => Some(0x8B),
506        0x0152 => Some(0x8C),
507        0x017D => Some(0x8E),
508        0x2018 => Some(0x91),
509        0x2019 => Some(0x92),
510        0x201C => Some(0x93),
511        0x201D => Some(0x94),
512        0x2022 => Some(0x95),
513        0x2013 => Some(0x96),
514        0x2014 => Some(0x97),
515        0x02DC => Some(0x98),
516        0x2122 => Some(0x99),
517        0x0161 => Some(0x9A),
518        0x203A => Some(0x9B),
519        0x0153 => Some(0x9C),
520        0x017E => Some(0x9E),
521        0x0178 => Some(0x9F),
522        _ => None,
523    }
524}
525
526#[cfg(test)]
527pub(crate) mod tests {
528    use super::*;
529    use crate::builtins::common::test_support;
530    use crate::builtins::io::filetext::{fclose, fopen, registry};
531    use crate::RuntimeError;
532    use runmat_accelerate_api::HostTensorView;
533    use runmat_builtins::{IntValue, Tensor};
534    use runmat_filesystem::File;
535    use runmat_time::system_time_now;
536    use std::io::Read;
537    use std::path::PathBuf;
538    use std::time::UNIX_EPOCH;
539
540    fn unwrap_error_message(err: RuntimeError) -> String {
541        err.message().to_string()
542    }
543
544    fn run_evaluate(args: &[Value]) -> BuiltinResult<FprintfEval> {
545        futures::executor::block_on(evaluate(args))
546    }
547
548    fn run_fopen(args: &[Value]) -> BuiltinResult<fopen::FopenEval> {
549        futures::executor::block_on(fopen::evaluate(args))
550    }
551
552    fn run_fclose(args: &[Value]) -> BuiltinResult<fclose::FcloseEval> {
553        futures::executor::block_on(fclose::evaluate(args))
554    }
555
556    fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
557        registry::test_guard()
558    }
559
560    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
561    #[test]
562    fn fprintf_matrix_column_major() {
563        let _guard = registry_guard();
564        registry::reset_for_tests();
565        let path = unique_path("fprintf_matrix");
566        let open = run_fopen(&[
567            Value::from(path.to_string_lossy().to_string()),
568            Value::from("w"),
569        ])
570        .expect("fopen");
571        let fid = open.as_open().unwrap().fid as i32;
572
573        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
574        let args = vec![
575            Value::Num(fid as f64),
576            Value::String("%d %d\n".to_string()),
577            Value::Tensor(tensor),
578        ];
579        let eval = run_evaluate(&args).expect("fprintf");
580        assert_eq!(eval.bytes_written(), 12);
581
582        run_fclose(&[Value::Num(fid as f64)]).unwrap();
583
584        let contents = test_support::fs::read_to_string(&path).expect("read");
585        assert_eq!(contents, "1 4\n2 5\n3 6\n");
586        test_support::fs::remove_file(path).unwrap();
587    }
588
589    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
590    #[test]
591    fn fprintf_ascii_encoding_errors() {
592        let _guard = registry_guard();
593        registry::reset_for_tests();
594        let path = unique_path("fprintf_ascii");
595        let open = run_fopen(&[
596            Value::from(path.to_string_lossy().to_string()),
597            Value::from("w"),
598            Value::from("native"),
599            Value::from("ascii"),
600        ])
601        .expect("fopen");
602        let fid = open.as_open().unwrap().fid as i32;
603
604        let args = vec![
605            Value::Num(fid as f64),
606            Value::String("%s".to_string()),
607            Value::String("café".to_string()),
608        ];
609        let err = unwrap_error_message(run_evaluate(&args).unwrap_err());
610        assert!(err.contains("cannot be encoded as ASCII"), "{err}");
611
612        run_fclose(&[Value::Num(fid as f64)]).unwrap();
613        test_support::fs::remove_file(path).unwrap();
614    }
615
616    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
617    #[test]
618    fn fprintf_gpu_gathers_values() {
619        let _guard = registry_guard();
620        registry::reset_for_tests();
621        let path = unique_path("fprintf_gpu");
622
623        test_support::with_test_provider(|provider| {
624            registry::reset_for_tests();
625            let open = run_fopen(&[
626                Value::from(path.to_string_lossy().to_string()),
627                Value::from("w"),
628            ])
629            .expect("fopen");
630            let fid = open.as_open().unwrap().fid as i32;
631
632            let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
633            let view = HostTensorView {
634                data: &tensor.data,
635                shape: &tensor.shape,
636            };
637            let handle = provider.upload(&view).expect("upload");
638            let args = vec![
639                Value::Num(fid as f64),
640                Value::String("%.1f,".to_string()),
641                Value::GpuTensor(handle),
642            ];
643            let eval = run_evaluate(&args).expect("fprintf");
644            assert_eq!(eval.bytes_written(), 12);
645
646            run_fclose(&[Value::Num(fid as f64)]).unwrap();
647        });
648
649        let mut file = File::open(&path).expect("open");
650        let mut contents = String::new();
651        file.read_to_string(&mut contents).expect("read");
652        assert_eq!(contents, "1.0,2.0,3.0,");
653        test_support::fs::remove_file(path).unwrap();
654    }
655
656    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
657    #[test]
658    fn fprintf_missing_format_errors() {
659        let err = unwrap_error_message(run_evaluate(&[Value::Num(1.0)]).unwrap_err());
660        assert!(err.contains("missing format string"), "{err}");
661    }
662
663    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
664    #[test]
665    fn fprintf_literal_with_extra_args_errors() {
666        let err = unwrap_error_message(
667            run_evaluate(&[
668                Value::String("literal text".to_string()),
669                Value::Int(IntValue::I32(1)),
670            ])
671            .unwrap_err(),
672        );
673        assert!(err.contains("contains no conversion specifiers"), "{err}");
674    }
675
676    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
677    #[test]
678    fn fprintf_invalid_identifier_errors() {
679        let err = unwrap_error_message(
680            run_evaluate(&[Value::Num(99.0), Value::String("value".to_string())]).unwrap_err(),
681        );
682        assert!(err.contains("Invalid file identifier"), "{err}");
683    }
684
685    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
686    #[test]
687    fn fprintf_read_only_error() {
688        let _guard = registry_guard();
689        registry::reset_for_tests();
690        let path = unique_path("fprintf_read_only");
691        test_support::fs::write(&path, b"readonly").unwrap();
692        let open = run_fopen(&[
693            Value::from(path.to_string_lossy().to_string()),
694            Value::from("r"),
695        ])
696        .expect("fopen");
697        let fid = open.as_open().unwrap().fid as i32;
698        let err = unwrap_error_message(
699            run_evaluate(&[Value::Num(fid as f64), Value::String("text".to_string())]).unwrap_err(),
700        );
701        assert!(err.contains("not open for writing"), "{err}");
702
703        run_fclose(&[Value::Num(fid as f64)]).unwrap();
704    }
705
706    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
707    #[test]
708    fn fprintf_encoding_aliases_encode_expected_bytes() {
709        let utf = encode_output("é", Some("utf_8")).expect("utf_8 alias");
710        assert_eq!(utf, "é".as_bytes());
711
712        let latin = encode_output("é", Some("cp819")).expect("cp819 alias");
713        assert_eq!(latin, vec![0xE9]);
714
715        let win = encode_output("€’", Some("windows-1252")).expect("windows-1252 alias");
716        assert_eq!(win, vec![0x80, 0x92]);
717    }
718
719    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
720    #[test]
721    fn fprintf_windows1252_reports_unencodable_characters() {
722        let err = encode_output("Ā", Some("cp1252")).expect_err("cp1252 should reject U+0100");
723        assert!(err.contains("cannot be encoded"), "{err}");
724    }
725
726    fn unique_path(prefix: &str) -> PathBuf {
727        let nanos = system_time_now()
728            .duration_since(UNIX_EPOCH)
729            .unwrap()
730            .as_nanos();
731        let filename = format!("runmat_{prefix}_{nanos}.txt");
732        std::env::temp_dir().join(filename)
733    }
734}