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 workspace::Workspace;
24
25/// One action the model asked for.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Action {
28    /// Read a text file.
29    Read {
30        /// The file, relative to the workspace root.
31        path: String,
32    },
33    /// List a folder; an empty path or `.` is the root.
34    List {
35        /// The folder, relative to the workspace root.
36        path: String,
37    },
38    /// Find a piece of text in the files under a folder.
39    Search {
40        /// The exact, case-sensitive text to find.
41        pattern: String,
42        /// Where to start; an empty path is the root.
43        path: String,
44    },
45    /// Replace one exact piece of text in a file, or create a new file.
46    Edit {
47        /// The file, relative to the workspace root.
48        path: String,
49        /// The text to replace; it must occur exactly once. Empty to create a new file.
50        old_text: String,
51        /// The text that takes its place.
52        new_text: String,
53    },
54    /// Run a shell command in the workspace root.
55    Run {
56        /// The command, given to `sh -c`.
57        command: String,
58    },
59}
60
61/// Which of the five actions an [`Action`] is, without its details.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum ActionKind {
64    /// [`Action::Read`].
65    Read,
66    /// [`Action::List`].
67    List,
68    /// [`Action::Search`].
69    Search,
70    /// [`Action::Edit`].
71    Edit,
72    /// [`Action::Run`].
73    Run,
74}
75
76impl ActionKind {
77    /// The tool name the model uses for this action, such as `read_file`.
78    #[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    /// The short English verb used in [`Action::summary`], such as `read`.
90    #[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    /// The five tools in Anthropic tool-use form: the JSON array for the request's `tools` field,
104    /// each with a `name`, a `description` and an `input_schema`.
105    #[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    /// Reads a tool call. An unknown tool or a bad input gives a sentence for the model.
170    ///
171    /// # Errors
172    ///
173    /// When `name` is not one of the five tools, or `input` is not an object with the fields the
174    /// tool needs as strings.
175    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    /// Whether the person must approve the action first: editing and running do, reading does not.
192    #[must_use]
193    pub fn needs_approval(&self) -> bool {
194        matches!(self.kind(), ActionKind::Edit | ActionKind::Run)
195    }
196
197    /// Which action this is.
198    #[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    /// What the action is about: the path for reading, listing and editing, the pattern for a
210    /// search and the command for running.
211    #[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    /// One short untranslated line for the transcript, such as `read src/main.rs` or
221    /// `run cargo test`. An application that translates builds its own line from [`Self::kind`]
222    /// and [`Self::subject`].
223    #[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
239/// Reads one string field of a tool input; a missing optional field is empty.
240fn 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/// What an action would do, shown on the approval card before anything changes.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum Preview {
255    /// Nothing to show beyond the action line.
256    Plain,
257    /// The edit as a line diff of the whole affected region with up to three lines of context
258    /// on each side.
259    Diff {
260        /// The file, relative to the workspace root.
261        path: String,
262        /// The lines of the region, in order, each tagged with its change.
263        lines: Vec<DiffLine>,
264    },
265    /// The command as it will be run.
266    Command {
267        /// The command, given to `sh -c`.
268        command: String,
269    },
270}
271
272/// One line of a [`Preview::Diff`].
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct DiffLine {
275    /// Whether the line stays, comes in or goes away.
276    pub change: Change,
277    /// The line, without its line ending.
278    pub text: String,
279}
280
281/// How a [`DiffLine`] changes.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
283pub enum Change {
284    /// The line is there before and after: context.
285    Same,
286    /// The line is new.
287    Added,
288    /// The line goes away.
289    Removed,
290}
291
292/// What an action did, as the tool result for the model.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct Outcome {
295    /// The result, or a sentence saying why the action was not done.
296    pub text: String,
297    /// Whether the action failed; a command that ran and exited with a non-zero code did not.
298    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}