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 workspace::Workspace;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Action {
28 Read {
30 path: String,
32 },
33 List {
35 path: String,
37 },
38 Search {
40 pattern: String,
42 path: String,
44 },
45 Edit {
47 path: String,
49 old_text: String,
51 new_text: String,
53 },
54 Run {
56 command: String,
58 },
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum ActionKind {
64 Read,
66 List,
68 Search,
70 Edit,
72 Run,
74}
75
76impl ActionKind {
77 #[must_use]
79 pub fn tool_name(self) -> &'static str {
80 match self {
81 Self::Read => "read_file",
82 Self::List => "list_dir",
83 Self::Search => "search",
84 Self::Edit => "edit_file",
85 Self::Run => "run_command",
86 }
87 }
88
89 #[must_use]
91 pub fn verb(self) -> &'static str {
92 match self {
93 Self::Read => "read",
94 Self::List => "list",
95 Self::Search => "search",
96 Self::Edit => "edit",
97 Self::Run => "run",
98 }
99 }
100}
101
102impl Action {
103 #[must_use]
106 pub fn definitions() -> Value {
107 json!([
108 {
109 "name": "read_file",
110 "description": "Read a UTF-8 text file in the working folder. Long files are cut; the reply says how many lines the file has.",
111 "input_schema": {
112 "type": "object",
113 "properties": {
114 "path": { "type": "string", "description": "File path relative to the working folder." }
115 },
116 "required": ["path"]
117 }
118 },
119 {
120 "name": "list_dir",
121 "description": "List a folder in the working folder, one entry per line, folders first with a trailing slash.",
122 "input_schema": {
123 "type": "object",
124 "properties": {
125 "path": { "type": "string", "description": "Folder path relative to the working folder; empty or \".\" for the working folder itself." }
126 },
127 "required": ["path"]
128 }
129 },
130 {
131 "name": "search",
132 "description": "Find an exact, case-sensitive piece of text in the text files under a folder. Skips .git, target, node_modules and binary files. Replies with path:line: text.",
133 "input_schema": {
134 "type": "object",
135 "properties": {
136 "pattern": { "type": "string", "description": "The exact text to find." },
137 "path": { "type": "string", "description": "Folder or file to search, relative to the working folder; the whole working folder when left out." }
138 },
139 "required": ["pattern"]
140 }
141 },
142 {
143 "name": "edit_file",
144 "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.",
145 "input_schema": {
146 "type": "object",
147 "properties": {
148 "path": { "type": "string", "description": "File path relative to the working folder." },
149 "old_text": { "type": "string", "description": "The exact text to replace, including whitespace; empty to create a new file." },
150 "new_text": { "type": "string", "description": "The text that takes its place." }
151 },
152 "required": ["path", "old_text", "new_text"]
153 }
154 },
155 {
156 "name": "run_command",
157 "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.",
158 "input_schema": {
159 "type": "object",
160 "properties": {
161 "command": { "type": "string", "description": "The shell command to run." }
162 },
163 "required": ["command"]
164 }
165 }
166 ])
167 }
168
169 pub fn from_call(name: &str, input: &Value) -> Result<Self, String> {
176 let field = |key: &str| text_field(name, input, key, true);
177 match name {
178 "read_file" => Ok(Self::Read { path: field("path")? }),
179 "list_dir" => Ok(Self::List { path: text_field(name, input, "path", false)? }),
180 "search" => Ok(Self::Search { pattern: field("pattern")?, path: text_field(name, input, "path", false)? }),
181 "edit_file" => {
182 Ok(Self::Edit { path: field("path")?, old_text: field("old_text")?, new_text: field("new_text")? })
183 }
184 "run_command" => Ok(Self::Run { command: field("command")? }),
185 other => Err(format!(
186 "There is no tool named `{other}`; use read_file, list_dir, search, edit_file or run_command."
187 )),
188 }
189 }
190
191 #[must_use]
193 pub fn needs_approval(&self) -> bool {
194 matches!(self.kind(), ActionKind::Edit | ActionKind::Run)
195 }
196
197 #[must_use]
199 pub fn kind(&self) -> ActionKind {
200 match self {
201 Self::Read { .. } => ActionKind::Read,
202 Self::List { .. } => ActionKind::List,
203 Self::Search { .. } => ActionKind::Search,
204 Self::Edit { .. } => ActionKind::Edit,
205 Self::Run { .. } => ActionKind::Run,
206 }
207 }
208
209 #[must_use]
212 pub fn subject(&self) -> &str {
213 match self {
214 Self::Read { path } | Self::List { path } | Self::Edit { path, .. } => path,
215 Self::Search { pattern, .. } => pattern,
216 Self::Run { command } => command,
217 }
218 }
219
220 #[must_use]
224 pub fn summary(&self) -> String {
225 let subject = match self {
226 Self::List { path } if path.is_empty() => ".",
227 _ => self.subject(),
228 };
229 let verb = self.kind().verb();
230 match self {
231 Self::Search { path, .. } if !path.is_empty() && path != "." => {
232 format!("{verb} {subject} in {path}")
233 }
234 _ => format!("{verb} {subject}"),
235 }
236 }
237}
238
239fn text_field(tool: &str, input: &Value, key: &str, required: bool) -> Result<String, String> {
241 let Some(object) = input.as_object() else {
242 return Err(format!("The input of `{tool}` must be a JSON object."));
243 };
244 match object.get(key) {
245 Some(Value::String(text)) => Ok(text.clone()),
246 None | Some(Value::Null) if !required => Ok(String::new()),
247 None | Some(Value::Null) => Err(format!("`{tool}` needs the field `{key}`.")),
248 Some(_) => Err(format!("The field `{key}` of `{tool}` must be a string.")),
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum Preview {
255 Plain,
257 Diff {
260 path: String,
262 lines: Vec<DiffLine>,
264 },
265 Command {
267 command: String,
269 },
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct DiffLine {
275 pub change: Change,
277 pub text: String,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
283pub enum Change {
284 Same,
286 Added,
288 Removed,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct Outcome {
295 pub text: String,
297 pub is_error: bool,
299}
300
301impl Outcome {
302 fn done(text: String) -> Self {
303 Self { text, is_error: false }
304 }
305
306 fn failed(text: String) -> Self {
307 Self { text, is_error: true }
308 }
309}