Skip to main content

runmat_runtime/builtins/io/repl_fs/
genpath.rs

1//! MATLAB-compatible `genpath` builtin for generating recursive search paths.
2
3use runmat_builtins::{CharArray, StringArray, Tensor, Value};
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::fs::{compare_names, expand_user_path, path_to_string};
7use crate::builtins::common::spec::{
8    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
9    ReductionNaN, ResidencyPolicy, ShapeRequirements,
10};
11use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
12
13use runmat_filesystem as vfs;
14use std::collections::HashSet;
15#[cfg(test)]
16use std::env;
17use std::path::{Path, PathBuf};
18
19const ERROR_FOLDER_TYPE: &str = "genpath: folder must be a character vector or string scalar";
20const ERROR_EXCLUDES_TYPE: &str = "genpath: excludes must be a character vector or string scalar";
21
22#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::genpath")]
23pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
24    name: "genpath",
25    op_kind: GpuOpKind::Custom("io"),
26    supported_precisions: &[],
27    broadcast: BroadcastSemantics::None,
28    provider_hooks: &[],
29    constant_strategy: ConstantStrategy::InlineLiteral,
30    residency: ResidencyPolicy::GatherImmediately,
31    nan_mode: ReductionNaN::Include,
32    two_pass_threshold: None,
33    workgroup_size: None,
34    accepts_nan_mode: false,
35    notes: "Filesystem traversal is a host-only operation; inputs are gathered before processing.",
36};
37
38#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::genpath")]
39pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
40    name: "genpath",
41    shape: ShapeRequirements::Any,
42    constant_strategy: ConstantStrategy::InlineLiteral,
43    elementwise: None,
44    reduction: None,
45    emits_nan: false,
46    notes:
47        "I/O-oriented builtins are not eligible for fusion; metadata registered for completeness.",
48};
49
50const BUILTIN_NAME: &str = "genpath";
51
52fn genpath_error(message: impl Into<String>) -> RuntimeError {
53    build_runtime_error(message)
54        .with_builtin(BUILTIN_NAME)
55        .build()
56}
57
58fn map_control_flow(err: RuntimeError) -> RuntimeError {
59    let identifier = err.identifier().map(str::to_string);
60    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
61        .with_builtin(BUILTIN_NAME)
62        .with_source(err);
63    if let Some(identifier) = identifier {
64        builder = builder.with_identifier(identifier);
65    }
66    builder.build()
67}
68
69#[runtime_builtin(
70    name = "genpath",
71    category = "io/repl_fs",
72    summary = "Generate a MATLAB-style search path string for a folder tree.",
73    keywords = "genpath,recursive path,search path,addpath",
74    accel = "cpu",
75    suppress_auto_output = true,
76    type_resolver(crate::builtins::io::type_resolvers::genpath_type),
77    builtin_path = "crate::builtins::io::repl_fs::genpath"
78)]
79async fn genpath_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
80    let gathered = gather_arguments(args).await?;
81    match gathered.len() {
82        0 => generate_from_current_directory().await,
83        1 => generate_from_root(&gathered[0], None).await,
84        2 => generate_from_root(&gathered[0], Some(&gathered[1])).await,
85        _ => Err(genpath_error("genpath: too many input arguments")),
86    }
87}
88
89async fn generate_from_current_directory() -> BuiltinResult<Value> {
90    let cwd = vfs::current_dir().map_err(|err| {
91        genpath_error(format!(
92            "genpath: unable to resolve current directory: {err}"
93        ))
94    })?;
95    let (canonical_path, canonical_str) =
96        canonicalize_existing_async(&cwd, "current directory").await?;
97    let excludes = ExcludeSet::default();
98    let mut seen = HashSet::new();
99    let mut segments = Vec::new();
100    traverse(
101        &canonical_path,
102        canonical_str,
103        &excludes,
104        &mut seen,
105        &mut segments,
106    )
107    .await?;
108    Ok(char_array_value(&join_segments(&segments)))
109}
110
111async fn generate_from_root(root: &Value, excludes: Option<&Value>) -> BuiltinResult<Value> {
112    let root_text = extract_text(root, ERROR_FOLDER_TYPE)?;
113    let root_info = normalize_root(&root_text).await?;
114    let exclude_text = excludes
115        .map(|value| extract_text(value, ERROR_EXCLUDES_TYPE))
116        .transpose()?;
117    let exclude_set = build_exclude_set(exclude_text.as_deref(), &root_info).await?;
118    let mut seen = HashSet::new();
119    let mut segments = Vec::new();
120    traverse(
121        &root_info.path,
122        root_info.canonical.clone(),
123        &exclude_set,
124        &mut seen,
125        &mut segments,
126    )
127    .await?;
128    Ok(char_array_value(&join_segments(&segments)))
129}
130
131async fn gather_arguments(args: Vec<Value>) -> BuiltinResult<Vec<Value>> {
132    let mut gathered = Vec::with_capacity(args.len());
133    for value in args {
134        let host_value = gather_if_needed_async(&value)
135            .await
136            .map_err(map_control_flow)?;
137        gathered.push(host_value);
138    }
139    Ok(gathered)
140}
141
142struct RootInfo {
143    path: PathBuf,
144    canonical: String,
145}
146
147async fn normalize_root(text: &str) -> BuiltinResult<RootInfo> {
148    if text.trim().is_empty() {
149        return Err(genpath_error(format!("genpath: folder '{text}' not found")));
150    }
151
152    let expanded = expand_user_path(text, "genpath").map_err(genpath_error)?;
153    let raw_path = PathBuf::from(&expanded);
154    let absolute = if raw_path.is_absolute() {
155        raw_path
156    } else {
157        let cwd = vfs::current_dir().map_err(|err| {
158            genpath_error(format!(
159                "genpath: unable to resolve current directory: {err}"
160            ))
161        })?;
162        cwd.join(raw_path)
163    };
164
165    let (canonical_path, canonical_str) = canonicalize_existing_async(&absolute, text).await?;
166
167    Ok(RootInfo {
168        path: canonical_path,
169        canonical: canonical_str,
170    })
171}
172
173async fn canonicalize_existing_async(
174    path: &Path,
175    display: &str,
176) -> BuiltinResult<(PathBuf, String)> {
177    let canonical = vfs::canonicalize_async(path)
178        .await
179        .map_err(|_| genpath_error(format!("genpath: folder '{display}' not found")))?;
180    let canonical_str = canonical_string_from_path(&canonical);
181    Ok((canonical, canonical_str))
182}
183
184#[cfg(test)]
185fn canonicalize_existing(path: &Path, display: &str) -> BuiltinResult<(PathBuf, String)> {
186    futures::executor::block_on(canonicalize_existing_async(path, display))
187}
188
189#[cfg(windows)]
190fn canonical_string_from_path(path: &Path) -> String {
191    let mut text = path_to_string(path);
192    if let Some(stripped) = text.strip_prefix(r"\\?\") {
193        text = stripped.to_string();
194    }
195    text
196}
197
198#[cfg(not(windows))]
199fn canonical_string_from_path(path: &Path) -> String {
200    path_to_string(path)
201}
202
203fn join_segments(segments: &[String]) -> String {
204    if segments.is_empty() {
205        return String::new();
206    }
207    let mut output = String::new();
208    for (index, segment) in segments.iter().enumerate() {
209        if index > 0 {
210            output.push(crate::builtins::common::path_state::PATH_LIST_SEPARATOR);
211        }
212        output.push_str(segment);
213    }
214    output
215}
216
217#[async_recursion::async_recursion(?Send)]
218async fn traverse(
219    path: &Path,
220    canonical: String,
221    excludes: &ExcludeSet,
222    seen: &mut HashSet<String>,
223    segments: &mut Vec<String>,
224) -> BuiltinResult<()> {
225    let normalized = normalize_case(&canonical);
226    if !seen.insert(normalized) {
227        return Ok(());
228    }
229
230    if excludes.contains(&canonical) {
231        return Ok(());
232    }
233
234    segments.push(canonical.clone());
235
236    let mut children = Vec::new();
237    let entries = match vfs::read_dir_async(path).await {
238        Ok(listing) => listing,
239        Err(_) => return Ok(()),
240    };
241    for entry in entries {
242        let source_path = entry.path().to_path_buf();
243        let metadata = match vfs::metadata_async(&source_path).await {
244            Ok(meta) => meta,
245            Err(_) => continue,
246        };
247        if !metadata.is_dir() {
248            continue;
249        }
250        let name = entry.file_name().to_string_lossy().into_owned();
251        if is_matlab_reserved_folder(&name) {
252            continue;
253        }
254        let child_path = match vfs::canonicalize_async(&source_path).await {
255            Ok(path) => path,
256            Err(_) => continue,
257        };
258
259        let child_str = canonical_string_from_path(&child_path);
260        children.push(ChildEntry {
261            path: child_path,
262            canonical: child_str,
263            name,
264        });
265    }
266
267    children.sort_by(|a, b| compare_names(&a.name, &b.name));
268
269    for child in children {
270        traverse(
271            &child.path,
272            child.canonical.clone(),
273            excludes,
274            seen,
275            segments,
276        )
277        .await?;
278    }
279
280    Ok(())
281}
282
283struct ChildEntry {
284    path: PathBuf,
285    canonical: String,
286    name: String,
287}
288
289fn is_matlab_reserved_folder(name: &str) -> bool {
290    if name.starts_with('@') || name.starts_with('+') {
291        return true;
292    }
293
294    #[cfg(windows)]
295    {
296        let lower = name.to_ascii_lowercase();
297        matches!(lower.as_str(), "private" | "resources")
298    }
299    #[cfg(not(windows))]
300    {
301        matches!(name, "private" | "resources")
302    }
303}
304
305#[derive(Default)]
306struct ExcludeSet {
307    entries: Vec<ExcludeEntry>,
308}
309
310impl ExcludeSet {
311    fn from_entries(entries: Vec<String>) -> Self {
312        let normalized_entries = entries
313            .into_iter()
314            .map(|canonical| {
315                let normalized = normalize_case(&canonical);
316                let mut prefix = normalized.clone();
317                if !prefix.ends_with(std::path::MAIN_SEPARATOR) {
318                    prefix.push(std::path::MAIN_SEPARATOR);
319                }
320                ExcludeEntry {
321                    normalized,
322                    normalized_with_sep: prefix,
323                }
324            })
325            .collect();
326
327        ExcludeSet {
328            entries: normalized_entries,
329        }
330    }
331
332    fn contains(&self, canonical: &str) -> bool {
333        if self.entries.is_empty() {
334            return false;
335        }
336        let key = normalize_case(canonical);
337        self.entries
338            .iter()
339            .any(|entry| key == entry.normalized || key.starts_with(&entry.normalized_with_sep))
340    }
341}
342
343struct ExcludeEntry {
344    normalized: String,
345    normalized_with_sep: String,
346}
347
348async fn build_exclude_set(excludes: Option<&str>, root: &RootInfo) -> BuiltinResult<ExcludeSet> {
349    let mut entries = Vec::new();
350    if let Some(text) = excludes {
351        for raw in text.split(crate::builtins::common::path_state::PATH_LIST_SEPARATOR) {
352            let trimmed = raw.trim();
353            if trimmed.is_empty() {
354                continue;
355            }
356
357            let expanded = match expand_user_path(trimmed, "genpath") {
358                Ok(val) => val,
359                Err(_) => continue,
360            };
361
362            let mut candidate = PathBuf::from(&expanded);
363            if !candidate.is_absolute() {
364                candidate = root.path.join(candidate);
365            }
366
367            if let Ok((_, canonical_str)) = canonicalize_existing_async(&candidate, trimmed).await {
368                entries.push(canonical_str);
369                continue;
370            }
371
372            // Fallback: try relative to the current working directory
373            if let Ok(cwd) = vfs::current_dir() {
374                let alt = if Path::new(trimmed).is_absolute() {
375                    PathBuf::from(trimmed)
376                } else {
377                    cwd.join(trimmed)
378                };
379                if let Ok((_, canonical_alt)) = canonicalize_existing_async(&alt, trimmed).await {
380                    entries.push(canonical_alt);
381                }
382            }
383        }
384    }
385
386    Ok(ExcludeSet::from_entries(entries))
387}
388
389fn normalize_case(text: &str) -> String {
390    #[cfg(windows)]
391    {
392        text.replace('/', "\\").to_ascii_lowercase()
393    }
394    #[cfg(not(windows))]
395    {
396        text.to_string()
397    }
398}
399
400fn char_array_value(text: &str) -> Value {
401    Value::CharArray(CharArray::new_row(text))
402}
403
404fn extract_text(value: &Value, type_error: &str) -> BuiltinResult<String> {
405    match value {
406        Value::String(text) => Ok(text.clone()),
407        Value::StringArray(StringArray { data, .. }) => {
408            if data.len() != 1 {
409                Err(genpath_error(type_error))
410            } else {
411                Ok(data[0].clone())
412            }
413        }
414        Value::CharArray(chars) => {
415            if chars.rows != 1 {
416                return Err(genpath_error(type_error));
417            }
418            Ok(chars.data.iter().collect())
419        }
420        Value::Tensor(tensor) => tensor_to_string(tensor, type_error),
421        _ => Err(genpath_error(type_error)),
422    }
423}
424
425fn tensor_to_string(tensor: &Tensor, type_error: &str) -> BuiltinResult<String> {
426    if tensor.shape.len() > 2 {
427        return Err(genpath_error(type_error));
428    }
429
430    if tensor.rows() != 1 {
431        return Err(genpath_error(type_error));
432    }
433
434    let mut text = String::with_capacity(tensor.data.len());
435    for &code in &tensor.data {
436        if !code.is_finite() {
437            return Err(genpath_error(type_error));
438        }
439        let rounded = code.round();
440        if (code - rounded).abs() > 1e-6 {
441            return Err(genpath_error(type_error));
442        }
443        let int_code = rounded as i64;
444        if !(0..=0x10FFFF).contains(&int_code) {
445            return Err(genpath_error(type_error));
446        }
447        let ch = char::from_u32(int_code as u32).ok_or_else(|| genpath_error(type_error))?;
448        text.push(ch);
449    }
450
451    Ok(text)
452}
453
454#[cfg(test)]
455pub(crate) mod tests {
456    use super::super::REPL_FS_TEST_LOCK;
457    use super::*;
458    use crate::builtins::common::path_state::PATH_LIST_SEPARATOR;
459    use runmat_builtins::{CharArray, StringArray, Tensor};
460    use std::convert::TryFrom;
461    use std::fs;
462    use tempfile::tempdir;
463
464    fn genpath_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
465        futures::executor::block_on(super::genpath_builtin(args))
466    }
467
468    struct DirGuard {
469        previous: PathBuf,
470    }
471
472    impl DirGuard {
473        fn change(to: &Path) -> Result<Self, String> {
474            let previous = env::current_dir()
475                .map_err(|err| format!("genpath: unable to capture current directory: {err}"))?;
476            env::set_current_dir(to)
477                .map_err(|err| format!("genpath: unable to change directory: {err}"))?;
478            Ok(Self { previous })
479        }
480    }
481
482    impl Drop for DirGuard {
483        fn drop(&mut self) {
484            let _ = env::set_current_dir(&self.previous);
485        }
486    }
487
488    fn canonical(path: &Path) -> String {
489        let (_, canonical_str) =
490            canonicalize_existing(path, &path_to_string(path)).expect("canonical path");
491        canonical_str
492    }
493
494    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
495    #[test]
496    fn genpath_returns_char_array() {
497        let _lock = REPL_FS_TEST_LOCK
498            .lock()
499            .unwrap_or_else(|poison| poison.into_inner());
500
501        let base = tempdir().expect("tempdir");
502        let result = genpath_builtin(vec![Value::String(
503            base.path().to_string_lossy().into_owned(),
504        )])
505        .expect("genpath");
506
507        match result {
508            Value::CharArray(CharArray { rows, .. }) => assert_eq!(rows, 1),
509            other => panic!("expected CharArray, got {other:?}"),
510        }
511    }
512
513    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
514    #[test]
515    fn genpath_without_arguments_uses_current_directory() {
516        let _lock = REPL_FS_TEST_LOCK
517            .lock()
518            .unwrap_or_else(|poison| poison.into_inner());
519
520        let base = tempdir().expect("base");
521        let alpha = base.path().join("alpha");
522        let beta = base.path().join("beta");
523        let gamma = alpha.join("gamma");
524        fs::create_dir(&alpha).expect("alpha");
525        fs::create_dir(&beta).expect("beta");
526        fs::create_dir(&gamma).expect("gamma");
527
528        let _guard = DirGuard::change(base.path()).expect("dir guard");
529
530        let value = genpath_builtin(Vec::new()).expect("genpath");
531        let text = String::try_from(&value).expect("string");
532        let segments: Vec<&str> = if text.is_empty() {
533            Vec::new()
534        } else {
535            text.split(PATH_LIST_SEPARATOR).collect()
536        };
537
538        let expected = [
539            canonical(base.path()),
540            canonical(&alpha),
541            canonical(&gamma),
542            canonical(&beta),
543        ];
544
545        assert_eq!(segments.len(), expected.len());
546        for (seg, exp) in segments.iter().zip(expected.iter()) {
547            assert_eq!(*seg, exp);
548        }
549    }
550
551    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
552    #[test]
553    fn genpath_accepts_char_array_root_argument() {
554        let _lock = REPL_FS_TEST_LOCK
555            .lock()
556            .unwrap_or_else(|poison| poison.into_inner());
557
558        let base = tempdir().expect("base");
559        let path_text = base.path().to_string_lossy().into_owned();
560        let char_arg = Value::CharArray(CharArray::new_row(&path_text));
561        let value = genpath_builtin(vec![char_arg]).expect("genpath");
562        let text = String::try_from(&value).expect("string");
563        assert!(
564            text.starts_with(&canonical(base.path())),
565            "expected output to begin with canonical root path"
566        );
567    }
568
569    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
570    #[test]
571    fn genpath_accepts_string_array_root_argument() {
572        let _lock = REPL_FS_TEST_LOCK
573            .lock()
574            .unwrap_or_else(|poison| poison.into_inner());
575
576        let base = tempdir().expect("base");
577        let array = StringArray::new(vec![base.path().to_string_lossy().into_owned()], vec![1])
578            .expect("string array");
579        let value = genpath_builtin(vec![Value::StringArray(array)]).expect("genpath");
580        let text = String::try_from(&value).expect("string");
581        assert!(
582            text.starts_with(&canonical(base.path())),
583            "expected output to begin with canonical root path"
584        );
585    }
586
587    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
588    #[test]
589    fn genpath_accepts_tensor_char_codes_root_argument() {
590        let _lock = REPL_FS_TEST_LOCK
591            .lock()
592            .unwrap_or_else(|poison| poison.into_inner());
593
594        let base = tempdir().expect("base");
595        let path_text = base.path().to_string_lossy().into_owned();
596        let codes: Vec<f64> = path_text.bytes().map(|b| b as f64).collect();
597        let tensor =
598            Tensor::new_2d(codes, 1, path_text.len()).expect("tensor from path characters");
599        let value = genpath_builtin(vec![Value::Tensor(tensor)]).expect("genpath");
600        let text = String::try_from(&value).expect("string");
601        assert!(
602            text.starts_with(&canonical(base.path())),
603            "expected output to begin with canonical root path"
604        );
605    }
606
607    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
608    #[test]
609    fn genpath_excludes_relative_entries() {
610        let _lock = REPL_FS_TEST_LOCK
611            .lock()
612            .unwrap_or_else(|poison| poison.into_inner());
613
614        let base = tempdir().expect("base");
615        let keep = base.path().join("keep");
616        let skip = base.path().join("skip");
617        fs::create_dir(&keep).expect("keep");
618        fs::create_dir(&skip).expect("skip");
619
620        let result = genpath_builtin(vec![
621            Value::String(base.path().to_string_lossy().into_owned()),
622            Value::String("skip".into()),
623        ])
624        .expect("genpath");
625
626        let text = String::try_from(&result).expect("string");
627        let segments: Vec<String> = if text.is_empty() {
628            Vec::new()
629        } else {
630            text.split(PATH_LIST_SEPARATOR)
631                .map(|segment| segment.to_string())
632                .collect()
633        };
634
635        assert!(
636            !segments.contains(&canonical(&skip)),
637            "expected skip directory to be excluded"
638        );
639        assert!(
640            segments.contains(&canonical(&keep)),
641            "expected keep directory to be present"
642        );
643    }
644
645    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
646    #[test]
647    fn genpath_errors_on_invalid_argument_type() {
648        let _lock = REPL_FS_TEST_LOCK
649            .lock()
650            .unwrap_or_else(|poison| poison.into_inner());
651
652        let err = genpath_builtin(vec![Value::Num(1.0)]).expect_err("expected error");
653        assert_eq!(err.message(), ERROR_FOLDER_TYPE);
654    }
655
656    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
657    #[test]
658    fn genpath_excludes_specified_directories() {
659        let _lock = REPL_FS_TEST_LOCK
660            .lock()
661            .unwrap_or_else(|poison| poison.into_inner());
662
663        let base = tempdir().expect("base");
664        let alpha = base.path().join("alpha");
665        let beta = base.path().join("beta");
666        let skip = alpha.join("skip");
667        fs::create_dir(&alpha).expect("alpha");
668        fs::create_dir(&beta).expect("beta");
669        fs::create_dir(&skip).expect("skip");
670
671        let exclude_string = format!(
672            "{}{}{}",
673            canonical(&alpha),
674            PATH_LIST_SEPARATOR,
675            canonical(&skip)
676        );
677
678        let result = genpath_builtin(vec![
679            Value::String(base.path().to_string_lossy().into_owned()),
680            Value::String(exclude_string),
681        ])
682        .expect("genpath");
683
684        let text = String::try_from(&result).expect("string");
685        let segments: Vec<&str> = if text.is_empty() {
686            Vec::new()
687        } else {
688            text.split(PATH_LIST_SEPARATOR).collect()
689        };
690
691        let expected = [canonical(base.path()), canonical(&beta)];
692
693        assert_eq!(segments.len(), expected.len());
694        for (seg, exp) in segments.iter().zip(expected.iter()) {
695            assert_eq!(*seg, exp);
696        }
697    }
698
699    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
700    #[test]
701    fn genpath_skips_matlab_reserved_directories() {
702        let _lock = REPL_FS_TEST_LOCK
703            .lock()
704            .unwrap_or_else(|poison| poison.into_inner());
705
706        let base = tempdir().expect("base");
707        let private_dir = base.path().join("private");
708        let class_dir = base.path().join("@MyClass");
709        let package_dir = base.path().join("+pkg");
710        let resources_dir = base.path().join("resources");
711        let keep_dir = base.path().join("keep");
712        let keep_child = keep_dir.join("child");
713
714        for dir in [
715            &private_dir,
716            &class_dir,
717            &package_dir,
718            &resources_dir,
719            &keep_dir,
720            &keep_child,
721        ] {
722            fs::create_dir_all(dir).expect("mkdir");
723        }
724
725        let result = genpath_builtin(vec![Value::String(
726            base.path().to_string_lossy().into_owned(),
727        )])
728        .expect("genpath");
729
730        let text = String::try_from(&result).expect("string");
731        let segments: Vec<String> = if text.is_empty() {
732            Vec::new()
733        } else {
734            text.split(PATH_LIST_SEPARATOR)
735                .map(|segment| segment.to_string())
736                .collect()
737        };
738
739        let expected = vec![
740            canonical(base.path()),
741            canonical(&keep_dir),
742            canonical(&keep_child),
743        ];
744
745        assert_eq!(segments, expected);
746
747        for skipped in [
748            canonical(&private_dir),
749            canonical(&class_dir),
750            canonical(&package_dir),
751            canonical(&resources_dir),
752        ] {
753            assert!(
754                !segments.contains(&skipped),
755                "expected {skipped} to be absent from the generated path"
756            );
757        }
758    }
759
760    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
761    #[test]
762    #[cfg(unix)]
763    fn genpath_deduplicates_symlink_targets() {
764        use std::os::unix::fs::symlink;
765
766        let _lock = REPL_FS_TEST_LOCK
767            .lock()
768            .unwrap_or_else(|poison| poison.into_inner());
769
770        let base = tempdir().expect("base");
771        let alpha = base.path().join("alpha");
772        let alias = base.path().join("alias_alpha");
773        fs::create_dir(&alpha).expect("alpha");
774        symlink(&alpha, &alias).expect("symlink");
775
776        let value = genpath_builtin(vec![Value::String(
777            base.path().to_string_lossy().into_owned(),
778        )])
779        .expect("genpath");
780
781        let text = String::try_from(&value).expect("string");
782        let segments: Vec<String> = if text.is_empty() {
783            Vec::new()
784        } else {
785            text.split(PATH_LIST_SEPARATOR)
786                .map(|segment| segment.to_string())
787                .collect()
788        };
789
790        let root = canonical(base.path());
791        let alpha_canonical = canonical(&alpha);
792
793        assert!(
794            segments.contains(&root),
795            "expected root directory to be present"
796        );
797        let count = segments
798            .iter()
799            .filter(|segment| **segment == alpha_canonical)
800            .count();
801        assert_eq!(count, 1, "expected canonical alpha path to appear once");
802    }
803
804    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
805    #[test]
806    fn genpath_errors_on_missing_root() {
807        let _lock = REPL_FS_TEST_LOCK
808            .lock()
809            .unwrap_or_else(|poison| poison.into_inner());
810
811        let missing = Value::String("this/does/not/exist".into());
812        let err = genpath_builtin(vec![missing]).expect_err("expected error");
813        assert!(err.message().contains("not found"));
814    }
815}