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> {
62 if tokio::fs::try_exists(normalized).await.unwrap_or(false) {
64 return canonicalize_async(normalized)
65 .await
66 .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", normalized.display(), e));
67 }
68
69 let mut current = normalized.to_path_buf();
71 while let Some(parent) = current.parent() {
72 if tokio::fs::try_exists(parent).await.unwrap_or(false) {
73 let canonical_parent = canonicalize_async(parent)
75 .await
76 .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", parent.display(), e))?;
77
78 let remainder = normalized.strip_prefix(parent).unwrap_or_else(|_| Path::new(""));
80
81 return if remainder.as_os_str().is_empty() {
83 Ok(canonical_parent)
84 } else {
85 Ok(canonical_parent.join(remainder))
86 };
87 }
88 current = parent.to_path_buf();
89 }
90
91 Ok(normalized.to_path_buf())
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[tokio::test]
100 async fn test_canonicalize_existing_file() {
101 let temp_dir = std::env::temp_dir();
103 let test_file = temp_dir.join("vtcode_test_existing.txt");
104 tokio::fs::write(&test_file, b"test").await.unwrap();
105
106 let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
107
108 assert!(canonical.is_absolute());
110 assert!(canonical.exists());
111
112 drop(tokio::fs::remove_file(&test_file).await);
114 }
115
116 #[tokio::test]
117 async fn test_canonicalize_missing_file() {
118 let temp_dir = std::env::temp_dir();
120 let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
121
122 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
123
124 assert!(canonical.is_absolute());
126 assert!(canonical.to_string_lossy().contains("missing_file.txt"));
127 }
128
129 #[tokio::test]
130 async fn test_canonicalize_deeply_missing_path() {
131 let temp_dir = std::env::temp_dir();
133 let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
134
135 let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
136
137 assert!(canonical.is_absolute());
139 assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
140 }
141
142 #[tokio::test]
143 async fn test_canonicalize_missing_file_with_existing_parent() {
144 let temp_dir = std::env::temp_dir();
146 let test_dir = temp_dir.join("vtcode_test_parent");
147 tokio::fs::create_dir_all(&test_dir).await.unwrap();
148
149 let missing_file = test_dir.join("missing.txt");
150 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
151
152 assert!(canonical.is_absolute());
154 assert!(canonical.to_string_lossy().ends_with("missing.txt"));
155
156 drop(tokio::fs::remove_dir(&test_dir).await);
158 }
159}