- pipeline: re-resolve imports for every file on update (not just changed), so add/remove/rename of a target fixes unchanged importers; propagate hash and read errors instead of treating unreadable files as empty - discovery: report unreadable walk entries to stderr instead of dropping them - imports: strip `pub` visibility so `pub use` re-exports resolve; share extension/module-entry probe lists (adds cc,cxx,hpp,hh,hxx,mts,cts,jsx,mjs,cjs) - store: DISTINCT + ORDER in importers_of to dedup repeated edges - mcp: validate required tool args (explicit isError), emit JSON-RPC -32700 parse errors and -32600 invalid-request instead of silently skipping - README: document the 13-tool surface vs toktoken's 27 and known gaps - tests: regression coverage for update re-resolution, pub-use re-exports, importer dedup, and MCP error reporting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
629 lines
17 KiB
Rust
629 lines
17 KiB
Rust
//! End-to-end CLI tests against small, self-created fixture repos.
|
|
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::OnceLock;
|
|
|
|
use serde_json::Value;
|
|
|
|
fn bin() -> &'static str {
|
|
env!("CARGO_BIN_EXE_rustytokens")
|
|
}
|
|
|
|
/// Shared temp cache dir so tests never touch the real ~/.cache/rustytokens.
|
|
fn cache_dir() -> &'static Path {
|
|
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
|
DIR.get_or_init(|| {
|
|
let d = std::env::temp_dir().join(format!("rustytokens-test-cache-{}", std::process::id()));
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
})
|
|
}
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures")
|
|
.join(name)
|
|
}
|
|
|
|
fn copy_dir(src: &Path, dst: &Path) {
|
|
std::fs::create_dir_all(dst).unwrap();
|
|
for entry in std::fs::read_dir(src).unwrap() {
|
|
let entry = entry.unwrap();
|
|
let to = dst.join(entry.file_name());
|
|
if entry.path().is_dir() {
|
|
copy_dir(&entry.path(), &to);
|
|
} else {
|
|
std::fs::copy(entry.path(), &to).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run the binary with the fixture's cache dir, returning parsed stdout JSON.
|
|
fn run_json(args: &[&str]) -> Value {
|
|
let out = Command::new(bin())
|
|
.args(args)
|
|
.env("XDG_CACHE_HOME", cache_dir())
|
|
.output()
|
|
.expect("run rustytokens");
|
|
assert!(
|
|
out.status.success(),
|
|
"command failed: {:?}\nstderr: {}",
|
|
args,
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
|
|
}
|
|
|
|
#[test]
|
|
fn rust_index_search_and_dead() {
|
|
let fx = fixture("rustdemo");
|
|
run_json(&["index", "create", fx.to_str().unwrap()]);
|
|
|
|
let hits = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"add",
|
|
]);
|
|
let hit = hits
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|v| v["name"] == "add")
|
|
.expect("search finds `add`");
|
|
assert_eq!(hit["kind"], "function");
|
|
|
|
// inspect symbol by id returns the exact source.
|
|
let id = hit["id"].as_i64().unwrap();
|
|
let sym = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"inspect",
|
|
"symbol",
|
|
&id.to_string(),
|
|
]);
|
|
assert!(
|
|
sym["source"].as_str().unwrap().contains("a + b"),
|
|
"source contains function body"
|
|
);
|
|
|
|
// `use rustdemo::add` in main.rs resolves via the package-name alias.
|
|
let importers = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"src/lib.rs",
|
|
]);
|
|
let names: Vec<&str> = importers
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(names.contains(&"src/main.rs"), "main.rs imports lib.rs");
|
|
|
|
// dead.rs is flagged; entry points lib.rs/main.rs are not.
|
|
let dead = run_json(&["--project", fx.to_str().unwrap(), "find", "dead"]);
|
|
let paths: Vec<&str> = dead["files"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|f| f["path"].as_str().unwrap())
|
|
.collect();
|
|
assert!(paths.contains(&"src/dead.rs"), "dead.rs flagged");
|
|
assert!(!paths.contains(&"src/lib.rs"), "lib.rs is an entry point");
|
|
assert!(!paths.contains(&"src/main.rs"), "main.rs is an entry point");
|
|
}
|
|
|
|
#[test]
|
|
fn inspect_blast_radius_finds_affected_files() {
|
|
let src = fixture("rustdemo");
|
|
let tmp = cache_dir().join("blast-rustdemo");
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
copy_dir(&src, &tmp);
|
|
run_json(&["index", "create", tmp.to_str().unwrap()]);
|
|
|
|
let blast = run_json(&[
|
|
"--project",
|
|
tmp.to_str().unwrap(),
|
|
"inspect",
|
|
"blast-radius",
|
|
"src/util.rs",
|
|
]);
|
|
assert_eq!(blast["count"], 2);
|
|
assert_eq!(blast["file"], "src/util.rs");
|
|
let affected: Vec<&str> = blast["affected"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|f| f["path"].as_str().unwrap())
|
|
.collect();
|
|
// lib.rs imports util.rs directly; main.rs reaches it via the crate name.
|
|
assert!(affected.contains(&"src/lib.rs"), "lib.rs imports util.rs");
|
|
assert!(
|
|
affected.contains(&"src/main.rs"),
|
|
"main.rs → lib.rs → util.rs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn python_import_resolution_and_dead() {
|
|
let fx = fixture("pydemo");
|
|
run_json(&["index", "create", fx.to_str().unwrap()]);
|
|
|
|
let importers = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"util.py",
|
|
]);
|
|
let names: Vec<&str> = importers
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(names.contains(&"main.py"), "main.py imports util.py");
|
|
|
|
let dead = run_json(&["--project", fx.to_str().unwrap(), "find", "dead"]);
|
|
let paths: Vec<&str> = dead["files"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|f| f["path"].as_str().unwrap())
|
|
.collect();
|
|
assert!(paths.contains(&"dead.py"), "dead.py flagged");
|
|
assert!(!paths.contains(&"util.py"), "util.py is imported");
|
|
}
|
|
|
|
#[test]
|
|
fn typescript_cycle_detection() {
|
|
let fx = fixture("tsdemo");
|
|
run_json(&["index", "create", fx.to_str().unwrap()]);
|
|
|
|
let cycles = run_json(&["--project", fx.to_str().unwrap(), "inspect", "cycles"]);
|
|
assert_eq!(cycles["count"], 1, "one cycle");
|
|
let cycle = &cycles["cycles"][0];
|
|
let files: Vec<&str> = cycle
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(
|
|
files.contains(&"a.ts") && files.contains(&"b.ts"),
|
|
"a↔b cycle"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn java_symbols_and_import_resolution() {
|
|
let fx = fixture("javademo");
|
|
run_json(&["index", "create", fx.to_str().unwrap()]);
|
|
|
|
let hits = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"helper",
|
|
]);
|
|
let hit = hits
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|v| v["name"] == "helper")
|
|
.expect("search finds `helper`");
|
|
assert_eq!(hit["kind"], "method");
|
|
|
|
// ctags reports Java constructors as `method` with a full `end` span, so
|
|
// the multi-line `Main` constructor must carry line_end > line_start.
|
|
let ctor = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"Main",
|
|
]);
|
|
let ctor_hit = ctor
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|v| v["name"] == "Main" && v["kind"] == "method")
|
|
.expect("search finds `Main` constructor");
|
|
assert!(
|
|
ctor_hit["line_end"].as_i64().unwrap() > ctor_hit["line_start"].as_i64().unwrap(),
|
|
"multi-line constructor has an extended span"
|
|
);
|
|
|
|
let importers = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"Util.java",
|
|
]);
|
|
let names: Vec<&str> = importers
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(names.contains(&"Main.java"), "Main.java imports Util.java");
|
|
}
|
|
|
|
#[test]
|
|
fn c_and_cpp_symbols_and_imports() {
|
|
let cdemo = fixture("cdemo");
|
|
run_json(&["index", "create", cdemo.to_str().unwrap()]);
|
|
|
|
let hits = run_json(&[
|
|
"--project",
|
|
cdemo.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"helper",
|
|
]);
|
|
assert!(
|
|
hits.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|v| v["name"] == "helper"),
|
|
"c demo indexes `helper`"
|
|
);
|
|
|
|
let importers = run_json(&[
|
|
"--project",
|
|
cdemo.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"util.h",
|
|
]);
|
|
let names: Vec<&str> = importers
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(names.contains(&"main.c") && names.contains(&"util.c"));
|
|
|
|
let cppdemo = fixture("cppdemo");
|
|
run_json(&["index", "create", cppdemo.to_str().unwrap()]);
|
|
|
|
let hits = run_json(&[
|
|
"--project",
|
|
cppdemo.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"add",
|
|
]);
|
|
assert!(
|
|
hits.as_array().unwrap().iter().any(|v| v["name"] == "add"),
|
|
"cpp demo indexes `add`"
|
|
);
|
|
|
|
// ctags reports C++ constructors/destructors as `function` with an `end`
|
|
// span; the multi-line `Math` ctor must carry line_end > line_start.
|
|
let ctor = run_json(&[
|
|
"--project",
|
|
cppdemo.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"Math",
|
|
]);
|
|
let ctor_hit = ctor
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|v| v["name"] == "Math" && v["kind"] == "function")
|
|
.expect("search finds `Math` constructor");
|
|
assert_eq!(ctor_hit["kind"], "function");
|
|
assert!(
|
|
ctor_hit["line_end"].as_i64().unwrap() > ctor_hit["line_start"].as_i64().unwrap(),
|
|
"multi-line constructor has an extended span"
|
|
);
|
|
|
|
// Destructor is also `function` with a ctags-owned `end` span (name `~Math`).
|
|
let dtor_hit = ctor
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|v| v["name"] == "~Math" && v["kind"] == "function")
|
|
.expect("search finds `~Math` destructor");
|
|
assert!(
|
|
dtor_hit["line_end"].as_i64().unwrap() > dtor_hit["line_start"].as_i64().unwrap(),
|
|
"multi-line destructor has an extended span"
|
|
);
|
|
|
|
let importers = run_json(&[
|
|
"--project",
|
|
cppdemo.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"math.hpp",
|
|
]);
|
|
let names: Vec<&str> = importers
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(
|
|
names.contains(&"main.cpp") && names.contains(&"math.cpp"),
|
|
"both cpp files include math.hpp"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn html_assets_are_discovered_and_imported() {
|
|
let fx = fixture("htmldemo");
|
|
run_json(&["index", "create", fx.to_str().unwrap()]);
|
|
|
|
let importers = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"app.js",
|
|
]);
|
|
let names: Vec<&str> = importers
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(
|
|
names.contains(&"index.html"),
|
|
"index.html imports app.js via <script src>"
|
|
);
|
|
|
|
let css = run_json(&[
|
|
"--project",
|
|
fx.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"style.css",
|
|
]);
|
|
let names: Vec<&str> = css
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(
|
|
names.contains(&"index.html"),
|
|
"index.html imports style.css via <link href>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_serve_handles_tool_call() {
|
|
let src = fixture("rustdemo");
|
|
let tmp = cache_dir().join("mcp-rustdemo");
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
copy_dir(&src, &tmp);
|
|
run_json(&["index", "create", tmp.to_str().unwrap()]);
|
|
|
|
let mut child = Command::new(bin())
|
|
.args(["--project", tmp.to_str().unwrap(), "serve"])
|
|
.env("XDG_CACHE_HOME", cache_dir())
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn serve");
|
|
|
|
{
|
|
let mut stdin = child.stdin.take().unwrap();
|
|
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize"}}"#).unwrap();
|
|
writeln!(
|
|
stdin,
|
|
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"search_symbols","arguments":{{"query":"add"}}}}}}"#
|
|
)
|
|
.unwrap();
|
|
} // drop stdin -> closes the pipe, server exits
|
|
|
|
let out = child.wait_with_output().unwrap();
|
|
assert!(out.status.success(), "serve exits cleanly");
|
|
|
|
let responses: Vec<Value> = String::from_utf8_lossy(&out.stdout)
|
|
.lines()
|
|
.filter_map(|l| serde_json::from_str(l).ok())
|
|
.collect();
|
|
let init = responses
|
|
.iter()
|
|
.find(|r| r["id"] == 1)
|
|
.expect("initialize response");
|
|
assert_eq!(init["result"]["serverInfo"]["name"], "rustytokens");
|
|
|
|
let call = responses
|
|
.iter()
|
|
.find(|r| r["id"] == 2)
|
|
.expect("tools/call response");
|
|
let text = call["result"]["content"][0]["text"].as_str().unwrap();
|
|
let hits: Value = serde_json::from_str(text).unwrap();
|
|
assert!(
|
|
hits.as_array().unwrap().iter().any(|h| h["name"] == "add"),
|
|
"search_symbols over MCP finds `add`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn index_update_reindexes_changed_files() {
|
|
let src = fixture("javademo");
|
|
let tmp = cache_dir().join("update-javademo");
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
copy_dir(&src, &tmp);
|
|
|
|
run_json(&["index", "create", tmp.to_str().unwrap()]);
|
|
|
|
let before = run_json(&[
|
|
"--project",
|
|
tmp.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"brandNew",
|
|
]);
|
|
assert!(
|
|
before.as_array().unwrap().is_empty(),
|
|
"new symbol absent before update"
|
|
);
|
|
|
|
let lib = tmp.join("BrandNew.java");
|
|
std::fs::write(
|
|
&lib,
|
|
"package demo;\n\npublic class BrandNew {\n public static int brandNew() { return 7; }\n}\n",
|
|
)
|
|
.unwrap();
|
|
|
|
run_json(&["index", "update", tmp.to_str().unwrap()]);
|
|
|
|
let after = run_json(&[
|
|
"--project",
|
|
tmp.to_str().unwrap(),
|
|
"search",
|
|
"symbols",
|
|
"brandNew",
|
|
]);
|
|
assert!(
|
|
after
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|h| h["name"] == "brandNew"),
|
|
"index update picks up the new symbol"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn index_update_reimports_unchanged_file_and_dedups() {
|
|
let tmp = cache_dir().join("update-reimport");
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
let src = tmp.join("src");
|
|
std::fs::create_dir_all(&src).unwrap();
|
|
// Two `use` lines targeting the same (not-yet-existing) module.
|
|
std::fs::write(
|
|
src.join("main.rs"),
|
|
"use util::hello;\nuse util::world;\n\nfn main() {}\n",
|
|
)
|
|
.unwrap();
|
|
|
|
run_json(&["index", "create", tmp.to_str().unwrap()]);
|
|
|
|
std::fs::write(
|
|
src.join("util.rs"),
|
|
"pub fn hello() {}\npub fn world() {}\n",
|
|
)
|
|
.unwrap();
|
|
run_json(&["index", "update", tmp.to_str().unwrap()]);
|
|
|
|
let after = run_json(&[
|
|
"--project",
|
|
tmp.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"src/util.rs",
|
|
]);
|
|
let names: Vec<&str> = after
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert_eq!(
|
|
names,
|
|
vec!["src/main.rs"],
|
|
"unchanged main.rs re-resolves to the new util.rs, deduplicated"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rust_pub_use_re_exports_resolve() {
|
|
let tmp = cache_dir().join("pub-use");
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
let src = tmp.join("src");
|
|
std::fs::create_dir_all(&src).unwrap();
|
|
std::fs::write(src.join("inner.rs"), "pub fn x() {}\n").unwrap();
|
|
std::fs::write(src.join("re.rs"), "pub use inner;\n").unwrap();
|
|
|
|
run_json(&["index", "create", tmp.to_str().unwrap()]);
|
|
|
|
let importers = run_json(&[
|
|
"--project",
|
|
tmp.to_str().unwrap(),
|
|
"find",
|
|
"importers",
|
|
"src/inner.rs",
|
|
]);
|
|
let names: Vec<&str> = importers
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
assert!(
|
|
names.contains(&"src/re.rs"),
|
|
"pub use in re.rs re-exports inner.rs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_reports_invalid_params_and_parse_errors() {
|
|
let src = fixture("rustdemo");
|
|
let tmp = cache_dir().join("mcp-errors");
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
copy_dir(&src, &tmp);
|
|
run_json(&["index", "create", tmp.to_str().unwrap()]);
|
|
|
|
let mut child = Command::new(bin())
|
|
.args(["--project", tmp.to_str().unwrap(), "serve"])
|
|
.env("XDG_CACHE_HOME", cache_dir())
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn serve");
|
|
|
|
{
|
|
let mut stdin = child.stdin.take().unwrap();
|
|
// Missing required `query` argument -> tool-level isError.
|
|
writeln!(
|
|
stdin,
|
|
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"search_symbols","arguments":{{}}}}}}"#
|
|
)
|
|
.unwrap();
|
|
// Malformed JSON -> JSON-RPC parse error (id null, code -32700).
|
|
writeln!(stdin, "this is not json").unwrap();
|
|
}
|
|
|
|
let out = child.wait_with_output().unwrap();
|
|
let responses: Vec<Value> = String::from_utf8_lossy(&out.stdout)
|
|
.lines()
|
|
.filter_map(|l| serde_json::from_str(l).ok())
|
|
.collect();
|
|
|
|
let call = responses
|
|
.iter()
|
|
.find(|r| r["id"] == 2)
|
|
.expect("tools/call response");
|
|
assert_eq!(call["result"]["isError"], true, "missing arg is an error");
|
|
assert!(
|
|
call["result"]["content"][0]["text"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("query"),
|
|
"error names the missing argument"
|
|
);
|
|
|
|
let parse = responses
|
|
.iter()
|
|
.find(|r| r["error"]["code"] == -32700)
|
|
.expect("parse error response");
|
|
assert_eq!(parse["error"]["message"], "parse error");
|
|
}
|