quvyta-cli 0.1.0-alpha.4

A small coding agent in the terminal: chat with a model that reads, searches, edits and runs in one folder, and asks before it changes anything (alpha)
Documentation
//! The five things the model may do inside the working folder: read a file, list a folder,
//! search for text, edit a file and run a command.
//!
//! The model asks for an action in tool-use form (a tool name and a JSON input). [`Action`]
//! parses that request, [`Workspace::preview`] shows what it would do without changing anything,
//! and [`Workspace::perform`] does it. Every path is confined to the workspace folder, and every
//! failure becomes an [`Outcome`] with a sentence the model can act on. This module knows
//! nothing about the screen: the application maps these types to its widgets.

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;

/// One action the model asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
    /// Read a text file, all of it or one range of lines.
    Read {
        /// The file, relative to the workspace root.
        path: String,
        /// Which lines of it to show; neither number given is its top, as it was before.
        lines: Lines,
    },
    /// List a folder; an empty path or `.` is the root.
    List {
        /// The folder, relative to the workspace root.
        path: String,
        /// How many levels to show, 1 for a list of names and more for a tree with sizes.
        depth: Option<i64>,
    },
    /// Find a piece of text in the files under a folder.
    Search {
        /// The text to find, matched as it is written unless the case is ignored.
        pattern: String,
        /// Where to start; an empty path is the root.
        path: String,
        /// A name pattern a file's path must match; none looks at every file.
        glob: Option<String>,
        /// Whether the case of the pattern counts.
        ignore_case: bool,
    },
    /// Replace one exact piece of text in a file, or create a new file.
    Edit {
        /// The file, relative to the workspace root.
        path: String,
        /// The text to replace; it must occur exactly once. Empty to create a new file.
        old_text: String,
        /// The text that takes its place.
        new_text: String,
    },
    /// Run a shell command in the workspace root.
    Run {
        /// The command, given to `sh -c`.
        command: String,
    },
}

/// Which of the five actions an [`Action`] is, without its details.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ActionKind {
    /// [`Action::Read`].
    Read,
    /// [`Action::List`].
    List,
    /// [`Action::Search`].
    Search,
    /// [`Action::Edit`].
    Edit,
    /// [`Action::Run`].
    Run,
}

impl ActionKind {
    /// The five, in the order the tools are offered.
    pub const ALL: [Self; 5] = [Self::Read, Self::List, Self::Search, Self::Edit, Self::Run];

    /// The action a tool name stands for, when it is one of the five.
    #[must_use]
    pub fn of_tool(name: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|kind| kind.tool_name() == name)
    }

    /// The tool name the model uses for this action, such as `read_file`.
    #[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",
        }
    }

    /// The short English verb used in [`Action::summary`], such as `read`.
    #[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 {
    /// The five tools in Anthropic tool-use form: the JSON array for the request's `tools` field,
    /// each with a `name`, a `description` and an `input_schema`. It is the model's manual, so
    /// every optional field carries a short example of a call that uses it.
    #[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"]
                }
            }
        ])
    }

    /// Reads a tool call. An unknown tool or a bad input gives a sentence for the model.
    ///
    /// # Errors
    ///
    /// When `name` is not one of the five tools, or `input` is not an object with the fields the
    /// tool needs: text as a string, a line or a depth as a whole number, a flag as true or
    /// false. Whether a range fits the file it names is left to the action, which reads the file.
    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."
            )),
        }
    }

    /// Whether the person must approve the action first: editing and running do, reading does not.
    #[must_use]
    pub fn needs_approval(&self) -> bool {
        matches!(self.kind(), ActionKind::Edit | ActionKind::Run)
    }

    /// Which action this is.
    #[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,
        }
    }

    /// What the action is about: the path for reading, listing and editing, the pattern for a
    /// search and the command for running. The new fields narrow an action; they are not what the
    /// action is about, so the person's line stays the same.
    #[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,
        }
    }

    /// One short untranslated line for the transcript, such as `read src/main.rs` or
    /// `run cargo test`. An application that translates builds its own line from [`Self::kind`]
    /// and [`Self::subject`].
    #[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}"),
        }
    }
}

/// Reads one string field of a tool input; a missing optional field is empty.
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.")),
    }
}

/// Reads one optional whole-number field of a tool input; a missing field is `None`. A number
/// below 1 or a range past the end of a file is left to the action, which knows the file.
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.")),
    }
}

/// Reads one optional flag of a tool input; a missing field is false.
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.")),
    }
}

/// Reads one optional text field of a tool input; a missing or empty one is `None`.
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()))
}

/// What an action would do, shown on the approval card before anything changes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Preview {
    /// Nothing to show beyond the action line.
    Plain,
    /// The edit as a line diff of the whole affected region with up to three lines of context
    /// on each side.
    Diff {
        /// The file, relative to the workspace root.
        path: String,
        /// The lines of the region, in order, each tagged with its change.
        lines: Vec<DiffLine>,
    },
    /// The command as it will be run.
    Command {
        /// The command, given to `sh -c`.
        command: String,
    },
}

/// One line of a [`Preview::Diff`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
    /// Whether the line stays, comes in or goes away.
    pub change: Change,
    /// The line, without its line ending.
    pub text: String,
}

/// How a [`DiffLine`] changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Change {
    /// The line is there before and after: context.
    Same,
    /// The line is new.
    Added,
    /// The line goes away.
    Removed,
}

/// What an action did, as the tool result for the model.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
    /// The result, or a sentence saying why the action was not done.
    pub text: String,
    /// Whether the action failed; a command that ran and exited with a non-zero code did not.
    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 }
    }
}