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