ferrous_actions/node/
path.rs

1use js_sys::JsString;
2use std::borrow::Cow;
3use std::sync::LazyLock;
4use wasm_bindgen::JsCast as _;
5
6/// Represents a path for the underlying platform
7#[derive(Clone)]
8pub struct Path {
9    inner: JsString,
10}
11
12static SEPARATOR: LazyLock<String> =
13    LazyLock::new(|| ffi::SEPARATOR.with(|v| v.dyn_ref::<JsString>().expect("separator wasn't a string").into()));
14
15static DELIMITER: LazyLock<String> =
16    LazyLock::new(|| ffi::DELIMITER.with(|v| v.dyn_ref::<JsString>().expect("delimiter wasn't a string").into()));
17
18impl std::fmt::Display for Path {
19    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
20        let string = String::from(&self.inner);
21        string.fmt(formatter)
22    }
23}
24
25impl PartialEq for Path {
26    fn eq(&self, rhs: &Path) -> bool {
27        // relative() resolves paths according to the CWD so we should only
28        // use it if they will both be resolved the same way
29        if self.is_absolute() == rhs.is_absolute() {
30            // This should handle both case-sensitivity and trailing slash issues
31            let relative = ffi::relative(&self.inner, &rhs.inner);
32            relative.length() == 0
33        } else {
34            false
35        }
36    }
37}
38
39impl Path {
40    /// Pushes the supplied path onto this one.
41    ///
42    /// If the supplied path is absolute, it will replace this one entirely.
43    pub fn push<P: Into<Path>>(&mut self, path: P) {
44        let path = path.into();
45        let joined = if path.is_absolute() {
46            path.inner
47        } else {
48            ffi::join(vec![self.inner.clone(), path.inner])
49        };
50        self.inner = joined;
51    }
52
53    /// Returns the path as a JavaScript string
54    pub fn to_js_string(&self) -> JsString {
55        self.inner.clone()
56    }
57
58    /// Returns the parent path
59    #[must_use]
60    pub fn parent(&self) -> Path {
61        let parent = ffi::dirname(&self.inner);
62        Path { inner: parent }
63    }
64
65    /// Returns `true` if the path is absolute, `false` otherwise
66    pub fn is_absolute(&self) -> bool {
67        ffi::is_absolute(&self.inner)
68    }
69
70    /// The basename of the path
71    pub fn file_name(&self) -> String {
72        let result = ffi::basename(&self.inner, None);
73        result.into()
74    }
75
76    /// Returns `true` if the path can be determined to exist
77    pub async fn exists(&self) -> bool {
78        super::fs::ffi::access(&self.inner, None).await.is_ok()
79    }
80
81    /// Combines two paths to form a new one. See `join`.
82    #[must_use]
83    pub fn join<P: Into<Path>>(&self, path: P) -> Path {
84        let mut result = self.clone();
85        result.push(path.into());
86        result
87    }
88
89    /// Returns this path relative to the supplied path
90    #[must_use]
91    pub fn relative_to<P: Into<Path>>(&self, path: P) -> Path {
92        let path = path.into();
93        let relative = ffi::relative(&path.inner, &self.inner);
94        if relative.length() == 0 {
95            ".".into()
96        } else {
97            relative.into()
98        }
99    }
100}
101
102impl std::fmt::Debug for Path {
103    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
104        write!(formatter, "{}", self)
105    }
106}
107
108impl From<&JsString> for Path {
109    fn from(path: &JsString) -> Path {
110        let path = ffi::normalize(path);
111        Path { inner: path }
112    }
113}
114
115impl From<JsString> for Path {
116    fn from(path: JsString) -> Path {
117        Path::from(&path)
118    }
119}
120
121impl From<&Path> for Path {
122    fn from(path: &Path) -> Path {
123        path.clone()
124    }
125}
126
127impl From<&str> for Path {
128    fn from(path: &str) -> Path {
129        let path: JsString = path.into();
130        let path = ffi::normalize(&path);
131        Path { inner: path }
132    }
133}
134
135impl From<&String> for Path {
136    fn from(path: &String) -> Path {
137        Path::from(path.as_str())
138    }
139}
140
141impl From<Path> for JsString {
142    fn from(path: Path) -> JsString {
143        path.inner
144    }
145}
146
147impl From<&Path> for JsString {
148    fn from(path: &Path) -> JsString {
149        path.inner.clone()
150    }
151}
152
153/// Returns the delimiter used to combined paths into a list
154pub fn delimiter() -> Cow<'static, str> {
155    DELIMITER.as_str().into()
156}
157
158/// Returns the separator for path components for this platform
159pub fn separator() -> Cow<'static, str> {
160    SEPARATOR.as_str().into()
161}
162
163/// Low-level bindings for node.js path functions
164pub mod ffi {
165    use js_sys::{JsString, Object};
166    use wasm_bindgen::prelude::*;
167
168    #[wasm_bindgen(module = "path")]
169    extern "C" {
170        #[wasm_bindgen(thread_local_v2, js_name = "delimiter")]
171        pub static DELIMITER: Object;
172
173        #[wasm_bindgen(thread_local_v2, js_name = "sep")]
174        pub static SEPARATOR: Object;
175
176        pub fn normalize(path: &JsString) -> JsString;
177        #[wasm_bindgen(variadic)]
178        pub fn join(paths: Vec<JsString>) -> JsString;
179        #[wasm_bindgen(variadic)]
180        pub fn resolve(paths: Vec<JsString>) -> JsString;
181        #[wasm_bindgen]
182        pub fn dirname(path: &JsString) -> JsString;
183        #[wasm_bindgen(js_name = "isAbsolute")]
184        pub fn is_absolute(path: &JsString) -> bool;
185        #[wasm_bindgen]
186        pub fn relative(from: &JsString, to: &JsString) -> JsString;
187        #[wasm_bindgen]
188        pub fn basename(path: &JsString, suffix: Option<JsString>) -> JsString;
189    }
190}
191
192#[cfg(test)]
193mod test {
194    use super::Path;
195    use crate::node;
196    use wasm_bindgen::JsValue;
197    use wasm_bindgen_test::wasm_bindgen_test;
198
199    #[wasm_bindgen_test]
200    fn check_absolute() {
201        let cwd = node::process::cwd();
202        assert!(cwd.is_absolute());
203    }
204
205    #[wasm_bindgen_test]
206    fn check_relative() {
207        let relative = Path::from(&format!("{}{}{}", "a", super::separator(), "b"));
208        assert!(!relative.is_absolute());
209    }
210
211    #[wasm_bindgen_test]
212    fn check_separator() {
213        let separator = super::separator();
214        assert!(separator == "/" || separator == "\\");
215    }
216
217    #[wasm_bindgen_test]
218    fn check_delimiter() {
219        let delimiter = super::delimiter();
220        assert!(delimiter == ";" || delimiter == ":");
221    }
222
223    #[wasm_bindgen_test]
224    fn check_parent() {
225        let parent_name = "parent";
226        let path = Path::from(&format!("{}{}{}", parent_name, super::separator(), "child"));
227        let parent_path = path.parent();
228        assert_eq!(parent_path.to_string(), parent_name);
229    }
230
231    #[wasm_bindgen_test]
232    fn check_basename() {
233        let child_base = "child.";
234        let child_ext = ".extension";
235        let child_name = format!("{}{}", child_base, child_ext);
236        let path = Path::from(&format!("{}{}{}", "parent", super::separator(), child_name));
237        assert_eq!(child_name, path.file_name());
238        assert_eq!(
239            child_name,
240            String::from(super::ffi::basename(&path.to_js_string(), None))
241        );
242        assert_eq!(
243            child_name,
244            String::from(super::ffi::basename(&path.to_js_string(), Some(".nomatch".into())))
245        );
246        assert_eq!(
247            child_base,
248            String::from(super::ffi::basename(&path.to_js_string(), Some(child_ext.into())))
249        );
250    }
251
252    #[wasm_bindgen_test]
253    fn check_push() {
254        let parent_name = "a";
255        let child_name = "b";
256        let path_string = format!("{}{}{}", parent_name, super::separator(), child_name);
257        let mut path = Path::from(parent_name);
258        path.push(child_name);
259        assert_eq!(path.to_string(), path_string);
260    }
261
262    #[wasm_bindgen_test]
263    fn check_join() {
264        let parent_name = "a";
265        let child_name = "b";
266        let path_string = format!("{}{}{}", parent_name, super::separator(), child_name);
267        let path = Path::from(parent_name).join(child_name);
268        assert_eq!(path.to_string(), path_string);
269    }
270
271    #[wasm_bindgen_test]
272    fn check_current_normalization() {
273        use itertools::Itertools as _;
274        let current = ".";
275        let long_current = std::iter::repeat(current).take(10).join(&super::separator());
276        assert_eq!(Path::from(&long_current).to_string(), current);
277    }
278
279    #[wasm_bindgen_test]
280    fn check_parent_normalization() {
281        use itertools::Itertools as _;
282        let parent = "..";
283        let current = ".";
284        let count = 10;
285
286        let long_current = std::iter::repeat("child")
287            .take(count)
288            .chain(std::iter::repeat(parent).take(count))
289            .join(&super::separator());
290        assert_eq!(Path::from(&long_current).to_string(), current);
291
292        let long_parent = std::iter::repeat("child")
293            .take(count)
294            .chain(std::iter::repeat(parent).take(count + 1))
295            .join(&super::separator());
296        assert_eq!(Path::from(&long_parent).to_string(), parent);
297    }
298
299    #[wasm_bindgen_test]
300    async fn check_exists() -> Result<(), JsValue> {
301        let temp = node::os::temp_dir();
302        let file_name = format!("ferrous-actions-exists-test - {}", chrono::Local::now());
303        let temp_file_path = temp.join(&file_name);
304        let data = "Nothing to see here\n";
305        node::fs::write_file(&temp_file_path, data.as_bytes()).await?;
306        assert!(temp_file_path.exists().await);
307        node::fs::remove_file(&temp_file_path).await?;
308        assert!(!temp_file_path.exists().await);
309        Ok(())
310    }
311
312    #[wasm_bindgen_test]
313    fn check_equality() {
314        use itertools::Itertools as _;
315
316        // We can't check case behaviour without knowing filesystem semantics.
317        // It's unclear if a trailing slash matters equality-wise.
318
319        assert_eq!(Path::from("a"), Path::from("a"));
320        assert_eq!(Path::from("."), Path::from("."));
321        assert_eq!(Path::from(".."), Path::from(".."));
322        assert_eq!(
323            Path::from(&format!("a{}..", super::separator())),
324            Path::from(&format!("b{}..", super::separator()))
325        );
326        assert_ne!(Path::from("."), Path::from(".."));
327        assert_ne!(Path::from("a"), Path::from("b"));
328
329        let path = ["a", "b", "c", "d"].into_iter().join(&super::separator());
330        assert_eq!(Path::from(&path), Path::from(&path));
331    }
332}