1use 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 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}