Skip to main content

qcli/actions/
mod.rs

1//! The five things the model may do inside the working folder: read a file, list a folder,
2//! search for text, edit a file and run a command.
3//!
4//! The model asks for an action in tool-use form (a tool name and a JSON input). [`Action`]
5//! parses that request, [`Workspace::preview`] shows what it would do without changing anything,
6//! and [`Workspace::perform`] does it. Every path is confined to the workspace folder, and every
7//! failure becomes an [`Outcome`] with a sentence the model can act on. This module knows
8//! nothing about the screen: the application maps these types to its widgets.
9
10mod 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/// One action the model asked for.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Action {
29    /// Read a text file, all of it or one range of lines.
30    Read {
31        /// The file, relative to the workspace root.
32        path: String,
33        /// Which lines of it to show; neither number given is its top, as it was before.
34        lines: Lines,
35    },
36    /// List a folder; an empty path or `.` is the root.
37    List {
38        /// The folder, relative to the workspace root.
39        path: String,
40        /// How many levels to show, 1 for a list of names and more for a tree with sizes.
41        depth: Option<i64>,
42    },
43    /// Find a piece of text in the files under a folder.
44    Search {
45        /// The text to find, matched as it is written unless the case is ignored.
46        pattern: String,
47        /// Where to start; an empty path is the root.
48        path: String,
49        /// A name pattern a file's path must match; none looks at every file.
50        glob: Option<String>,
51        /// Whether the case of the pattern counts.
52        ignore_case: bool,
53    },
54    /// Replace one exact piece of text in a file, or create a new file.
55    Edit {
56        /// The file, relative to the workspace root.
57        path: String,
58        /// The text to replace; it must occur exactly once. Empty to create a new file.
59        old_text: String,
60        /// The text that takes its place.
61        new_text: String,
62    },
63    /// Run a shell command in the workspace root.
64    Run {
65        /// The command, given to `sh -c`.
66        command: String,
67    },
68}
69
70/// Which of the five actions an [`Action`] is, without its details.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum ActionKind {
73    /// [`Action::Read`].
74    Read,
75    /// [`Action::List`].
76    List,
77    /// [`Action::Search`].
78    Search,
79    /// [`Action::Edit`].
80    Edit,
81    /// [`Action::Run`].
82    Run,
83}
84
85impl ActionKind {
86    /// The five, in the order the tools are offered.
87    pub const ALL: [Self; 5] = [Self::Read, Self::List, Self::Search, Self::Edit, Self::Run];
88
89    /// The action a tool name stands for, when it is one of the five.
90    #[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    /// The tool name the model uses for this action, such as `read_file`.
96    #[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    /// The short English verb used in [`Action::summary`], such as `read`.
108    #[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    /// The five tools in Anthropic tool-use form: the JSON array for the request's `tools` field,
122    /// each with a `name`, a `description` and an `input_schema`. It is the model's manual, so
123    /// every optional field carries a short example of a call that uses it.
124    #[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    /// Reads a tool call. An unknown tool or a bad input gives a sentence for the model.
194    ///
195    /// # Errors
196    ///
197    /// When `name` is not one of the five tools, or `input` is not an object with the fields the
198    /// tool needs: text as a string, a line or a depth as a whole number, a flag as true or
199    /// false. Whether a range fits the file it names is left to the action, which reads the file.
200    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    /// Whether the person must approve the action first: editing and running do, reading does not.
231    #[must_use]
232    pub fn needs_approval(&self) -> bool {
233        matches!(self.kind(), ActionKind::Edit | ActionKind::Run)
234    }
235
236    /// Which action this is.
237    #[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    /// What the action is about: the path for reading, listing and editing, the pattern for a
249    /// search and the command for running. The new fields narrow an action; they are not what the
250    /// action is about, so the person's line stays the same.
251    #[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    /// One short untranslated line for the transcript, such as `read src/main.rs` or
261    /// `run cargo test`. An application that translates builds its own line from [`Self::kind`]
262    /// and [`Self::subject`].
263    #[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
279/// Reads one string field of a tool input; a missing optional field is empty.
280fn 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
292/// Reads one optional whole-number field of a tool input; a missing field is `None`. A number
293/// below 1 or a range past the end of a file is left to the action, which knows the file.
294fn 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
307/// Reads one optional flag of a tool input; a missing field is false.
308fn 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
319/// Reads one optional text field of a tool input; a missing or empty one is `None`.
320fn 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/// What an action would do, shown on the approval card before anything changes.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum Preview {
327    /// Nothing to show beyond the action line.
328    Plain,
329    /// The edit as a line diff of the whole affected region with up to three lines of context
330    /// on each side.
331    Diff {
332        /// The file, relative to the workspace root.
333        path: String,
334        /// The lines of the region, in order, each tagged with its change.
335        lines: Vec<DiffLine>,
336    },
337    /// The command as it will be run.
338    Command {
339        /// The command, given to `sh -c`.
340        command: String,
341    },
342}
343
344/// One line of a [`Preview::Diff`].
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct DiffLine {
347    /// Whether the line stays, comes in or goes away.
348    pub change: Change,
349    /// The line, without its line ending.
350    pub text: String,
351}
352
353/// How a [`DiffLine`] changes.
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
355pub enum Change {
356    /// The line is there before and after: context.
357    Same,
358    /// The line is new.
359    Added,
360    /// The line goes away.
361    Removed,
362}
363
364/// What an action did, as the tool result for the model.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct Outcome {
367    /// The result, or a sentence saying why the action was not done.
368    pub text: String,
369    /// Whether the action failed; a command that ran and exited with a non-zero code did not.
370    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}