path_rs/normalize.rs
1//! Lexical path normalization and filesystem canonicalization.
2//!
3//! # Important distinction
4//!
5//! - [`normalize`] is **lexical**: no filesystem access, no symlink resolution.
6//! - [`canonicalize_existing`] accesses the filesystem, requires existence, and resolves symlinks.
7
8use crate::error::PathError;
9use crate::internal::components::is_drive_relative_path;
10use crate::internal::validation::reject_nul_path;
11use std::path::{Component, Path, PathBuf};
12
13/// Lexically normalize a path without accessing the filesystem.
14///
15/// Collapses `.`, resolves `..` where possible, and removes empty segments
16/// produced by repeated separators. Leading `..` components on relative paths
17/// are preserved. Absolute paths that would escape the root via `..` stop at
18/// the root (Unix `/` or Windows drive/UNC root).
19///
20/// # Filesystem access
21///
22/// **No.** This never calls `std::fs::canonicalize` and does not follow symlinks.
23///
24/// # Drive-relative paths
25///
26/// Windows drive-relative paths (`C:foo`, `C:`) are rejected.
27///
28/// # Examples
29///
30/// ```
31/// use path_rs::normalize;
32/// use std::path::PathBuf;
33///
34/// let p = normalize("foo/./bar/../baz").unwrap();
35/// assert_eq!(p, PathBuf::from("foo/baz"));
36/// ```
37pub fn normalize(path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
38 let path = path.as_ref();
39 reject_nul_path(path)?;
40
41 if path.as_os_str().is_empty() {
42 return Err(PathError::EmptyInput);
43 }
44
45 if is_drive_relative_path(path) {
46 return Err(PathError::drive_relative(path));
47 }
48
49 let mut out = PathBuf::new();
50 let mut absolute = false;
51
52 for component in path.components() {
53 match component {
54 Component::Prefix(prefix) => {
55 out.push(prefix.as_os_str());
56 }
57 Component::RootDir => {
58 out.push(component.as_os_str());
59 absolute = true;
60 }
61 Component::CurDir => {
62 // Skip `.`
63 }
64 Component::ParentDir => {
65 match out.components().next_back() {
66 None => {
67 if !absolute {
68 out.push("..");
69 }
70 }
71 Some(Component::ParentDir) => {
72 // Relative path already stacked with `..`
73 out.push("..");
74 }
75 Some(Component::RootDir) | Some(Component::Prefix(_)) => {
76 // Cannot escape absolute root / prefix.
77 }
78 Some(Component::Normal(_)) => {
79 out.pop();
80 }
81 Some(Component::CurDir) => {
82 out.pop();
83 out.push("..");
84 }
85 }
86 }
87 Component::Normal(c) => {
88 out.push(c);
89 }
90 }
91 }
92
93 // Preserve a bare root / prefix-only path.
94 if out.as_os_str().is_empty() {
95 if absolute {
96 // Should not happen if RootDir was pushed.
97 out.push(Component::RootDir.as_os_str());
98 } else {
99 out.push(".");
100 }
101 }
102
103 Ok(out)
104}
105
106/// Canonicalize an existing path using the filesystem.
107///
108/// This resolves symlinks and requires every component to exist (platform rules apply).
109/// On Windows, the result may be a verbatim `\\?\` path; [`crate::platform::simplify_for_display`]
110/// can be used for user-facing output.
111///
112/// # Filesystem access
113///
114/// **Yes.** Requires the path to exist. Resolves symlinks.
115///
116/// # Examples
117///
118/// ```no_run
119/// use path_rs::canonicalize_existing;
120///
121/// let p = canonicalize_existing(".").unwrap();
122/// assert!(p.is_absolute());
123/// ```
124pub fn canonicalize_existing(path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
125 let path = path.as_ref();
126 reject_nul_path(path)?;
127
128 if is_drive_relative_path(path) {
129 return Err(PathError::drive_relative(path));
130 }
131
132 std::fs::canonicalize(path).map_err(|e| PathError::filesystem(path, e))
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn collapses_dot_and_parent() {
141 assert_eq!(
142 normalize("foo/./bar/../baz").unwrap(),
143 PathBuf::from("foo/baz")
144 );
145 assert_eq!(normalize("./foo").unwrap(), PathBuf::from("foo"));
146 assert_eq!(normalize("foo//bar").unwrap(), PathBuf::from("foo/bar"));
147 }
148
149 #[test]
150 fn preserves_leading_parent_on_relative() {
151 assert_eq!(normalize("../foo").unwrap(), PathBuf::from("../foo"));
152 assert_eq!(normalize("foo/../../bar").unwrap(), PathBuf::from("../bar"));
153 }
154
155 #[test]
156 fn empty_rejected() {
157 assert!(matches!(normalize(""), Err(PathError::EmptyInput)));
158 }
159}