Skip to main content

rpi_codegraph/
lib.rs

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/// Resolve the `codegraph` executable path. On Windows, npm/Scoop shims
61/// don't resolve reliably via `Command::new("codegraph")`, so we ask
62/// PowerShell to find the native application shim.
63#[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
88/// If the project has no `.codegraph/` directory, run `codegraph init` to
89/// create the initial index automatically.
90fn 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
111/// Run `codegraph sync` to pick up any file changes since the last index.
112fn 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    // Command::new("codegraph") does not reliably resolve npm/Scoop .cmd shims
133    // on Windows. Let PowerShell discover the native application shim.
134    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    // Auto-init if no index exists, then sync to pick up recent changes.
308    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    // Extra host metadata (including __rpi) must remain accepted.
474    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}