Skip to main content

oxihuman_core/
path_normalizer.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Path resolution and normalization utilities.
6
7/// A normalized path wrapper.
8#[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
50/// Normalize a path string by resolving `.` and `..` components.
51pub 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
71/// Join two path segments.
72pub 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
80/// Return the file extension (without dot), if any.
81pub 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
91/// Return true if the path is absolute.
92pub fn is_absolute(path: &str) -> bool {
93    path.starts_with('/')
94}
95
96/// Make a relative path absolute by prepending `base`.
97pub 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
105/// Strip prefix from a path.
106pub 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}