//! 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 = 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" ); // 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"], 1); 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(); assert_eq!(affected, ["src/lib.rs"], "lib.rs imports 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