1use std::io::Write;
4
5use runmat_builtins::Value;
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::format::{
9 decode_escape_sequences, flatten_arguments, format_variadic_with_cursor, ArgCursor,
10};
11use crate::builtins::common::spec::{
12 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
13 ReductionNaN, ResidencyPolicy, ShapeRequirements,
14};
15use crate::builtins::io::filetext::registry::{self, FileInfo, SharedFileHandle};
16use crate::console::{record_console_output, ConsoleStream};
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19const INVALID_IDENTIFIER_MESSAGE: &str =
20 "fprintf: Invalid file identifier. Use fopen to generate a valid file ID.";
21const MISSING_FORMAT_MESSAGE: &str = "fprintf: missing format string";
22const BUILTIN_NAME: &str = "fprintf";
23
24#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
25pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
26 name: "fprintf",
27 op_kind: GpuOpKind::Custom("io-file-write"),
28 supported_precisions: &[],
29 broadcast: BroadcastSemantics::None,
30 provider_hooks: &[],
31 constant_strategy: ConstantStrategy::InlineLiteral,
32 residency: ResidencyPolicy::GatherImmediately,
33 nan_mode: ReductionNaN::Include,
34 two_pass_threshold: None,
35 workgroup_size: None,
36 accepts_nan_mode: false,
37 notes: "Host-only text I/O. Arguments residing on the GPU are gathered before formatting.",
38};
39
40fn fprintf_error(message: impl Into<String>) -> RuntimeError {
41 build_runtime_error(message)
42 .with_builtin(BUILTIN_NAME)
43 .build()
44}
45
46fn map_control_flow(err: RuntimeError) -> RuntimeError {
47 let message = err.message().to_string();
48 let identifier = err.identifier().map(|value| value.to_string());
49 let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {message}"))
50 .with_builtin(BUILTIN_NAME)
51 .with_source(err);
52 if let Some(identifier) = identifier {
53 builder = builder.with_identifier(identifier);
54 }
55 builder.build()
56}
57
58fn map_string_result<T>(result: Result<T, String>) -> BuiltinResult<T> {
59 result.map_err(fprintf_error)
60}
61
62#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
63pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
64 name: "fprintf",
65 shape: ShapeRequirements::Any,
66 constant_strategy: ConstantStrategy::InlineLiteral,
67 elementwise: None,
68 reduction: None,
69 emits_nan: false,
70 notes: "Formatting is a side-effecting sink and never participates in fusion.",
71};
72
73#[derive(Debug)]
75pub struct FprintfEval {
76 bytes_written: usize,
77}
78
79impl FprintfEval {
80 pub fn bytes_written(&self) -> usize {
82 self.bytes_written
83 }
84}
85
86pub async fn evaluate(args: &[Value]) -> BuiltinResult<FprintfEval> {
88 if args.is_empty() {
89 return Err(fprintf_error("fprintf: not enough input arguments"));
90 }
91
92 let mut all: Vec<Value> = Vec::with_capacity(args.len());
94 for v in args {
95 all.push(gather_value(v).await?);
96 }
97
98 let mut fmt_idx: Option<usize> = None;
100 let mut format_string_val: Option<String> = None;
101 for (i, value) in all.iter().enumerate() {
102 if match_stream_label(value).is_some() {
104 continue;
105 }
106 if let Some(Value::String(s)) = map_string_result(coerce_to_format_string(value))? {
107 fmt_idx = Some(i);
108 format_string_val = Some(s);
109 break;
110 }
111 }
112 let fmt_idx = fmt_idx.ok_or_else(|| fprintf_error(MISSING_FORMAT_MESSAGE))?;
113 let raw_format = format_string_val.unwrap();
114
115 let mut target_idx: Option<usize> = None;
117 let mut target: OutputTarget = OutputTarget::Stdout;
118 let mut first_stream: Option<(usize, SpecialStream)> = None;
120 for (i, value) in all.iter().enumerate().take(fmt_idx) {
121 if let Some(stream) = match_stream_label(value) {
122 first_stream = Some((i, stream));
123 break;
124 }
125 }
126 if let Some((idx, stream)) = first_stream {
127 target_idx = Some(idx);
128 target = match stream {
129 SpecialStream::Stdout => OutputTarget::Stdout,
130 SpecialStream::Stderr => OutputTarget::Stderr,
131 };
132 } else {
133 for (i, value) in all.iter().enumerate().take(fmt_idx) {
135 if matches!(value, Value::Num(_) | Value::Int(_) | Value::Tensor(_)) {
136 if let Ok(fid) = parse_fid(value) {
137 target_idx = Some(i);
138 target = map_string_result(target_from_fid(fid))?;
139 break;
140 }
141 }
142 }
143 }
144
145 let mut data_args: Vec<Value> = Vec::with_capacity(all.len().saturating_sub(1));
147 for (i, v) in all.into_iter().enumerate() {
148 if i == fmt_idx {
149 continue;
150 }
151 if let Some(tidx) = target_idx {
152 if i == tidx {
153 continue;
154 }
155 }
156 data_args.push(v);
157 }
158
159 let format_string =
160 decode_escape_sequences("fprintf", &raw_format).map_err(map_control_flow)?;
161 let flattened_args = flatten_arguments(&data_args, "fprintf")
162 .await
163 .map_err(map_control_flow)?;
164 let rendered = format_with_repetition(&format_string, &flattened_args)?;
165 let bytes = map_string_result(encode_output(&rendered, target.encoding_label()))?;
166 map_string_result(target.write(&bytes))?;
167 Ok(FprintfEval {
168 bytes_written: bytes.len(),
169 })
170}
171
172fn try_tensor_char_row_as_string(value: &Value) -> Option<Result<String, String>> {
175 match value {
176 Value::Tensor(t) => {
177 let is_row = (t.shape.len() == 2 && t.shape[0] == 1 && t.data.len() == t.shape[1])
178 || (t.shape.len() == 1 && t.data.len() == t.shape[0]);
179 if is_row {
180 let mut out = String::with_capacity(t.data.len());
181 for &code in &t.data {
182 if !code.is_finite() {
183 return Some(Err(
184 "fprintf: formatSpec must be a character row vector or string scalar"
185 .to_string(),
186 ));
187 }
188 let v = code as u32;
189 if let Some(ch) = char::from_u32(v) {
191 out.push(ch);
192 } else {
193 return Some(Err(
194 "fprintf: formatSpec contains invalid character code".to_string()
195 ));
196 }
197 }
198 return Some(Ok(out));
199 }
200 None
201 }
202 _ => None,
203 }
204}
205
206fn coerce_to_format_string(value: &Value) -> Result<Option<Value>, String> {
207 match value {
208 Value::String(s) => Ok(Some(Value::String(s.clone()))),
209 Value::StringArray(sa) if sa.data.len() == 1 => Ok(Some(Value::String(sa.data[0].clone()))),
210 Value::CharArray(ca) => {
211 let s: String = ca.data.iter().collect();
212 Ok(Some(Value::String(s)))
213 }
214 Value::Tensor(t) => {
215 if t.data.len() >= 2 {
219 match try_tensor_char_row_as_string(value) {
220 Some(Ok(s)) => Ok(Some(Value::String(s))),
221 Some(Err(e)) => Err(e),
222 None => Ok(None),
223 }
224 } else {
225 Ok(None)
226 }
227 }
228 _ => Ok(None),
229 }
230}
231
232#[runtime_builtin(
233 name = "fprintf",
234 category = "io/filetext",
235 summary = "Write formatted text to files or standard streams.",
236 keywords = "fprintf,format,printf,io",
237 accel = "cpu",
238 sink = true,
239 suppress_auto_output = true,
240 type_resolver(crate::builtins::io::type_resolvers::fprintf_type),
241 builtin_path = "crate::builtins::io::filetext::fprintf"
242)]
243async fn fprintf_builtin(first: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
244 let mut args = Vec::with_capacity(rest.len() + 1);
245 args.push(first);
246 args.extend(rest);
247 let eval = evaluate(&args).await?;
248 Ok(Value::Num(eval.bytes_written() as f64))
249}
250
251#[derive(Clone, Copy)]
252enum SpecialStream {
253 Stdout,
254 Stderr,
255}
256
257enum OutputTarget {
258 Stdout,
259 Stderr,
260 File {
261 handle: SharedFileHandle,
262 encoding: String,
263 },
264}
265
266impl OutputTarget {
267 fn encoding_label(&self) -> Option<&str> {
268 match self {
269 OutputTarget::Stdout | OutputTarget::Stderr => None,
270 OutputTarget::File { encoding, .. } => Some(encoding.as_str()),
271 }
272 }
273
274 fn write(&self, bytes: &[u8]) -> Result<(), String> {
275 match self {
276 OutputTarget::Stdout => {
277 record_console_chunk(ConsoleStream::Stdout, bytes);
278 Ok(())
279 }
280 OutputTarget::Stderr => {
281 record_console_chunk(ConsoleStream::Stderr, bytes);
282 Ok(())
283 }
284 OutputTarget::File { handle, .. } => {
285 let mut guard = handle.lock().map_err(|_| {
286 "fprintf: failed to lock file handle (poisoned mutex)".to_string()
287 })?;
288 let file = guard
289 .as_mut()
290 .ok_or_else(|| INVALID_IDENTIFIER_MESSAGE.to_string())?;
291 file.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}