simple_fs/reshape/
collapser.rs

1//! Collapse Camino Utf8Path paths similarly to canonicalize, but without performing I/O.
2//!
3//! Adapted from [cargo-binstall](https://github.com/cargo-bins/cargo-binstall/blob/main/crates/normalize-path/src/lib.rs)
4//! and Rust's `path::normalize`.
5
6use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
7
8/// Collapses a path buffer without performing I/O.
9///
10/// - Resolves `../` segments where possible.
11/// - Removes `./` segments, except when leading
12/// - All redundant separators and up-level references are collapsed.
13///
14/// Example:
15/// - `a/b/../c` becomes `a/c`
16/// - `a/../../c` becomes `../c`
17/// - `./some` becomes `./some`
18/// - `./some/./path` becomes `./some/path`
19/// - `/a/../c` becomes `/c`
20/// - `/a/../../c` becomes `/c`
21///
22/// However, this does not resolve symbolic links.
23/// It consumes the input `Utf8PathBuf` and returns a new one.
24pub fn into_collapsed(path: impl Into<Utf8PathBuf>) -> Utf8PathBuf {
25	let path_buf = path.into();
26
27	// For empty paths, return empty path
28	if path_buf.as_str().is_empty() {
29		return path_buf;
30	}
31
32	// Fast path: if the path is already collapsed, return it as is
33	if is_collapsed(&path_buf) {
34		return path_buf;
35	}
36
37	let mut components = Vec::new();
38	let mut normal_seen = false;
39
40	// Process each component
41	for component in path_buf.components() {
42		match component {
43			Utf8Component::Prefix(prefix) => {
44				components.push(Utf8Component::Prefix(prefix));
45			}
46			Utf8Component::RootDir => {
47				components.push(Utf8Component::RootDir);
48				normal_seen = false; // Reset after root dir
49			}
50			Utf8Component::CurDir => {
51				// Only keep current dir at the beginning of a relative path
52				if components.is_empty() {
53					components.push(component);
54				}
55				// Otherwise, ignore it (it's redundant)
56			}
57			Utf8Component::ParentDir => {
58				// If we've seen a normal component and we're not at the root,
59				// pop the last component instead of adding the parent
60				if normal_seen && !components.is_empty() {
61					match components.last() {
62						Some(Utf8Component::Normal(_)) => {
63							components.pop();
64							normal_seen = components.iter().any(|c| matches!(c, Utf8Component::Normal(_)));
65							continue;
66						}
67						Some(Utf8Component::ParentDir) => {}
68						Some(Utf8Component::RootDir) | Some(Utf8Component::Prefix(_)) => {
69							// For absolute paths, we can discard parent dirs that
70							// would go beyond the root
71							continue;
72						}
73						_ => {}
74					}
75				}
76				components.push(component);
77			}
78			Utf8Component::Normal(name) => {
79				components.push(Utf8Component::Normal(name));
80				normal_seen = true;
81			}
82		}
83	}
84
85	// If we've collapsed everything away, return "." or "" appropriately
86	if components.is_empty() {
87		if path_buf.as_str().starts_with("./") {
88			return Utf8PathBuf::from(".");
89		} else {
90			return Utf8PathBuf::from("");
91		}
92	}
93
94	// Reconstruct the path from the collapsed components
95	let mut result = Utf8PathBuf::new();
96	for component in components {
97		result.push(component.as_str());
98	}
99
100	result
101}
102
103/// Same as [`into_collapsed`] except that if `Component::Prefix` or `Component::RootDir`
104/// is encountered in a path that is supposed to be relative, or if the path attempts
105/// to navigate above its starting point using `..`, it returns `None`.
106///
107/// Useful for ensuring a path stays within a certain relative directory structure.
108pub fn try_into_collapsed(path: impl Into<Utf8PathBuf>) -> Option<Utf8PathBuf> {
109	let path_buf = path.into();
110
111	// Fast path: if the path is already collapsed and doesn't contain problematic components,
112	// return it as is
113	if is_collapsed(&path_buf) && !contains_problematic_components(&path_buf) {
114		return Some(path_buf);
115	}
116
117	let mut components = Vec::new();
118	let mut normal_seen = false;
119	let mut parent_count = 0;
120
121	// Process each component
122	for component in path_buf.components() {
123		match component {
124			Utf8Component::Prefix(_) => {
125				// A prefix indicates this is not a relative path
126				return None;
127			}
128			Utf8Component::RootDir => {
129				// A root directory indicates this is not a relative path
130				return None;
131			}
132			Utf8Component::CurDir => {
133				// Only keep current dir at the beginning of a relative path
134				if components.is_empty() {
135					components.push(component);
136				}
137				// Otherwise, ignore it (it's redundant)
138			}
139			Utf8Component::ParentDir => {
140				if normal_seen {
141					// If we've seen a normal component, pop the last component
142					if let Some(Utf8Component::Normal(_)) = components.last() {
143						components.pop();
144						normal_seen = components.iter().any(|c| matches!(c, Utf8Component::Normal(_)));
145						continue;
146					}
147				} else {
148					// If we haven't seen a normal component, this is a leading ".."
149					parent_count += 1;
150				}
151				components.push(component);
152			}
153			Utf8Component::Normal(name) => {
154				components.push(Utf8Component::Normal(name));
155				normal_seen = true;
156			}
157		}
158	}
159
160	// If there are any parent dirs still in the path, check if they would try to go
161	// beyond the starting dir
162	if parent_count > 0 && components.iter().filter(|c| matches!(c, Utf8Component::Normal(_))).count() < parent_count {
163		return None;
164	}
165
166	// If we've collapsed everything away, return "." or "" appropriately
167	if components.is_empty() {
168		if path_buf.as_str().starts_with("./") {
169			return Some(Utf8PathBuf::from("."));
170		} else {
171			return Some(Utf8PathBuf::from(""));
172		}
173	}
174
175	// Reconstruct the path from the collapsed components
176	let mut result = Utf8PathBuf::new();
177	for component in components {
178		result.push(component.as_str());
179	}
180
181	Some(result)
182}
183
184/// Returns `true` if the path is already collapsed.
185///
186/// A path is considered collapsed if it contains no `.` components
187/// and no `..` components that immediately follow a normal component.
188/// Leading `..` components in relative paths are allowed.
189/// Absolute paths should not contain `..` at all after the root/prefix.
190pub fn is_collapsed(path: impl AsRef<Utf8Path>) -> bool {
191	let path = path.as_ref();
192	let mut components = path.components().peekable();
193	let mut is_absolute = false;
194	let mut previous_was_normal = false;
195
196	while let Some(component) = components.next() {
197		match component {
198			Utf8Component::Prefix(_) | Utf8Component::RootDir => {
199				is_absolute = true;
200			}
201			Utf8Component::CurDir => {
202				// Current dir components are allowed only at the beginning of a relative path
203				if previous_was_normal || is_absolute || components.peek().is_some() {
204					return false;
205				}
206			}
207			Utf8Component::ParentDir => {
208				// In absolute paths, parent dir components should never appear
209				if is_absolute {
210					return false;
211				}
212				// In relative paths, parent dir should not follow a normal component
213				if previous_was_normal {
214					return false;
215				}
216			}
217			Utf8Component::Normal(_) => {
218				previous_was_normal = true;
219			}
220		}
221	}
222
223	true
224}
225
226// Helper function for try_into_collapsed
227fn contains_problematic_components(path: &Utf8Path) -> bool {
228	let mut has_parent_after_normal = false;
229	let mut has_prefix_or_root = false;
230	let mut normal_seen = false;
231
232	for component in path.components() {
233		match component {
234			Utf8Component::Prefix(_) | Utf8Component::RootDir => {
235				has_prefix_or_root = true;
236			}
237			Utf8Component::ParentDir => {
238				if normal_seen {
239					has_parent_after_normal = true;
240				}
241			}
242			Utf8Component::Normal(_) => {
243				normal_seen = true;
244			}
245			_ => {}
246		}
247	}
248
249	has_prefix_or_root || has_parent_after_normal
250}
251
252// region:    --- Tests
253
254#[cfg(test)]
255mod tests {
256	type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>; // For tests.
257
258	use super::*;
259
260	// -- Tests for into_collapsed
261
262	#[test]
263	fn test_reshape_collapser_into_collapsed_simple() -> Result<()> {
264		// -- Setup & Fixtures
265		let data = &[
266			// Basic cases
267			("a/b/c", "a/b/c"),
268			("a/./b", "a/b"),
269			("./a/b", "./a/b"),
270			("./a/b", "./a/b"),
271			("a/./b/.", "a/b"),
272			("/a/./b/.", "/a/b"),
273			("a/../b", "b"),
274			("../a/b", "../a/b"),         // Keep leading ..
275			("../a/b/..", "../a"),        // Keep leading ..
276			("../a/b/../../..", "../.."), // Keep leading ..
277			("a/b/..", "a"),
278			("a/b/../..", ""),          // Collapses to current dir
279			("../../a/b", "../../a/b"), // Keep multiple leading ..
280			(".", "."),                 // "."
281			("..", ".."),               // ".." stays ".."
282		];
283
284		// -- Exec & Check
285		for (input, expected) in data {
286			let input_path = Utf8PathBuf::from(input);
287			let result_path = into_collapsed(input_path);
288			let expected_path = Utf8PathBuf::from(expected);
289			assert_eq!(
290				result_path, expected_path,
291				"Input: '{input}', Expected: '{expected}', Got: '{result_path}'"
292			);
293		}
294
295		Ok(())
296	}
297}
298
299// endregion: --- Tests