runmat_runtime/builtins/io/repl_fs/
savepath.rs

1//! MATLAB-compatible `savepath` builtin for persisting the session search path.
2
3use 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
246/// Evaluate `savepath` and expose all MATLAB-style outputs.
247pub 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        // Restore permissions so tempdir cleanup succeeds.
685        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}