Skip to main content

vtcode_commons/paths/
normalize.rs

1#![expect(
2    unused_results,
3    reason = "Path normalization intentionally ignores the boolean returned by pop after a validated component check."
4)]
5
6use std::path::{Component, Path, PathBuf};
7
8/// Normalize a path by resolving `.` and `..` components lexically.
9pub fn normalize_path(path: &Path) -> PathBuf {
10    let mut normalized = PathBuf::new();
11    for component in path.components() {
12        match component {
13            Component::ParentDir => {
14                normalized.pop();
15            }
16            Component::CurDir => {}
17            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
18            Component::RootDir => normalized.push(component.as_os_str()),
19            Component::Normal(part) => normalized.push(part),
20        }
21    }
22    normalized
23}
24
25/// Expand a leading `~` or `~/` to the user's home directory. The function is
26/// intentionally forgiving: paths that don't start with `~` are returned as-is,
27/// and when the home directory cannot be determined the original path is
28/// preserved so callers can surface a downstream error rather than panicking.
29///
30/// This is the canonical implementation used by the tool registry and the
31/// sandbox runtime; both call sites previously carried near-identical copies.
32pub fn expand_tilde(path: &str) -> PathBuf {
33    if path == "~" {
34        return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path));
35    }
36    if let Some(rest) = path.strip_prefix("~/")
37        && let Some(home) = dirs::home_dir()
38    {
39        return home.join(rest);
40    }
41    PathBuf::from(path)
42}
43
44/// Normalize identifiers to ASCII alphanumerics with lowercase output.
45pub fn normalize_ascii_identifier(value: &str) -> String {
46    let mut normalized = String::with_capacity(value.len());
47    for ch in value.chars() {
48        if ch.is_ascii_alphanumeric() {
49            normalized.push(ch.to_ascii_lowercase());
50        }
51    }
52    normalized
53}
54
55/// Extract the filename from a path, with fallback to the full path.
56pub fn file_name_from_path(path: &str) -> String {
57    Path::new(path)
58        .file_name()
59        .and_then(|name| name.to_str())
60        .map(|s| s.to_string())
61        .unwrap_or_else(|| path.to_string())
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use std::path::PathBuf;
68
69    #[test]
70    fn expand_tilde_passes_through_absolute_paths() {
71        let absolute = "/etc/hosts";
72        assert_eq!(expand_tilde(absolute), PathBuf::from(absolute));
73    }
74
75    #[test]
76    fn expand_tilde_passes_through_relative_paths() {
77        let relative = "src/main.rs";
78        assert_eq!(expand_tilde(relative), PathBuf::from(relative));
79    }
80
81    #[test]
82    fn expand_tilde_resolves_bare_tilde_to_home() {
83        if let Some(home) = dirs::home_dir() {
84            assert_eq!(expand_tilde("~"), home);
85        }
86    }
87
88    #[test]
89    fn expand_tilde_resolves_tilde_slash_prefix() {
90        if let Some(home) = dirs::home_dir() {
91            let resolved = expand_tilde("~/projects/vtcode");
92            assert_eq!(resolved, home.join("projects/vtcode"));
93        }
94    }
95}