1use anyhow::{anyhow, Result};
2use assistant_tool::{ActionLog, Tool};
3use gpui::{App, AppContext, Entity, Task};
4use language_model::LanguageModelRequestMessage;
5use project::Project;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::{path::PathBuf, sync::Arc};
9use util::paths::PathMatcher;
10use worktree::Snapshot;
11
12#[derive(Debug, Serialize, Deserialize, JsonSchema)]
13pub struct PathSearchToolInput {
14 /// The glob to search all project paths for.
15 ///
16 /// <example>
17 /// If the project has the following root directories:
18 ///
19 /// - directory1/a/something.txt
20 /// - directory2/a/things.txt
21 /// - directory3/a/other.txt
22 ///
23 /// You can get back the first two paths by providing a glob of "*thing*.txt"
24 /// </example>
25 pub glob: String,
26
27 /// Optional starting position for paginated results (0-based).
28 /// When not provided, starts from the beginning.
29 #[serde(default)]
30 pub offset: Option<usize>,
31}
32
33const RESULTS_PER_PAGE: usize = 50;
34
35pub struct PathSearchTool;
36
37impl Tool for PathSearchTool {
38 fn name(&self) -> String {
39 "path-search".into()
40 }
41
42 fn description(&self) -> String {
43 include_str!("./path_search_tool/description.md").into()
44 }
45
46 fn input_schema(&self) -> serde_json::Value {
47 let schema = schemars::schema_for!(PathSearchToolInput);
48 serde_json::to_value(&schema).unwrap()
49 }
50
51 fn ui_text(&self, input: &serde_json::Value) -> String {
52 match serde_json::from_value::<PathSearchToolInput>(input.clone()) {
53 Ok(input) => format!("Find paths matching “`{}`”", input.glob),
54 Err(_) => "Search paths".to_string(),
55 }
56 }
57
58 fn run(
59 self: Arc<Self>,
60 input: serde_json::Value,
61 _messages: &[LanguageModelRequestMessage],
62 project: Entity<Project>,
63 _action_log: Entity<ActionLog>,
64 cx: &mut App,
65 ) -> Task<Result<String>> {
66 let (offset, glob) = match serde_json::from_value::<PathSearchToolInput>(input) {
67 Ok(input) => (input.offset.unwrap_or(0), input.glob),
68 Err(err) => return Task::ready(Err(anyhow!(err))),
69 };
70 let path_matcher = match PathMatcher::new(&[glob.clone()]) {
71 Ok(matcher) => matcher,
72 Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
73 };
74 let snapshots: Vec<Snapshot> = project
75 .read(cx)
76 .worktrees(cx)
77 .map(|worktree| worktree.read(cx).snapshot())
78 .collect();
79
80 cx.background_spawn(async move {
81 let mut matches = Vec::new();
82
83 for worktree in snapshots {
84 let root_name = worktree.root_name();
85
86 // Don't consider ignored entries.
87 for entry in worktree.entries(false, 0) {
88 if path_matcher.is_match(&entry.path) {
89 matches.push(
90 PathBuf::from(root_name)
91 .join(&entry.path)
92 .to_string_lossy()
93 .to_string(),
94 );
95 }
96 }
97 }
98
99 if matches.is_empty() {
100 Ok(format!("No paths in the project matched the glob {glob:?}"))
101 } else {
102 // Sort to group entries in the same directory together.
103 matches.sort();
104
105 let total_matches = matches.len();
106 let response = if total_matches > offset + RESULTS_PER_PAGE {
107 let paginated_matches: Vec<_> = matches
108 .into_iter()
109 .skip(offset)
110 .take(RESULTS_PER_PAGE)
111 .collect();
112
113 format!(
114 "Found {} total matches. Showing results {}-{} (provide 'offset' parameter for more results):\n\n{}",
115 total_matches,
116 offset + 1,
117 offset + paginated_matches.len(),
118 paginated_matches.join("\n")
119 )
120 } else {
121 matches.join("\n")
122 };
123
124 Ok(response)
125 }
126 })
127 }
128}