1use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use crate::core::policy::diagnostics;
25use serde_json::{Map, Value};
26
27const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
30
31const FIELD_CLAMP: usize = 200;
35
36#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum Route {
39 LeanCtx,
41 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#[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#[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
82pub 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
115pub 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
133pub 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#[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#[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
206fn redact(s: &str) -> String {
209 crate::core::redaction::redact_text(s)
210}
211
212fn 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
219fn should_rotate(len: u64) -> bool {
221 len > MAX_LOG_BYTES
222}
223
224fn 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
238fn 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 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 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 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}