1use runmat_builtins::{
4 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6};
7use runmat_macros::runtime_builtin;
8use runmat_value::{CharArray, StringArray, Tensor, Value};
9
10use crate::builtins::common::fs::{expand_user_path, home_directory};
11use crate::builtins::common::path_state::{current_path_string, PATH_LIST_SEPARATOR};
12use crate::builtins::common::spec::{
13 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14 ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::io::repl_fs::tensor_char_codes_to_string;
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19use runmat_filesystem as vfs;
20use std::env;
21use std::io;
22use std::path::{Path, PathBuf};
23
24const DEFAULT_FILENAME: &str = "pathdef.m";
25
26#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::savepath")]
27pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
28 name: "savepath",
29 op_kind: GpuOpKind::Custom("io"),
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:
40 "Filesystem persistence executes on the host; GPU-resident filenames are gathered before writing pathdef.m.",
41};
42
43#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::savepath")]
44pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
45 name: "savepath",
46 shape: ShapeRequirements::Any,
47 constant_strategy: ConstantStrategy::InlineLiteral,
48 elementwise: None,
49 reduction: None,
50 emits_nan: false,
51 notes:
52 "Filesystem side-effects are not eligible for fusion; metadata registered for completeness.",
53};
54
55const BUILTIN_NAME: &str = "savepath";
56
57const SAVEPATH_OUTPUT_STATUS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
58 name: "status",
59 ty: BuiltinParamType::NumericScalar,
60 arity: BuiltinParamArity::Required,
61 default: None,
62 description: "0 on success, 1 on failure.",
63}];
64const SAVEPATH_OUTPUT_STATUS_MESSAGE_ID: [BuiltinParamDescriptor; 3] = [
65 BuiltinParamDescriptor {
66 name: "status",
67 ty: BuiltinParamType::NumericScalar,
68 arity: BuiltinParamArity::Required,
69 default: None,
70 description: "0 on success, 1 on failure.",
71 },
72 BuiltinParamDescriptor {
73 name: "message",
74 ty: BuiltinParamType::StringScalar,
75 arity: BuiltinParamArity::Required,
76 default: None,
77 description: "Failure message text, or empty on success.",
78 },
79 BuiltinParamDescriptor {
80 name: "message_id",
81 ty: BuiltinParamType::StringScalar,
82 arity: BuiltinParamArity::Required,
83 default: None,
84 description: "Failure identifier, or empty on success.",
85 },
86];
87const SAVEPATH_INPUTS_NONE: [BuiltinParamDescriptor; 0] = [];
88const SAVEPATH_INPUTS_FILENAME: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
89 name: "filename",
90 ty: BuiltinParamType::StringScalar,
91 arity: BuiltinParamArity::Required,
92 default: Some("\"pathdef.m\""),
93 description: "Target file or target directory for persisted pathdef output.",
94}];
95const SAVEPATH_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
96 BuiltinSignatureDescriptor {
97 label: "status = savepath()",
98 inputs: &SAVEPATH_INPUTS_NONE,
99 outputs: &SAVEPATH_OUTPUT_STATUS,
100 },
101 BuiltinSignatureDescriptor {
102 label: "status = savepath(filename)",
103 inputs: &SAVEPATH_INPUTS_FILENAME,
104 outputs: &SAVEPATH_OUTPUT_STATUS,
105 },
106 BuiltinSignatureDescriptor {
107 label: "[status, message, message_id] = savepath()",
108 inputs: &SAVEPATH_INPUTS_NONE,
109 outputs: &SAVEPATH_OUTPUT_STATUS_MESSAGE_ID,
110 },
111 BuiltinSignatureDescriptor {
112 label: "[status, message, message_id] = savepath(filename)",
113 inputs: &SAVEPATH_INPUTS_FILENAME,
114 outputs: &SAVEPATH_OUTPUT_STATUS_MESSAGE_ID,
115 },
116];
117const SAVEPATH_ERROR_ARG_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
118 code: "RM.SAVEPATH.ARG_TYPE",
119 identifier: None,
120 when: "Filename input is not a character vector, string scalar/array scalar, or tensor of character codes.",
121 message: "savepath: filename must be a character vector or string scalar",
122};
123const SAVEPATH_ERROR_EMPTY_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
124 code: "RM.SAVEPATH.EMPTY_FILENAME",
125 identifier: None,
126 when: "Explicit filename argument is empty.",
127 message: "savepath: filename must not be empty",
128};
129const SAVEPATH_ERROR_TOO_MANY_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
130 code: "RM.SAVEPATH.TOO_MANY_INPUTS",
131 identifier: None,
132 when: "More than one positional input argument is provided.",
133 message: "savepath: too many input arguments",
134};
135const SAVEPATH_ERROR_CANNOT_WRITE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
136 code: "RM.SAVEPATH.CANNOT_WRITE",
137 identifier: Some("RunMat:savepath:cannotWriteFile"),
138 when: "Pathdef file could not be written.",
139 message: "savepath: unable to write file",
140};
141const SAVEPATH_ERROR_CANNOT_RESOLVE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
142 code: "RM.SAVEPATH.CANNOT_RESOLVE",
143 identifier: Some("RunMat:savepath:cannotResolveFile"),
144 when: "Pathdef output path could not be resolved.",
145 message: "savepath: unable to resolve output path",
146};
147const SAVEPATH_ERRORS: [BuiltinErrorDescriptor; 5] = [
148 SAVEPATH_ERROR_ARG_TYPE,
149 SAVEPATH_ERROR_EMPTY_FILENAME,
150 SAVEPATH_ERROR_TOO_MANY_INPUTS,
151 SAVEPATH_ERROR_CANNOT_WRITE,
152 SAVEPATH_ERROR_CANNOT_RESOLVE,
153];
154pub const SAVEPATH_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
155 signatures: &SAVEPATH_SIGNATURES,
156 output_mode: BuiltinOutputMode::ByRequestedOutputCount,
157 completion_policy: BuiltinCompletionPolicy::Public,
158 errors: &SAVEPATH_ERRORS,
159};
160
161fn savepath_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
162 savepath_error_with_message(error.message, error)
163}
164
165fn savepath_error_with_message(
166 message: impl Into<String>,
167 error: &'static BuiltinErrorDescriptor,
168) -> RuntimeError {
169 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
170 if let Some(identifier) = error.identifier {
171 builder = builder.with_identifier(identifier);
172 }
173 builder.build()
174}
175
176fn map_control_flow(err: RuntimeError) -> RuntimeError {
177 let identifier = err.identifier().map(str::to_string);
178 let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
179 .with_builtin(BUILTIN_NAME)
180 .with_source(err);
181 if let Some(identifier) = identifier {
182 builder = builder.with_identifier(identifier);
183 }
184 builder.build()
185}
186
187#[runtime_builtin(
188 name = "savepath",
189 category = "io/repl_fs",
190 summary = "Write the current MATLAB search path to pathdef.m with status outputs.",
191 keywords = "savepath,pathdef,search path,runmat path,persist path",
192 accel = "cpu",
193 suppress_auto_output = true,
194 type_resolver(crate::builtins::io::type_resolvers::savepath_type),
195 descriptor(crate::builtins::io::repl_fs::savepath::SAVEPATH_DESCRIPTOR),
196 builtin_path = "crate::builtins::io::repl_fs::savepath"
197)]
198async fn savepath_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
199 let eval = evaluate(&args).await?;
200 if let Some(out_count) = crate::output_count::current_output_count() {
201 if out_count == 0 {
202 return Ok(Value::OutputList(Vec::new()));
203 }
204 return Ok(crate::output_count::output_list_with_padding(
205 out_count,
206 eval.outputs(),
207 ));
208 }
209 Ok(eval.first_output())
210}
211
212pub async fn evaluate(args: &[Value]) -> BuiltinResult<SavepathResult> {
214 let gathered = gather_arguments(args).await?;
215 let target = match gathered.len() {
216 0 => match default_target_path().await {
217 Ok(path) => path,
218 Err(err) => return Ok(SavepathResult::failure(err.message, err.message_error)),
219 },
220 1 => {
221 let raw = extract_filename(&gathered[0])?;
222 if raw.is_empty() {
223 return Err(savepath_error(&SAVEPATH_ERROR_EMPTY_FILENAME));
224 }
225 match resolve_explicit_path(&raw).await {
226 Ok(path) => path,
227 Err(err) => return Ok(SavepathResult::failure(err.message, err.message_error)),
228 }
229 }
230 _ => return Err(savepath_error(&SAVEPATH_ERROR_TOO_MANY_INPUTS)),
231 };
232
233 let path_string = current_path_string();
234 match persist_path(&target, &path_string).await {
235 Ok(()) => Ok(SavepathResult::success()),
236 Err(err) => Ok(SavepathResult::failure(err.message, err.message_error)),
237 }
238}
239
240#[derive(Debug, Clone)]
241pub struct SavepathResult {
242 status: f64,
243 message: String,
244 message_id: String,
245}
246
247impl SavepathResult {
248 fn success() -> Self {
249 Self {
250 status: 0.0,
251 message: String::new(),
252 message_id: String::new(),
253 }
254 }
255
256 fn failure(message: String, message_error: &'static BuiltinErrorDescriptor) -> Self {
257 Self {
258 status: 1.0,
259 message,
260 message_id: message_error.identifier.unwrap_or_default().to_string(),
261 }
262 }
263
264 pub fn first_output(&self) -> Value {
265 Value::Num(self.status)
266 }
267
268 pub fn outputs(&self) -> Vec<Value> {
269 vec![
270 Value::Num(self.status),
271 char_array_value(&self.message),
272 char_array_value(&self.message_id),
273 ]
274 }
275
276 #[cfg(test)]
277 pub(crate) fn status(&self) -> f64 {
278 self.status
279 }
280
281 #[cfg(test)]
282 pub(crate) fn message(&self) -> &str {
283 &self.message
284 }
285
286 #[cfg(test)]
287 pub(crate) fn message_id(&self) -> &str {
288 &self.message_id
289 }
290}
291
292struct SavepathFailure {
293 message: String,
294 message_error: &'static BuiltinErrorDescriptor,
295}
296
297impl SavepathFailure {
298 fn new(message: String, message_error: &'static BuiltinErrorDescriptor) -> Self {
299 Self {
300 message,
301 message_error,
302 }
303 }
304
305 fn cannot_write(path: &Path, error: io::Error) -> Self {
306 Self::new(
307 format!(
308 "savepath: unable to write \"{}\": {}",
309 path.display(),
310 error
311 ),
312 &SAVEPATH_ERROR_CANNOT_WRITE,
313 )
314 }
315}
316
317async fn persist_path(target: &Path, path_string: &str) -> Result<(), SavepathFailure> {
318 if let Some(parent) = target.parent() {
319 if let Err(err) = vfs::create_dir_all_async(parent).await {
320 return Err(SavepathFailure::cannot_write(target, err));
321 }
322 }
323
324 let contents = build_pathdef_contents(path_string);
325 vfs::write_async(target, contents.as_bytes())
326 .await
327 .map_err(|err| SavepathFailure::cannot_write(target, err))
328}
329
330async fn default_target_path() -> Result<PathBuf, SavepathFailure> {
331 if let Ok(override_path) = env::var("RUNMAT_PATHDEF") {
332 if override_path.trim().is_empty() {
333 return Err(SavepathFailure::new(
334 "savepath: RUNMAT_PATHDEF is empty".to_string(),
335 &SAVEPATH_ERROR_CANNOT_RESOLVE,
336 ));
337 }
338 return resolve_explicit_path(&override_path).await;
339 }
340
341 let home = home_directory().ok_or_else(|| {
342 SavepathFailure::new(
343 "savepath: unable to determine default pathdef location".to_string(),
344 &SAVEPATH_ERROR_CANNOT_RESOLVE,
345 )
346 })?;
347 Ok(home.join(".runmat").join(DEFAULT_FILENAME))
348}
349
350async fn resolve_explicit_path(text: &str) -> Result<PathBuf, SavepathFailure> {
351 let expanded = match expand_user_path(text, "savepath") {
352 Ok(path) => path,
353 Err(err) => return Err(SavepathFailure::new(err, &SAVEPATH_ERROR_CANNOT_RESOLVE)),
354 };
355 let mut path = PathBuf::from(&expanded);
356 if path_should_be_directory(&path, text).await {
357 path.push(DEFAULT_FILENAME);
358 }
359 Ok(path)
360}
361
362async fn path_should_be_directory(path: &Path, original: &str) -> bool {
363 if original.ends_with(std::path::MAIN_SEPARATOR) || original.ends_with('/') {
364 return true;
365 }
366 if cfg!(windows) && original.ends_with('\\') {
367 return true;
368 }
369 match vfs::metadata_async(path).await {
370 Ok(metadata) => metadata.is_dir(),
371 Err(_) => false,
372 }
373}
374
375fn build_pathdef_contents(path_string: &str) -> String {
376 let mut contents = String::new();
377 contents.push_str("function p = pathdef\n");
378 contents.push_str("%PATHDEF Search path defaults generated by RunMat savepath.\n");
379 contents.push_str(
380 "% This file reproduces the MATLAB search path at the time savepath was called.\n",
381 );
382 if !path_string.is_empty() {
383 contents.push_str("%\n");
384 contents.push_str("% Directories on the saved path (in order):\n");
385 for entry in path_string.split(PATH_LIST_SEPARATOR) {
386 contents.push_str("% ");
387 contents.push_str(entry);
388 contents.push('\n');
389 }
390 }
391 contents.push('\n');
392 let escaped = path_string.replace('\'', "''");
393 contents.push_str("p = '");
394 contents.push_str(&escaped);
395 contents.push_str("';\n");
396 contents.push_str("end\n");
397 contents
398}
399
400fn extract_filename(value: &Value) -> BuiltinResult<String> {
401 match value {
402 Value::String(text) => Ok(text.clone()),
403 Value::StringArray(StringArray { data, .. }) => {
404 if data.len() != 1 {
405 Err(savepath_error(&SAVEPATH_ERROR_ARG_TYPE))
406 } else {
407 Ok(data[0].clone())
408 }
409 }
410 Value::CharArray(chars) => {
411 if chars.rows != 1 {
412 return Err(savepath_error(&SAVEPATH_ERROR_ARG_TYPE));
413 }
414 Ok(chars.data.iter().collect())
415 }
416 Value::Tensor(tensor) => tensor_to_string(tensor),
417 Value::GpuTensor(_) => Err(savepath_error(&SAVEPATH_ERROR_ARG_TYPE)),
418 _ => Err(savepath_error(&SAVEPATH_ERROR_ARG_TYPE)),
419 }
420}
421
422fn tensor_to_string(tensor: &Tensor) -> BuiltinResult<String> {
423 if tensor.shape.len() > 2 {
424 return Err(savepath_error(&SAVEPATH_ERROR_ARG_TYPE));
425 }
426 if tensor.rows() > 1 {
427 return Err(savepath_error(&SAVEPATH_ERROR_ARG_TYPE));
428 }
429
430 tensor_char_codes_to_string(tensor).ok_or_else(|| savepath_error(&SAVEPATH_ERROR_ARG_TYPE))
431}
432
433async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
434 let mut gathered = Vec::with_capacity(args.len());
435 for value in args {
436 gathered.push(
437 gather_if_needed_async(value)
438 .await
439 .map_err(map_control_flow)?,
440 );
441 }
442 Ok(gathered)
443}
444
445fn char_array_value(text: &str) -> Value {
446 Value::CharArray(CharArray::new_row(text))
447}
448
449#[cfg(test)]
450pub(crate) mod tests {
451 use super::super::REPL_FS_TEST_LOCK;
452 use super::*;
453 use crate::builtins::common::path_state::{current_path_string, set_path_string};
454 use crate::builtins::common::test_support;
455 #[cfg(feature = "wgpu")]
456 use runmat_accelerate_api::AccelProvider;
457 use runmat_accelerate_api::HostTensorView;
458 use std::fs;
459 use tempfile::tempdir;
460
461 fn savepath_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
462 futures::executor::block_on(super::savepath_builtin(args))
463 }
464
465 fn evaluate(args: &[Value]) -> BuiltinResult<SavepathResult> {
466 futures::executor::block_on(super::evaluate(args))
467 }
468
469 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
470 #[test]
471 fn savepath_descriptor_signatures_cover_core_forms() {
472 let labels: Vec<&str> = SAVEPATH_DESCRIPTOR
473 .signatures
474 .iter()
475 .map(|sig| sig.label)
476 .collect();
477 assert!(labels.contains(&"status = savepath()"));
478 assert!(labels.contains(&"status = savepath(filename)"));
479 assert!(labels.contains(&"[status, message, message_id] = savepath()"));
480 assert!(labels.contains(&"[status, message, message_id] = savepath(filename)"));
481 }
482
483 struct PathGuard {
484 previous: String,
485 }
486
487 impl PathGuard {
488 fn new() -> Self {
489 Self {
490 previous: current_path_string(),
491 }
492 }
493 }
494
495 impl Drop for PathGuard {
496 fn drop(&mut self) {
497 set_path_string(&self.previous);
498 }
499 }
500
501 struct PathdefEnvGuard {
502 previous: Option<String>,
503 }
504
505 impl PathdefEnvGuard {
506 fn set(path: &Path) -> Self {
507 let previous = env::var("RUNMAT_PATHDEF").ok();
508 env::set_var("RUNMAT_PATHDEF", path.to_string_lossy().to_string());
509 Self { previous }
510 }
511
512 fn set_raw(value: &str) -> Self {
513 let previous = env::var("RUNMAT_PATHDEF").ok();
514 env::set_var("RUNMAT_PATHDEF", value);
515 Self { previous }
516 }
517 }
518
519 impl Drop for PathdefEnvGuard {
520 fn drop(&mut self) {
521 if let Some(ref value) = self.previous {
522 env::set_var("RUNMAT_PATHDEF", value);
523 } else {
524 env::remove_var("RUNMAT_PATHDEF");
525 }
526 }
527 }
528
529 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
530 #[test]
531 fn savepath_writes_to_default_location_with_env_override() {
532 let _lock = REPL_FS_TEST_LOCK
533 .lock()
534 .unwrap_or_else(|poison| poison.into_inner());
535 let _guard = PathGuard::new();
536
537 let temp = tempdir().expect("tempdir");
538 let target = temp.path().join("pathdef_default.m");
539 let _env_guard = PathdefEnvGuard::set(&target);
540
541 let path_a = temp.path().join("toolbox");
542 let path_b = temp.path().join("utils");
543 let path_string = format!(
544 "{}{}{}",
545 path_a.to_string_lossy(),
546 PATH_LIST_SEPARATOR,
547 path_b.to_string_lossy()
548 );
549 set_path_string(&path_string);
550
551 let eval = evaluate(&[]).expect("evaluate");
552 assert_eq!(eval.status(), 0.0);
553 assert!(eval.message().is_empty());
554 assert!(eval.message_id().is_empty());
555
556 let contents = fs::read_to_string(&target).expect("pathdef contents");
557 assert!(contents.contains("function p = pathdef"));
558 assert!(contents.contains(path_a.to_string_lossy().as_ref()));
559 assert!(contents.contains(path_b.to_string_lossy().as_ref()));
560 assert_eq!(current_path_string(), path_string);
561 }
562
563 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
564 #[test]
565 fn savepath_env_override_empty_returns_failure() {
566 let _lock = REPL_FS_TEST_LOCK
567 .lock()
568 .unwrap_or_else(|poison| poison.into_inner());
569 let _guard = PathGuard::new();
570
571 let _env_guard = PathdefEnvGuard::set_raw("");
572 set_path_string("");
573
574 let eval = evaluate(&[]).expect("evaluate");
575 assert_eq!(eval.status(), 1.0);
576 assert!(eval.message().contains("RUNMAT_PATHDEF is empty"));
577 assert_eq!(
578 eval.message_id(),
579 SAVEPATH_ERROR_CANNOT_RESOLVE.identifier.unwrap_or_default()
580 );
581 }
582
583 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
584 #[test]
585 fn savepath_accepts_explicit_filename_argument() {
586 let _lock = REPL_FS_TEST_LOCK
587 .lock()
588 .unwrap_or_else(|poison| poison.into_inner());
589 let _guard = PathGuard::new();
590
591 let temp = tempdir().expect("tempdir");
592 let target = temp.path().join("custom_pathdef.m");
593 set_path_string("");
594
595 let eval =
596 evaluate(&[Value::from(target.to_string_lossy().to_string())]).expect("evaluate");
597 assert_eq!(eval.status(), 0.0);
598 assert!(target.exists());
599 }
600
601 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
602 #[test]
603 fn savepath_appends_default_filename_for_directories() {
604 let _lock = REPL_FS_TEST_LOCK
605 .lock()
606 .unwrap_or_else(|poison| poison.into_inner());
607 let _guard = PathGuard::new();
608
609 let temp = tempdir().expect("tempdir");
610 let dir = temp.path().join("profile");
611 fs::create_dir_all(&dir).expect("create dir");
612 let expected = dir.join(DEFAULT_FILENAME);
613
614 let eval = evaluate(&[Value::from(dir.to_string_lossy().to_string())]).expect("evaluate");
615 assert_eq!(eval.status(), 0.0);
616 assert!(expected.exists());
617 }
618
619 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
620 #[test]
621 fn savepath_appends_default_filename_for_trailing_separator() {
622 let _lock = REPL_FS_TEST_LOCK
623 .lock()
624 .unwrap_or_else(|poison| poison.into_inner());
625 let _guard = PathGuard::new();
626
627 let temp = tempdir().expect("tempdir");
628 let dir = temp.path().join("profile_trailing");
629 let mut raw = dir.to_string_lossy().to_string();
630 raw.push(std::path::MAIN_SEPARATOR);
631
632 set_path_string("");
633 let eval = evaluate(&[Value::from(raw)]).expect("evaluate");
634 assert_eq!(eval.status(), 0.0);
635 assert!(dir.join(DEFAULT_FILENAME).exists());
636 }
637
638 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
639 #[test]
640 fn savepath_returns_failure_when_write_fails() {
641 let _lock = REPL_FS_TEST_LOCK
642 .lock()
643 .unwrap_or_else(|poison| poison.into_inner());
644 let _guard = PathGuard::new();
645
646 let temp = tempdir().expect("tempdir");
647 let target = temp.path().join("readonly_pathdef.m");
648 fs::write(&target, "locked").expect("write");
649 let mut perms = fs::metadata(&target).expect("metadata").permissions();
650 let original_perms = perms.clone();
651 perms.set_readonly(true);
652 fs::set_permissions(&target, perms).expect("set readonly");
653
654 let eval =
655 evaluate(&[Value::from(target.to_string_lossy().to_string())]).expect("evaluate");
656 assert_eq!(eval.status(), 1.0);
657 assert!(eval.message().contains("unable to write"));
658 assert_eq!(
659 eval.message_id(),
660 SAVEPATH_ERROR_CANNOT_WRITE.identifier.unwrap_or_default()
661 );
662
663 let _ = fs::set_permissions(&target, original_perms);
665 }
666
667 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
668 #[test]
669 fn savepath_outputs_vector_contains_message_and_id() {
670 let _lock = REPL_FS_TEST_LOCK
671 .lock()
672 .unwrap_or_else(|poison| poison.into_inner());
673 let _guard = PathGuard::new();
674
675 let temp = tempdir().expect("tempdir");
676 let target = temp.path().join("outputs_pathdef.m");
677 let eval =
678 evaluate(&[Value::from(target.to_string_lossy().to_string())]).expect("evaluate");
679 let outputs = eval.outputs();
680 assert_eq!(outputs.len(), 3);
681 assert!(matches!(outputs[0], Value::Num(0.0)));
682 assert!(matches!(outputs[1], Value::CharArray(ref ca) if ca.cols == 0));
683 assert!(matches!(outputs[2], Value::CharArray(ref ca) if ca.cols == 0));
684 }
685
686 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
687 #[test]
688 fn savepath_rejects_empty_filename() {
689 let _lock = REPL_FS_TEST_LOCK
690 .lock()
691 .unwrap_or_else(|poison| poison.into_inner());
692 let _guard = PathGuard::new();
693
694 let err = evaluate(&[Value::from(String::new())]).expect_err("expected error");
695 assert_eq!(err.message(), SAVEPATH_ERROR_EMPTY_FILENAME.message);
696 }
697
698 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
699 #[test]
700 fn savepath_rejects_non_string_input() {
701 let err = savepath_builtin(vec![Value::Num(1.0)]).expect_err("expected error");
702 assert!(err.message().contains("savepath"));
703 }
704
705 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
706 #[test]
707 fn savepath_accepts_string_array_scalar_argument() {
708 let _lock = REPL_FS_TEST_LOCK
709 .lock()
710 .unwrap_or_else(|poison| poison.into_inner());
711 let _guard = PathGuard::new();
712
713 let temp = tempdir().expect("tempdir");
714 let target = temp.path().join("string_array_pathdef.m");
715 let array = StringArray::new(vec![target.to_string_lossy().to_string()], vec![1])
716 .expect("string array");
717
718 set_path_string("");
719 let eval = evaluate(&[Value::StringArray(array)]).expect("evaluate");
720 assert_eq!(eval.status(), 0.0);
721 assert!(target.exists());
722 }
723
724 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
725 #[test]
726 fn savepath_rejects_multi_element_string_array() {
727 let array = StringArray::new(vec!["a".to_string(), "b".to_string()], vec![1, 2])
728 .expect("string array");
729 let err = extract_filename(&Value::StringArray(array)).expect_err("expected error");
730 assert_eq!(err.message(), SAVEPATH_ERROR_ARG_TYPE.message);
731 }
732
733 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
734 #[test]
735 fn savepath_rejects_multi_row_char_array() {
736 let chars = CharArray::new("abcd".chars().collect(), 2, 2).expect("char array");
737 let err = extract_filename(&Value::CharArray(chars)).expect_err("expected error");
738 assert_eq!(err.message(), SAVEPATH_ERROR_ARG_TYPE.message);
739 }
740
741 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
742 #[test]
743 fn savepath_rejects_tensor_with_fractional_codes() {
744 let tensor = Tensor::new(vec![65.5], vec![1, 1]).expect("tensor");
745 let err = extract_filename(&Value::Tensor(tensor)).expect_err("expected error");
746 assert_eq!(err.message(), SAVEPATH_ERROR_ARG_TYPE.message);
747 }
748
749 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
750 #[test]
751 fn savepath_supports_gpu_tensor_filename() {
752 let _lock = REPL_FS_TEST_LOCK
753 .lock()
754 .unwrap_or_else(|poison| poison.into_inner());
755 let _guard = PathGuard::new();
756
757 let temp = tempdir().expect("tempdir");
758 let target = temp.path().join("gpu_tensor_pathdef.m");
759 set_path_string("");
760
761 test_support::with_test_provider(|provider| {
762 let text = target.to_string_lossy().to_string();
763 let ascii: Vec<f64> = text.chars().map(|ch| ch as u32 as f64).collect();
764 let tensor = Tensor::new(ascii.clone(), vec![1, ascii.len()]).expect("tensor");
765 let view = HostTensorView {
766 data: &tensor.materialize_f64(),
767 shape: &tensor.shape,
768 };
769 let handle = provider.upload(&view).expect("upload");
770
771 let eval = evaluate(&[Value::GpuTensor(handle.clone())]).expect("evaluate");
772 assert_eq!(eval.status(), 0.0);
773
774 provider.free(&handle).expect("free");
775 });
776
777 assert!(target.exists());
778 }
779
780 #[cfg(feature = "wgpu")]
781 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
782 #[test]
783 fn savepath_supports_gpu_tensor_filename_with_wgpu_provider() {
784 let _lock = REPL_FS_TEST_LOCK
785 .lock()
786 .unwrap_or_else(|poison| poison.into_inner());
787 let _guard = PathGuard::new();
788
789 let temp = tempdir().expect("tempdir");
790 let target = temp.path().join("wgpu_tensor_pathdef.m");
791 set_path_string("");
792
793 let provider = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
794 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
795 )
796 .expect("wgpu provider");
797
798 let text = target.to_string_lossy().to_string();
799 let ascii: Vec<f64> = text.chars().map(|ch| ch as u32 as f64).collect();
800 let tensor = Tensor::new(ascii.clone(), vec![1, ascii.len()]).expect("tensor");
801 let view = HostTensorView {
802 data: &tensor.materialize_f64(),
803 shape: &tensor.shape,
804 };
805 let handle = provider.upload(&view).expect("upload");
806
807 let eval = evaluate(&[Value::GpuTensor(handle.clone())]).expect("evaluate");
808 assert_eq!(eval.status(), 0.0);
809 assert!(target.exists());
810
811 provider.free(&handle).expect("free");
812 }
813}