pi/core/platform/
debug_dump.rs1use std::fmt::Write as _;
18use std::io;
19use std::path::{Path, PathBuf};
20
21use regex::Regex;
22
23use crate::core::config::{get_debug_log_path, get_debug_log_path_with};
24
25#[derive(Debug)]
27pub struct DebugDumpInput<'a> {
28 pub timestamp: &'a str,
30 pub width: u16,
32 pub height: u16,
34 pub rendered_lines: &'a [(String, u16)],
36 pub messages: &'a [String],
38}
39
40#[must_use]
45pub fn render_debug_dump(input: &DebugDumpInput<'_>) -> String {
46 let mut out = String::new();
47 out.push_str(input.timestamp);
48 out.push('\n');
49 let _ = writeln!(out, "Terminal: {}x{}", input.width, input.height);
50 let _ = writeln!(out, "Total lines: {}", input.rendered_lines.len());
51 out.push_str("\n=== All rendered lines with visible widths ===\n");
52 for (idx, (line, width)) in input.rendered_lines.iter().enumerate() {
53 let redacted = redact_secrets(line);
54 let _ = writeln!(out, "[{idx}] (w={width}) {redacted}");
55 }
56 out.push_str("\n=== Agent messages (JSONL) ===\n");
57 for message in input.messages {
58 let redacted = redact_secrets(message);
59 out.push_str(&redacted);
60 out.push('\n');
61 }
62 out
63}
64
65#[must_use]
76pub fn redact_secrets(text: &str) -> String {
77 let Ok(patterns) = redaction_patterns() else {
78 return "[REDACTED]".to_owned();
79 };
80 let mut value = text.to_owned();
81 for (regex, template) in patterns {
82 value = regex.replace_all(&value, template).into_owned();
83 }
84 value
85}
86
87pub fn write_debug_dump_atomically(path: &Path, content: &str) -> io::Result<()> {
96 if let Some(parent) = path.parent()
97 && !parent.as_os_str().is_empty()
98 {
99 std::fs::create_dir_all(parent)?;
100 }
101 let temp_path = sibling_temp_path(path);
102 std::fs::write(&temp_path, content)?;
103 std::fs::rename(&temp_path, path).or_else(|_| {
104 let _ = std::fs::remove_file(&temp_path);
107 std::fs::write(path, content)
108 })
109}
110
111pub fn write_debug_dump(input: &DebugDumpInput<'_>) -> io::Result<PathBuf> {
119 let path = get_debug_log_path();
120 let rendered = render_debug_dump(input);
121 write_debug_dump_atomically(&path, &rendered)?;
122 Ok(path)
123}
124
125pub fn write_debug_dump_with(input: &DebugDumpInput<'_>, agent_dir: &Path) -> io::Result<PathBuf> {
131 let path = get_debug_log_path_with(agent_dir);
132 let rendered = render_debug_dump(input);
133 write_debug_dump_atomically(&path, &rendered)?;
134 Ok(path)
135}
136
137fn sibling_temp_path(path: &Path) -> PathBuf {
139 let pid = std::process::id();
140 let mut name = path.file_name().map_or_else(
141 || "pi-debug.log".to_owned(),
142 |n| n.to_string_lossy().into_owned(),
143 );
144 let _ = write!(name, ".tmp.{pid}");
145 path.with_file_name(name)
146}
147
148fn redaction_patterns() -> Result<Vec<(Regex, String)>, regex::Error> {
153 [
154 (
155 r"(?i)(authorization\s*:\s*)(?:bearer|basic|token|digest)?\s*\S+",
156 "$1[REDACTED]",
157 ),
158 (r"(?i)(bearer\s+)\S+", "$1[REDACTED]"),
159 (r"(?i)(x-api-key\s*:\s*)\S+", "$1[REDACTED]"),
160 (r"(sk-)[A-Za-z0-9_-]{20,}", "$1[REDACTED]"),
161 (r#"(?i)("(?:api[_-]?key)"\s*:\s*")[^"]*"#, "$1[REDACTED]\""),
162 ]
163 .into_iter()
164 .map(|(pattern, replacement)| Regex::new(pattern).map(|regex| (regex, replacement.to_owned())))
165 .collect()
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 type TestResult = Result<(), Box<dyn std::error::Error>>;
172
173 fn sample_input<'a>(lines: &'a [(String, u16)], messages: &'a [String]) -> DebugDumpInput<'a> {
174 DebugDumpInput {
175 timestamp: "2026-07-18T00:00:00Z",
176 width: 80,
177 height: 24,
178 rendered_lines: lines,
179 messages,
180 }
181 }
182
183 #[test]
184 fn render_includes_sections_and_counts() {
185 let lines = vec![("hello".to_owned(), 5u16), ("world".to_owned(), 5)];
186 let messages = vec!["{\"role\":\"user\"}".to_owned()];
187 let out = render_debug_dump(&sample_input(&lines, &messages));
188 assert!(out.starts_with("2026-07-18T00:00:00Z\n"));
189 assert!(out.contains("Terminal: 80x24"));
190 assert!(out.contains("Total lines: 2"));
191 assert!(out.contains("=== All rendered lines with visible widths ==="));
192 assert!(out.contains("[0] (w=5) hello"));
193 assert!(out.contains("=== Agent messages (JSONL) ==="));
194 assert!(out.contains("{\"role\":\"user\"}"));
195 }
196
197 #[test]
198 fn redact_masks_authorization_and_bearer() {
199 let secret = format!("tok{}", "a".repeat(30));
202 let header = format!("Authorization: Bearer {secret}");
203 let redacted = redact_secrets(&header);
204 assert!(!redacted.contains(&secret), "got: {redacted}");
205 assert!(redacted.contains("Authorization:"));
206 assert!(redacted.contains("[REDACTED]"));
207 let bare = format!("token bearer {secret} end");
208 let redacted = redact_secrets(&bare);
209 assert!(!redacted.contains(&secret), "got: {redacted}");
210 }
211
212 #[test]
213 fn redact_masks_sk_prefix_key() {
214 let body = format!("key sk-{}", "b".repeat(40));
215 let redacted = redact_secrets(&body);
216 assert!(redacted.contains("sk-"));
217 assert!(redacted.contains("[REDACTED]"));
218 assert!(!redacted.contains(&"b".repeat(40)));
219 }
220
221 #[test]
222 fn redact_masks_json_api_key_field() {
223 let value = format!("value{}", "c".repeat(20));
224 let json = format!("{{\"apiKey\":\"{value}\",\"n\":1}}");
225 let redacted = redact_secrets(&json);
226 assert!(redacted.contains("\"apiKey\":\"[REDACTED]\""));
227 assert!(!redacted.contains(&value));
228 assert!(redacted.contains("\"n\":1"));
230 }
231
232 #[test]
233 fn redact_leaves_plain_text_untouched() {
234 let text = "The quick brown fox jumps over the lazy dog.";
235 assert_eq!(redact_secrets(text), text);
236 }
237
238 #[test]
239 fn atomic_write_creates_file_and_parents() -> TestResult {
240 let dir = tempfile::tempdir()?;
241 let target = dir.path().join("nested").join("pi-debug.log");
242 write_debug_dump_atomically(&target, "body")?;
243 assert_eq!(std::fs::read_to_string(&target)?, "body");
244 let parent = target
246 .parent()
247 .ok_or_else(|| std::io::Error::other("debug dump target has no parent"))?;
248 let entries = std::fs::read_dir(parent)?.count();
249 assert_eq!(entries, 1);
250 Ok(())
251 }
252
253 #[test]
254 fn atomic_write_overwrites_existing() -> TestResult {
255 let dir = tempfile::tempdir()?;
256 let target = dir.path().join("pi-debug.log");
257 std::fs::write(&target, "old")?;
258 write_debug_dump_atomically(&target, "new")?;
259 assert_eq!(std::fs::read_to_string(&target)?, "new");
260 Ok(())
261 }
262
263 #[test]
264 fn write_to_agent_dir_uses_config_path() -> TestResult {
265 let dir = tempfile::tempdir()?;
266 let lines = vec![("hi".to_owned(), 2u16)];
267 let input = sample_input(&lines, &[]);
268 let path = write_debug_dump_with(&input, dir.path())?;
269 assert_eq!(path, dir.path().join("pi-debug.log"));
270 let content = std::fs::read_to_string(&path)?;
271 assert!(content.contains("Terminal: 80x24"));
272 Ok(())
273 }
274}