1use runmat_builtins::{CharArray, StringArray, Tensor, Value};
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::fs::{expand_user_path, home_directory};
7use crate::builtins::common::path_state::{current_path_string, PATH_LIST_SEPARATOR};
8use crate::builtins::common::spec::{
9 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
10 ReductionNaN, ResidencyPolicy, ShapeRequirements,
11};
12#[cfg(feature = "doc_export")]
13use crate::register_builtin_doc_text;
14use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
15
16use std::env;
17use std::fs::{self, File};
18use std::io::{self, Write};
19use std::path::{Path, PathBuf};
20
21const DEFAULT_FILENAME: &str = "pathdef.m";
22const ERROR_ARG_TYPE: &str = "savepath: filename must be a character vector or string scalar";
23const ERROR_EMPTY_FILENAME: &str = "savepath: filename must not be empty";
24const MESSAGE_ID_CANNOT_WRITE: &str = "MATLAB:savepath:cannotWriteFile";
25const MESSAGE_ID_CANNOT_RESOLVE: &str = "MATLAB:savepath:cannotResolveFile";
26
27#[cfg(feature = "doc_export")]
28pub const DOC_MD: &str = r#"---
29title: "savepath"
30category: "io/repl_fs"
31keywords: ["savepath", "pathdef", "search path", "runmat path", "persist path"]
32summary: "Persist the current MATLAB search path to pathdef.m with status and diagnostic outputs."
33references:
34 - https://www.mathworks.com/help/matlab/ref/savepath.html
35gpu_support:
36 elementwise: false
37 reduction: false
38 precisions: []
39 broadcasting: "none"
40 notes: "Runs entirely on the CPU. gpuArray inputs are gathered before resolving the target file."
41fusion:
42 elementwise: false
43 reduction: false
44 max_inputs: 1
45 constants: "inline"
46requires_feature: null
47tested:
48 unit: "builtins::io::repl_fs::savepath::tests"
49 integration: "builtins::io::repl_fs::savepath::tests::savepath_returns_failure_when_write_fails"
50---
51
52# What does the `savepath` function do in MATLAB / RunMat?
53`savepath` writes the current MATLAB search path to a `pathdef.m` file so that
54future sessions can restore the same ordering. The file is a MATLAB function
55that returns the `path` character vector, matching MathWorks MATLAB semantics.
56
57## How does the `savepath` function behave in MATLAB / RunMat?
58- `savepath()` with no inputs writes to the default RunMat location
59 (`$HOME/.runmat/pathdef.m` on Linux/macOS, `%USERPROFILE%\.runmat\pathdef.m`
60 on Windows). The directory is created automatically when required.
61- `savepath(file)` writes to the specified file. Relative paths are resolved
62 against the current working directory, `~` expands to the user's home folder,
63 and supplying a directory (with or without a trailing separator) appends the
64 standard `pathdef.m` filename automatically.
65- The function does not modify the in-memory search path - it only writes the
66 current state to disk. Callers can therefore continue editing the path after
67 saving without interference.
68- `status = savepath(...)` returns `0` on success and `1` when the file cannot
69 be written. `[status, message, messageID] = savepath(...)` returns MATLAB-style
70 diagnostics describing the failure. Both message outputs are empty on success.
71- Invalid argument types raise `savepath: filename must be a character vector or
72 string scalar`. Empty filenames raise `savepath: filename must not be empty`.
73- When the `RUNMAT_PATHDEF` environment variable is set, the zero-argument form
74 uses that override instead of the default location.
75
76## `savepath` Function GPU Execution Behaviour
77`savepath` runs entirely on the host. If callers supply a GPU-resident string,
78RunMat gathers it back to CPU memory before resolving the target path. No
79acceleration provider hooks or kernels are required.
80
81## GPU residency in RunMat (Do I need `gpuArray`?)
82No. Because `savepath` interacts with the filesystem, GPU residency provides no
83benefit. The builtin automatically gathers GPU text inputs so existing scripts
84continue to work even if they accidentally construct filenames on the device.
85
86## Examples of using the `savepath` function in MATLAB / RunMat
87
88### Save The Current Search Path To The Default Location
89```matlab
90status = savepath();
91```
92Expected output:
93```matlab
94status =
95 0
96```
97
98### Persist A Project-Specific Pathdef File
99```matlab
100status = savepath("config/project_pathdef.m");
101```
102Expected output:
103```matlab
104status =
105 0
106```
107
108### Capture Status, Message, And Message ID
109```matlab
110[status, message, messageID] = savepath("config/pathdef.m");
111if status ~= 0
112 warning("Failed to save the path: %s (%s)", message, messageID);
113end
114```
115
116### Append Genpath Output And Persist The Result
117```matlab
118tooling = genpath("third_party/toolchain");
119addpath(tooling, "-end");
120savepath();
121```
122
123### Save A Pathdef Using A Directory Argument
124```matlab
125mkdir("~/.runmat/projectA");
126savepath("~/.runmat/projectA/");
127```
128Expected behavior:
129```matlab
130% Creates ~/.runmat/projectA/pathdef.m with the current search path.
131```
132
133### Override The Target File With RUNMAT_PATHDEF
134```matlab
135setenv("RUNMAT_PATHDEF", fullfile(tempdir, "pathdef-dev.m"));
136savepath();
137```
138Expected behavior:
139```matlab
140% The file tempdir/pathdef-dev.m now contains the MATLAB path definition.
141```
142
143### Use gpuArray Inputs Transparently
144```matlab
145status = savepath(gpuArray("pathdefs/pathdef_gpu.m"));
146```
147Expected output:
148```matlab
149status =
150 0
151```
152
153### Inspect The Generated pathdef.m File
154```matlab
155savepath("toolbox/pathdef.m");
156type toolbox/pathdef.m;
157```
158Expected behavior:
159```matlab
160% Displays the MATLAB function that reproduces the saved search path.
161```
162
163## FAQ
164- **Where does `savepath` write by default?** RunMat uses
165 `$HOME/.runmat/pathdef.m` (Linux/macOS) or `%USERPROFILE%\.runmat\pathdef.m`
166 (Windows). Set `RUNMAT_PATHDEF` to override this location.
167- **Does `savepath` create missing folders?** Yes. When the parent directory
168 does not exist, RunMat creates it automatically before writing the file.
169- **What happens if the file is read-only?** `savepath` returns `status = 1`
170 together with the diagnostic message and message ID
171 `MATLAB:savepath:cannotWriteFile`. The existing file is left untouched.
172- **Does `savepath` modify the current path?** No. It only writes out the path.
173 Use `addpath`, `rmpath`, or `path` to change the in-memory value.
174- **Are argument types validated?** Yes. Inputs must be character vectors or
175 string scalars. String arrays with multiple elements and numeric arrays raise
176 an error.
177- **Is the generated file MATLAB-compatible?** Yes. RunMat writes a MATLAB
178 function named `pathdef` that returns the exact character vector stored by
179 the `path` builtin, so MathWorks MATLAB and RunMat can both execute it.
180- **How do I restore the path later?** Evaluate the generated `pathdef.m`
181 (for example by calling `run('~/pathdef.m')`) and pass the returned value to
182 `path()`. Future RunMat releases will load the default file automatically.
183- **Can I store multiple path definitions?** Absolutely. Call `savepath` with
184 different filenames for each profile, then run the desired file to switch.
185- **Is `savepath` safe to call concurrently?** The builtin serializes through
186 the filesystem. When multiple sessions write to the same path at once, the
187 last write wins - this matches MATLAB's behavior.
188- **Does `savepath` include the current folder (`pwd`)?** The file mirrors the
189 output of the `path` builtin, which omits the implicit current folder exactly
190 as MATLAB does.
191
192## See Also
193[path](./path), [addpath](./addpath), [rmpath](./rmpath), [genpath](./genpath)
194
195## Source & Feedback
196- Source: [`crates/runmat-runtime/src/builtins/io/repl_fs/savepath.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/repl_fs/savepath.rs)
197- Issues: [Open a GitHub ticket](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
198"#;
199
200pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
201 name: "savepath",
202 op_kind: GpuOpKind::Custom("io"),
203 supported_precisions: &[],
204 broadcast: BroadcastSemantics::None,
205 provider_hooks: &[],
206 constant_strategy: ConstantStrategy::InlineLiteral,
207 residency: ResidencyPolicy::GatherImmediately,
208 nan_mode: ReductionNaN::Include,
209 two_pass_threshold: None,
210 workgroup_size: None,
211 accepts_nan_mode: false,
212 notes:
213 "Filesystem persistence executes on the host; GPU-resident filenames are gathered before writing pathdef.m.",
214};
215
216register_builtin_gpu_spec!(GPU_SPEC);
217
218pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
219 name: "savepath",
220 shape: ShapeRequirements::Any,
221 constant_strategy: ConstantStrategy::InlineLiteral,
222 elementwise: None,
223 reduction: None,
224 emits_nan: false,
225 notes:
226 "Filesystem side-effects are not eligible for fusion; metadata registered for completeness.",
227};
228
229register_builtin_fusion_spec!(FUSION_SPEC);
230
231#[cfg(feature = "doc_export")]
232register_builtin_doc_text!("savepath", DOC_MD);
233
234#[runtime_builtin(
235 name = "savepath",
236 category = "io/repl_fs",
237 summary = "Persist the current MATLAB search path to pathdef.m with status outputs.",
238 keywords = "savepath,pathdef,search path,runmat path,persist path",
239 accel = "cpu"
240)]
241fn savepath_builtin(args: Vec<Value>) -> Result<Value, String> {
242 let eval = evaluate(&args)?;
243 Ok(eval.first_output())
244}
245
246pub fn evaluate(args: &[Value]) -> Result<SavepathResult, String> {
248 let gathered = gather_arguments(args)?;
249 let target = match gathered.len() {
250 0 => match default_target_path() {
251 Ok(path) => path,
252 Err(err) => return Ok(SavepathResult::failure(err.message, err.message_id)),
253 },
254 1 => {
255 let raw = extract_filename(&gathered[0])?;
256 if raw.is_empty() {
257 return Err(ERROR_EMPTY_FILENAME.to_string());
258 }
259 match resolve_explicit_path(&raw) {
260 Ok(path) => path,
261 Err(err) => return Ok(SavepathResult::failure(err.message, err.message_id)),
262 }
263 }
264 _ => return Err("savepath: too many input arguments".to_string()),
265 };
266
267 let path_string = current_path_string();
268 match persist_path(&target, &path_string) {
269 Ok(()) => Ok(SavepathResult::success()),
270 Err(err) => Ok(SavepathResult::failure(err.message, err.message_id)),
271 }
272}
273
274#[derive(Debug, Clone)]
275pub struct SavepathResult {
276 status: f64,
277 message: String,
278 message_id: String,
279}
280
281impl SavepathResult {
282 fn success() -> Self {
283 Self {
284 status: 0.0,
285 message: String::new(),
286 message_id: String::new(),
287 }
288 }
289
290 fn failure(message: String, message_id: &'static str) -> Self {
291 Self {
292 status: 1.0,
293 message,
294 message_id: message_id.to_string(),
295 }
296 }
297
298 pub fn first_output(&self) -> Value {
299 Value::Num(self.status)
300 }
301
302 pub fn outputs(&self) -> Vec<Value> {
303 vec![
304 Value::Num(self.status),
305 char_array_value(&self.message),
306 char_array_value(&self.message_id),
307 ]
308 }
309
310 #[cfg(test)]
311 pub(crate) fn status(&self) -> f64 {
312 self.status
313 }
314
315 #[cfg(test)]
316 pub(crate) fn message(&self) -> &str {
317 &self.message
318 }
319
320 #[cfg(test)]
321 pub(crate) fn message_id(&self) -> &str {
322 &self.message_id
323 }
324}
325
326struct SavepathFailure {
327 message: String,
328 message_id: &'static str,
329}
330
331impl SavepathFailure {
332 fn new(message: String, message_id: &'static str) -> Self {
333 Self {
334 message,
335 message_id,
336 }
337 }
338
339 fn cannot_write(path: &Path, error: io::Error) -> Self {
340 Self::new(
341 format!(
342 "savepath: unable to write \"{}\": {}",
343 path.display(),
344 error
345 ),
346 MESSAGE_ID_CANNOT_WRITE,
347 )
348 }
349}
350
351fn persist_path(target: &Path, path_string: &str) -> Result<(), SavepathFailure> {
352 if let Some(parent) = target.parent() {
353 if let Err(err) = fs::create_dir_all(parent) {
354 return Err(SavepathFailure::cannot_write(target, err));
355 }
356 }
357
358 let contents = build_pathdef_contents(path_string);
359 match File::create(target) {
360 Ok(mut file) => {
361 if let Err(err) = file.write_all(contents.as_bytes()) {
362 return Err(SavepathFailure::cannot_write(target, err));
363 }
364 if let Err(err) = file.flush() {
365 return Err(SavepathFailure::cannot_write(target, err));
366 }
367 Ok(())
368 }
369 Err(err) => Err(SavepathFailure::cannot_write(target, err)),
370 }
371}
372
373fn default_target_path() -> Result<PathBuf, SavepathFailure> {
374 if let Ok(override_path) = env::var("RUNMAT_PATHDEF") {
375 if override_path.trim().is_empty() {
376 return Err(SavepathFailure::new(
377 "savepath: RUNMAT_PATHDEF is empty".to_string(),
378 MESSAGE_ID_CANNOT_RESOLVE,
379 ));
380 }
381 return resolve_explicit_path(&override_path);
382 }
383
384 let home = home_directory().ok_or_else(|| {
385 SavepathFailure::new(
386 "savepath: unable to determine default pathdef location".to_string(),
387 MESSAGE_ID_CANNOT_RESOLVE,
388 )
389 })?;
390 Ok(home.join(".runmat").join(DEFAULT_FILENAME))
391}
392
393fn resolve_explicit_path(text: &str) -> Result<PathBuf, SavepathFailure> {
394 let expanded = match expand_user_path(text, "savepath") {
395 Ok(path) => path,
396 Err(err) => return Err(SavepathFailure::new(err, MESSAGE_ID_CANNOT_RESOLVE)),
397 };
398 let mut path = PathBuf::from(&expanded);
399 if path_should_be_directory(&path, text) {
400 path.push(DEFAULT_FILENAME);
401 }
402 Ok(path)
403}
404
405fn path_should_be_directory(path: &Path, original: &str) -> bool {
406 if original.ends_with(std::path::MAIN_SEPARATOR) || original.ends_with('/') {
407 return true;
408 }
409 if cfg!(windows) && original.ends_with('\\') {
410 return true;
411 }
412 match fs::metadata(path) {
413 Ok(metadata) => metadata.is_dir(),
414 Err(_) => false,
415 }
416}
417
418fn build_pathdef_contents(path_string: &str) -> String {
419 let mut contents = String::new();
420 contents.push_str("function p = pathdef\n");
421 contents.push_str("%PATHDEF Search path defaults generated by RunMat savepath.\n");
422 contents.push_str(
423 "% This file reproduces the MATLAB search path at the time savepath was called.\n",
424 );
425 if !path_string.is_empty() {
426 contents.push_str("%\n");
427 contents.push_str("% Directories on the saved path (in order):\n");
428 for entry in path_string.split(PATH_LIST_SEPARATOR) {
429 contents.push_str("% ");
430 contents.push_str(entry);
431 contents.push('\n');
432 }
433 }
434 contents.push('\n');
435 let escaped = path_string.replace('\'', "''");
436 contents.push_str("p = '");
437 contents.push_str(&escaped);
438 contents.push_str("';\n");
439 contents.push_str("end\n");
440 contents
441}
442
443fn extract_filename(value: &Value) -> Result<String, String> {
444 match value {
445 Value::String(text) => Ok(text.clone()),
446 Value::StringArray(StringArray { data, .. }) => {
447 if data.len() != 1 {
448 Err(ERROR_ARG_TYPE.to_string())
449 } else {
450 Ok(data[0].clone())
451 }
452 }
453 Value::CharArray(chars) => {
454 if chars.rows != 1 {
455 return Err(ERROR_ARG_TYPE.to_string());
456 }
457 Ok(chars.data.iter().collect())
458 }
459 Value::Tensor(tensor) => tensor_to_string(tensor),
460 Value::GpuTensor(_) => Err(ERROR_ARG_TYPE.to_string()),
461 _ => Err(ERROR_ARG_TYPE.to_string()),
462 }
463}
464
465fn tensor_to_string(tensor: &Tensor) -> Result<String, String> {
466 if tensor.shape.len() > 2 {
467 return Err(ERROR_ARG_TYPE.to_string());
468 }
469 if tensor.rows() > 1 {
470 return Err(ERROR_ARG_TYPE.to_string());
471 }
472
473 let mut text = String::with_capacity(tensor.data.len());
474 for &code in &tensor.data {
475 if !code.is_finite() {
476 return Err(ERROR_ARG_TYPE.to_string());
477 }
478 let rounded = code.round();
479 if (code - rounded).abs() > 1e-6 {
480 return Err(ERROR_ARG_TYPE.to_string());
481 }
482 let int_code = rounded as i64;
483 if !(0..=0x10FFFF).contains(&int_code) {
484 return Err(ERROR_ARG_TYPE.to_string());
485 }
486 let ch = char::from_u32(int_code as u32).ok_or_else(|| ERROR_ARG_TYPE.to_string())?;
487 text.push(ch);
488 }
489 Ok(text)
490}
491
492fn gather_arguments(args: &[Value]) -> Result<Vec<Value>, String> {
493 let mut gathered = Vec::with_capacity(args.len());
494 for value in args {
495 gathered.push(gather_if_needed(value).map_err(|err| format!("savepath: {err}"))?);
496 }
497 Ok(gathered)
498}
499
500fn char_array_value(text: &str) -> Value {
501 Value::CharArray(CharArray::new_row(text))
502}
503
504#[cfg(test)]
505mod tests {
506 use super::super::REPL_FS_TEST_LOCK;
507 use super::*;
508 use crate::builtins::common::path_state::{current_path_string, set_path_string};
509 use crate::builtins::common::test_support;
510 #[cfg(feature = "wgpu")]
511 use runmat_accelerate_api::AccelProvider;
512 use runmat_accelerate_api::HostTensorView;
513 use std::fs;
514 use tempfile::tempdir;
515
516 struct PathGuard {
517 previous: String,
518 }
519
520 impl PathGuard {
521 fn new() -> Self {
522 Self {
523 previous: current_path_string(),
524 }
525 }
526 }
527
528 impl Drop for PathGuard {
529 fn drop(&mut self) {
530 set_path_string(&self.previous);
531 }
532 }
533
534 struct PathdefEnvGuard {
535 previous: Option<String>,
536 }
537
538 impl PathdefEnvGuard {
539 fn set(path: &Path) -> Self {
540 let previous = env::var("RUNMAT_PATHDEF").ok();
541 env::set_var("RUNMAT_PATHDEF", path.to_string_lossy().to_string());
542 Self { previous }
543 }
544
545 fn set_raw(value: &str) -> Self {
546 let previous = env::var("RUNMAT_PATHDEF").ok();
547 env::set_var("RUNMAT_PATHDEF", value);
548 Self { previous }
549 }
550 }
551
552 impl Drop for PathdefEnvGuard {
553 fn drop(&mut self) {
554 if let Some(ref value) = self.previous {
555 env::set_var("RUNMAT_PATHDEF", value);
556 } else {
557 env::remove_var("RUNMAT_PATHDEF");
558 }
559 }
560 }
561
562 #[test]
563 fn savepath_writes_to_default_location_with_env_override() {
564 let _lock = REPL_FS_TEST_LOCK
565 .lock()
566 .unwrap_or_else(|poison| poison.into_inner());
567 let _guard = PathGuard::new();
568
569 let temp = tempdir().expect("tempdir");
570 let target = temp.path().join("pathdef_default.m");
571 let _env_guard = PathdefEnvGuard::set(&target);
572
573 let path_a = temp.path().join("toolbox");
574 let path_b = temp.path().join("utils");
575 let path_string = format!(
576 "{}{}{}",
577 path_a.to_string_lossy(),
578 PATH_LIST_SEPARATOR,
579 path_b.to_string_lossy()
580 );
581 set_path_string(&path_string);
582
583 let eval = evaluate(&[]).expect("evaluate");
584 assert_eq!(eval.status(), 0.0);
585 assert!(eval.message().is_empty());
586 assert!(eval.message_id().is_empty());
587
588 let contents = fs::read_to_string(&target).expect("pathdef contents");
589 assert!(contents.contains("function p = pathdef"));
590 assert!(contents.contains(path_a.to_string_lossy().as_ref()));
591 assert!(contents.contains(path_b.to_string_lossy().as_ref()));
592 assert_eq!(current_path_string(), path_string);
593 }
594
595 #[test]
596 fn savepath_env_override_empty_returns_failure() {
597 let _lock = REPL_FS_TEST_LOCK
598 .lock()
599 .unwrap_or_else(|poison| poison.into_inner());
600 let _guard = PathGuard::new();
601
602 let _env_guard = PathdefEnvGuard::set_raw("");
603 set_path_string("");
604
605 let eval = evaluate(&[]).expect("evaluate");
606 assert_eq!(eval.status(), 1.0);
607 assert!(eval.message().contains("RUNMAT_PATHDEF is empty"));
608 assert_eq!(eval.message_id(), MESSAGE_ID_CANNOT_RESOLVE);
609 }
610
611 #[test]
612 fn savepath_accepts_explicit_filename_argument() {
613 let _lock = REPL_FS_TEST_LOCK
614 .lock()
615 .unwrap_or_else(|poison| poison.into_inner());
616 let _guard = PathGuard::new();
617
618 let temp = tempdir().expect("tempdir");
619 let target = temp.path().join("custom_pathdef.m");
620 set_path_string("");
621
622 let eval =
623 evaluate(&[Value::from(target.to_string_lossy().to_string())]).expect("evaluate");
624 assert_eq!(eval.status(), 0.0);
625 assert!(target.exists());
626 }
627
628 #[test]
629 fn savepath_appends_default_filename_for_directories() {
630 let _lock = REPL_FS_TEST_LOCK
631 .lock()
632 .unwrap_or_else(|poison| poison.into_inner());
633 let _guard = PathGuard::new();
634
635 let temp = tempdir().expect("tempdir");
636 let dir = temp.path().join("profile");
637 fs::create_dir_all(&dir).expect("create dir");
638 let expected = dir.join(DEFAULT_FILENAME);
639
640 let eval = evaluate(&[Value::from(dir.to_string_lossy().to_string())]).expect("evaluate");
641 assert_eq!(eval.status(), 0.0);
642 assert!(expected.exists());
643 }
644
645 #[test]
646 fn savepath_appends_default_filename_for_trailing_separator() {
647 let _lock = REPL_FS_TEST_LOCK
648 .lock()
649 .unwrap_or_else(|poison| poison.into_inner());
650 let _guard = PathGuard::new();
651
652 let temp = tempdir().expect("tempdir");
653 let dir = temp.path().join("profile_trailing");
654 let mut raw = dir.to_string_lossy().to_string();
655 raw.push(std::path::MAIN_SEPARATOR);
656
657 set_path_string("");
658 let eval = evaluate(&[Value::from(raw)]).expect("evaluate");
659 assert_eq!(eval.status(), 0.0);
660 assert!(dir.join(DEFAULT_FILENAME).exists());
661 }
662
663 #[test]
664 fn savepath_returns_failure_when_write_fails() {
665 let _lock = REPL_FS_TEST_LOCK
666 .lock()
667 .unwrap_or_else(|poison| poison.into_inner());
668 let _guard = PathGuard::new();
669
670 let temp = tempdir().expect("tempdir");
671 let target = temp.path().join("readonly_pathdef.m");
672 fs::write(&target, "locked").expect("write");
673 let mut perms = fs::metadata(&target).expect("metadata").permissions();
674 let original_perms = perms.clone();
675 perms.set_readonly(true);
676 fs::set_permissions(&target, perms).expect("set readonly");
677
678 let eval =
679 evaluate(&[Value::from(target.to_string_lossy().to_string())]).expect("evaluate");
680 assert_eq!(eval.status(), 1.0);
681 assert!(eval.message().contains("unable to write"));
682 assert_eq!(eval.message_id(), MESSAGE_ID_CANNOT_WRITE);
683
684 let _ = fs::set_permissions(&target, original_perms);
686 }
687
688 #[test]
689 fn savepath_outputs_vector_contains_message_and_id() {
690 let _lock = REPL_FS_TEST_LOCK
691 .lock()
692 .unwrap_or_else(|poison| poison.into_inner());
693 let _guard = PathGuard::new();
694
695 let temp = tempdir().expect("tempdir");
696 let target = temp.path().join("outputs_pathdef.m");
697 let eval =
698 evaluate(&[Value::from(target.to_string_lossy().to_string())]).expect("evaluate");
699 let outputs = eval.outputs();
700 assert_eq!(outputs.len(), 3);
701 assert!(matches!(outputs[0], Value::Num(0.0)));
702 assert!(matches!(outputs[1], Value::CharArray(ref ca) if ca.cols == 0));
703 assert!(matches!(outputs[2], Value::CharArray(ref ca) if ca.cols == 0));
704 }
705
706 #[test]
707 fn savepath_rejects_empty_filename() {
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 err = evaluate(&[Value::from(String::new())]).expect_err("expected error");
714 assert_eq!(err, ERROR_EMPTY_FILENAME);
715 }
716
717 #[test]
718 fn savepath_rejects_non_string_input() {
719 let err = savepath_builtin(vec![Value::Num(1.0)]).expect_err("expected error");
720 assert!(err.contains("savepath"));
721 }
722
723 #[test]
724 fn savepath_accepts_string_array_scalar_argument() {
725 let _lock = REPL_FS_TEST_LOCK
726 .lock()
727 .unwrap_or_else(|poison| poison.into_inner());
728 let _guard = PathGuard::new();
729
730 let temp = tempdir().expect("tempdir");
731 let target = temp.path().join("string_array_pathdef.m");
732 let array = StringArray::new(vec![target.to_string_lossy().to_string()], vec![1])
733 .expect("string array");
734
735 set_path_string("");
736 let eval = evaluate(&[Value::StringArray(array)]).expect("evaluate");
737 assert_eq!(eval.status(), 0.0);
738 assert!(target.exists());
739 }
740
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, ERROR_ARG_TYPE);
747 }
748
749 #[test]
750 fn savepath_rejects_multi_row_char_array() {
751 let chars = CharArray::new("abcd".chars().collect(), 2, 2).expect("char array");
752 let err = extract_filename(&Value::CharArray(chars)).expect_err("expected error");
753 assert_eq!(err, ERROR_ARG_TYPE);
754 }
755
756 #[test]
757 fn savepath_rejects_tensor_with_fractional_codes() {
758 let tensor = Tensor::new(vec![65.5], vec![1, 1]).expect("tensor");
759 let err = extract_filename(&Value::Tensor(tensor)).expect_err("expected error");
760 assert_eq!(err, ERROR_ARG_TYPE);
761 }
762
763 #[test]
764 fn savepath_supports_gpu_tensor_filename() {
765 let _lock = REPL_FS_TEST_LOCK
766 .lock()
767 .unwrap_or_else(|poison| poison.into_inner());
768 let _guard = PathGuard::new();
769
770 let temp = tempdir().expect("tempdir");
771 let target = temp.path().join("gpu_tensor_pathdef.m");
772 set_path_string("");
773
774 test_support::with_test_provider(|provider| {
775 let text = target.to_string_lossy().to_string();
776 let ascii: Vec<f64> = text.chars().map(|ch| ch as u32 as f64).collect();
777 let tensor = Tensor::new(ascii.clone(), vec![1, ascii.len()]).expect("tensor");
778 let view = HostTensorView {
779 data: &tensor.data,
780 shape: &tensor.shape,
781 };
782 let handle = provider.upload(&view).expect("upload");
783
784 let eval = evaluate(&[Value::GpuTensor(handle.clone())]).expect("evaluate");
785 assert_eq!(eval.status(), 0.0);
786
787 provider.free(&handle).expect("free");
788 });
789
790 assert!(target.exists());
791 }
792
793 #[cfg(feature = "wgpu")]
794 #[test]
795 fn savepath_supports_gpu_tensor_filename_with_wgpu_provider() {
796 let _lock = REPL_FS_TEST_LOCK
797 .lock()
798 .unwrap_or_else(|poison| poison.into_inner());
799 let _guard = PathGuard::new();
800
801 let temp = tempdir().expect("tempdir");
802 let target = temp.path().join("wgpu_tensor_pathdef.m");
803 set_path_string("");
804
805 let provider = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
806 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
807 )
808 .expect("wgpu provider");
809
810 let text = target.to_string_lossy().to_string();
811 let ascii: Vec<f64> = text.chars().map(|ch| ch as u32 as f64).collect();
812 let tensor = Tensor::new(ascii.clone(), vec![1, ascii.len()]).expect("tensor");
813 let view = HostTensorView {
814 data: &tensor.data,
815 shape: &tensor.shape,
816 };
817 let handle = provider.upload(&view).expect("upload");
818
819 let eval = evaluate(&[Value::GpuTensor(handle.clone())]).expect("evaluate");
820 assert_eq!(eval.status(), 0.0);
821 assert!(target.exists());
822
823 provider.free(&handle).expect("free");
824 }
825
826 #[test]
827 #[cfg(feature = "doc_export")]
828 fn doc_examples_present() {
829 let blocks = crate::builtins::common::test_support::doc_examples(DOC_MD);
830 assert!(!blocks.is_empty());
831 }
832}