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