#[must_use]
pub fn normalize_path(path: &str) -> String {
if path.is_empty() {
return String::new();
}
let absolute = path.starts_with('/');
let trailing_slash = path.len() > 1 && path.ends_with('/');
let mut stack: Vec<&str> = Vec::new();
for segment in path.split('/').filter(|s| !s.is_empty()) {
match segment {
"." => {}
".." => {
stack.pop();
}
other => stack.push(other)
}
}
if stack.is_empty() {
return if absolute {
"/".to_string()
} else {
String::new()
};
}
let body = stack.join("/");
let mut result = String::with_capacity(body.len() + 2);
if absolute {
result.push('/');
}
result.push_str(&body);
if trailing_slash {
result.push('/');
}
result
}
#[must_use]
pub fn is_absolute(path: &str) -> bool {
path.starts_with('/')
}
#[must_use]
pub fn join_paths(base: &str, path: &str) -> String {
if path.starts_with('/') {
normalize_path(path)
} else {
let base = base.trim_end_matches('/');
normalize_path(&format!("{base}/{path}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_collapses_double_slashes() {
assert_eq!(normalize_path("/docs//api"), "/docs/api");
assert_eq!(normalize_path("a//b///c"), "a/b/c");
}
#[test]
fn normalize_preserves_trailing_slash_for_directories() {
assert_eq!(normalize_path("/docs/"), "/docs/");
assert_eq!(normalize_path("/"), "/");
}
#[test]
fn normalize_resolves_double_dot() {
assert_eq!(normalize_path("/foo/bar/../baz"), "/foo/baz");
assert_eq!(normalize_path("/foo/bar/../baz/"), "/foo/baz/");
assert_eq!(normalize_path("/a/b/c/../../d"), "/a/d");
}
#[test]
fn normalize_resolves_single_dot() {
assert_eq!(normalize_path("/a/./b/c/../d"), "/a/b/d");
assert_eq!(normalize_path("./foo/./bar"), "foo/bar");
}
#[test]
fn normalize_does_not_escape_root() {
assert_eq!(normalize_path("/../foo"), "/foo");
assert_eq!(normalize_path("/../../foo"), "/foo");
assert_eq!(normalize_path("/.."), "/");
}
#[test]
fn normalize_handles_empty_input() {
assert_eq!(normalize_path(""), "");
}
#[test]
fn normalize_relative_paths() {
assert_eq!(normalize_path("foo/../bar"), "bar");
assert_eq!(normalize_path("foo/./bar"), "foo/bar");
}
#[test]
fn is_absolute_returns_true_for_rooted_paths() {
assert!(is_absolute("/"));
assert!(is_absolute("/docs"));
assert!(is_absolute("/docs/api"));
}
#[test]
fn is_absolute_returns_false_for_relative_paths() {
assert!(!is_absolute(""));
assert!(!is_absolute("docs"));
assert!(!is_absolute("./docs"));
assert!(!is_absolute("../docs"));
}
#[test]
fn join_paths_with_absolute() {
assert_eq!(join_paths("/base", "/docs"), "/docs");
assert_eq!(join_paths("/base", "/"), "/");
}
#[test]
fn join_paths_with_relative() {
assert_eq!(join_paths("/base", "docs"), "/base/docs");
assert_eq!(join_paths("/base/", "docs"), "/base/docs");
}
}