1use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use serde_json::{Map, Value};
25
26const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
29
30const FIELD_CLAMP: usize = 200;
34
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
37pub enum Route {
38 LeanCtx,
40 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#[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#[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
77pub 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
97pub 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
109pub 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#[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#[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
164fn redact(s: &str) -> String {
167 crate::core::redaction::redact_text(s)
168}
169
170fn 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
177fn should_rotate(len: u64) -> bool {
179 len > MAX_LOG_BYTES
180}
181
182fn 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
196fn 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 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 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}