Skip to main content

shape_runtime/stdlib_io/
path_ops.rs

1//! Path utility implementations for the io module.
2//!
3//! All path operations are synchronous and pure string manipulation,
4//! except `resolve` which calls `fs::canonicalize`.
5
6use shape_value::ValueWord;
7use std::path::Path;
8use std::sync::Arc;
9
10/// io.join(parts...) -> string
11pub fn io_join(
12    args: &[ValueWord],
13    _ctx: &crate::module_exports::ModuleContext,
14) -> Result<ValueWord, String> {
15    if args.is_empty() {
16        return Err("io.join() requires at least one path argument".to_string());
17    }
18
19    let mut result = std::path::PathBuf::new();
20    for arg in args {
21        if let Some(s) = arg.as_str() {
22            result.push(s);
23        } else if let Some(view) = arg.as_any_array() {
24            let arr = view.to_generic();
25            for item in arr.iter() {
26                if let Some(s) = item.as_str() {
27                    result.push(s);
28                }
29            }
30        } else {
31            return Err("io.join() arguments must be strings".to_string());
32        }
33    }
34    Ok(ValueWord::from_string(Arc::new(
35        result.to_string_lossy().to_string(),
36    )))
37}
38
39/// io.dirname(path) -> string
40pub fn io_dirname(
41    args: &[ValueWord],
42    _ctx: &crate::module_exports::ModuleContext,
43) -> Result<ValueWord, String> {
44    let path = args
45        .first()
46        .and_then(|a| a.as_str())
47        .ok_or_else(|| "io.dirname() requires a string path".to_string())?;
48
49    let parent = Path::new(path)
50        .parent()
51        .map(|p| p.to_string_lossy().to_string())
52        .unwrap_or_default();
53    Ok(ValueWord::from_string(Arc::new(parent)))
54}
55
56/// io.basename(path) -> string
57pub fn io_basename(
58    args: &[ValueWord],
59    _ctx: &crate::module_exports::ModuleContext,
60) -> Result<ValueWord, String> {
61    let path = args
62        .first()
63        .and_then(|a| a.as_str())
64        .ok_or_else(|| "io.basename() requires a string path".to_string())?;
65
66    let name = Path::new(path)
67        .file_name()
68        .map(|n| n.to_string_lossy().to_string())
69        .unwrap_or_default();
70    Ok(ValueWord::from_string(Arc::new(name)))
71}
72
73/// io.extension(path) -> string
74pub fn io_extension(
75    args: &[ValueWord],
76    _ctx: &crate::module_exports::ModuleContext,
77) -> Result<ValueWord, String> {
78    let path = args
79        .first()
80        .and_then(|a| a.as_str())
81        .ok_or_else(|| "io.extension() requires a string path".to_string())?;
82
83    let ext = Path::new(path)
84        .extension()
85        .map(|e| e.to_string_lossy().to_string())
86        .unwrap_or_default();
87    Ok(ValueWord::from_string(Arc::new(ext)))
88}
89
90/// io.resolve(path) -> string (canonicalize)
91pub fn io_resolve(
92    args: &[ValueWord],
93    _ctx: &crate::module_exports::ModuleContext,
94) -> Result<ValueWord, String> {
95    let path = args
96        .first()
97        .and_then(|a| a.as_str())
98        .ok_or_else(|| "io.resolve() requires a string path".to_string())?;
99
100    let resolved =
101        std::fs::canonicalize(path).map_err(|e| format!("io.resolve(\"{}\"): {}", path, e))?;
102    Ok(ValueWord::from_string(Arc::new(
103        resolved.to_string_lossy().to_string(),
104    )))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn test_ctx() -> crate::module_exports::ModuleContext<'static> {
112        let registry = Box::leak(Box::new(crate::type_schema::TypeSchemaRegistry::new()));
113        crate::module_exports::ModuleContext {
114            schemas: registry,
115            invoke_callable: None,
116            raw_invoker: None,
117            function_hashes: None,
118            vm_state: None,
119            granted_permissions: None,
120            scope_constraints: None,
121            set_pending_resume: None,
122            set_pending_frame_resume: None,
123        }
124    }
125
126    #[test]
127    fn test_join_two_parts() {
128        let ctx = test_ctx();
129        let result = io_join(
130            &[
131                ValueWord::from_string(Arc::new("/home".to_string())),
132                ValueWord::from_string(Arc::new("user".to_string())),
133            ],
134            &ctx,
135        )
136        .unwrap();
137        assert_eq!(result.as_str().unwrap(), "/home/user");
138    }
139
140    #[test]
141    fn test_join_three_parts() {
142        let ctx = test_ctx();
143        let result = io_join(
144            &[
145                ValueWord::from_string(Arc::new("/a".to_string())),
146                ValueWord::from_string(Arc::new("b".to_string())),
147                ValueWord::from_string(Arc::new("c.txt".to_string())),
148            ],
149            &ctx,
150        )
151        .unwrap();
152        assert_eq!(result.as_str().unwrap(), "/a/b/c.txt");
153    }
154
155    #[test]
156    fn test_dirname() {
157        let ctx = test_ctx();
158        let result = io_dirname(
159            &[ValueWord::from_string(Arc::new(
160                "/home/user/file.txt".to_string(),
161            ))],
162            &ctx,
163        )
164        .unwrap();
165        assert_eq!(result.as_str().unwrap(), "/home/user");
166    }
167
168    #[test]
169    fn test_basename() {
170        let ctx = test_ctx();
171        let result = io_basename(
172            &[ValueWord::from_string(Arc::new(
173                "/home/user/file.txt".to_string(),
174            ))],
175            &ctx,
176        )
177        .unwrap();
178        assert_eq!(result.as_str().unwrap(), "file.txt");
179    }
180
181    #[test]
182    fn test_extension() {
183        let ctx = test_ctx();
184        let result = io_extension(
185            &[ValueWord::from_string(Arc::new(
186                "/home/user/file.txt".to_string(),
187            ))],
188            &ctx,
189        )
190        .unwrap();
191        assert_eq!(result.as_str().unwrap(), "txt");
192    }
193
194    #[test]
195    fn test_extension_none() {
196        let ctx = test_ctx();
197        let result = io_extension(
198            &[ValueWord::from_string(Arc::new(
199                "/home/user/Makefile".to_string(),
200            ))],
201            &ctx,
202        )
203        .unwrap();
204        assert_eq!(result.as_str().unwrap(), "");
205    }
206
207    #[test]
208    fn test_resolve_tmp() {
209        let ctx = test_ctx();
210        let result = io_resolve(
211            &[ValueWord::from_string(Arc::new("/tmp".to_string()))],
212            &ctx,
213        )
214        .unwrap();
215        let resolved = result.as_str().unwrap();
216        // Should be an absolute path
217        assert!(resolved.starts_with('/'));
218    }
219}