//! POSIX path helpers with Node `path.posix` semantics (normalize/join/basename/dirname). pub fn normalize(path: &str) -> String { if path.is_empty() { return ".".into(); } let absolute = path.starts_with('/'); let trailing = path.ends_with('/'); let mut parts: Vec<&str> = Vec::new(); for segment in path.split('/') { match segment { "" | "." => {} ".." => { if parts.last().is_some_and(|p| *p != "..") { parts.pop(); } else if !absolute { parts.push(".."); } } other => parts.push(other), } } let mut out = parts.join("/"); if out.is_empty() && !absolute { out = ".".into(); } if !out.is_empty() && trailing { out.push('/'); } if absolute { format!("/{out}") } else { out } } pub fn join(base: &str, name: &str) -> String { let joined = match (base.is_empty(), name.is_empty()) { (true, true) => return ".".into(), (true, false) => name.to_string(), (false, true) => base.to_string(), _ => format!("{base}/{name}"), }; normalize(&joined) } pub fn basename(path: &str) -> String { path.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string() } /// Directory part of a normalized path (`/a/b` → `/a`, `/a` → `/`). pub fn dirname(path: &str) -> String { let trimmed = path.trim_end_matches('/'); if trimmed.is_empty() { return if path.starts_with('/') { "/".into() } else { ".".into() }; } match trimmed.rfind('/') { Some(0) => "/".into(), Some(i) => { let parent = trimmed[..i].trim_end_matches('/'); if parent.is_empty() { "/".into() } else { parent.to_string() } } None => ".".into(), } } #[cfg(test)] mod tests { use super::*; #[test] fn node_compatible() { assert_eq!(normalize("/a//b/../c/./d/"), "/a/c/d/"); assert_eq!(normalize("/../.."), "/"); assert_eq!(normalize("/"), "/"); assert_eq!(join("/", "x"), "/x"); assert_eq!(join("/a/b", "c"), "/a/b/c"); assert_eq!(basename("/a/b/"), "b"); assert_eq!(dirname("/a/b"), "/a"); assert_eq!(dirname("/a"), "/"); assert_eq!(dirname("/a//b"), "/a"); } }