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    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
211/// Evaluate `savepath` and expose all MATLAB-style outputs.
212pub 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        // Restore permissions so tempdir cleanup succeeds.
680        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}