oxihuman_core/
path_normalizer.rs1#![allow(dead_code)]
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct NormalizedPath(String);
10
11impl NormalizedPath {
12 pub fn new(path: &str) -> Self {
13 NormalizedPath(normalize_path(path))
14 }
15
16 pub fn as_str(&self) -> &str {
17 &self.0
18 }
19
20 pub fn join(&self, segment: &str) -> Self {
21 let combined = format!(
22 "{}/{}",
23 self.0.trim_end_matches('/'),
24 segment.trim_start_matches('/')
25 );
26 NormalizedPath::new(&combined)
27 }
28
29 pub fn parent(&self) -> Option<NormalizedPath> {
30 let p = self.0.trim_end_matches('/');
31 let idx = p.rfind('/')?;
32 if idx == 0 {
33 Some(NormalizedPath("/".to_string()))
34 } else {
35 Some(NormalizedPath(p[..idx].to_string()))
36 }
37 }
38
39 pub fn file_name(&self) -> Option<&str> {
40 self.0.trim_end_matches('/').rsplit('/').next()
41 }
42}
43
44impl std::fmt::Display for NormalizedPath {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 write!(f, "{}", self.0)
47 }
48}
49
50pub fn normalize_path(path: &str) -> String {
52 let is_abs = path.starts_with('/');
53 let mut parts: Vec<&str> = Vec::new();
54 for seg in path.split('/') {
55 match seg {
56 "" | "." => {}
57 ".." => {
58 parts.pop();
59 }
60 s => parts.push(s),
61 }
62 }
63 let joined = parts.join("/");
64 if is_abs {
65 format!("/{}", joined)
66 } else {
67 joined
68 }
69}
70
71pub fn join_paths(base: &str, segment: &str) -> String {
73 if segment.starts_with('/') {
74 normalize_path(segment)
75 } else {
76 normalize_path(&format!("{}/{}", base, segment))
77 }
78}
79
80pub fn file_extension(path: &str) -> Option<&str> {
82 let name = path.rsplit('/').next()?;
83 let dot = name.rfind('.')?;
84 if dot == 0 {
85 None
86 } else {
87 Some(&name[dot + 1..])
88 }
89}
90
91pub fn is_absolute(path: &str) -> bool {
93 path.starts_with('/')
94}
95
96pub fn make_absolute(base: &str, path: &str) -> String {
98 if is_absolute(path) {
99 normalize_path(path)
100 } else {
101 join_paths(base, path)
102 }
103}
104
105pub fn strip_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
107 path.strip_prefix(prefix)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn test_normalize_dotdot() {
116 assert_eq!(normalize_path("/a/b/../c"), "/a/c");
117 }
118
119 #[test]
120 fn test_normalize_dot() {
121 assert_eq!(normalize_path("/a/./b"), "/a/b");
122 }
123
124 #[test]
125 fn test_normalize_double_slash() {
126 assert_eq!(normalize_path("/a//b"), "/a/b");
127 }
128
129 #[test]
130 fn test_join_paths_relative() {
131 assert_eq!(join_paths("/a/b", "c/d"), "/a/b/c/d");
132 }
133
134 #[test]
135 fn test_join_paths_abs_segment() {
136 assert_eq!(join_paths("/a/b", "/c/d"), "/c/d");
137 }
138
139 #[test]
140 fn test_file_extension() {
141 assert_eq!(file_extension("/foo/bar.rs"), Some("rs"));
142 assert_eq!(file_extension("/foo/bar"), None);
143 }
144
145 #[test]
146 fn test_is_absolute() {
147 assert!(is_absolute("/foo"));
148 assert!(!is_absolute("foo"));
149 }
150
151 #[test]
152 fn test_make_absolute() {
153 assert_eq!(make_absolute("/base", "rel"), "/base/rel");
154 assert_eq!(make_absolute("/base", "/abs"), "/abs");
155 }
156
157 #[test]
158 fn test_normalized_path_join() {
159 let p = NormalizedPath::new("/a/b");
160 let q = p.join("c");
161 assert_eq!(q.as_str(), "/a/b/c");
162 }
163
164 #[test]
165 fn test_normalized_path_parent() {
166 let p = NormalizedPath::new("/a/b/c");
167 let parent = p.parent().expect("should succeed");
168 assert_eq!(parent.as_str(), "/a/b");
169 }
170}