mod diff;
mod edit;
mod list;
mod read;
mod run;
mod search;
mod workspace;
#[cfg(test)]
mod tests;
use serde_json::{Value, json};
pub use read::Lines;
pub use workspace::Workspace;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Read {
path: String,
lines: Lines,
},
List {
path: String,
depth: Option<i64>,
},
Search {
pattern: String,
path: String,
glob: Option<String>,
ignore_case: bool,
},
Edit {
path: String,
old_text: String,
new_text: String,
},
Run {
command: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ActionKind {
Read,
List,
Search,
Edit,
Run,
}
impl ActionKind {
pub const ALL: [Self; 5] = [Self::Read, Self::List, Self::Search, Self::Edit, Self::Run];
#[must_use]
pub fn of_tool(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|kind| kind.tool_name() == name)
}
#[must_use]
pub fn tool_name(self) -> &'static str {
match self {
Self::Read => "read_file",
Self::List => "list_dir",
Self::Search => "search",
Self::Edit => "edit_file",
Self::Run => "run_command",
}
}
#[must_use]
pub fn verb(self) -> &'static str {
match self {
Self::Read => "read",
Self::List => "list",
Self::Search => "search",
Self::Edit => "edit",
Self::Run => "run",
}
}
}
impl Action {
#[must_use]
pub fn definitions() -> Value {
json!([
{
"name": "read_file",
"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.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to the working folder." },
"start_line": { "type": "integer", "description": "First line to show, counted from 1 and included. For lines 500 to 700 of a long file: 500." },
"end_line": { "type": "integer", "description": "Last line to show, included. For lines 500 to 700 of a long file: 700." }
},
"required": ["path"]
}
},
{
"name": "list_dir",
"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.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Folder path relative to the working folder; empty or \".\" for the working folder itself." },
"depth": { "type": "integer", "description": "How many levels to show, 1 to 3. To see the files inside the folders of the working folder, 2." }
},
"required": ["path"]
}
},
{
"name": "search",
"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.",
"input_schema": {
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "The exact text to find." },
"path": { "type": "string", "description": "Folder or file to search, relative to the working folder; the whole working folder when left out." },
"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\"." },
"ignore_case": { "type": "boolean", "description": "true to match without case, so \"widget\" finds \"Widget\". False or left out matches as it is written." }
},
"required": ["pattern"]
}
},
{
"name": "edit_file",
"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.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to the working folder." },
"old_text": { "type": "string", "description": "The exact text to replace, including whitespace; empty to create a new file." },
"new_text": { "type": "string", "description": "The text that takes its place." }
},
"required": ["path", "old_text", "new_text"]
}
},
{
"name": "run_command",
"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.",
"input_schema": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "The shell command to run." }
},
"required": ["command"]
}
}
])
}
pub fn from_call(name: &str, input: &Value) -> Result<Self, String> {
let field = |key: &str| text_field(name, input, key, true);
match name {
"read_file" => Ok(Self::Read {
path: field("path")?,
lines: Lines {
start: count_field(name, input, "start_line")?,
end: count_field(name, input, "end_line")?,
},
}),
"list_dir" => Ok(Self::List {
path: text_field(name, input, "path", false)?,
depth: count_field(name, input, "depth")?,
}),
"search" => Ok(Self::Search {
pattern: field("pattern")?,
path: text_field(name, input, "path", false)?,
glob: optional_field(name, input, "glob")?,
ignore_case: flag_field(name, input, "ignore_case")?,
}),
"edit_file" => {
Ok(Self::Edit { path: field("path")?, old_text: field("old_text")?, new_text: field("new_text")? })
}
"run_command" => Ok(Self::Run { command: field("command")? }),
other => Err(format!(
"There is no tool named `{other}`; use read_file, list_dir, search, edit_file or run_command."
)),
}
}
#[must_use]
pub fn needs_approval(&self) -> bool {
matches!(self.kind(), ActionKind::Edit | ActionKind::Run)
}
#[must_use]
pub fn kind(&self) -> ActionKind {
match self {
Self::Read { .. } => ActionKind::Read,
Self::List { .. } => ActionKind::List,
Self::Search { .. } => ActionKind::Search,
Self::Edit { .. } => ActionKind::Edit,
Self::Run { .. } => ActionKind::Run,
}
}
#[must_use]
pub fn subject(&self) -> &str {
match self {
Self::Read { path, .. } | Self::List { path, .. } | Self::Edit { path, .. } => path,
Self::Search { pattern, .. } => pattern,
Self::Run { command } => command,
}
}
#[must_use]
pub fn summary(&self) -> String {
let subject = match self {
Self::List { path, .. } if path.is_empty() => ".",
_ => self.subject(),
};
let verb = self.kind().verb();
match self {
Self::Search { path, .. } if !path.is_empty() && path != "." => {
format!("{verb} {subject} in {path}")
}
_ => format!("{verb} {subject}"),
}
}
}
fn text_field(tool: &str, input: &Value, key: &str, required: bool) -> Result<String, String> {
let Some(object) = input.as_object() else {
return Err(format!("The input of `{tool}` must be a JSON object."));
};
match object.get(key) {
Some(Value::String(text)) => Ok(text.clone()),
None | Some(Value::Null) if !required => Ok(String::new()),
None | Some(Value::Null) => Err(format!("`{tool}` needs the field `{key}`.")),
Some(_) => Err(format!("The field `{key}` of `{tool}` must be a string.")),
}
}
fn count_field(tool: &str, input: &Value, key: &str) -> Result<Option<i64>, String> {
let Some(object) = input.as_object() else {
return Err(format!("The input of `{tool}` must be a JSON object."));
};
match object.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(number)) => {
number.as_i64().map(Some).ok_or_else(|| format!("The field `{key}` of `{tool}` must be a whole number."))
}
Some(_) => Err(format!("The field `{key}` of `{tool}` must be a number.")),
}
}
fn flag_field(tool: &str, input: &Value, key: &str) -> Result<bool, String> {
let Some(object) = input.as_object() else {
return Err(format!("The input of `{tool}` must be a JSON object."));
};
match object.get(key) {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(flag)) => Ok(*flag),
Some(_) => Err(format!("The field `{key}` of `{tool}` must be true or false.")),
}
}
fn optional_field(tool: &str, input: &Value, key: &str) -> Result<Option<String>, String> {
Ok(Some(text_field(tool, input, key, false)?).filter(|text| !text.is_empty()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Preview {
Plain,
Diff {
path: String,
lines: Vec<DiffLine>,
},
Command {
command: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
pub change: Change,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Change {
Same,
Added,
Removed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub text: String,
pub is_error: bool,
}
impl Outcome {
fn done(text: String) -> Self {
Self { text, is_error: false }
}
fn failed(text: String) -> Self {
Self { text, is_error: true }
}
}