Skip to main content

hanzo_utils_absolute_path/
lib.rs

1use dirs::home_dir;
2use path_absolutize::Absolutize;
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde::Deserializer;
6use serde::Serialize;
7use serde::de::Error as SerdeError;
8use std::cell::RefCell;
9use std::path::Display;
10use std::path::Path;
11use std::path::PathBuf;
12use ts_rs::TS;
13
14/// A path that is guaranteed to be absolute and normalized (though it is not
15/// guaranteed to be canonicalized or exist on the filesystem).
16///
17/// IMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set
18/// using [AbsolutePathBufGuard::new]. If no base path is set, the
19/// deserialization will fail unless the path being deserialized is already
20/// absolute.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema, TS)]
22pub struct AbsolutePathBuf(PathBuf);
23
24impl AbsolutePathBuf {
25    fn maybe_expand_home_directory(path: &Path) -> PathBuf {
26        let Some(path_str) = path.to_str() else {
27            return path.to_path_buf();
28        };
29        if cfg!(not(target_os = "windows"))
30            && let Some(home) = home_dir()
31        {
32            if path_str == "~" {
33                return home;
34            }
35            if let Some(rest) = path_str.strip_prefix("~/") {
36                let rest = rest.trim_start_matches('/');
37                if rest.is_empty() {
38                    return home;
39                }
40                return home.join(rest);
41            }
42        }
43        path.to_path_buf()
44    }
45
46    pub fn resolve_path_against_base<P: AsRef<Path>, B: AsRef<Path>>(
47        path: P,
48        base_path: B,
49    ) -> std::io::Result<Self> {
50        let expanded = Self::maybe_expand_home_directory(path.as_ref());
51        let absolute_path = expanded.absolutize_from(base_path.as_ref())?;
52        Ok(Self(absolute_path.into_owned()))
53    }
54
55    pub fn from_absolute_path<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
56        let expanded = Self::maybe_expand_home_directory(path.as_ref());
57        let absolute_path = expanded.absolutize()?;
58        Ok(Self(absolute_path.into_owned()))
59    }
60
61    pub fn current_dir() -> std::io::Result<Self> {
62        let current_dir = std::env::current_dir()?;
63        Self::from_absolute_path(current_dir)
64    }
65
66    pub fn join<P: AsRef<Path>>(&self, path: P) -> std::io::Result<Self> {
67        Self::resolve_path_against_base(path, &self.0)
68    }
69
70    pub fn parent(&self) -> Option<Self> {
71        self.0.parent().map(|p| {
72            #[expect(clippy::expect_used)]
73            Self::from_absolute_path(p).expect("parent of AbsolutePathBuf must be absolute")
74        })
75    }
76
77    pub fn as_path(&self) -> &Path {
78        &self.0
79    }
80
81    pub fn into_path_buf(self) -> PathBuf {
82        self.0
83    }
84
85    pub fn to_path_buf(&self) -> PathBuf {
86        self.0.clone()
87    }
88
89    pub fn to_string_lossy(&self) -> std::borrow::Cow<'_, str> {
90        self.0.to_string_lossy()
91    }
92
93    pub fn display(&self) -> Display<'_> {
94        self.0.display()
95    }
96}
97
98impl AsRef<Path> for AbsolutePathBuf {
99    fn as_ref(&self) -> &Path {
100        &self.0
101    }
102}
103
104impl From<AbsolutePathBuf> for PathBuf {
105    fn from(path: AbsolutePathBuf) -> Self {
106        path.into_path_buf()
107    }
108}
109
110impl TryFrom<&Path> for AbsolutePathBuf {
111    type Error = std::io::Error;
112
113    fn try_from(value: &Path) -> Result<Self, Self::Error> {
114        Self::from_absolute_path(value)
115    }
116}
117
118impl TryFrom<PathBuf> for AbsolutePathBuf {
119    type Error = std::io::Error;
120
121    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
122        Self::from_absolute_path(value)
123    }
124}
125
126impl TryFrom<&str> for AbsolutePathBuf {
127    type Error = std::io::Error;
128
129    fn try_from(value: &str) -> Result<Self, Self::Error> {
130        Self::from_absolute_path(value)
131    }
132}
133
134impl TryFrom<String> for AbsolutePathBuf {
135    type Error = std::io::Error;
136
137    fn try_from(value: String) -> Result<Self, Self::Error> {
138        Self::from_absolute_path(value)
139    }
140}
141
142thread_local! {
143    static ABSOLUTE_PATH_BASE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
144}
145
146/// Ensure this guard is held while deserializing `AbsolutePathBuf` values to
147/// provide a base path for resolving relative paths. Because this relies on
148/// thread-local storage, the deserialization must be single-threaded and
149/// occur on the same thread that created the guard.
150pub struct AbsolutePathBufGuard;
151
152impl AbsolutePathBufGuard {
153    pub fn new(base_path: &Path) -> Self {
154        ABSOLUTE_PATH_BASE.with(|cell| {
155            *cell.borrow_mut() = Some(base_path.to_path_buf());
156        });
157        Self
158    }
159}
160
161impl Drop for AbsolutePathBufGuard {
162    fn drop(&mut self) {
163        ABSOLUTE_PATH_BASE.with(|cell| {
164            *cell.borrow_mut() = None;
165        });
166    }
167}
168
169impl<'de> Deserialize<'de> for AbsolutePathBuf {
170    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171    where
172        D: Deserializer<'de>,
173    {
174        let path = PathBuf::deserialize(deserializer)?;
175        ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() {
176            Some(base) => {
177                Ok(Self::resolve_path_against_base(path, base).map_err(SerdeError::custom)?)
178            }
179            None if path.is_absolute() => {
180                Self::from_absolute_path(path).map_err(SerdeError::custom)
181            }
182            None => Err(SerdeError::custom(
183                "AbsolutePathBuf deserialized without a base path",
184            )),
185        })
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use pretty_assertions::assert_eq;
193    use tempfile::tempdir;
194
195    #[test]
196    fn create_with_absolute_path_ignores_base_path() {
197        let base_dir = tempdir().expect("base dir");
198        let absolute_dir = tempdir().expect("absolute dir");
199        let base_path = base_dir.path();
200        let absolute_path = absolute_dir.path().join("file.txt");
201        let abs_path_buf =
202            AbsolutePathBuf::resolve_path_against_base(absolute_path.clone(), base_path)
203                .expect("failed to create");
204        assert_eq!(abs_path_buf.as_path(), absolute_path.as_path());
205    }
206
207    #[test]
208    fn relative_path_is_resolved_against_base_path() {
209        let temp_dir = tempdir().expect("base dir");
210        let base_dir = temp_dir.path();
211        let abs_path_buf = AbsolutePathBuf::resolve_path_against_base("file.txt", base_dir)
212            .expect("failed to create");
213        assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path());
214    }
215
216    #[test]
217    fn guard_used_in_deserialization() {
218        let temp_dir = tempdir().expect("base dir");
219        let base_dir = temp_dir.path();
220        let relative_path = "subdir/file.txt";
221        let abs_path_buf = {
222            let _guard = AbsolutePathBufGuard::new(base_dir);
223            serde_json::from_str::<AbsolutePathBuf>(&format!(r#""{relative_path}""#))
224                .expect("failed to deserialize")
225        };
226        assert_eq!(
227            abs_path_buf.as_path(),
228            base_dir.join(relative_path).as_path()
229        );
230    }
231
232    #[cfg(not(target_os = "windows"))]
233    #[test]
234    fn home_directory_root_on_non_windows_is_expanded_in_deserialization() {
235        let Some(home) = home_dir() else {
236            return;
237        };
238        let temp_dir = tempdir().expect("base dir");
239        let abs_path_buf = {
240            let _guard = AbsolutePathBufGuard::new(temp_dir.path());
241            serde_json::from_str::<AbsolutePathBuf>("\"~\"").expect("failed to deserialize")
242        };
243        assert_eq!(abs_path_buf.as_path(), home.as_path());
244    }
245
246    #[cfg(not(target_os = "windows"))]
247    #[test]
248    fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() {
249        let Some(home) = home_dir() else {
250            return;
251        };
252        let temp_dir = tempdir().expect("base dir");
253        let abs_path_buf = {
254            let _guard = AbsolutePathBufGuard::new(temp_dir.path());
255            serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize")
256        };
257        assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
258    }
259
260    #[cfg(not(target_os = "windows"))]
261    #[test]
262    fn home_directory_double_slash_on_non_windows_is_expanded_in_deserialization() {
263        let Some(home) = home_dir() else {
264            return;
265        };
266        let temp_dir = tempdir().expect("base dir");
267        let abs_path_buf = {
268            let _guard = AbsolutePathBufGuard::new(temp_dir.path());
269            serde_json::from_str::<AbsolutePathBuf>("\"~//code\"").expect("failed to deserialize")
270        };
271        assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
272    }
273
274    #[cfg(target_os = "windows")]
275    #[test]
276    fn home_directory_on_windows_is_not_expanded_in_deserialization() {
277        let temp_dir = tempdir().expect("base dir");
278        let base_dir = temp_dir.path();
279        let abs_path_buf = {
280            let _guard = AbsolutePathBufGuard::new(base_dir);
281            serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize")
282        };
283        assert_eq!(
284            abs_path_buf.as_path(),
285            base_dir.join("~").join("code").as_path()
286        );
287    }
288}
289