Skip to main content

vtcode_core/tools/file_ops/
path_policy.rs

1use 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        // Try exact path first
139        paths.push(self.workspace_root.join(path));
140
141        // If it's just a filename, try common directories that exist in most projects
142        if !path.contains('/') && !path.contains('\\') {
143            // Generic source directories found in most projects
144            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        // Try case-insensitive variants for filenames
160        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    /// Public helper to normalize and validate a user-provided path against the workspace root.
250    /// Inline-delegating wrapper that returns the inner future directly to
251    /// avoid an extra coroutine state machine (audit section 16).
252    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}