vtcode_commons/paths/
canonicalize.rs1use anyhow::{Result, anyhow};
2use std::path::{Path, PathBuf};
3use tracing::warn;
4
5pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
17 dunce::canonicalize(path)
18}
19
20pub async fn canonicalize_async(path: impl AsRef<Path> + Send) -> std::io::Result<PathBuf> {
25 let path = path.as_ref().to_path_buf();
26 match tokio::task::spawn_blocking(move || dunce::canonicalize(&path)).await {
27 Ok(inner) => inner,
28 Err(join_error) => Err(std::io::Error::other(join_error)),
29 }
30}
31
32pub fn canonicalize_workspace(workspace_root: &Path) -> PathBuf {
34 canonicalize(workspace_root).unwrap_or_else(|error| {
35 warn!(
36 path = %workspace_root.display(),
37 %error,
38 "Failed to canonicalize workspace root; falling back to provided path"
39 );
40 workspace_root.to_path_buf()
41 })
42}
43
44pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
63 let mut current = normalized.to_path_buf();
66 loop {
67 match tokio::fs::symlink_metadata(¤t).await {
68 Ok(_) => {
69 let canonical_parent = canonicalize_async(¤t)
70 .await
71 .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", current.display(), e))?;
72
73 let remainder = normalized.strip_prefix(¤t).unwrap_or_else(|_| Path::new(""));
74
75 return if remainder.as_os_str().is_empty() {
77 Ok(canonical_parent)
78 } else {
79 Ok(canonical_parent.join(remainder))
80 };
81 }
82 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
83 Err(error) => return Err(anyhow!("Failed to inspect path '{}': {}", current.display(), error)),
84 }
85 let Some(parent) = current.parent() else { break };
86 current = parent.to_path_buf();
87 }
88
89 Ok(normalized.to_path_buf())
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[tokio::test]
98 async fn test_canonicalize_existing_file() {
99 let temp_dir = std::env::temp_dir();
101 let test_file = temp_dir.join("vtcode_test_existing.txt");
102 tokio::fs::write(&test_file, b"test").await.unwrap();
103
104 let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
105
106 assert!(canonical.is_absolute());
108 assert!(canonical.exists());
109
110 drop(tokio::fs::remove_file(&test_file).await);
112 }
113
114 #[tokio::test]
115 async fn test_canonicalize_missing_file() {
116 let temp_dir = std::env::temp_dir();
118 let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
119
120 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
121
122 assert!(canonical.is_absolute());
124 assert!(canonical.to_string_lossy().contains("missing_file.txt"));
125 }
126
127 #[tokio::test]
128 async fn test_canonicalize_deeply_missing_path() {
129 let temp_dir = std::env::temp_dir();
131 let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
132
133 let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
134
135 assert!(canonical.is_absolute());
137 assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
138 }
139
140 #[tokio::test]
141 async fn test_canonicalize_missing_file_with_existing_parent() {
142 let temp_dir = std::env::temp_dir();
144 let test_dir = temp_dir.join("vtcode_test_parent");
145 tokio::fs::create_dir_all(&test_dir).await.unwrap();
146
147 let missing_file = test_dir.join("missing.txt");
148 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
149
150 assert!(canonical.is_absolute());
152 assert!(canonical.to_string_lossy().ends_with("missing.txt"));
153
154 drop(tokio::fs::remove_dir(&test_dir).await);
156 }
157
158 #[cfg(unix)]
159 #[tokio::test]
160 async fn dangling_symlink_is_not_treated_as_a_missing_component() {
161 let workspace = tempfile::tempdir().unwrap();
162 let outside = tempfile::tempdir().unwrap();
163 let link = workspace.path().join("link");
164 std::os::unix::fs::symlink(outside.path().join("missing"), &link).unwrap();
165 assert!(canonicalize_allow_missing(&link).await.is_err());
166 assert!(canonicalize_allow_missing(&link.join("child")).await.is_err());
167 assert!(canonicalize_allow_missing(&workspace.path().join("new/child")).await.is_ok());
168 let valid_link = workspace.path().join("valid");
169 std::os::unix::fs::symlink(outside.path(), &valid_link).unwrap();
170 let resolved = canonicalize_allow_missing(&valid_link.join("new/child")).await.unwrap();
171 assert_eq!(resolved, canonicalize_async(outside.path()).await.unwrap().join("new/child"));
172 }
173}