quvyta-cli 0.1.0-alpha.5

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
use serde_json::{Value, json};

use super::super::{Action, ActionKind, Lines};
use super::{edit, list, read, run, search};

/// A read of the lines from `start` to `end`, as the model asks for it.
fn read_between(path: &str, start: i64, end: i64) -> Action {
    Action::Read { path: path.to_owned(), lines: Lines { start: Some(start), end: Some(end) } }
}

/// A listing `depth` levels deep, as the model asks for it.
fn list_at(path: &str, depth: i64) -> Action {
    Action::List { path: path.to_owned(), depth: Some(depth) }
}

/// A search narrowed by a name pattern and case, as the model asks for it.
fn search_for(pattern: &str, path: &str, glob: &str, ignore_case: bool) -> Action {
    Action::Search { pattern: pattern.to_owned(), path: path.to_owned(), glob: Some(glob.to_owned()), ignore_case }
}

#[test]
fn every_tool_is_read_from_its_call() {
    assert_eq!(Action::from_call("read_file", &json!({ "path": "src/main.rs" })), Ok(read("src/main.rs")));
    assert_eq!(Action::from_call("list_dir", &json!({ "path": "src" })), Ok(list("src")));
    assert_eq!(
        Action::from_call("search", &json!({ "pattern": "fn main", "path": "src" })),
        Ok(search("fn main", "src"))
    );
    assert_eq!(
        Action::from_call("edit_file", &json!({ "path": "a.txt", "old_text": "one", "new_text": "two" })),
        Ok(edit("a.txt", "one", "two"))
    );
    assert_eq!(Action::from_call("run_command", &json!({ "command": "cargo test" })), Ok(run("cargo test")));
}

#[test]
fn the_new_fields_are_read_from_their_calls() {
    assert_eq!(
        Action::from_call("read_file", &json!({ "path": "long.rs", "start_line": 500, "end_line": 700 })),
        Ok(read_between("long.rs", 500, 700))
    );
    assert_eq!(Action::from_call("list_dir", &json!({ "path": "src", "depth": 2 })), Ok(list_at("src", 2)));
    assert_eq!(
        Action::from_call(
            "search",
            &json!({ "pattern": "widget", "path": "src", "glob": "*.rs", "ignore_case": true })
        ),
        Ok(search_for("widget", "src", "*.rs", true))
    );
    // One end of a range, one flag off, an empty glob: each is a call the model may make.
    assert_eq!(
        Action::from_call("read_file", &json!({ "path": "long.rs", "start_line": 900 })),
        Ok(Action::Read { path: "long.rs".to_owned(), lines: Lines { start: Some(900), end: None } })
    );
    assert_eq!(
        Action::from_call("search", &json!({ "pattern": "widget", "ignore_case": false, "glob": "" })),
        Ok(Action::Search { pattern: "widget".to_owned(), path: String::new(), glob: None, ignore_case: false })
    );
    // A number below one reaches the action, which knows the file; the parser only reads it.
    assert_eq!(
        Action::from_call("read_file", &json!({ "path": "long.rs", "start_line": 0 })),
        Ok(Action::Read { path: "long.rs".to_owned(), lines: Lines { start: Some(0), end: None } })
    );
    assert_eq!(
        Action::from_call("read_file", &json!({ "path": "long.rs", "start_line": -3 })),
        Ok(Action::Read { path: "long.rs".to_owned(), lines: Lines { start: Some(-3), end: None } })
    );
}

#[test]
fn optional_paths_default_to_the_root() {
    assert_eq!(Action::from_call("search", &json!({ "pattern": "x" })), Ok(search("x", "")));
    assert_eq!(Action::from_call("list_dir", &json!({})), Ok(list("")));
}

#[test]
fn bad_calls_give_a_sentence_for_the_model() {
    let unknown = Action::from_call("delete_file", &json!({ "path": "a" })).unwrap_err();
    assert!(unknown.contains("no tool named `delete_file`"), "{unknown}");

    let missing = Action::from_call("read_file", &json!({})).unwrap_err();
    assert!(missing.contains("needs the field `path`"), "{missing}");

    let wrong = Action::from_call("run_command", &json!({ "command": 3 })).unwrap_err();
    assert!(wrong.contains("must be a string"), "{wrong}");

    let not_object = Action::from_call("read_file", &json!("a.txt")).unwrap_err();
    assert!(not_object.contains("JSON object"), "{not_object}");

    let edit_missing = Action::from_call("edit_file", &json!({ "path": "a", "old_text": "" })).unwrap_err();
    assert!(edit_missing.contains("`new_text`"), "{edit_missing}");

    // A new field given as something other than its own kind is a sentence, not a guess.
    let lines_as_text = Action::from_call("read_file", &json!({ "path": "a", "start_line": "500" })).unwrap_err();
    assert!(lines_as_text.contains("`start_line`") && lines_as_text.contains("must be a number"), "{lines_as_text}");
    let depth_as_float = Action::from_call("list_dir", &json!({ "path": "", "depth": 2.5 })).unwrap_err();
    assert!(depth_as_float.contains("`depth`") && depth_as_float.contains("whole number"), "{depth_as_float}");
    let flag_as_text = Action::from_call("search", &json!({ "pattern": "x", "ignore_case": "true" })).unwrap_err();
    assert!(flag_as_text.contains("`ignore_case`") && flag_as_text.contains("true or false"), "{flag_as_text}");
    let not_object_deeper = Action::from_call("list_dir", &json!(3)).unwrap_err();
    assert!(not_object_deeper.contains("JSON object"), "{not_object_deeper}");
}

