use serde_json::{Value, json};
use super::super::{Action, ActionKind, Lines};
use super::{edit, list, read, run, search};
fn read_between(path: &str, start: i64, end: i64) -> Action {
Action::Read { path: path.to_owned(), lines: Lines { start: Some(start), end: Some(end) } }
}
fn list_at(path: &str, depth: i64) -> Action {
Action::List { path: path.to_owned(), depth: Some(depth) }
}
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))
);
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 })
);
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}");
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}");
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}");
}
assert_eq!(required(&tools, "read_file"), ["path"]);
assert_eq!(required(&tools, "list_dir"), ["path"]);
assert_eq!(required(&tools, "search"), ["pattern"]);
}
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()
}
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() {
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");
}