1use anyhow::{anyhow, Result};
2use assistant_tool::{ActionLog, Tool};
3use gpui::{App, Entity, Task};
4use language_model::LanguageModelRequestMessage;
5use project::Project;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::{fs, path::PathBuf, sync::Arc};
9use util::paths::PathMatcher;
10
11#[derive(Debug, Serialize, Deserialize, JsonSchema)]
12pub struct DeletePathToolInput {
13 /// The glob to match files in the project to delete.
14 ///
15 /// <example>
16 /// If the project has the following files:
17 ///
18 /// - directory1/a/something.txt
19 /// - directory2/a/things.txt
20 /// - directory3/a/other.txt
21 ///
22 /// You can delete the first two files by providing a glob of "*thing*.txt"
23 /// </example>
24 pub glob: String,
25}
26
27pub struct DeletePathTool;
28
29impl Tool for DeletePathTool {
30 fn name(&self) -> String {
31 "delete-path".into()
32 }
33
34 fn description(&self) -> String {
35 include_str!("./delete_path_tool/description.md").into()
36 }
37
38 fn input_schema(&self) -> serde_json::Value {
39 let schema = schemars::schema_for!(DeletePathToolInput);
40 serde_json::to_value(&schema).unwrap()
41 }
42
43 fn run(
44 self: Arc<Self>,
45 input: serde_json::Value,
46 _messages: &[LanguageModelRequestMessage],
47 project: Entity<Project>,
48 _action_log: Entity<ActionLog>,
49 cx: &mut App,
50 ) -> Task<Result<String>> {
51 let glob = match serde_json::from_value::<DeletePathToolInput>(input) {
52 Ok(input) => input.glob,
53 Err(err) => return Task::ready(Err(anyhow!(err))),
54 };
55 let path_matcher = match PathMatcher::new(&[glob.clone()]) {
56 Ok(matcher) => matcher,
57 Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {}", err))),
58 };
59
60 struct Match {
61 display_path: String,
62 path: PathBuf,
63 }
64
65 let mut matches = Vec::new();
66 let mut deleted_paths = Vec::new();
67 let mut errors = Vec::new();
68
69 for worktree_handle in project.read(cx).worktrees(cx) {
70 let worktree = worktree_handle.read(cx);
71 let worktree_root = worktree.abs_path().to_path_buf();
72
73 // Don't consider ignored entries.
74 for entry in worktree.entries(false, 0) {
75 if path_matcher.is_match(&entry.path) {
76 matches.push(Match {
77 path: worktree_root.join(&entry.path),
78 display_path: entry.path.display().to_string(),
79 });
80 }
81 }
82 }
83
84 if matches.is_empty() {
85 return Task::ready(Ok(format!("No paths in the project matched {glob:?}")));
86 }
87
88 let paths_matched = matches.len();
89
90 // Delete the files
91 for Match { path, display_path } in matches {
92 match fs::remove_file(&path) {
93 Ok(()) => {
94 deleted_paths.push(display_path);
95 }
96 Err(file_err) => {
97 // Try to remove directory if it's not a file. Retrying as a directory
98 // on error saves a syscall compared to checking whether it's
99 // a directory up front for every single file.
100 if let Err(dir_err) = fs::remove_dir_all(&path) {
101 let error = if path.is_dir() {
102 format!("Failed to delete directory {}: {dir_err}", display_path)
103 } else {
104 format!("Failed to delete file {}: {file_err}", display_path)
105 };
106
107 errors.push(error);
108 } else {
109 deleted_paths.push(display_path);
110 }
111 }
112 }
113 }
114
115 if errors.is_empty() {
116 // 0 deleted paths should never happen if there were no errors;
117 // we already returned if matches was empty.
118 let answer = if deleted_paths.len() == 1 {
119 format!(
120 "Deleted {}",
121 deleted_paths.first().unwrap_or(&String::new())
122 )
123 } else {
124 // Sort to group entries in the same directory together
125 deleted_paths.sort();
126
127 let mut buf = format!("Deleted these {} paths:\n", deleted_paths.len());
128
129 for path in deleted_paths.iter() {
130 buf.push('\n');
131 buf.push_str(path);
132 }
133
134 buf
135 };
136
137 Task::ready(Ok(answer))
138 } else {
139 if deleted_paths.is_empty() {
140 Task::ready(Err(anyhow!(
141 "{glob:?} matched {} deleted because of {}:\n{}",
142 if paths_matched == 1 {
143 "1 path, but it was not".to_string()
144 } else {
145 format!("{} paths, but none were", paths_matched)
146 },
147 if errors.len() == 1 {
148 "this error".to_string()
149 } else {
150 format!("{} errors", errors.len())
151 },
152 errors.join("\n")
153 )))
154 } else {
155 // Sort to group entries in the same directory together
156 deleted_paths.sort();
157 Task::ready(Ok(format!(
158 "Deleted {} paths matching glob {glob:?}:\n{}\n\nErrors:\n{}",
159 deleted_paths.len(),
160 deleted_paths.join("\n"),
161 errors.join("\n")
162 )))
163 }
164 }
165 }
166}