zerobox_utils_absolute_path/
lib.rs1use 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#[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 if let Some(path_str) = path.to_str()
27 && let Some(home) = home_dir()
28 && let Some(rest) = path_str.strip_prefix('~')
29 {
30 if rest.is_empty() {
31 return home;
32 } else if let Some(rest) = rest.strip_prefix('/') {
33 return home.join(rest.trim_start_matches('/'));
34 } else if cfg!(windows)
35 && let Some(rest) = rest.strip_prefix('\\')
36 {
37 return home.join(rest.trim_start_matches('\\'));
38 }
39 }
40 path.to_path_buf()
41 }
42
43 pub fn resolve_path_against_base<P: AsRef<Path>, B: AsRef<Path>>(
44 path: P,
45 base_path: B,
46 ) -> std::io::Result<Self> {
47 let expanded = Self::maybe_expand_home_directory(path.as_ref());
48 let absolute_path = expanded.absolutize_from(base_path.as_ref())?;
49 Ok(Self(absolute_path.into_owned()))
50 }
51
52 pub fn from_absolute_path<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
53 let expanded = Self::maybe_expand_home_directory(path.as_ref());
54 let absolute_path = expanded.absolutize()?;
55 Ok(Self(absolute_path.into_owned()))
56 }
57
58 pub fn current_dir() -> std::io::Result<Self> {
59 let current_dir = std::env::current_dir()?;
60 Self::from_absolute_path(current_dir)
61 }
62
63 pub fn relative_to_current_dir<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
66 Self::resolve_path_against_base(path, std::env::current_dir()?)
67 }
68
69 pub fn join<P: AsRef<Path>>(&self, path: P) -> std::io::Result<Self> {
70 Self::resolve_path_against_base(path, &self.0)
71 }
72
73 pub fn parent(&self) -> Option<Self> {
74 self.0.parent().map(|p| {
75 debug_assert!(
76 p.is_absolute(),
77 "parent of AbsolutePathBuf must be absolute"
78 );
79 Self(p.to_path_buf())
80 })
81 }
82
83 pub fn as_path(&self) -> &Path {
84 &self.0
85 }
86
87 pub fn into_path_buf(self) -> PathBuf {
88 self.0
89 }
90
91 pub fn to_path_buf(&self) -> PathBuf {
92 self.0.clone()
93 }
94
95 pub fn to_string_lossy(&self) -> std::borrow::Cow<'_, str> {
96 self.0.to_string_lossy()
97 }
98
99 pub fn display(&self) -> Display<'_> {
100 self.0.display()
101 }
102}
103
104impl AsRef<Path> for AbsolutePathBuf {
105 fn as_ref(&self) -> &Path {
106 &self.0
107 }
108}
109
110impl std::ops::Deref for AbsolutePathBuf {
111 type Target = Path;
112
113 fn deref(&self) -> &Self::Target {
114 &self.0
115 }
116}
117
118impl From<AbsolutePathBuf> for PathBuf {
119 fn from(path: AbsolutePathBuf) -> Self {
120 path.into_path_buf()
121 }
122}
123
124impl TryFrom<&Path> for AbsolutePathBuf {
125 type Error = std::io::Error;
126
127 fn try_from(value: &Path) -> Result<Self, Self::Error> {
128 Self::from_absolute_path(value)
129 }
130}
131
132impl TryFrom<PathBuf> for AbsolutePathBuf {
133 type Error = std::io::Error;
134
135 fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
136 Self::from_absolute_path(value)
137 }
138}
139
140impl TryFrom<&str> for AbsolutePathBuf {
141 type Error = std::io::Error;
142
143 fn try_from(value: &str) -> Result<Self, Self::Error> {
144 Self::from_absolute_path(value)
145 }
146}
147
148impl TryFrom<String> for AbsolutePathBuf {
149 type Error = std::io::Error;
150
151 fn try_from(value: String) -> Result<Self, Self::Error> {
152 Self::from_absolute_path(value)
153 }
154}
155
156thread_local! {
157 static ABSOLUTE_PATH_BASE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
158}
159
160pub struct AbsolutePathBufGuard;
165
166impl AbsolutePathBufGuard {
167 pub fn new(base_path: &Path) -> Self {
168 ABSOLUTE_PATH_BASE.with(|cell| {
169 *cell.borrow_mut() = Some(base_path.to_path_buf());
170 });
171 Self
172 }
173}
174
175impl Drop for AbsolutePathBufGuard {
176 fn drop(&mut self) {
177 ABSOLUTE_PATH_BASE.with(|cell| {
178 *cell.borrow_mut() = None;
179 });
180 }
181}
182
183impl<'de> Deserialize<'de> for AbsolutePathBuf {
184 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
185 where
186 D: Deserializer<'de>,
187 {
188 let path = PathBuf::deserialize(deserializer)?;
189 ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() {
190 Some(base) => {
191 Ok(Self::resolve_path_against_base(path, base).map_err(SerdeError::custom)?)
192 }
193 None if path.is_absolute() => {
194 Self::from_absolute_path(path).map_err(SerdeError::custom)
195 }
196 None => Err(SerdeError::custom(
197 "AbsolutePathBuf deserialized without a base path",
198 )),
199 })
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use pretty_assertions::assert_eq;
207 use tempfile::tempdir;
208
209 #[test]
210 fn create_with_absolute_path_ignores_base_path() {
211 let base_dir = tempdir().expect("base dir");
212 let absolute_dir = tempdir().expect("absolute dir");
213 let base_path = base_dir.path();
214 let absolute_path = absolute_dir.path().join("file.txt");
215 let abs_path_buf =
216 AbsolutePathBuf::resolve_path_against_base(absolute_path.clone(), base_path)
217 .expect("failed to create");
218 assert_eq!(abs_path_buf.as_path(), absolute_path.as_path());
219 }
220
221 #[test]
222 fn relative_path_is_resolved_against_base_path() {
223 let temp_dir = tempdir().expect("base dir");
224 let base_dir = temp_dir.path();
225 let abs_path_buf = AbsolutePathBuf::resolve_path_against_base("file.txt", base_dir)
226 .expect("failed to create");
227 assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path());
228 }
229
230 #[test]
231 fn relative_to_current_dir_resolves_relative_path() -> std::io::Result<()> {
232 let current_dir = std::env::current_dir()?;
233 let abs_path_buf = AbsolutePathBuf::relative_to_current_dir("file.txt")?;
234 assert_eq!(
235 abs_path_buf.as_path(),
236 current_dir.join("file.txt").as_path()
237 );
238 Ok(())
239 }
240
241 #[test]
242 fn guard_used_in_deserialization() {
243 let temp_dir = tempdir().expect("base dir");
244 let base_dir = temp_dir.path();
245 let relative_path = "subdir/file.txt";
246 let abs_path_buf = {
247 let _guard = AbsolutePathBufGuard::new(base_dir);
248 serde_json::from_str::<AbsolutePathBuf>(&format!(r#""{relative_path}""#))
249 .expect("failed to deserialize")
250 };
251 assert_eq!(
252 abs_path_buf.as_path(),
253 base_dir.join(relative_path).as_path()
254 );
255 }
256
257 #[test]
258 fn home_directory_root_is_expanded_in_deserialization() {
259 let Some(home) = home_dir() else {
260 return;
261 };
262 let temp_dir = tempdir().expect("base dir");
263 let abs_path_buf = {
264 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
265 serde_json::from_str::<AbsolutePathBuf>("\"~\"").expect("failed to deserialize")
266 };
267 assert_eq!(abs_path_buf.as_path(), home.as_path());
268 }
269
270 #[test]
271 fn home_directory_subpath_is_expanded_in_deserialization() {
272 let Some(home) = home_dir() else {
273 return;
274 };
275 let temp_dir = tempdir().expect("base dir");
276 let abs_path_buf = {
277 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
278 serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize")
279 };
280 assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
281 }
282
283 #[test]
284 fn home_directory_double_slash_is_expanded_in_deserialization() {
285 let Some(home) = home_dir() else {
286 return;
287 };
288 let temp_dir = tempdir().expect("base dir");
289 let abs_path_buf = {
290 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
291 serde_json::from_str::<AbsolutePathBuf>("\"~//code\"").expect("failed to deserialize")
292 };
293 assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
294 }
295
296 #[cfg(target_os = "windows")]
297 #[test]
298 fn home_directory_backslash_subpath_is_expanded_in_deserialization() {
299 let Some(home) = home_dir() else {
300 return;
301 };
302 let temp_dir = tempdir().expect("base dir");
303 let abs_path_buf = {
304 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
305 let input =
306 serde_json::to_string(r#"~\code"#).expect("string should serialize as JSON");
307 serde_json::from_str::<AbsolutePathBuf>(&input).expect("is valid abs path")
308 };
309 assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
310 }
311}