Skip to main content

runmat_runtime/builtins/io/repl_fs/
savepath.rs

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