Skip to main content

vtcode_core/tools/code_search/
identity.rs

1use std::path::{Component, Path, PathBuf};
2
3use crate::tools::code_search::CodeSearchRequest;
4use vtcode_commons::canonicalize;
5
6/// Lexically normalize a search path without erasing leading `..` components.
7///
8/// The shared path helper intentionally clamps a relative path at an empty
9/// buffer, which is useful for some workspace display paths but would make an
10/// escaping `../target` search collide with an in-workspace `target` cache
11/// entry. Preserve unresolved parents so replay identity remains fail-closed.
12pub fn normalised_path(path: &str) -> String {
13    let mut normalised = PathBuf::new();
14    for component in Path::new(path).components() {
15        match component {
16            Component::ParentDir => {
17                if matches!(normalised.components().next_back(), Some(Component::Normal(_))) {
18                    normalised.pop();
19                } else if !normalised.has_root() {
20                    normalised.push("..");
21                }
22            }
23            Component::CurDir => {}
24            Component::Prefix(prefix) => normalised.push(prefix.as_os_str()),
25            Component::RootDir => normalised.push(component.as_os_str()),
26            Component::Normal(part) => normalised.push(part),
27        }
28    }
29    if normalised.as_os_str().is_empty() {
30        ".".to_string()
31    } else {
32        normalised.to_string_lossy().into_owned()
33    }
34}
35
36fn normalised_identity_value(args: &serde_json::Value, include_max_results: bool) -> Option<serde_json::Value> {
37    let request = serde_json::from_value::<CodeSearchRequest>(args.clone()).ok()?;
38    let mut normalised = request.normalise().ok()?;
39    normalised.filters.file_types.sort_unstable();
40    let mut identity = serde_json::json!({
41        "query": normalised.query,
42        "path": normalised_path(normalised.filters.path.as_str()),
43        "file_types": normalised.filters.file_types,
44        "result_types": normalised.filters.result_types,
45    });
46    if include_max_results {
47        identity["max_results"] = normalised.filters.max_results.into();
48    }
49    Some(identity)
50}
51
52/// Normalised identity for result caching and replay.
53///
54/// The effective result limit affects both the returned results and echoed
55/// filters, so it is part of this identity.
56pub fn normalised_identity(args: &serde_json::Value) -> Option<String> {
57    serde_json::to_string(&normalised_identity_value(args, true)?).ok()
58}
59
60/// Normalised identity for detecting repeated search behaviour.
61///
62/// Loop detection deliberately ignores the result limit: changing only the
63/// requested extent does not make an otherwise repeated search distinct.
64pub fn normalised_loop_identity(args: &serde_json::Value) -> Option<String> {
65    serde_json::to_string(&normalised_identity_value(args, false)?).ok()
66}
67
68/// Return whether a mutated path lies within the file or directory scope of a
69/// valid `code_search` request.
70///
71/// Both paths are resolved against a canonical workspace root so relative and
72/// absolute tool arguments share one component-aware comparison. Existing
73/// prefixes are canonicalised while missing suffixes remain lexical, which
74/// keeps deleted and newly-created targets comparable.
75pub fn scope_contains_mutated_path(args: &serde_json::Value, mutated_path: &Path, workspace_root: &Path) -> bool {
76    let Ok(request) = serde_json::from_value::<CodeSearchRequest>(args.clone()) else {
77        return false;
78    };
79    let Ok(request) = request.normalise() else {
80        return false;
81    };
82    let workspace_root = canonicalize_existing_prefix(workspace_root);
83    let resolve = |path: &Path| {
84        let absolute = if path.is_absolute() {
85            path.to_path_buf()
86        } else {
87            workspace_root.join(path)
88        };
89        canonicalize_existing_prefix(&absolute)
90    };
91    let scope = resolve(Path::new(request.filters.path.as_str()));
92    let mutated_path = resolve(mutated_path);
93    mutated_path.starts_with(&scope) || scope.starts_with(&mutated_path)
94}
95
96/// Canonicalise the longest existing prefix, then append any missing suffix.
97/// This resolves symlinked workspace roots and existing directory aliases while
98/// retaining deleted or newly-created mutation targets for replay checks.
99pub(super) fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
100    let normalised = crate::utils::path::normalize_path(path);
101    let mut existing_prefix = normalised.as_path();
102    let mut missing_components = Vec::new();
103
104    loop {
105        if let Ok(canonical) = canonicalize(existing_prefix) {
106            return missing_components.iter().rev().fold(canonical, |mut resolved, component| {
107                resolved.push(component);
108                resolved
109            });
110        }
111        let Some(component) = existing_prefix.file_name() else {
112            return normalised;
113        };
114        missing_components.push(component.to_os_string());
115        let Some(parent) = existing_prefix.parent() else {
116            return normalised;
117        };
118        existing_prefix = parent;
119    }
120}