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 workspace::Workspace;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Read {
path: String,
},
List {
path: String,
},
Search {
pattern: String,
path: String,
},
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 {
#[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. Long files are cut; the reply says how many lines the file has.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to the working folder." }
},
"required": ["path"]
}
},
{
"name": "list_dir",
"description": "List a folder in the working folder, one entry per line, folders first with a trailing slash.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Folder path relative to the working folder; empty or \".\" for the working folder itself." }
},
"required": ["path"]
}
},
{
"name": "search",
"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.",
"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." }
},
"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")? }),
"list_dir" => Ok(Self::List { path: text_field(name, input, "path", false)? }),
"search" => Ok(Self::Search { pattern: field("pattern")?, path: text_field(name, input, "path", false)? }),
"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.")),
}
}
#[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 }
}
}