vtcode_core/tools/file_ops/
path_policy.rs1use super::FileOpsTool;
2use crate::tools::jaro_winkler_similarity;
3use anyhow::{Result, anyhow};
4use ignore::DirEntry;
5use std::cmp::Ordering;
6use std::future::Future;
7use std::path::{Path, PathBuf};
8use vtcode_commons::walk::{build_default_walker, is_excluded_dir};
9
10const MAX_PATH_SUGGESTIONS: usize = 3;
11const MAX_PATH_SUGGESTION_SCAN: usize = 20_000;
12const MIN_PATH_SUGGESTION_SCORE: f32 = 0.78;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub(super) enum PathSuggestionKind {
16 Any,
17 File,
18}
19
20impl PathSuggestionKind {
21 fn matches(self, entry: &DirEntry) -> bool {
22 match self {
23 Self::Any => true,
24 Self::File => entry.file_type().is_some_and(|ft| ft.is_file()),
25 }
26 }
27}
28
29fn normalize_path_for_suggestion(path: &str) -> String {
30 path.replace('\\', "/")
31 .trim_matches('/')
32 .to_ascii_lowercase()
33}
34
35fn suggestion_basename(path: &str) -> &str {
36 path.rsplit('/').next().unwrap_or(path)
37}
38
39fn suggestion_score(requested_path: &str, candidate_path: &str) -> f32 {
40 let requested_name = suggestion_basename(requested_path);
41 let candidate_name = suggestion_basename(candidate_path);
42
43 let full_score = jaro_winkler_similarity(requested_path, candidate_path);
44 let name_score = if requested_name.is_empty() || candidate_name.is_empty() {
45 0.0
46 } else {
47 jaro_winkler_similarity(requested_name, candidate_name)
48 };
49
50 let mut score = full_score.max(name_score * 0.85);
51
52 if !requested_name.is_empty() && requested_name == candidate_name {
53 score += 0.20;
54 } else if !requested_name.is_empty()
55 && (candidate_name.contains(requested_name) || requested_name.contains(candidate_name))
56 {
57 score += 0.06;
58 }
59
60 if candidate_path.ends_with(requested_path) || requested_path.ends_with(candidate_path) {
61 score += 0.12;
62 }
63
64 score.min(1.0)
65}
66
67impl FileOpsTool {
68 pub(super) fn canonical_workspace_root(&self) -> &PathBuf {
69 &self.canonical_workspace_root
70 }
71
72 pub(super) fn workspace_relative_display(&self, path: &Path) -> String {
73 if let Ok(relative) = path.strip_prefix(&self.workspace_root) {
74 relative.to_string_lossy().into_owned()
75 } else if let Ok(relative) = path.strip_prefix(self.canonical_workspace_root()) {
76 relative.to_string_lossy().into_owned()
77 } else {
78 path.to_string_lossy().into_owned()
79 }
80 }
81
82 pub(super) fn absolute_candidate(&self, path: &Path) -> PathBuf {
83 if path.is_absolute() {
84 path.to_path_buf()
85 } else {
86 self.workspace_root.join(path)
87 }
88 }
89
90 pub(super) async fn normalize_and_validate_user_path(&self, path: &str) -> Result<PathBuf> {
91 self.normalize_and_validate_candidate(Path::new(path), path)
92 .await
93 }
94
95 pub(super) async fn normalize_and_validate_candidate(
96 &self,
97 path: &Path,
98 original_display: &str,
99 ) -> Result<PathBuf> {
100 use crate::utils::path::normalize_path;
101 let absolute = self.absolute_candidate(path);
102 let normalized = normalize_path(&absolute);
103 let normalized_root = normalize_path(&self.workspace_root);
104 let canonical = self.canonicalize_allow_missing(&normalized).await?;
105 let canonical_root = normalize_path(self.canonical_workspace_root());
106
107 let within_workspace = normalized.starts_with(&normalized_root)
108 || normalized.starts_with(&canonical_root)
109 || canonical.starts_with(&normalized_root)
110 || canonical.starts_with(self.canonical_workspace_root());
111
112 if !within_workspace {
113 return Err(anyhow!(
114 "Error: Path '{}' resolves outside the workspace.",
115 original_display
116 ));
117 }
118
119 Ok(canonical)
120 }
121
122 pub(super) fn canonicalize_allow_missing<'a>(
123 &'a self,
124 normalized: &'a Path,
125 ) -> impl Future<Output = Result<PathBuf>> + 'a {
126 crate::utils::path::canonicalize_allow_missing(normalized)
127 }
128
129 pub(super) fn resolve_file_path(&self, path: &str) -> Result<Vec<PathBuf>> {
130 let mut paths = Vec::new();
131 let requested = PathBuf::from(path);
132
133 if requested.is_absolute() {
134 paths.push(requested);
135 return Ok(paths);
136 }
137
138 paths.push(self.workspace_root.join(path));
140
141 if !path.contains('/') && !path.contains('\\') {
143 paths.push(self.workspace_root.join("src").join(path));
145 paths.push(self.workspace_root.join("lib").join(path));
146 paths.push(self.workspace_root.join("bin").join(path));
147 paths.push(self.workspace_root.join("app").join(path));
148 paths.push(self.workspace_root.join("source").join(path));
149 paths.push(self.workspace_root.join("sources").join(path));
150 paths.push(self.workspace_root.join("include").join(path));
151 paths.push(self.workspace_root.join("docs").join(path));
152 paths.push(self.workspace_root.join("doc").join(path));
153 paths.push(self.workspace_root.join("examples").join(path));
154 paths.push(self.workspace_root.join("example").join(path));
155 paths.push(self.workspace_root.join("tests").join(path));
156 paths.push(self.workspace_root.join("test").join(path));
157 }
158
159 if !path.contains('/')
161 && !path.contains('\\')
162 && let Ok(entries) = std::fs::read_dir(&self.workspace_root)
163 {
164 for entry in entries.flatten() {
165 if let Ok(name) = entry.file_name().into_string()
166 && name.to_lowercase() == path.to_lowercase()
167 {
168 paths.push(entry.path());
169 }
170 }
171 }
172
173 Ok(paths)
174 }
175
176 pub(super) fn missing_path_suggestion_suffix(
177 &self,
178 requested_path: &str,
179 kind: PathSuggestionKind,
180 ) -> String {
181 let suggestions = self.suggest_workspace_paths(requested_path, kind);
182 if suggestions.is_empty() {
183 String::new()
184 } else {
185 format!(" Did you mean: {}?", suggestions.join(", "))
186 }
187 }
188
189 pub(super) fn suggest_workspace_paths(
190 &self,
191 requested_path: &str,
192 kind: PathSuggestionKind,
193 ) -> Vec<String> {
194 let requested_path = normalize_path_for_suggestion(requested_path);
195 if requested_path.is_empty() || requested_path == "." {
196 return Vec::new();
197 }
198
199 let mut scored_paths = Vec::with_capacity(MAX_PATH_SUGGESTIONS * 2);
200 let mut scanned = 0usize;
201
202 let walker = build_default_walker(&self.workspace_root)
203 .filter_entry(|entry| !is_excluded_dir(entry))
204 .build();
205
206 for entry in walker {
207 let Ok(entry) = entry else {
208 continue;
209 };
210 if entry.depth() == 0 || !kind.matches(&entry) {
211 continue;
212 }
213
214 scanned += 1;
215 if scanned > MAX_PATH_SUGGESTION_SCAN {
216 break;
217 }
218
219 let display_path = self.workspace_relative_display(entry.path());
220 let normalized_candidate = normalize_path_for_suggestion(&display_path);
221 if normalized_candidate.is_empty() || normalized_candidate == requested_path {
222 continue;
223 }
224
225 let score = suggestion_score(&requested_path, &normalized_candidate);
226 if score < MIN_PATH_SUGGESTION_SCORE {
227 continue;
228 }
229
230 scored_paths.push((score, display_path));
231 }
232
233 scored_paths.sort_by(|left, right| {
234 right
235 .0
236 .partial_cmp(&left.0)
237 .unwrap_or(Ordering::Equal)
238 .then_with(|| left.1.cmp(&right.1))
239 });
240 scored_paths.dedup_by(|left, right| left.1 == right.1);
241
242 scored_paths
243 .into_iter()
244 .take(MAX_PATH_SUGGESTIONS)
245 .map(|(_, path)| path)
246 .collect()
247 }
248
249 pub fn normalize_user_path<'a>(
253 &'a self,
254 path: &'a str,
255 ) -> impl Future<Output = Result<PathBuf>> + 'a {
256 self.normalize_and_validate_user_path(path)
257 }
258}