lean_ctx/tools/registered/
ctx_compare.rs1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::core::compress_preview;
6use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str, require_resolved_path};
7use crate::tool_defs::tool_def;
8
9pub struct CtxCompareTool;
15
16impl McpTool for CtxCompareTool {
17 fn name(&self) -> &'static str {
18 "ctx_compare"
19 }
20
21 fn tool_def(&self) -> Tool {
22 tool_def(
23 "ctx_compare",
24 "Preview compression — original vs the bytes lean-ctx would emit, with token counts + line diff.\n\
25 INPUT (pick one): path=<file> (read pipeline) | content=<text> [+ ext=rs|json|csv] (read pipeline) | command=<cmd> + output=<text> (shell pipeline).\n\
26 Read-only: never changes files, cache, or session. Use to decide whether a mode/pipeline is worth it.\n\
27 ANTIPATTERN: not for reading files (use ctx_read) or restoring archived output (use ctx_expand).",
28 json!({
29 "type": "object",
30 "properties": {
31 "path": { "type": "string", "description": "File to preview via the read/aggressive pipeline" },
32 "content": { "type": "string", "description": "Inline content to preview (read pipeline)" },
33 "ext": { "type": "string", "description": "Extension for inline content, e.g. rs, json, csv" },
34 "command": { "type": "string", "description": "Shell command for the shell pipeline (pair with output)" },
35 "output": { "type": "string", "description": "Command output to preview (shell pipeline)" }
36 }
37 }),
38 )
39 }
40
41 fn handle(
42 &self,
43 args: &Map<String, Value>,
44 ctx: &ToolContext,
45 ) -> Result<ToolOutput, ErrorData> {
46 if args.contains_key("path") {
48 let resolved = require_resolved_path(ctx, args, "path")?;
49 let content = match std::fs::read_to_string(&resolved) {
50 Ok(c) => c,
51 Err(e) => {
52 return Ok(ToolOutput::simple(format!(
53 "ctx_compare: cannot read {resolved}: {e}"
54 )));
55 }
56 };
57 let ext = compress_preview::ext_of(&resolved);
58 let preview = compress_preview::preview_read(&content, ext.as_deref());
59 return Ok(ToolOutput::simple(preview.render()));
60 }
61
62 if let Some(content) = get_str(args, "content") {
63 let ext = get_str(args, "ext");
64 let preview = compress_preview::preview_read(&content, ext.as_deref());
65 return Ok(ToolOutput::simple(preview.render()));
66 }
67
68 if let Some(command) = get_str(args, "command") {
69 let output = get_str(args, "output").unwrap_or_default();
70 let preview = compress_preview::preview_shell(&command, &output);
71 return Ok(ToolOutput::simple(preview.render()));
72 }
73
74 Err(ErrorData::invalid_params(
75 "ctx_compare needs one of: path, content, or command (+output)".to_string(),
76 None,
77 ))
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 fn ctx() -> ToolContext {
86 ToolContext::default()
87 }
88
89 #[test]
90 fn previews_inline_content_with_token_accounting() {
91 let mut args = Map::new();
92 args.insert(
93 "content".to_string(),
94 Value::String("// drop me\nfn a() {}\n".to_string()),
95 );
96 args.insert("ext".to_string(), Value::String("rs".to_string()));
97 let out = CtxCompareTool.handle(&args, &ctx()).unwrap();
98 assert!(out.text.contains("compress preview"));
99 assert!(out.text.contains("read/aggressive"));
100 assert!(
103 out.text.contains("-1: // drop me"),
104 "comment should appear as a removal in the diff: {}",
105 out.text
106 );
107 }
108
109 #[test]
110 fn previews_shell_pipeline() {
111 let mut args = Map::new();
112 args.insert(
113 "command".to_string(),
114 Value::String("cargo build".to_string()),
115 );
116 args.insert(
117 "output".to_string(),
118 Value::String("Compiling foo\n".repeat(40)),
119 );
120 let out = CtxCompareTool.handle(&args, &ctx()).unwrap();
121 assert!(out.text.contains("pipeline: shell"));
122 }
123
124 #[test]
125 fn errors_without_any_input() {
126 let args = Map::new();
127 let msg = match CtxCompareTool.handle(&args, &ctx()) {
128 Err(e) => format!("{e:?}"),
129 Ok(_) => panic!("expected an error when no input is given"),
130 };
131 assert!(msg.contains("needs one of"), "got: {msg}");
132 }
133}