1mod diff;
11mod edit;
12mod list;
13mod read;
14mod run;
15mod search;
16mod workspace;
17
18#[cfg(test)]
19mod tests;
20
21use serde_json::{Value, json};
22
23pub use read::Lines;
24pub use workspace::Workspace;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Action {
29 Read {
31 path: String,
33 lines: Lines,
35 },
36 List {
38 path: String,
40 depth: Option<i64>,
42 },
43 Search {
45 pattern: String,
47 path: String,
49 glob: Option<String>,
51 ignore_case: bool,
53 },
54 Edit {
56 path: String,
58 old_text: String,
60 new_text: String,
62 },
63 Run {
65 command: String,
67 },
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum ActionKind {
73 Read,
75 List,
77 Search,
79 Edit,
81 Run,
83}
84
85impl ActionKind {
86 pub const ALL: [Self; 5] = [Self::Read, Self::List, Self::Search, Self::Edit, Self::Run];
88
89 #[must_use]
91 pub fn of_tool(name: &str) -> Option<Self> {
92 Self::ALL.into_iter().find(|kind| kind.tool_name() == name)
93 }
94
95 #[must_use]
97 pub fn tool_name(self) -> &'static str {
98 match self {
99 Self::Read => "read_file",
100 Self::List => "list_dir",
101 Self::Search => "search",
102 Self::Edit => "edit_file",
103 Self::Run => "run_command",
104 }
105 }
106
107 #[must_use]
109 pub fn verb(self) -> &'static str {
110 match self {
111 Self::Read => "read",
112 Self::List => "list",
113 Self::Search => "search",
114 Self::Edit => "edit",
115 Self::Run => "run",
116 }
117 }
118}
119
120impl Action {
121 #[must_use]
125 pub fn definitions() -> Value {
126 json!([
127 {
128 "name": "read_file",
129 "description": "Read a UTF-8 text file in the working folder. Without start_line and end_line the first 2000 lines come back and the reply says how much was cut; with them only those lines come back, and the reply says which lines they are and how much of the file is not shown.",
130 "input_schema": {
131 "type": "object",
132 "properties": {
133 "path": { "type": "string", "description": "File path relative to the working folder." },
134 "start_line": { "type": "integer", "description": "First line to show, counted from 1 and included. For lines 500 to 700 of a long file: 500." },
135 "end_line": { "type": "integer", "description": "Last line to show, included. For lines 500 to 700 of a long file: 700." }
136 },
137 "required": ["path"]
138 }
139 },
140 {
141 "name": "list_dir",
142 "description": "List a folder in the working folder. A depth of 1 gives one entry per line, folders first with a trailing slash. A depth of 2 or 3 gives an indented tree, folders first, with the size of each file beside its name, and does not go into .git, target or node_modules.",
143 "input_schema": {
144 "type": "object",
145 "properties": {
146 "path": { "type": "string", "description": "Folder path relative to the working folder; empty or \".\" for the working folder itself." },
147 "depth": { "type": "integer", "description": "How many levels to show, 1 to 3. To see the files inside the folders of the working folder, 2." }
148 },
149 "required": ["path"]
150 }
151 },
152 {
153 "name": "search",
154 "description": "Find an exact piece of text in the text files under a folder. Skips .git, target, node_modules and binary files. Replies with path:line: text. ignore_case finds the same text in another case; glob narrows the walk to the files whose path matches it. When a search finds nothing the reply names both so it can be tried again.",
155 "input_schema": {
156 "type": "object",
157 "properties": {
158 "pattern": { "type": "string", "description": "The exact text to find." },
159 "path": { "type": "string", "description": "Folder or file to search, relative to the working folder; the whole working folder when left out." },
160 "glob": { "type": "string", "description": "A name pattern the file's path must match: * for any number of characters, the folder separator included, and ? for exactly one character. To look only in the Rust files under src, at any depth: \"src/*.rs\"." },
161 "ignore_case": { "type": "boolean", "description": "true to match without case, so \"widget\" finds \"Widget\". False or left out matches as it is written." }
162 },
163 "required": ["pattern"]
164 }
165 },
166 {
167 "name": "edit_file",
168 "description": "Replace one exact piece of text in a file with new text. old_text must occur exactly once; include surrounding lines to make it unique. With an empty old_text, create a new file containing new_text. The person approves every edit first.",
169 "input_schema": {
170 "type": "object",
171 "properties": {
172 "path": { "type": "string", "description": "File path relative to the working folder." },
173 "old_text": { "type": "string", "description": "The exact text to replace, including whitespace; empty to create a new file." },
174 "new_text": { "type": "string", "description": "The text that takes its place." }
175 },
176 "required": ["path", "old_text", "new_text"]
177 }
178 },
179 {
180 "name": "run_command",
181 "description": "Run a shell command with sh -c in the working folder, without input, for at most 120 seconds. Replies with the combined output (the end of it when long) and the exit code. The person approves every command first.",
182 "input_schema": {
183 "type": "object",
184 "properties": {
185 "command": { "type": "string", "description": "The shell command to run." }
186 },
187 "required": ["command"]
188 }
189 }
190 ])
191 }
192
193 pub fn from_call(name: &str, input: &Value) -> Result<Self, String> {
201 let field = |key: &str| text_field(name, input, key, true);
202 match name {
203 "read_file" => Ok(Self::Read {
204 path: field("path")?,
205 lines: Lines {
206 start: count_field(name, input, "start_line")?,
207 end: count_field(name, input, "end_line")?,
208 },
209 }),
210 "list_dir" => Ok(Self::List {
211 path: text_field(name, input, "path", false)?,
212 depth: count_field(name, input, "depth")?,
213 }),
214 "search" => Ok(Self::Search {
215 pattern: field("pattern")?,
216 path: text_field(name, input, "path", false)?,
217 glob: optional_field(name, input, "glob")?,
218 ignore_case: flag_field(name, input, "ignore_case")?,
219 }),
220 "edit_file" => {
221 Ok(Self::Edit { path: field("path")?, old_text: field("old_text")?, new_text: field("new_text")? })
222 }
223 "run_command" => Ok(Self::Run { command: field("command")? }),
224 other => Err(format!(
225 "There is no tool named `{other}`; use read_file, list_dir, search, edit_file or run_command."
226 )),
227 }
228 }
229
230 #[must_use]
232 pub fn needs_approval(&self) -> bool {
233 matches!(self.kind(), ActionKind::Edit | ActionKind::Run)
234 }
235
236 #[must_use]
238 pub fn kind(&self) -> ActionKind {
239 match self {
240 Self::Read { .. } => ActionKind::Read,
241 Self::List { .. } => ActionKind::List,
242 Self::Search { .. } => ActionKind::Search,
243 Self::Edit { .. } => ActionKind::Edit,
244 Self::Run { .. } => ActionKind::Run,
245 }
246 }
247
248 #[must_use]
252 pub fn subject(&self) -> &str {
253 match self {
254 Self::Read { path, .. } | Self::List { path, .. } | Self::Edit { path, .. } => path,
255 Self::Search { pattern, .. } => pattern,
256 Self::Run { command } => command,
257 }
258 }
259
260 #[must_use]
264 pub fn summary(&self) -> String {
265 let subject = match self {
266 Self::List { path, .. } if path.is_empty() => ".",
267 _ => self.subject(),
268 };
269 let verb = self.kind().verb();
270 match self {
271 Self::Search { path, .. } if !path.is_empty() && path != "." => {
272 format!("{verb} {subject} in {path}")
273 }
274 _ => format!("{verb} {subject}"),
275 }
276 }
277}
278
279fn text_field(tool: &str, input: &Value, key: &str, required: bool) -> Result<String, String> {
281 let Some(object) = input.as_object() else {
282 return Err(format!("The input of `{tool}` must be a JSON object."));
283 };
284 match object.get(key) {
285 Some(Value::String(text)) => Ok(text.clone()),
286 None | Some(Value::Null) if !required => Ok(String::new()),
287 None | Some(Value::Null) => Err(format!("`{tool}` needs the field `{key}`.")),
288 Some(_) => Err(format!("The field `{key}` of `{tool}` must be a string.")),
289 }
290}
291
292fn count_field(tool: &str, input: &Value, key: &str) -> Result<Option<i64>, String> {
295 let Some(object) = input.as_object() else {
296 return Err(format!("The input of `{tool}` must be a JSON object."));
297 };
298 match object.get(key) {
299 None | Some(Value::Null) => Ok(None),
300 Some(Value::Number(number)) => {
301 number.as_i64().map(Some).ok_or_else(|| format!("The field `{key}` of `{tool}` must be a whole number."))
302 }
303 Some(_) => Err(format!("The field `{key}` of `{tool}` must be a number.")),
304 }
305}
306
307fn flag_field(tool: &str, input: &Value, key: &str) -> Result<bool, String> {
309 let Some(object) = input.as_object() else {
310 return Err(format!("The input of `{tool}` must be a JSON object."));
311 };
312 match object.get(key) {
313 None | Some(Value::Null) => Ok(false),
314 Some(Value::Bool(flag)) => Ok(*flag),
315 Some(_) => Err(format!("The field `{key}` of `{tool}` must be true or false.")),
316 }
317}
318
319fn optional_field(tool: &str, input: &Value, key: &str) -> Result<Option<String>, String> {
321 Ok(Some(text_field(tool, input, key, false)?).filter(|text| !text.is_empty()))
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum Preview {
327 Plain,
329 Diff {
332 path: String,
334 lines: Vec<DiffLine>,
336 },
337 Command {
339 command: String,
341 },
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct DiffLine {
347 pub change: Change,
349 pub text: String,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
355pub enum Change {
356 Same,
358 Added,
360 Removed,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct Outcome {
367 pub text: String,
369 pub is_error: bool,
371}
372
373impl Outcome {
374 fn done(text: String) -> Self {
375 Self { text, is_error: false }
376 }
377
378 fn failed(text: String) -> Self {
379 Self { text, is_error: true }
380 }
381}