diff --git a/crates/ctx-exec/tests/compress_test.rs b/crates/ctx-exec/tests/compress_test.rs index 8334e78..0ccce71 100644 --- a/crates/ctx-exec/tests/compress_test.rs +++ b/crates/ctx-exec/tests/compress_test.rs @@ -473,3 +473,86 @@ fn passthrough_is_not_flagged_as_ineffective() { assert_eq!(result.stats.saved_percent, 0); assert!(!result.stats.compression_ineffective()); } + +// --- Edge pins: degenerate shapes (CTX-0040) --------------------------------- + +/// Small output without a trailing newline passes through byte-verbatim: +/// no terminator may be added or stripped. +#[test] +fn passthrough_without_trailing_newline_is_byte_verbatim() { + for input in ["a", "a\nb", "plain line", "l1\nl2\nl3"] { + let batch = compress(input, &opts()).unwrap(); + assert_eq!(batch.text, input.as_bytes(), "{input:?}"); + assert_eq!(batch.stats.total_lines, input.lines().count()); + assert!(!batch.stats.collapsed); + let streamed = stream_bytes(input.as_bytes(), &opts()); + assert_eq!(streamed.text, input.as_bytes(), "stream parity: {input:?}"); + assert_eq!(streamed.stats, batch.stats); + } +} + +#[test] +fn single_non_critical_line_passes_through_without_newline() { + // Complements `single_line_output_is_kept_as_is` (which uses a critical + // line): an uninteresting single line is equally untouchable, newline + // or not. + for input in ["just noise", "just noise\n"] { + let result = compress(input, &opts()).unwrap(); + assert_eq!(result.text, input.as_bytes()); + assert_eq!(result.stats.total_lines, 1); + assert_eq!(result.stats.kept_lines, 1); + } +} + +/// The collapse boundary is exact: `threshold` lines pass through, and one +/// more line folds. Default threshold is 20 (head 5 + tail 5). +#[test] +fn exactly_collapse_threshold_lines_pass_through() { + let options = opts(); + let at = lines(options.collapse_threshold, "l"); + let result = compress(&at, &options).unwrap(); + assert_eq!(result.text, at.as_bytes()); + assert!(!result.stats.collapsed, "{:?}", result.stats); + assert_eq!(result.stats.omitted_lines, 0); +} + +#[test] +fn collapse_threshold_minus_one_lines_pass_through() { + let options = opts(); + let under = lines(options.collapse_threshold - 1, "l"); + let result = compress(&under, &options).unwrap(); + assert_eq!(result.text, under.as_bytes()); + assert!(!result.stats.collapsed); +} + +#[test] +fn collapse_threshold_plus_one_lines_fold() { + let options = opts(); + let n = options.collapse_threshold + 1; + let over = format!("{}\n", lines(n, "l")); + let result = compress(&over, &options).unwrap(); + assert!(result.stats.collapsed, "{:?}", result.stats); + // head 5 + tail 5 kept, the middle folds into one marker. + assert_eq!( + result.stats.omitted_lines, + n - DEFAULT_HEAD_LINES - DEFAULT_TAIL_LINES + ); + let text = String::from_utf8_lossy(&result.text); + assert!(text.starts_with("l1\nl2\nl3\nl4\nl5\n... [11 lines omitted]")); + assert!(text.ends_with("\nl17\nl18\nl19\nl20\nl21")); + // One line past the threshold must actually save something. + assert!(result.stats.saved_percent > 0); +} + +/// A stream that is closed without ever pushing anything behaves like empty +/// output: empty text, zeroed stats, no ineffective flag. +#[test] +fn stream_compressor_with_no_pushes_is_empty() { + let sc = StreamCompressor::new(&opts()).unwrap(); + let result = sc.finish(); + assert!(result.text.is_empty()); + assert_eq!(result.stats.total_lines, 0); + assert_eq!(result.stats.kept_lines, 0); + assert_eq!(result.stats.saved_percent, 0); + assert!(!result.stats.compression_ineffective()); +} diff --git a/crates/ctx-symbol/tests/degenerate_test.rs b/crates/ctx-symbol/tests/degenerate_test.rs new file mode 100644 index 0000000..d204c6c --- /dev/null +++ b/crates/ctx-symbol/tests/degenerate_test.rs @@ -0,0 +1,343 @@ +//! Degenerate-input corpus for the symbol engine: BOM-prefixed sources, +//! CRLF line endings across languages, empty files, and heavily ERROR-node +//! trees (garbage syntax). Nothing here may panic, and every output must be +//! a deterministic function of the input (CTX-0040). + +use std::path::Path; + +use ctx_symbol::{ + Symbol, compact_symbol, extract_symbols, outline, parse, parse_error_count, slice_by_name, +}; + +const BOM: &str = "\u{feff}"; + +fn path_of(name: &str) -> &'static Path { + let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); + Path::new(leaked) +} + +/// Two independent runs must agree exactly (symbols, order, ranges, docs). +fn assert_deterministic(source: &str, path: &Path) { + let a = outline(source, path).expect("first parse"); + let b = outline(source, path).expect("second parse"); + assert_eq!( + format!("{a:?}"), + format!("{b:?}"), + "nondeterministic symbols" + ); + assert_eq!( + parse_error_count(&parse(path, source).unwrap()), + parse_error_count(&parse(path, source).unwrap()), + "nondeterministic error count" + ); +} + +/// Every byte range must land inside the original source and slice back out +/// as valid UTF-8. +fn assert_slices_in_bounds<'a>(source: &str, symbols: &'a [Symbol]) { + for s in symbols { + let bytes = source + .as_bytes() + .get(s.byte_range.clone()) + .unwrap_or_else(|| panic!("range {:?} of {} out of bounds", s.byte_range, s.name)); + std::str::from_utf8(bytes) + .unwrap_or_else(|e| panic!("invalid utf-8 slice for {}: {e}", s.name)); + assert!( + s.end_line >= s.start_line && s.start_line >= 1, + "bad line span for {}: {}-{}", + s.name, + s.start_line, + s.end_line + ); + } +} + +// --- BOM-prefixed sources ---------------------------------------------------- + +const BOM_RUST_SRC: &str = + "\u{feff}/// Adds two numbers.\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n"; + +#[test] +fn bom_prefixed_rust_ranges_exclude_the_bom() { + let source = BOM_RUST_SRC; + let p = path_of("bom.rs"); + let symbols = outline(source, p).expect("parses despite BOM"); + assert!( + !symbols.is_empty(), + "a leading BOM must not suppress extraction" + ); + for s in &symbols { + assert!( + s.byte_range.start >= BOM.len(), + "{} range starts at {}, inside the {}-byte BOM", + s.name, + s.byte_range.start, + BOM.len() + ); + let text = std::str::from_utf8(&source.as_bytes()[s.byte_range.clone()]).unwrap(); + assert!( + !text.contains('\u{feff}'), + "{} slice includes the BOM: {text:?}", + s.name + ); + } + assert_slices_in_bounds(source, &symbols); + assert_deterministic(source, p); +} + +#[test] +fn bom_prefixed_slice_by_name_returns_clean_source() { + let slice = slice_by_name(BOM_RUST_SRC, path_of("bom.rs"), "add").expect("add found"); + assert!(slice.contains("pub fn add(a: i32, b: i32)"), "{slice:?}"); + assert!( + !slice.contains('\u{feff}'), + "BOM leaked into slice: {slice:?}" + ); +} + +#[test] +fn bom_prefixed_sources_across_languages_are_tolerated() { + // Each snippet must still yield at least one symbol after the BOM. + let cases: &[(&str, String)] = &[ + ("bom.py", format!("{BOM}def add(a, b):\n return a + b\n")), + ( + "bom.js", + format!("{BOM}export function add(a, b) {{\n return a + b;\n}}\n"), + ), + ( + "bom.go", + format!("{BOM}func Add(a, b int) int {{\n\treturn a + b\n}}\n"), + ), + ( + "bom.c", + format!("{BOM}int add(int a, int b) {{\n return a + b;\n}}\n"), + ), + ( + "bom.ts", + format!( + "{BOM}export function add(a: number, b: number): number {{\n return a + b;\n}}\n" + ), + ), + ]; + for (name, src) in cases { + let p = path_of(name); + let parsed = parse(p, src).unwrap_or_else(|e| panic!("{name}: {e}")); + let symbols = extract_symbols(&parsed); + assert!( + !symbols.is_empty(), + "{name}: a leading BOM must not suppress extraction" + ); + for s in &symbols { + assert!( + s.byte_range.start >= BOM.len(), + "{name}: {} range starts inside the BOM", + s.name + ); + } + assert_slices_in_bounds(src, &symbols); + assert_deterministic(src, p); + } +} + +// --- CRLF sources across languages ------------------------------------------- + +/// One compact multi-definition snippet per language, all `\r\n` line +/// endings. Bodies are long enough that compaction folds them. +const CRLF_CASES: &[(&str, &str)] = &[ + ( + "crlf.rs", + "pub fn add(a: i32, b: i32) -> i32 {\r\n let s = a + b;\r\n let t = a * b;\r\n s + t\r\n}\r\n\r\npub const ANSWER: i32 = 42;\r\n", + ), + ( + "crlf.py", + "def add(a, b):\r\n s = a + b\r\n t = a * b\r\n return s + t\r\n\r\nclass Point:\r\n def norm(self):\r\n return 1.0\r\n", + ), + ( + "crlf.js", + "export function add(a, b) {\r\n const s = a + b;\r\n const t = a * b;\r\n return s + t;\r\n}\r\n\r\nconst MAX = 3;\r\n", + ), + ( + "crlf.go", + "func Add(a, b int) int {\r\n\ts := a + b\r\n\tt := a * b\r\n\treturn s + t\r\n}\r\n", + ), + ( + "crlf.c", + "int add(int a, int b)\r\n{\r\n int s = a + b;\r\n int t = a * b;\r\n return s + t;\r\n}\r\n", + ), + ( + "crlf.java", + "public class Point {\r\n private double x;\r\n\r\n public double norm() {\r\n return x;\r\n }\r\n}\r\n", + ), +]; + +#[test] +fn crlf_sources_across_languages_extract_and_compact_deterministically() { + for (name, src) in CRLF_CASES { + let p = path_of(name); + let parsed = parse(p, src).unwrap_or_else(|e| panic!("{name}: {e}")); + let symbols = extract_symbols(&parsed); + assert!(!symbols.is_empty(), "{name}: CRLF suppressed extraction"); + assert_slices_in_bounds(src, &symbols); + for s in &symbols { + let compact = compact_symbol(&parsed, s); + assert_eq!( + compact, + compact_symbol(&parsed, s), + "{name}: nondeterministic compaction of {}", + s.name + ); + // A pure-CRLF source must stay CRLF-only in compact output: no + // bare-LF terminators mixed in. + assert_eq!( + compact.matches('\n').count(), + compact.matches("\r\n").count(), + "{name}: {} compact mixes LF into CRLF output: {compact:?}", + s.name + ); + } + assert_deterministic(src, p); + } +} + +#[test] +fn crlf_ranges_never_point_at_a_lone_cr() { + // Slicing by byte range must respect the two-byte terminator: no slice + // may end on a dangling `\r`, and none may start with one mid-line. + for (name, src) in CRLF_CASES { + let p = path_of(name); + let symbols = outline(src, p).unwrap_or_else(|e| panic!("{name}: {e}")); + for s in &symbols { + let text = std::str::from_utf8(&src.as_bytes()[s.byte_range.clone()]).unwrap(); + assert!( + !text.starts_with('\r') && !text.ends_with('\r'), + "{name}: {} slice has a dangling CR: {text:?}", + s.name + ); + } + } +} + +// --- Empty files -------------------------------------------------------------- + +#[test] +fn empty_files_yield_no_symbols_across_languages() { + for ext in [ + "rs", "py", "js", "ts", "go", "c", "cpp", "cs", "java", "rb", "lua", "md", "html", "css", + ] { + let p = path_of(&format!("empty.{ext}")); + let parsed = parse(p, "").unwrap_or_else(|e| panic!("{ext}: {e}")); + let symbols = extract_symbols(&parsed); + assert!(symbols.is_empty(), "{ext}: empty file produced {symbols:?}"); + assert_eq!( + parse_error_count(&parsed), + 0, + "{ext}: empty file has errors" + ); + assert_deterministic("", p); + } +} + +#[test] +fn whitespace_only_files_behave_like_empty_files() { + for body in ["\n", "\r\n\r\n", " \n\t\n"] { + let p = path_of("blank.rs"); + let symbols = outline(body, p).expect("parses"); + assert!(symbols.is_empty(), "{body:?}: produced {symbols:?}"); + assert_deterministic(body, p); + } +} + +#[test] +fn empty_file_slice_by_name_reports_not_found() { + let err = slice_by_name("", path_of("empty.rs"), "anything").expect_err("must not find"); + assert!( + err.to_string().contains("symbol not found"), + "unexpected error: {err}" + ); + // Same contract for whitespace-only files, deterministically. + for body in ["\n", " \t\n"] { + let err = slice_by_name(body, path_of("empty.rs"), "anything").expect_err("must not find"); + assert!(err.to_string().contains("symbol not found"), "{err}"); + } +} + +// --- Heavily ERROR-node trees -------------------------------------------------- + +/// Garbage inputs that must produce ERROR/MISSING nodes without panicking. +const GARBAGE: &[&str] = &[ + r#"]}}}) ((( "]""#, + r#"@@@@ $$$$ ^^^^ &&&&&"#, + r#""""'''{{{ |||||"#, + "(((((((((", + r#"fn fn fn fn }}}}{{{{"#, + r#"\\<<<>>>???"#, +]; + +#[test] +fn garbage_trees_never_panic_and_stay_deterministic() { + for ext in ["rs", "py", "js", "go", "c"] { + for (i, junk) in GARBAGE.iter().enumerate() { + let name = format!("garbage{i}.{ext}"); + let p = path_of(&name); + let parsed = parse(p, junk).unwrap_or_else(|e| panic!("{name}: {e}")); + let _ = parse_error_count(&parsed); // must not panic + let symbols = extract_symbols(&parsed); + assert_slices_in_bounds(junk, &symbols); + for s in &symbols { + let _ = compact_symbol(&parsed, s); // must not panic + } + assert_deterministic(junk, p); + } + } +} + +#[test] +fn garbage_trees_actually_report_errors() { + // The corpus above must really be garbage: every grammar reports + // ERROR/MISSING nodes for each junk input (otherwise these tests would + // pass vacuously). + for ext in ["rs", "py", "js", "go", "c"] { + for (i, junk) in GARBAGE.iter().enumerate() { + let name = format!("garbage{i}.{ext}"); + let parsed = parse(path_of(&name), junk).expect("parses with recovery"); + assert!( + parse_error_count(&parsed) > 0, + "{name}: {junk:?} parsed cleanly; corpus is not degenerate" + ); + } + } +} + +#[test] +fn garbage_after_valid_prefix_keeps_the_valid_symbols() { + // Error recovery must not eat the healthy part of a file. + let src = "pub fn valid_one(a: i32) -> i32 {\n a * 2\n}\n\n@@@ }}} broken garbage (((\n"; + let p = path_of("mixed.rs"); + let parsed = parse(p, src).expect("parses with recovery"); + assert!( + parse_error_count(&parsed) > 0, + "the garbage tail must be reported" + ); + let symbols = extract_symbols(&parsed); + let one = symbols + .iter() + .find(|s| s.name == "valid_one") + .expect("valid symbol survives error recovery"); + let text = std::str::from_utf8(&src.as_bytes()[one.byte_range.clone()]).unwrap(); + assert!(text.contains("a * 2"), "unexpected slice: {text:?}"); + assert!(!text.contains("@@@"), "slice swallowed garbage: {text:?}"); + assert_slices_in_bounds(src, &symbols); + assert_deterministic(src, p); +} + +#[test] +fn bom_only_file_is_handled_without_panicking() { + // A file containing nothing but the BOM: no definitions can exist, but + // parsing, extraction, and slicing must all stay calm and stable. + let p = path_of("bom-only.rs"); + let symbols = outline(BOM, p).expect("parses"); + assert!(symbols.is_empty(), "BOM-only file produced {symbols:?}"); + let err = slice_by_name(BOM, p, "anything").expect_err("nothing to find"); + assert!(err.to_string().contains("symbol not found"), "{err}"); + assert_deterministic(BOM, p); +} diff --git a/crates/ctxctl/tests/cli_test.rs b/crates/ctxctl/tests/cli_test.rs index 55458ad..3af36ed 100644 --- a/crates/ctxctl/tests/cli_test.rs +++ b/crates/ctxctl/tests/cli_test.rs @@ -1112,6 +1112,32 @@ fn exec_merges_stderr_without_blank_line_gap() { assert!(!text.contains("b\n\nERR"), "blank gap: {text:?}"); } +#[test] +fn exec_stderr_only_output_is_compressed_and_reported() { + // A command that writes nothing to stdout still gets the full wiring: + // merged stderr content is compressed, savings are reported, and the + // child's exit code propagates (CTX-0040). + let output = run(&["exec", "sh -c 'echo warning: only-stderr >&2'"]); + assert_eq!(output.status.code(), Some(0), "stderr: {}", stderr(&output)); + let text = stdout(&output); + assert!(text.starts_with("$ sh"), "command not echoed: {text}"); + let text = body(&output); + assert!( + text.contains("warning: only-stderr"), + "payload lost: {text:?}" + ); + assert!(text.contains("Saved ~"), "no savings line: {text}"); + + let failing = run(&["exec", "--json", "sh -c 'echo fatal: bad >&2; exit 7'"]); + assert_eq!(failing.status.code(), Some(7), "exit code must propagate"); + let value: Value = serde_json::from_str(&stdout(&failing)).expect("valid json"); + assert_eq!(value["exit_code"], 7); + assert!( + value["compressed"].as_str().unwrap().contains("fatal: bad"), + "stderr payload in json envelope: {value}" + ); +} + #[test] fn exec_keeps_rustc_location_after_error_header() { // rustc prints ` --> file:line:col` right under each diagnostic; diff --git a/crates/ctxctl/tests/mcp_test.rs b/crates/ctxctl/tests/mcp_test.rs index 7b98cba..c4d9153 100644 --- a/crates/ctxctl/tests/mcp_test.rs +++ b/crates/ctxctl/tests/mcp_test.rs @@ -17,6 +17,8 @@ struct Server { /// Directory the server was launched in: it pins this as its workspace /// root, so fixtures must be written here and referenced relatively. workspace: std::path::PathBuf, + /// Isolated XDG config dir handed to the server; removed in `shutdown`. + xdg_config: std::path::PathBuf, } impl Server { @@ -27,9 +29,14 @@ impl Server { NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed), )); std::fs::create_dir_all(&workspace).expect("create server workspace"); + let xdg_config = xdg_isolation_dir(); let mut child = Command::new(env!("CARGO_BIN_EXE_ctxctl")) .arg("mcp") .current_dir(&workspace) + // Hermetic like the CLI suite's `base()`: the server loads the + // same config precedence chain, so an ambient XDG config must + // not leak into tool behavior. + .env("XDG_CONFIG_HOME", &xdg_config) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -42,6 +49,7 @@ impl Server { stdin: Some(stdin), reader, workspace, + xdg_config, } } @@ -65,6 +73,7 @@ impl Server { self.stdin = None; // drops ChildStdin -> EOF on the server side let status = self.child.wait().expect("wait after EOF"); std::fs::remove_dir_all(&self.workspace).ok(); + std::fs::remove_dir_all(&self.xdg_config).ok(); status } @@ -85,6 +94,20 @@ impl Server { } } +/// Fresh, empty XDG config dir so the spawned server cannot pick up an +/// ambient user config (the test host may legitimately run a tuned one). +/// Unique per server so parallel spawns cannot race; removed in `shutdown`. +fn xdg_isolation_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ctxctl-mcp-xdg-{}-{}", + std::process::id(), + NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed), + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create xdg isolation dir"); + dir +} + fn result_text(response: &serde_json::Value) -> &str { response["result"]["content"][0]["text"] .as_str() @@ -280,3 +303,174 @@ fn hanging_exec_child_is_killed_at_timeout() { "server must kill the child instead of waiting for it; took {elapsed:?}" ); } + +// --- Tool matrix (CTX-0040) --------------------------------------------------- +// +// Every tool must complete a full stdio JSON-RPC round-trip. Fixtures are +// embedded from the package's tests/fixtures and written into the server's +// pinned workspace, then referenced relatively: CTX-0033 rejects anything +// outside that workspace, including machine-local absolute paths. + +/// Repo fixture embedded at compile time, written into each server workspace. +const SAMPLE_RS: &str = include_str!("fixtures/sample.rs"); + +/// Repo fixture embedded at compile time, written into each server workspace. +const DEPS_RS: &str = include_str!("fixtures/deps.rs"); + +/// A deterministic >collapse-threshold output with one critical line, as a +/// single quoted `printf` command (metacharacter validation passes it). +fn noisy_printf_cmd() -> String { + let mut body = String::new(); + for i in 1..=12 { + body.push_str(&format!("step {i}\\n")); + } + body.push_str("error: boom\\n"); + for i in 13..=25 { + body.push_str(&format!("step {i}\\n")); + } + format!("printf '{body}'") +} + +#[test] +fn tool_matrix_round_trips_every_tool_over_stdio() { + let mut server = Server::spawn(); + let sample = server.fixture("sample.rs", SAMPLE_RS); + let deps_fixture = server.fixture("deps.rs", DEPS_RS); + + let init = server.request( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test"}}}"#, + ); + assert_eq!(init["result"]["serverInfo"]["name"], "ctxctl"); + + // outline + let outline = server.call(10, "ctxctl_outline", &serde_json::json!({ "file": sample })); + assert_eq!(outline["id"], 10); + assert_eq!(outline["result"]["isError"], json_null(), "{outline}"); + let text = result_text(&outline); + assert!(text.contains("4 symbols"), "{text}"); + assert!(text.contains("pub fn add(a: i32, b: i32) -> i32"), "{text}"); + assert!(text.contains("saved ~"), "{text}"); + + // symbol + let symbol = server.call( + 11, + "ctxctl_symbol", + &serde_json::json!({ "file": sample, "name": "add" }), + ); + assert_eq!(symbol["result"]["isError"], json_null(), "{symbol}"); + let text = result_text(&symbol); + assert!(text.starts_with("# add"), "no locator: {text}"); + assert!( + text.contains("pub fn add(a: i32, b: i32) -> i32 {"), + "{text}" + ); + assert!(text.contains("a + b"), "{text}"); + // Byte stability holds per connection: an identical call returns an + // identical payload. + let again = server.call( + 12, + "ctxctl_symbol", + &serde_json::json!({ "file": sample, "name": "add" }), + ); + assert_eq!( + result_text(&symbol), + result_text(&again), + "identical tool calls must be byte-stable" + ); + + // read + let read = server.call( + 13, + "ctxctl_read", + &serde_json::json!({ "file": sample, "lines": "4-4" }), + ); + assert_eq!(read["result"]["isError"], json_null(), "{read}"); + let text = result_text(&read); + assert!( + text.contains("pub fn add(a: i32, b: i32) -> i32 {"), + "{text}" + ); + assert!(!text.contains("ANSWER"), "range too wide: {text}"); + + // deps + let deps = server.call( + 14, + "ctxctl_deps", + &serde_json::json!({ "file": deps_fixture }), + ); + assert_eq!(deps["result"]["isError"], json_null(), "{deps}"); + let text = result_text(&deps); + assert!(text.contains("5 imports"), "{text}"); + assert!(text.contains("serde::Deserialize"), "{text}"); + assert!(text.contains("local"), "{text}"); + + // exec + let cmd = noisy_printf_cmd(); + let exec = server.call(15, "ctxctl_exec", &serde_json::json!({ "cmd": cmd })); + assert_eq!(exec["result"]["isError"], json_null(), "{exec}"); + let text = result_text(&exec); + assert!(text.starts_with("$ "), "command not echoed: {text}"); + assert!(text.contains("error: boom"), "critical line lost: {text}"); + assert!(text.contains("lines omitted"), "no fold marker: {text}"); + assert!(text.contains("Saved ~"), "no savings line: {text}"); + + let status = server.shutdown(); + assert!(status.success(), "server should exit 0 at EOF"); +} + +fn json_null() -> serde_json::Value { + serde_json::Value::Null +} + +#[test] +fn nonexistent_file_becomes_is_error_naming_the_problem() { + // Relative name inside the pinned workspace so the read (not the + // CTX-0033 escape guard) is what fails on the missing file. + let mut server = Server::spawn(); + let answer = server.call( + 20, + "ctxctl_read", + &serde_json::json!({ "file": "missing.rs", "lines": "1-2" }), + ); + let deps_answer = server.call( + 21, + "ctxctl_deps", + &serde_json::json!({ "file": "missing.rs" }), + ); + server.shutdown(); + assert_eq!(answer["id"], 20); + assert_eq!(answer["result"]["isError"], true, "{answer}"); + let text = result_text(&answer); + assert!(text.contains("failed to read"), "{text}"); + assert_eq!(deps_answer["result"]["isError"], true, "{deps_answer}"); +} + +#[test] +fn mistyped_lines_argument_names_the_key() { + // An integer `lines` used to be silently unusable; it must be rejected + // naming both the key and the expected type. + let mut server = Server::spawn(); + let sample = server.fixture("sample.rs", SAMPLE_RS); + let answer = server.call( + 22, + "ctxctl_read", + &serde_json::json!({ "file": sample, "lines": 4 }), + ); + server.shutdown(); + assert_eq!(answer["result"]["isError"], true, "{answer}"); + let text = result_text(&answer); + assert!(text.contains("lines"), "{text}"); + assert!(text.contains("string"), "{text}"); +} + +#[test] +fn unknown_tool_name_becomes_is_error_result() { + let mut server = Server::spawn(); + let answer = server.call(23, "ctxctl_nope", &serde_json::json!({ "whatever": true })); + server.shutdown(); + assert_eq!(answer["id"], 23); + assert_eq!(answer["result"]["isError"], true, "{answer}"); + let text = result_text(&answer); + assert!(text.contains("unknown tool"), "{text}"); + assert!(text.contains("ctxctl_nope"), "{text}"); +}