1#![forbid(unsafe_code)]
2
3use rpi_plugin_sdk::handles::ToolHandles;
4use std::ffi::c_void;
5use std::io::{BufRead, BufReader, Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Child, ChildStdin, Command, Stdio};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{mpsc, Arc, Mutex};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use rpi_plugin_sdk::{
14 FreeStringFn, StbString, StbStringRef, StepHandle, StepResult, ToolPartialCb,
15};
16use serde_json::{json, Value};
17
18const SESSION_TIMEOUT: Duration = Duration::from_secs(20);
19const POLL_INTERVAL: Duration = Duration::from_millis(50);
20const MAX_OUTPUT_CHARS: usize = 25_000;
21const MAX_DIAGNOSTIC_CHARS: usize = 1_000;
22
23static HANDLES: ToolHandles<Drive> = ToolHandles::new();
24
25struct Drive {
26 tool: &'static str,
27 params: Value,
28 completed: bool,
29}
30
31struct ToolSpec {
32 name: &'static str,
33 description: String,
34 parameters: String,
35 execute: extern "C" fn(StbStringRef, StbString, Option<FreeStringFn>) -> StepHandle,
36}
37
38fn project_root(params: &Value) -> Result<PathBuf, String> {
39 let requested = params
40 .get("projectPath")
41 .and_then(Value::as_str)
42 .filter(|value| !value.trim().is_empty());
43 let root = match requested {
44 Some(value) => PathBuf::from(value.trim()),
45 None => std::env::current_dir()
46 .map_err(|err| format!("cannot resolve current directory: {err}"))?,
47 };
48 if !root.is_absolute() {
49 return Err("CodeGraph projectPath must be an absolute path".into());
50 }
51 if !root.is_dir() {
52 return Err(format!(
53 "CodeGraph projectPath is not an accessible directory: {}",
54 root.display()
55 ));
56 }
57 Ok(root)
58}
59
60#[cfg(windows)]
64fn resolve_codegraph_cmd() -> Result<Command, String> {
65 let output = Command::new("powershell.exe")
66 .args([
67 "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
68 "-Command",
69 "(Get-Command codegraph -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source",
70 ])
71 .output()
72 .map_err(|e| format!("failed to locate codegraph: {e}"))?;
73 if !output.status.success() {
74 return Err("codegraph command not found; install @colbymchenry/codegraph".into());
75 }
76 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
77 if path.is_empty() {
78 return Err("codegraph command not found; install @colbymchenry/codegraph".into());
79 }
80 Ok(Command::new(path))
81}
82
83#[cfg(not(windows))]
84fn resolve_codegraph_cmd() -> Result<Command, String> {
85 Ok(Command::new("codegraph"))
86}
87
88fn ensure_initialized(root: &Path) -> Result<(), String> {
91 if root.join(".codegraph").is_dir() {
92 return Ok(());
93 }
94 let mut cmd = resolve_codegraph_cmd()?;
95 let output = cmd
96 .args(["init"])
97 .arg(root)
98 .current_dir(root)
99 .stdin(Stdio::null())
100 .stdout(Stdio::piped())
101 .stderr(Stdio::piped())
102 .output()
103 .map_err(|e| format!("failed to run codegraph init: {e}"))?;
104 if !output.status.success() {
105 let stderr = String::from_utf8_lossy(&output.stderr);
106 return Err(format!("codegraph init failed: {stderr}"));
107 }
108 Ok(())
109}
110
111fn sync_index(root: &Path) -> Result<(), String> {
113 let mut cmd = resolve_codegraph_cmd()?;
114 let output = cmd
115 .args(["sync"])
116 .arg(root)
117 .current_dir(root)
118 .stdin(Stdio::null())
119 .stdout(Stdio::piped())
120 .stderr(Stdio::piped())
121 .output()
122 .map_err(|e| format!("failed to run codegraph sync: {e}"))?;
123 if !output.status.success() {
124 let stderr = String::from_utf8_lossy(&output.stderr);
125 return Err(format!("codegraph sync failed: {stderr}"));
126 }
127 Ok(())
128}
129
130#[cfg(windows)]
131fn spawn_server(root: &Path) -> Result<Child, String> {
132 const SCRIPT: &str = concat!(
135 "& { param([string]$ProjectPath) ",
136 "$ErrorActionPreference = 'Stop'; ",
137 "$cmd = Get-Command codegraph -CommandType Application -ErrorAction Stop | Select-Object -First 1; ",
138 "if (-not $cmd) { throw 'codegraph command not found'; }; ",
139 "& $cmd.Source serve --mcp --path $ProjectPath; exit $LASTEXITCODE; }"
140 );
141 Command::new("powershell.exe")
142 .args([
143 "-NoProfile",
144 "-NonInteractive",
145 "-ExecutionPolicy",
146 "Bypass",
147 "-Command",
148 SCRIPT,
149 ])
150 .arg(root)
151 .current_dir(root)
152 .stdin(Stdio::piped())
153 .stdout(Stdio::piped())
154 .stderr(Stdio::piped())
155 .spawn()
156 .map_err(|err| format!("failed to start CodeGraph MCP server: {err}"))
157}
158
159#[cfg(not(windows))]
160fn spawn_server(root: &Path) -> Result<Child, String> {
161 Command::new("codegraph")
162 .args(["serve", "--mcp", "--path"])
163 .arg(root)
164 .current_dir(root)
165 .stdin(Stdio::piped())
166 .stdout(Stdio::piped())
167 .stderr(Stdio::piped())
168 .spawn()
169 .map_err(|err| {
170 format!("failed to start CodeGraph MCP server: {err}; install @colbymchenry/codegraph")
171 })
172}
173
174fn write_rpc(stdin: &mut ChildStdin, payload: &Value) -> Result<(), String> {
175 serde_json::to_writer(&mut *stdin, payload)
176 .map_err(|err| format!("failed to encode CodeGraph request: {err}"))?;
177 stdin
178 .write_all(b"\n")
179 .and_then(|_| stdin.flush())
180 .map_err(|err| format!("failed to write to CodeGraph MCP server: {err}"))
181}
182
183fn wait_for_response(
184 receiver: &mpsc::Receiver<String>,
185 id: u64,
186 deadline: Instant,
187 cancelled: &AtomicBool,
188) -> Result<Value, String> {
189 loop {
190 if cancelled.load(Ordering::SeqCst) {
191 return Err("codegraph request cancelled".into());
192 }
193 let now = Instant::now();
194 if now >= deadline {
195 return Err(format!(
196 "CodeGraph MCP session timed out after {} seconds; try `codegraph unlock`",
197 SESSION_TIMEOUT.as_secs()
198 ));
199 }
200 let wait = POLL_INTERVAL.min(deadline.saturating_duration_since(now));
201 match receiver.recv_timeout(wait) {
202 Ok(line) => {
203 let Ok(message) = serde_json::from_str::<Value>(&line) else {
204 continue;
205 };
206 if message.get("id").and_then(Value::as_u64) != Some(id) {
207 continue;
208 }
209 if let Some(error) = message.get("error") {
210 let text = error
211 .get("message")
212 .and_then(Value::as_str)
213 .map(str::to_owned)
214 .unwrap_or_else(|| error.to_string());
215 return Err(text);
216 }
217 return Ok(message.get("result").cloned().unwrap_or(Value::Null));
218 }
219 Err(mpsc::RecvTimeoutError::Timeout) => continue,
220 Err(mpsc::RecvTimeoutError::Disconnected) => {
221 return Err("CodeGraph MCP process closed before responding".into())
222 }
223 }
224 }
225}
226
227fn response_text(result: &Value) -> Result<String, String> {
228 let text = result
229 .get("content")
230 .and_then(Value::as_array)
231 .into_iter()
232 .flatten()
233 .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
234 .filter_map(|part| part.get("text").and_then(Value::as_str))
235 .collect::<Vec<_>>()
236 .join("\n");
237 if result.get("isError").and_then(Value::as_bool) == Some(true) {
238 return Err(if text.is_empty() {
239 "CodeGraph tool failed".into()
240 } else {
241 text
242 });
243 }
244 let output = if text.is_empty() {
245 result.to_string()
246 } else {
247 text
248 };
249 Ok(truncate_chars(&output, MAX_OUTPUT_CHARS))
250}
251
252fn truncate_chars(text: &str, max_chars: usize) -> String {
253 if text.chars().count() <= max_chars {
254 return text.to_owned();
255 }
256 let byte_end = text
257 .char_indices()
258 .nth(max_chars)
259 .map(|(index, _)| index)
260 .unwrap_or(text.len());
261 let prefix = &text[..byte_end];
262 let cut = prefix
263 .rfind('\n')
264 .filter(|index| *index >= byte_end.saturating_mul(4) / 5)
265 .unwrap_or(byte_end);
266 format!(
267 "{}\n\n... (output truncated to {max_chars} characters)",
268 &text[..cut]
269 )
270}
271
272fn sanitize_diagnostic(text: &str) -> String {
273 let mut cleaned = String::with_capacity(text.len().min(MAX_DIAGNOSTIC_CHARS));
274 for raw in text.lines() {
275 let mut line = raw.replace('\u{1b}', "");
276 for marker in ["TOKEN=", "SECRET=", "PASSWORD=", "API_KEY=", "APIKEY="] {
277 if let Some(start) = line.to_ascii_uppercase().find(marker) {
278 let value_start = start + marker.len();
279 let value_end = line[value_start..]
280 .find(char::is_whitespace)
281 .map(|offset| value_start + offset)
282 .unwrap_or(line.len());
283 line.replace_range(value_start..value_end, "[redacted]");
284 }
285 }
286 if let Some(start) = line.to_ascii_lowercase().find("bearer ") {
287 let value_start = start + "bearer ".len();
288 let value_end = line[value_start..]
289 .find(char::is_whitespace)
290 .map(|offset| value_start + offset)
291 .unwrap_or(line.len());
292 line.replace_range(value_start..value_end, "[redacted]");
293 }
294 if !cleaned.is_empty() {
295 cleaned.push('\n');
296 }
297 cleaned.push_str(&line);
298 if cleaned.chars().count() >= MAX_DIAGNOSTIC_CHARS {
299 return truncate_chars(&cleaned, MAX_DIAGNOSTIC_CHARS);
300 }
301 }
302 cleaned
303}
304
305fn call_codegraph(tool: &str, params: &Value, cancelled: &AtomicBool) -> Result<String, String> {
306 let root = project_root(params)?;
307 ensure_initialized(&root)?;
309 sync_index(&root)?;
310 let mut child = spawn_server(&root)?;
311 let mut stdin = child
312 .stdin
313 .take()
314 .ok_or_else(|| "CodeGraph MCP stdin is unavailable".to_string())?;
315 let stdout = child
316 .stdout
317 .take()
318 .ok_or_else(|| "CodeGraph MCP stdout is unavailable".to_string())?;
319 let stderr = child
320 .stderr
321 .take()
322 .ok_or_else(|| "CodeGraph MCP stderr is unavailable".to_string())?;
323
324 let (sender, receiver) = mpsc::channel();
325 thread::spawn(move || {
326 for line in BufReader::new(stdout).lines().map_while(Result::ok) {
327 if sender.send(line).is_err() {
328 break;
329 }
330 }
331 });
332 let diagnostic = Arc::new(Mutex::new(String::new()));
333 let diagnostic_writer = Arc::clone(&diagnostic);
334 thread::spawn(move || {
335 let mut reader = BufReader::new(stderr).take(16 * 1024);
336 let mut text = String::new();
337 let _ = reader.read_to_string(&mut text);
338 if let Ok(mut destination) = diagnostic_writer.lock() {
339 *destination = text;
340 }
341 });
342
343 let deadline = Instant::now() + SESSION_TIMEOUT;
344 let operation = (|| {
345 let path = root.to_string_lossy().replace('\\', "/");
346 let root_uri = if cfg!(windows) {
347 format!("file:///{}", path.trim_start_matches('/'))
348 } else {
349 format!("file://{path}")
350 };
351 write_rpc(
352 &mut stdin,
353 &json!({
354 "jsonrpc": "2.0",
355 "id": 1,
356 "method": "initialize",
357 "params": {
358 "protocolVersion": "2024-11-05",
359 "rootUri": root_uri.clone(),
360 "workspaceFolders": [{"uri": root_uri, "name": root.file_name().and_then(|v| v.to_str()).unwrap_or("project")}],
361 "capabilities": {},
362 "clientInfo": {"name": "rpi-codegraph", "version": env!("CARGO_PKG_VERSION")}
363 }
364 }),
365 )?;
366 wait_for_response(&receiver, 1, deadline, cancelled)?;
367 write_rpc(
368 &mut stdin,
369 &json!({"jsonrpc":"2.0","method":"initialized","params":{}}),
370 )?;
371 write_rpc(
372 &mut stdin,
373 &json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":params}}),
374 )?;
375 let result = wait_for_response(&receiver, 2, deadline, cancelled)?;
376 response_text(&result)
377 })();
378
379 drop(stdin);
380 let _ = child.kill();
381 let _ = child.wait();
382
383 operation.map_err(|message| {
384 let stderr = diagnostic
385 .lock()
386 .ok()
387 .map(|value| sanitize_diagnostic(&value))
388 .unwrap_or_default();
389 if stderr.is_empty() || message.contains(&stderr) {
390 message
391 } else {
392 format!("{message}: {stderr}")
393 }
394 })
395}
396
397fn execute_tool(tool: &'static str, params: StbString, free: Option<FreeStringFn>) -> StepHandle {
398 let text = params.to_string_lossy();
399 params.free_with(free);
400 HANDLES.insert(Drive {
401 tool,
402 params: serde_json::from_str(&text).unwrap_or(Value::Null),
403 completed: false,
404 })
405}
406
407macro_rules! tool_execute {
408 ($function:ident, $tool:literal) => {
409 extern "C" fn $function(
410 _: StbStringRef,
411 params: StbString,
412 free: Option<FreeStringFn>,
413 ) -> StepHandle {
414 execute_tool($tool, params, free)
415 }
416 };
417}
418
419tool_execute!(execute_search, "codegraph_search");
420tool_execute!(execute_callers, "codegraph_callers");
421tool_execute!(execute_callees, "codegraph_callees");
422tool_execute!(execute_impact, "codegraph_impact");
423tool_execute!(execute_explore, "codegraph_explore");
424tool_execute!(execute_node, "codegraph_node");
425tool_execute!(execute_status, "codegraph_status");
426tool_execute!(execute_files, "codegraph_files");
427
428extern "C" fn poll(handle: StepHandle, _: Option<ToolPartialCb>, _: *mut c_void) -> StepResult {
429 if handle.is_null() {
430 return StepResult::err(StbString::from_string("null codegraph handle".into()));
431 }
432 let Some(entry) = HANDLES.get(handle) else {
433 return StepResult::err(StbString::from_string(
434 "invalid or destroyed tool handle".into(),
435 ));
436 };
437 let mut drive = entry.lock();
438 if entry.cancelled.load(Ordering::SeqCst) {
439 return StepResult::err(StbString::from_string("codegraph request cancelled".into()));
440 }
441 if drive.completed {
442 return StepResult::err(StbString::from_string(
443 "codegraph polled after completion".into(),
444 ));
445 }
446 drive.completed = true;
447 match call_codegraph(drive.tool, &drive.params, &entry.cancelled) {
448 Ok(text) => StepResult::done(StbString::from_string(
449 json!({"content":[{"type":"text","text":text}]}).to_string(),
450 )),
451 Err(error) => StepResult::err(StbString::from_string(error)),
452 }
453}
454
455extern "C" fn cancel(handle: StepHandle) {
456 HANDLES.cancel(handle);
457}
458
459extern "C" fn destroy(handle: StepHandle) {
460 drop(HANDLES.remove(handle));
461}
462
463use rpi_plugin_sdk::callbacks::free_string;
464
465fn parameters(mut properties: serde_json::Map<String, Value>, required: &[&str]) -> String {
466 properties.insert(
467 "projectPath".into(),
468 json!({
469 "type": "string",
470 "description": "Optional absolute project path; omitted or blank defaults to the current working directory."
471 }),
472 );
473 let mut schema =
475 json!({"type":"object", "properties": properties, "additionalProperties": true});
476 if !required.is_empty() {
477 schema["required"] = json!(required);
478 }
479 schema.to_string()
480}
481
482fn description(purpose: &str) -> String {
483 format!("{purpose} Requires codegraph CLI. Every call (including status) auto-initializes if needed and syncs, writing the project index. Text truncates at 25,000 characters plus a marker. If unavailable or incomplete, use read/grep/find to verify.")
484}
485
486fn tool_specs() -> Vec<ToolSpec> {
487 vec![
488 ToolSpec {
489 name: "codegraph_search",
490 description: description("Search indexed symbols by name; returns compact locations, not source. Use node for one definition or explore for an area."),
491 parameters: parameters(json!({
492 "query":{"type":"string","description":"Required symbol name or partial name to search (for example signIn or AuthService)."},
493 "kind":{"type":"string","description":"Optional symbol-kind filter; omitted searches all indexed kinds.","enum":["function","method","class","interface","type","variable","route","component"]},
494 "limit":{"type":"integer","description":"Optional maximum results requested; default hint 10. Upstream applies its own limits; the plugin does not clamp or inject defaults.","default":10}
495 }).as_object().unwrap().clone(), &["query"]),
496 execute: execute_search,
497 },
498 ToolSpec {
499 name: "codegraph_callers",
500 description: description("Find indexed functions/methods that call a symbol. Use file to disambiguate names; dynamic dispatch or unresolved calls may be missing."),
501 parameters: parameters(json!({
502 "symbol":{"type":"string","description":"Required definition name to find callers for."},
503 "file":{"type":"string","description":"Optional definition path or suffix to disambiguate same-named symbols; omitted lets upstream resolve matches."},
504 "limit":{"type":"integer","description":"Optional requested caller count; default hint 20. Upstream applies its own caps.","default":20}
505 }).as_object().unwrap().clone(), &["symbol"]),
506 execute: execute_callers,
507 },
508 ToolSpec {
509 name: "codegraph_callees",
510 description: description("Find indexed functions/methods called by a symbol. Use file to disambiguate names; this is a static relationship view, not a runtime trace."),
511 parameters: parameters(json!({
512 "symbol":{"type":"string","description":"Required definition name to find outgoing calls for."},
513 "file":{"type":"string","description":"Optional definition path or suffix to disambiguate same-named symbols; omitted lets upstream resolve matches."},
514 "limit":{"type":"integer","description":"Optional requested callee count; default hint 20. Upstream applies its own caps.","default":20}
515 }).as_object().unwrap().clone(), &["symbol"]),
516 execute: execute_callees,
517 },
518 ToolSpec {
519 name: "codegraph_impact",
520 description: description("Estimate the dependency impact radius of changing a symbol before refactoring. Depth bounds traversal; results do not prove all affected code is covered."),
521 parameters: parameters(json!({
522 "symbol":{"type":"string","description":"Required definition name whose dependents should be analyzed."},
523 "file":{"type":"string","description":"Optional definition path or suffix to disambiguate same-named symbols; omitted lets upstream resolve matches."},
524 "depth":{"type":"integer","description":"Optional requested dependency traversal levels; default hint 2. Upstream applies its own caps; deeper is not proof of completeness.","default":2}
525 }).as_object().unwrap().clone(), &["symbol"]),
526 execute: execute_impact,
527 },
528 ToolSpec {
529 name: "codegraph_explore",
530 description: description("Explore a question, symbol, file or flow; returns relevant source grouped by file and relationships. Prefer specific code terms, and narrow the query if source is trimmed."),
531 parameters: parameters(json!({
532 "query":{"type":"string","description":"Required question, symbol/file names or short code terms identifying the area or flow; specific terms help relevance."},
533 "maxFiles":{"type":"integer","description":"Optional requested source-file budget; registered default hint 8. Omission is forwarded unchanged, so the installed upstream version chooses its actual default and caps.","default":8}
534 }).as_object().unwrap().clone(), &["query"]),
535 execute: execute_explore,
536 },
537 ToolSpec {
538 name: "codegraph_node",
539 description: description("Two modes: supply symbol for definition details/relationships (includeCode for source), or file without symbol for line-numbered file source (symbolsOnly for outline). Supply at least symbol or file. File/line disambiguate symbol definitions; file mode supports offset/limit. Availability depends on the installed upstream version."),
540 parameters: parameters(json!({
541 "symbol":{"type":"string","description":"Symbol mode: definition name. Required in use unless file is supplied for file mode; omit for whole-file source."},
542 "includeCode":{"type":"boolean","description":"Symbol mode: request source (default hint false); containers may return an outline. Ignored in file mode.","default":false},
543 "file":{"type":"string","description":"File mode: path or basename to read when symbol is omitted. Symbol mode: optional definition path/suffix to disambiguate."},
544 "offset":{"type":"integer","description":"File mode only: optional 1-based starting line; omission starts at the beginning. Upstream controls normalization."},
545 "limit":{"type":"integer","description":"File mode only: optional requested maximum lines; installed upstream defaults apply (locally inspected version defaults to 2000 lines with a separate character budget), then plugin text truncation applies."},
546 "symbolsOnly":{"type":"boolean","description":"File mode only: return symbol map/dependents instead of source; default hint false.","default":false},
547 "line":{"type":"integer","description":"Symbol mode only: optional 1-based definition line to disambiguate with file; not a file read offset."}
548 }).as_object().unwrap().clone(), &[]),
549 execute: execute_node,
550 },
551 ToolSpec {
552 name: "codegraph_status",
553 description: description("Report index health, file/node/edge counts and any pending sync information AFTER automatic init and sync. This is not a read-only pre-sync inspection and may fail during preparation."),
554 parameters: parameters(serde_json::Map::new(), &[]),
555 execute: execute_status,
556 },
557 ToolSpec {
558 name: "codegraph_files",
559 description: description("Show the indexed file tree, optionally filtered by directory/glob with format, metadata and depth controls. Not a complete filesystem listing: unindexed or unsupported files can be absent."),
560 parameters: parameters(json!({
561 "path":{"type":"string","description":"Optional directory filter within the project, for example src/components; omitted includes all indexed paths."},
562 "pattern":{"type":"string","description":"Optional glob filter such as **/*.test.ts; interpreted by upstream, not the shell."},
563 "format":{"type":"string","description":"Optional output layout: tree (hierarchical default hint), flat (list), grouped (by language).","enum":["tree","flat","grouped"],"default":"tree"},
564 "includeMetadata":{"type":"boolean","description":"Optional language/symbol-count metadata; default hint true.","default":true},
565 "maxDepth":{"type":"integer","description":"Optional maximum directory depth; omission leaves depth unlimited upstream, but response text remains bounded."}
566 }).as_object().unwrap().clone(), &[]),
567 execute: execute_files,
568 },
569 ]
570}
571rpi_plugin_sdk::export_plugin!(|api| {
572 let Some(register) = api.register_tool else {
573 return 1;
574 };
575 for spec in tool_specs() {
576 let schema = Box::new(rpi_plugin_sdk::StableToolSchema {
577 name: StbString::from_string(spec.name.into()),
578 description: StbString::from_string(spec.description),
579 parameters: StbString::from_string(spec.parameters),
580 });
581 let rc = register(&*schema, spec.execute, poll, cancel, destroy, free_string);
582 drop(schema);
583 if rc != 0 {
584 return rc;
585 }
586 }
587 0
588});
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn registered_schemas_preserve_shape_optional_fields_and_guidance() {
596 let specs = tool_specs();
597 assert_eq!(specs.len(), 8);
598 let mut names = std::collections::HashSet::new();
599 for spec in specs {
600 assert!(names.insert(spec.name));
601 let schema: Value = serde_json::from_str(&spec.parameters)
602 .unwrap_or_else(|error| panic!("invalid schema for {}: {error}", spec.name));
603 assert_eq!(schema["type"], "object");
604 assert_eq!(schema["additionalProperties"], true, "accept host __rpi");
605 let properties = schema["properties"].as_object().unwrap();
606 for (name, property) in properties {
607 assert!(
608 property["description"]
609 .as_str()
610 .is_some_and(|s| !s.is_empty()),
611 "{}.{name}",
612 spec.name
613 );
614 assert!(property["type"].is_string());
615 }
616 let required = schema
617 .get("required")
618 .and_then(Value::as_array)
619 .cloned()
620 .unwrap_or_default();
621 let expected = match spec.name {
622 "codegraph_search" | "codegraph_explore" => vec![json!("query")],
623 "codegraph_callers" | "codegraph_callees" | "codegraph_impact" => {
624 vec![json!("symbol")]
625 }
626 _ => vec![],
627 };
628 assert_eq!(required, expected, "optional fields stay optional");
629 for field in &required {
630 assert!(properties.contains_key(field.as_str().unwrap()));
631 }
632 let project_guidance = properties["projectPath"]["description"].as_str().unwrap();
633 assert!(project_guidance.contains("absolute"));
634 assert!(project_guidance.contains("current working directory"));
635 assert!(project_guidance.len() <= 120);
636 for text in [
637 "codegraph CLI",
638 "Every call",
639 "including status",
640 "auto-initializes",
641 "syncs",
642 "writing",
643 "25,000",
644 "unavailable",
645 "read/grep/find",
646 ] {
647 assert!(
648 spec.description.contains(text),
649 "{} missing {text}",
650 spec.name
651 );
652 }
653 match spec.name {
654 "codegraph_search" => {
655 assert_eq!(properties["limit"]["default"], 10);
656 assert_eq!(properties["kind"]["enum"].as_array().unwrap().len(), 8);
657 }
658 "codegraph_callers" | "codegraph_callees" => {
659 assert_eq!(properties["limit"]["default"], 20)
660 }
661 "codegraph_impact" => assert_eq!(properties["depth"]["default"], 2),
662 "codegraph_explore" => assert_eq!(properties["maxFiles"]["default"], 8),
663 "codegraph_node" => {
664 assert!(spec.description.contains("at least symbol or file"));
665 assert_eq!(properties["includeCode"]["default"], false);
666 assert_eq!(properties["symbolsOnly"]["default"], false);
667 assert!(properties["offset"]["description"]
668 .as_str()
669 .unwrap()
670 .contains("1-based"));
671 }
672 "codegraph_status" => {
673 assert_eq!(properties.len(), 1);
674 assert!(spec.description.contains("not a read-only pre-sync"));
675 }
676 "codegraph_files" => {
677 assert_eq!(
678 properties["format"]["enum"],
679 json!(["tree", "flat", "grouped"])
680 );
681 assert_eq!(properties["format"]["default"], "tree");
682 assert_eq!(properties["includeMetadata"]["default"], true);
683 }
684 _ => unreachable!(),
685 }
686 }
687 }
688
689 #[test]
690 fn shared_guidance_stays_concise() {
691 let guidance = description("");
692 assert!((200..=300).contains(&guidance.trim().len()));
693 }
694
695 #[test]
696 fn extracts_text_and_rejects_mcp_errors() {
697 let result = json!({"content":[{"type":"text","text":"one"},{"type":"image","data":"x"},{"type":"text","text":"two"}]});
698 assert_eq!(response_text(&result).unwrap(), "one\ntwo");
699 assert!(
700 response_text(&json!({"isError":true,"content":[{"type":"text","text":"bad"}]}))
701 .is_err()
702 );
703 }
704
705 #[test]
706 fn truncates_on_unicode_boundaries() {
707 let text = "你".repeat(30);
708 let output = truncate_chars(&text, 10);
709 assert!(output.starts_with(&"你".repeat(10)));
710 assert!(output.contains("output truncated"));
711 }
712
713 #[test]
714 fn redacts_common_secrets() {
715 let output = sanitize_diagnostic("failed TOKEN=abc Bearer xyz API_KEY=hidden");
716 assert!(!output.contains("abc"));
717 assert!(!output.contains("xyz"));
718 assert!(!output.contains("hidden"));
719 }
720}