Skip to main content

lean_ctx/core/
debug_log.rs

1//! Opt-in human-readable debug log of intercepted tool activity (#520).
2//!
3//! Off by default. Enable with the `LEAN_CTX_DEBUG_LOG` env var (truthy) or the
4//! `debug_log = true` config key. Records two kinds of events to
5//! `<state_dir>/logs/debug.log`:
6//!
7//! 1. **MCP tool calls** handled by the lean-ctx server (`ctx_*`) — tool name, a
8//!    redacted argument summary, a one-line result preview, byte size, token
9//!    savings and wall time.
10//! 2. **Hook routing decisions** — for every native tool call lean-ctx *can*
11//!    intercept (shell / Read / Grep), whether it was routed to `lean-ctx` or
12//!    left to the editor's **native** tool, and *why*. This is #520's core ask:
13//!    explain why one call used lean-ctx and the next fell back to the native
14//!    Read/Grep tool.
15//!
16//! All writes are best-effort and never panic — logging must never break a hook
17//! subprocess or a tool call. Secrets in arguments/commands/results are scrubbed
18//! via [`crate::core::redaction::redact_text`] before they hit disk.
19
20use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use serde_json::{Map, Value};
25
26/// Rotate once the log crosses this size, keeping a single `.1` backup so the
27/// file cannot grow without bound on a long-lived daemon.
28const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
29
30/// Clamp any single field rendered into a log line so one huge argument or
31/// result cannot dominate the file (or leak a large blob). Bytes; clamped on a
32/// UTF-8 char boundary.
33const FIELD_CLAMP: usize = 200;
34
35/// Where a hook sent an intercepted native tool call.
36#[derive(Clone, Copy, PartialEq, Eq, Debug)]
37pub enum Route {
38    /// Rewritten / redirected through lean-ctx (compression + caching).
39    LeanCtx,
40    /// Left to the editor's native tool.
41    Native,
42}
43
44impl Route {
45    fn label(self) -> &'static str {
46        match self {
47            Route::LeanCtx => "lean-ctx",
48            Route::Native => "native",
49        }
50    }
51}
52
53/// Whether the opt-in debug log is active.
54///
55/// The env var wins over config so a user can flip it per-session
56/// (`LEAN_CTX_DEBUG_LOG=1`) without editing config; the `debug_log` config key
57/// makes it persistent and is what hook subprocesses read when the IDE does not
58/// export the env var into the hook environment.
59#[must_use]
60pub fn is_enabled() -> bool {
61    if let Ok(v) = std::env::var("LEAN_CTX_DEBUG_LOG") {
62        let v = v.trim().to_ascii_lowercase();
63        return !matches!(v.as_str(), "" | "0" | "false" | "off" | "no");
64    }
65    crate::core::config::Config::load().debug_log
66}
67
68/// `<state_dir>/logs/debug.log`. Returns `None` if the state dir cannot be
69/// resolved or the `logs/` directory cannot be created.
70#[must_use]
71pub fn log_path() -> Option<PathBuf> {
72    let dir = crate::core::paths::state_dir().ok()?.join("logs");
73    std::fs::create_dir_all(&dir).ok()?;
74    Some(dir.join("debug.log"))
75}
76
77/// Record an MCP tool call handled by the lean-ctx server.
78pub fn log_mcp_call(
79    tool: &str,
80    args: Option<&Map<String, Value>>,
81    result_first_line: &str,
82    result_bytes: usize,
83    saved_tokens: usize,
84    elapsed: Duration,
85) {
86    if !is_enabled() {
87        return;
88    }
89    let args_summary = summarize_args(args);
90    let preview = clamp(&redact(result_first_line));
91    append(&format!(
92        "mcp  {tool}({args_summary}) -> {preview} [{result_bytes}B, saved≈{saved_tokens} tok, {}ms]",
93        elapsed.as_millis()
94    ));
95}
96
97/// Record an MCP tool call that failed before producing a result.
98pub fn log_mcp_error(tool: &str, args: Option<&Map<String, Value>>, error: &str) {
99    if !is_enabled() {
100        return;
101    }
102    let args_summary = summarize_args(args);
103    append(&format!(
104        "mcp  {tool}({args_summary}) -> ERROR: {}",
105        clamp(&redact(error))
106    ));
107}
108
109/// Record a hook routing decision for an intercepted native tool call.
110///
111/// `subject` is the command / path / pattern that was inspected; `reason`
112/// explains the choice (e.g. `"rewritable shell command"`, `"sensitive path"`).
113pub fn log_hook_decision(event: &str, tool: &str, route: Route, subject: &str, reason: &str) {
114    if !is_enabled() {
115        return;
116    }
117    append(&format!(
118        "hook {event} {tool} -> {} ({reason}): {}",
119        route.label(),
120        clamp(&redact(subject))
121    ));
122}
123
124/// Return the log content for display (most-recent `tail_lines`, `0` = all).
125#[must_use]
126pub fn read_log(tail_lines: usize) -> String {
127    let Some(path) = log_path() else {
128        return "Debug log unavailable (state dir not resolvable).".to_string();
129    };
130    if !path.exists() {
131        return format!(
132            "No debug-log entries yet. Enable with `LEAN_CTX_DEBUG_LOG=1` or \
133             `lean-ctx config set debug_log true`, then re-run your tool calls.\nPath: {}",
134            path.display()
135        );
136    }
137    let content = std::fs::read_to_string(&path).unwrap_or_default();
138    if tail_lines == 0 {
139        return content;
140    }
141    let lines: Vec<&str> = content.lines().collect();
142    let start = lines.len().saturating_sub(tail_lines);
143    lines[start..].join("\n")
144}
145
146/// Delete the debug log (and its rotated backup). Returns a status line.
147#[must_use]
148pub fn clear() -> String {
149    let Some(path) = log_path() else {
150        return "Debug log unavailable (state dir not resolvable).".to_string();
151    };
152    let mut removed = 0u32;
153    for p in [path.clone(), rotated_path(&path)] {
154        if p.exists() && std::fs::remove_file(&p).is_ok() {
155            removed += 1;
156        }
157    }
158    format!(
159        "Cleared {removed} debug-log file(s) from {}",
160        path.display()
161    )
162}
163
164// ---- internals -------------------------------------------------------------
165
166fn redact(s: &str) -> String {
167    crate::core::redaction::redact_text(s)
168}
169
170/// Rotated-backup path for `debug.log` → `debug.log.1`.
171fn rotated_path(path: &Path) -> PathBuf {
172    let mut name = path.file_name().unwrap_or_default().to_os_string();
173    name.push(".1");
174    path.with_file_name(name)
175}
176
177/// Pure rotation predicate (unit-testable without writing `MAX_LOG_BYTES`).
178fn should_rotate(len: u64) -> bool {
179    len > MAX_LOG_BYTES
180}
181
182/// Collapse newlines to a single visible marker and clamp to [`FIELD_CLAMP`]
183/// bytes on a char boundary so every event stays on exactly one line.
184fn clamp(s: &str) -> String {
185    let one_line = s.replace('\n', "⏎").replace('\r', "");
186    if one_line.len() <= FIELD_CLAMP {
187        return one_line;
188    }
189    let mut end = FIELD_CLAMP;
190    while end > 0 && !one_line.is_char_boundary(end) {
191        end -= 1;
192    }
193    format!("{}…", &one_line[..end])
194}
195
196/// `key="val", key2=42` with string values redacted + clamped and keys sorted
197/// for stable output. Structural args (action / mode / path / pattern / command)
198/// are the useful signal; long or secret-bearing values stay short.
199fn summarize_args(args: Option<&Map<String, Value>>) -> String {
200    let Some(map) = args else {
201        return String::new();
202    };
203    let mut keys: Vec<&String> = map.keys().collect();
204    keys.sort();
205    let summary = keys
206        .iter()
207        .map(|k| {
208            let rendered = match map.get(*k) {
209                Some(Value::String(s)) => format!("{:?}", clamp(&redact(s))),
210                Some(other) => clamp(&other.to_string()),
211                None => String::new(),
212            };
213            format!("{k}={rendered}")
214        })
215        .collect::<Vec<_>>()
216        .join(", ");
217    clamp(&summary)
218}
219
220fn rotate_if_large(path: &Path) {
221    if let Ok(meta) = std::fs::metadata(path)
222        && should_rotate(meta.len())
223    {
224        let _ = std::fs::rename(path, rotated_path(path));
225    }
226}
227
228fn append(message: &str) {
229    let Some(path) = log_path() else {
230        return;
231    };
232    rotate_if_large(&path);
233    let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
234    let line = format!("{ts} {message}\n");
235    let _ = std::fs::OpenOptions::new()
236        .create(true)
237        .append(true)
238        .open(&path)
239        .and_then(|mut f| f.write_all(line.as_bytes()));
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn enabled_guard() {
247        crate::test_env::set_var("LEAN_CTX_DEBUG_LOG", "1");
248    }
249    fn disable_guard() {
250        crate::test_env::remove_var("LEAN_CTX_DEBUG_LOG");
251    }
252
253    #[test]
254    fn disabled_by_default_writes_nothing() {
255        let iso = crate::core::data_dir::isolated_data_dir();
256        disable_guard();
257        // Config default is `debug_log = false`, so nothing should be written.
258        log_mcp_call("ctx_read", None, "hello", 5, 0, Duration::from_millis(1));
259        let path = iso.path().join("logs").join("debug.log");
260        assert!(!path.exists(), "debug.log must not exist when disabled");
261    }
262
263    #[test]
264    fn env_enables_and_records_mcp_call() {
265        let _iso = crate::core::data_dir::isolated_data_dir();
266        enabled_guard();
267        let mut args = Map::new();
268        args.insert("path".into(), Value::String("src/main.rs".into()));
269        args.insert("mode".into(), Value::String("full".into()));
270
271        log_mcp_call(
272            "ctx_read",
273            Some(&args),
274            "first line of result",
275            1234,
276            87,
277            Duration::from_millis(12),
278        );
279
280        let content = std::fs::read_to_string(log_path().unwrap()).unwrap();
281        assert!(content.contains("mcp  ctx_read("), "tool name + marker");
282        assert!(content.contains("path=\"src/main.rs\""), "arg summary");
283        assert!(content.contains("mode=\"full\""), "arg summary");
284        assert!(content.contains("saved≈87 tok"), "savings");
285        assert!(content.contains("1234B"), "byte size");
286        disable_guard();
287    }
288
289    #[test]
290    fn redacts_secrets_in_args_and_results() {
291        let _iso = crate::core::data_dir::isolated_data_dir();
292        enabled_guard();
293        let secret = "token=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
294        let mut args = Map::new();
295        args.insert("command".into(), Value::String(secret.into()));
296
297        log_mcp_call("ctx_shell", Some(&args), secret, 10, 0, Duration::ZERO);
298
299        let content = std::fs::read_to_string(log_path().unwrap()).unwrap();
300        assert!(content.contains("[REDACTED"), "secret must be redacted");
301        assert!(
302            !content.contains("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"),
303            "raw secret must not be written"
304        );
305        disable_guard();
306    }
307
308    #[test]
309    fn hook_decision_records_route_and_reason() {
310        let _iso = crate::core::data_dir::isolated_data_dir();
311        enabled_guard();
312
313        log_hook_decision(
314            "rewrite",
315            "Bash",
316            Route::LeanCtx,
317            "cat foo.rs",
318            "rewritable",
319        );
320        log_hook_decision(
321            "redirect",
322            "Read",
323            Route::Native,
324            "/etc/passwd",
325            "sensitive path",
326        );
327
328        let content = std::fs::read_to_string(log_path().unwrap()).unwrap();
329        assert!(content.contains("hook rewrite Bash -> lean-ctx (rewritable): cat foo.rs"));
330        assert!(content.contains("hook redirect Read -> native (sensitive path): /etc/passwd"));
331        disable_guard();
332    }
333
334    #[test]
335    fn clamp_truncates_long_fields_on_char_boundary() {
336        let long = "x".repeat(FIELD_CLAMP + 50);
337        let out = clamp(&long);
338        assert!(out.len() <= FIELD_CLAMP + 4, "clamped near FIELD_CLAMP");
339        assert!(out.ends_with('…'), "clamp marker appended");
340
341        let multiline = "line1\nline2";
342        assert_eq!(clamp(multiline), "line1⏎line2", "newlines collapsed");
343    }
344
345    #[test]
346    fn should_rotate_predicate() {
347        assert!(!should_rotate(0));
348        assert!(!should_rotate(MAX_LOG_BYTES));
349        assert!(should_rotate(MAX_LOG_BYTES + 1));
350    }
351
352    #[test]
353    fn rotates_when_file_exceeds_max() {
354        let _iso = crate::core::data_dir::isolated_data_dir();
355        enabled_guard();
356        let path = log_path().unwrap();
357        // Sparse file of MAX+1 bytes: metadata().len() reports the size without
358        // actually writing 5 MiB to disk.
359        let f = std::fs::File::create(&path).unwrap();
360        f.set_len(MAX_LOG_BYTES + 1).unwrap();
361        drop(f);
362
363        log_mcp_call("ctx_read", None, "after rotation", 14, 0, Duration::ZERO);
364
365        assert!(rotated_path(&path).exists(), "backup debug.log.1 created");
366        let fresh = std::fs::read_to_string(&path).unwrap();
367        assert!(fresh.contains("after rotation"), "new log started");
368        disable_guard();
369    }
370}