1use 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#[derive(Debug)]
77pub struct FprintfEval {
78 bytes_written: usize,
79}
80
81impl FprintfEval {
82 pub fn bytes_written(&self) -> usize {
84 self.bytes_written
85 }
86}
87
88pub 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 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 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 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 let mut target_idx: Option<usize> = None;
119 let mut target: OutputTarget = OutputTarget::Stdout;
120 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 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 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
174fn 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 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 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}