use std::process::Command;
use std::io::Write;
fn cx() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_cx"));
cmd.current_dir(env!("CARGO_MANIFEST_DIR"));
cmd
}
fn temp_project(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
for (path, content) in files {
let full = dir.path().join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let mut f = std::fs::File::create(&full).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
dir
}
fn cx_in(dir: &std::path::Path) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_cx"));
cmd.current_dir(dir);
cmd
}
#[test]
fn overview_main_rs() {
let out = cx().args(["overview", "src/main.rs"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("{name,kind,signature}:"), "should have TOON header: {stdout}");
assert!(stdout.contains("main,fn,"));
assert!(stdout.contains("resolve_root,fn,"));
}
#[test]
fn symbols_kind_fn() {
let out = cx().args(["symbols", "--kind", "fn", "--all"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success());
assert!(stdout.contains("main,fn,"));
assert!(stdout.contains("print_toon,fn,"));
}
#[test]
fn definition_main() {
let out = cx().args(["definition", "--name", "main"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success());
assert!(stdout.contains("src/main.rs"), "{stdout}");
assert!(stdout.contains("fn main()"), "{stdout}");
assert!(stdout.contains("Cli::parse()"), "{stdout}");
}
#[test]
fn overview_nonexistent_exits_1() {
let out = cx().args(["overview", "nonexistent.rs"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("nonexistent.rs"), "stderr should mention the file: {stderr}");
}
#[test]
fn symbols_no_match_exits_0() {
let out = cx().args(["symbols", "--name", "zzz_no_match"]).output().unwrap();
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn json_overview() {
let out = cx().args(["--json", "overview", "src/main.rs"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success());
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.expect("should be valid JSON");
assert!(parsed.is_array());
}
#[test]
fn json_definition_always_array() {
let out = cx().args(["--json", "definition", "--name", "main"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success());
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.expect("should be valid JSON");
assert!(parsed.is_array(), "definition JSON should always be an array: {stdout}");
assert_eq!(parsed.as_array().unwrap().len(), 1);
}
#[test]
fn definition_from_disambiguates() {
let dir = temp_project(&[
("src/a.rs", "pub fn helper() { 1 }\n"),
("src/b.rs", "pub fn helper() { 2 }\n"),
]);
let out = cx_in(dir.path()).args(["--json", "definition", "--name", "helper"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(parsed.as_array().unwrap().len(), 2, "should find both: {stdout}");
let out2 = cx_in(dir.path())
.args(["--json", "definition", "--name", "helper", "--from", "src/a.rs"])
.output()
.unwrap();
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let parsed2: serde_json::Value = serde_json::from_str(&stdout2).unwrap();
let arr = parsed2.as_array().unwrap();
assert_eq!(arr.len(), 1, "should find one: {stdout2}");
assert_eq!(arr[0]["file"].as_str().unwrap(), "src/a.rs");
}
#[test]
fn definition_max_lines_truncates() {
let mut body = String::from("pub fn big() {\n");
for i in 0..250 {
body.push_str(&format!(" let x{i} = {i};\n"));
}
body.push_str("}\n");
let dir = temp_project(&[("src/big.rs", &body)]);
let out = cx_in(dir.path())
.args(["--json", "definition", "--name", "big"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let item = &parsed.as_array().unwrap()[0];
assert_eq!(item["truncated"].as_bool(), Some(true), "should be truncated: {stdout}");
assert!(item["lines"].as_u64().unwrap() > 200, "should report total lines: {stdout}");
}
#[test]
fn cache_path_prints_path() {
let dir = temp_project(&[("src/main.rs", "fn main() {}\n")]);
let out = cx_in(dir.path()).args(["cache", "path"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("indexes/") || stdout.contains("indexes\\"), "should contain indexes path: {stdout}");
assert!(stdout.trim().ends_with(".db"), "should end with .db: {stdout}");
}
#[test]
fn cache_clean_removes_index() {
let dir = temp_project(&[("src/main.rs", "fn main() {}\n")]);
let out = cx_in(dir.path()).args(["overview", "src/main.rs"]).output().unwrap();
assert!(out.status.success());
let out = cx_in(dir.path()).args(["cache", "path"]).output().unwrap();
let cache_path = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(std::path::Path::new(&cache_path).exists(), "index should exist after build");
let out = cx_in(dir.path()).args(["cache", "clean"]).output().unwrap();
assert!(out.status.success());
assert!(!std::path::Path::new(&cache_path).exists(), "index should be gone after clean");
}
#[test]
fn index_not_in_repo_root() {
let dir = temp_project(&[("src/main.rs", "fn main() {}\n")]);
let _ = cx_in(dir.path()).args(["overview", "src/main.rs"]).output().unwrap();
assert!(!dir.path().join(".cx-index.db").exists(), "should not create .cx-index.db in repo root");
}
#[test]
fn version_flag() {
let out = cx().arg("--version").output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.starts_with("cx "), "should print version: {stdout}");
}
#[test]
fn unsupported_file_type_error() {
let dir = temp_project(&[
("README.md", "# Hello\n"),
("src/main.rs", "fn main() {}\n"),
]);
let out = cx_in(dir.path()).args(["overview", "README.md"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("unsupported file type: .md"), "should hint at unsupported type: {stderr}");
}
#[test]
fn no_matches_stderr() {
let out = cx().args(["definition", "--name", "zzz_nonexistent"]).output().unwrap();
assert_eq!(out.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("no matches"), "should print no matches: {stderr}");
}
#[test]
fn definition_kind_filter() {
let dir = temp_project(&[
("src/lib.rs", "pub struct Foo;\npub fn Foo() {}\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "definition", "--name", "Foo", "--kind", "fn"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1, "should find only the fn, not the struct: {stdout}");
assert!(arr[0]["body"].as_str().unwrap().contains("fn Foo()"));
}
#[test]
fn references_file_filter() {
let dir = temp_project(&[
("src/a.rs", "pub struct Foo;\nfn use_foo(f: Foo) {}\n"),
("src/b.rs", "use crate::Foo;\nfn bar(f: Foo) {}\n"),
]);
let out = cx_in(dir.path())
.args(["references", "--name", "Foo", "--file", "src/a.rs"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("src/a.rs"), "should find refs in a.rs: {stdout}");
assert!(!stdout.contains("src/b.rs"), "should not include b.rs: {stdout}");
}
#[test]
fn references_dedup_same_line() {
let dir = temp_project(&[
("src/lib.rs", "fn convert(x: Foo) -> Foo { x }\npub struct Foo;\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "references", "--name", "Foo"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let arr = parsed.as_array().unwrap();
let line1_refs: Vec<_> = arr.iter().filter(|r| r["line"] == 1).collect();
assert_eq!(line1_refs.len(), 1, "same-line refs should be deduped: {stdout}");
}
#[test]
fn overview_directory_single_level() {
let dir = temp_project(&[
("src/main.rs", "fn main() {}\n"),
("src/lib.rs", "pub fn hello() {}\npub struct Config;\n"),
("src/util/helpers.rs", "pub fn help() {}\n"),
("src/util/math.rs", "pub fn add() {}\npub fn sub() {}\n"),
]);
let out = cx_in(dir.path()).args(["overview", "src/"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("util/"), "should group util as subdir: {stdout}");
assert!(!stdout.contains("helpers.rs"), "should not show nested files: {stdout}");
assert!(stdout.contains("main.rs"), "should show direct file: {stdout}");
assert!(stdout.contains("lib.rs"), "should show direct file: {stdout}");
}
#[test]
fn overview_directory_root() {
let dir = temp_project(&[
("src/main.rs", "fn main() {}\n"),
("src/lib.rs", "pub fn hello() {}\n"),
]);
let out = cx_in(dir.path()).args(["overview", "."]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.contains("src/"), "should show src as subdir: {stdout}");
}
#[test]
fn overview_directory_filters_test_files() {
let dir = temp_project(&[
("src/app.ts", "export function main() {}\n"),
("src/app.test.ts", "describe('app', () => {})\n"),
("tests/integration.rs", "fn test_it() {}\n"),
]);
let out = cx_in(dir.path()).args(["overview", "."]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(!stdout.contains("app.test.ts"), "should filter test files: {stdout}");
assert!(!stdout.contains("tests/"), "should filter test dirs: {stdout}");
}
#[test]
fn overview_directory_nonexistent() {
let dir = temp_project(&[("src/main.rs", "fn main() {}\n")]);
let out = cx_in(dir.path()).args(["overview", "nonexistent/"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
}
#[test]
fn json_definition_has_expected_fields() {
let out = cx().args(["--json", "definition", "--name", "main"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let item = &parsed.as_array().unwrap()[0];
assert!(item["file"].is_string());
assert!(item["line"].is_number());
assert!(item["body"].is_string());
}
#[test]
fn definition_default_limit_truncates() {
let dir = temp_project(&[
("src/a.rs", "pub fn helper() { 1 }\n"),
("src/b.rs", "pub fn helper() { 2 }\n"),
("src/c.rs", "pub fn helper() { 3 }\n"),
("src/d.rs", "pub fn helper() { 4 }\n"),
("src/e.rs", "pub fn helper() { 5 }\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "definition", "--name", "helper"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.is_object(), "paginated output should be an object: {stdout}");
assert_eq!(parsed["total"].as_u64().unwrap(), 5);
assert_eq!(parsed["results"].as_array().unwrap().len(), 3);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("3/5"), "should show 3/5 in hint: {stderr}");
assert!(stderr.contains("--offset 3"), "should suggest next offset: {stderr}");
assert!(stderr.contains("--all"), "should suggest --all: {stderr}");
}
#[test]
fn definition_offset_paginates() {
let dir = temp_project(&[
("src/a.rs", "pub fn helper() { 1 }\n"),
("src/b.rs", "pub fn helper() { 2 }\n"),
("src/c.rs", "pub fn helper() { 3 }\n"),
("src/d.rs", "pub fn helper() { 4 }\n"),
("src/e.rs", "pub fn helper() { 5 }\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "definition", "--name", "helper", "--offset", "3"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.is_object(), "offset > 0 should produce paginated envelope: {stdout}");
assert_eq!(parsed["total"].as_u64().unwrap(), 5);
assert_eq!(parsed["offset"].as_u64().unwrap(), 3);
assert_eq!(parsed["results"].as_array().unwrap().len(), 2);
}
#[test]
fn definition_all_bypasses_limit() {
let dir = temp_project(&[
("src/a.rs", "pub fn helper() { 1 }\n"),
("src/b.rs", "pub fn helper() { 2 }\n"),
("src/c.rs", "pub fn helper() { 3 }\n"),
("src/d.rs", "pub fn helper() { 4 }\n"),
("src/e.rs", "pub fn helper() { 5 }\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "--all", "definition", "--name", "helper"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.is_array(), "should be bare array with --all: {stdout}");
assert_eq!(parsed.as_array().unwrap().len(), 5);
}
#[test]
fn definition_from_skips_default_limit() {
let dir = temp_project(&[
("src/a.rs", "pub fn thing() { 1 }\npub fn thing2() { 2 }\nstruct thing3;\nstruct thing4;\nenum thing5 {}\n"),
("src/b.rs", "pub fn thing() { 10 }\n"),
("src/c.rs", "pub fn thing() { 20 }\n"),
("src/d.rs", "pub fn thing() { 30 }\n"),
("src/e.rs", "pub fn thing() { 40 }\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "definition", "--name", "thing", "--from", "src/a.rs"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.is_array(), "should be bare array when --from used: {stdout}");
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["file"].as_str().unwrap().contains("a.rs"));
let out2 = cx_in(dir.path())
.args(["--json", "definition", "--name", "thing"])
.output()
.unwrap();
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let parsed2: serde_json::Value = serde_json::from_str(&stdout2).unwrap();
assert!(parsed2.is_object(), "without --from should be paginated: {stdout2}");
assert_eq!(parsed2["total"].as_u64().unwrap(), 5);
assert_eq!(parsed2["results"].as_array().unwrap().len(), 3);
}
#[test]
fn definition_explicit_limit_overrides_default() {
let dir = temp_project(&[
("src/a.rs", "pub fn helper() { 1 }\n"),
("src/b.rs", "pub fn helper() { 2 }\n"),
("src/c.rs", "pub fn helper() { 3 }\n"),
("src/d.rs", "pub fn helper() { 4 }\n"),
("src/e.rs", "pub fn helper() { 5 }\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "--limit", "2", "definition", "--name", "helper"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.is_object(), "should be paginated: {stdout}");
assert_eq!(parsed["results"].as_array().unwrap().len(), 2);
assert_eq!(parsed["total"].as_u64().unwrap(), 5);
}
#[test]
fn symbols_pagination_hint_on_stderr() {
let dir = temp_project(&[
("src/a.rs", "pub fn a1() {}\npub fn a2() {}\n"),
("src/b.rs", "pub fn b1() {}\npub fn b2() {}\n"),
]);
let out = cx_in(dir.path())
.args(["--limit", "2", "symbols"])
.output()
.unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("2/4"), "should show 2/4: {stderr}");
assert!(stderr.contains("--offset 2"), "should suggest next offset: {stderr}");
}
#[test]
fn references_pagination() {
let dir = temp_project(&[
("src/a.rs", "pub struct Foo;\nfn use1(f: Foo) {}\nfn use2(f: Foo) {}\n"),
("src/b.rs", "use crate::Foo;\nfn use3(f: Foo) {}\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "--limit", "2", "references", "--name", "Foo"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
if parsed.is_object() {
assert_eq!(parsed["results"].as_array().unwrap().len(), 2);
assert!(parsed["total"].as_u64().unwrap() >= 2);
}
}
#[test]
fn json_paginated_has_metadata() {
let dir = temp_project(&[
("src/a.rs", "pub fn helper() { 1 }\n"),
("src/b.rs", "pub fn helper() { 2 }\n"),
("src/c.rs", "pub fn helper() { 3 }\n"),
("src/d.rs", "pub fn helper() { 4 }\n"),
]);
let out = cx_in(dir.path())
.args(["--json", "--limit", "2", "definition", "--name", "helper"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed["total"].is_number(), "should have total: {stdout}");
assert!(parsed["offset"].is_number(), "should have offset: {stdout}");
assert!(parsed["limit"].is_number(), "should have limit: {stdout}");
assert!(parsed["results"].is_array(), "should have results: {stdout}");
assert_eq!(parsed["offset"].as_u64().unwrap(), 0);
assert_eq!(parsed["limit"].as_u64().unwrap(), 2);
}
#[test]
fn no_pagination_when_under_limit() {
let out = cx().args(["--json", "definition", "--name", "main"]).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.is_array(), "single result should be bare array: {stdout}");
}