Skip to main content

lean_ctx/tools/registered/
ctx_compare.rs

1use 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
9/// `ctx_compare` — read-only compression preview (#984).
10///
11/// Surfaces the production compressors' effect side by side so an agent (or a
12/// human) can see *exactly* what lean-ctx would emit and how many tokens it
13/// saves, without re-deriving the pipeline.
14pub 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        // Precedence: path > inline content > shell command/output.
47        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        // The comment survives only as a diff *deletion* (`-N: …`), proving it was
101        // stripped from the compressed form.
102        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}