#[test]
fn definitions_name_the_five_tools_the_parser_accepts() {
    let definitions = Action::definitions();
    let tools = definitions.as_array().expect("an array");
    let names: Vec<&str> = tools.iter().map(|tool| tool["name"].as_str().expect("a name")).collect();
    assert_eq!(
        names,
        [
            ActionKind::Read.tool_name(),
            ActionKind::List.tool_name(),
            ActionKind::Search.tool_name(),
            ActionKind::Edit.tool_name(),
            ActionKind::Run.tool_name(),
        ]
    );
    for tool in tools {
        let name = tool["name"].as_str().expect("a name");
        assert!(!tool["description"].as_str().expect("a description").is_empty());
        let schema = &tool["input_schema"];
        assert_eq!(schema["type"], "object", "{name}");
        // A call carrying exactly the required fields as strings must parse.
        let mut input = serde_json::Map::new();
        for field in schema["required"].as_array().expect("required fields") {
            let field = field.as_str().expect("a field name");
            assert!(schema["properties"][field].is_object(), "{name}.{field}");
            input.insert(field.to_owned(), json!("x"));
        }
        let parsed = Action::from_call(name, &input.into()).expect("parses");
        assert_eq!(parsed.kind().tool_name(), name);
    }
}

#[test]
fn the_manual_describes_every_new_field_with_an_example() {
    let tools = Action::definitions();
    for (tool, name, kind, example) in [
        ("read_file", "start_line", "integer", "500"),
        ("read_file", "end_line", "integer", "700"),
        ("list_dir", "depth", "integer", "2"),
        ("search", "glob", "string", "*.rs"),
        ("search", "ignore_case", "boolean", "Widget"),
    ] {
        let described = field(&tools, tool, name);
        assert_eq!(described["type"], kind, "{tool}.{name}");
        let text = described["description"].as_str().unwrap_or_default();
        assert!(text.contains(example), "{tool}.{name} carries no example: {text}");
    }
    // The new fields are optional: each tool still asks for only what it always asked for.
    assert_eq!(required(&tools, "read_file"), ["path"]);
    assert_eq!(required(&tools, "list_dir"), ["path"]);
    assert_eq!(required(&tools, "search"), ["pattern"]);
}

/// What the manual says about one field of one tool.
fn field(tools: &Value, tool: &str, name: &str) -> Value {
    let schema =
        &tools[tools.as_array().expect("the five tools").iter().position(|t| t["name"] == tool).expect("a tool")];
    let described = &schema["input_schema"]["properties"][name];
    assert!(!described.is_null(), "{tool} has no field {name} in its manual");
    described.clone()
}

/// The fields a tool cannot be called without.
fn required(tools: &Value, tool: &str) -> Vec<String> {
    let schema =
        &tools[tools.as_array().expect("the five tools").iter().position(|t| t["name"] == tool).expect("a tool")];
    schema["input_schema"]["required"]
        .as_array()
        .expect("required fields")
        .iter()
        .map(|field| field.as_str().expect("a field name").to_owned())
        .collect()
}

#[test]
fn only_edit_and_run_ask_first() {
    assert!(!read("a").needs_approval());
    assert!(!list("").needs_approval());
    assert!(!search("x", "").needs_approval());
    assert!(edit("a", "b", "c").needs_approval());
    assert!(run("printf hi").needs_approval());
}

#[test]
fn summary_kind_and_subject() {
    assert_eq!(read("src/main.rs").summary(), "read src/main.rs");
    assert_eq!(list("").summary(), "list .");
    assert_eq!(search("fn main", "").summary(), "search fn main");
    assert_eq!(search("fn main", "src").summary(), "search fn main in src");
    assert_eq!(edit("a.rs", "x", "y").summary(), "edit a.rs");
    assert_eq!(run("cargo test").summary(), "run cargo test");

    assert_eq!(search("fn main", "src").kind(), ActionKind::Search);
    assert_eq!(search("fn main", "src").subject(), "fn main");
    assert_eq!(run("cargo test").subject(), "cargo test");
    assert_eq!(edit("a.rs", "x", "y").subject(), "a.rs");
}

#[test]
fn a_narrower_action_still_says_the_same_line_to_the_person() {
    // The new fields narrow what an action does; the transcript line is the same as it was.
    assert_eq!(read_between("src/main.rs", 500, 700).summary(), "read src/main.rs");
    assert_eq!(read_between("src/main.rs", 500, 700).subject(), "src/main.rs");
    assert_eq!(list_at("", 3).summary(), "list .");
    assert_eq!(list_at("src", 2).summary(), "list src");
    assert_eq!(search_for("fn main", "", "*.rs", true).summary(), "search fn main");
    assert_eq!(search_for("fn main", "src", "*.rs", true).summary(), "search fn main in src");
}