1use crate::snapshot::ProcessSnapshot;
4
5pub fn render_markdown(snapshot: &ProcessSnapshot) -> String {
7 let info = &snapshot.process;
8 let mut out = String::new();
9 out.push_str(&format!(
10 "# peek report — {} (PID {})\n\n",
11 info.name, info.pid
12 ));
13 out.push_str(&format!(
14 "> Generated {} (peek {})\n\n",
15 snapshot.captured_at.format("%Y-%m-%d %H:%M:%S UTC"),
16 snapshot.peek_version
17 ));
18
19 out.push_str("## Process\n\n| Field | Value |\n|---|---|\n");
20 out.push_str(&format!("| Name | `{}` |\n", info.name));
21 out.push_str(&format!("| PID | {} |\n", info.pid));
22 out.push_str(&format!("| PPID | {} |\n", info.ppid));
23 if let Some(exe) = &info.exe {
24 out.push_str(&format!("| Exe | `{}` |\n", exe));
25 }
26 out.push_str(&format!("| Command | `{}` |\n", info.cmdline));
27 out.push_str(&format!("| State | {} |\n", info.state));
28 out.push_str(&format!("| UID:GID | {}:{} |\n", info.uid, info.gid));
29 if let Some(s) = info.started_at {
30 out.push_str(&format!("| Started | {} |\n", s));
31 }
32 out.push_str(&format!("| Threads | {} |\n", info.threads));
33 out.push_str(&format!("| RSS KB | {} |\n", info.rss_kb));
34 if let Some(p) = info.pss_kb {
35 out.push_str(&format!("| PSS KB | {} |\n", p));
36 }
37 if let Some(s) = info.swap_kb {
38 out.push_str(&format!("| Swap KB | {} |\n", s));
39 }
40 out.push_str(&format!("| VSZ KB | {} |\n", info.vm_size_kb));
41
42 if let Some(cpu) = info.cpu_percent {
43 out.push_str("\n## Resources\n\n| Field | Value |\n|---|---|\n");
44 out.push_str(&format!("| CPU % | {:.1} |\n", cpu));
45 out.push_str(&format!("| RSS KB | {} |\n", info.rss_kb));
46 if let Some(p) = info.pss_kb {
47 out.push_str(&format!("| PSS KB | {} |\n", p));
48 }
49 if let Some(s) = info.swap_kb {
50 out.push_str(&format!("| Swap KB | {} |\n", s));
51 }
52 if let Some(r) = info.io_read_bytes {
53 out.push_str(&format!("| Disk read | {} B |\n", r));
54 }
55 if let Some(w) = info.io_write_bytes {
56 out.push_str(&format!("| Disk write | {} B |\n", w));
57 }
58 if let Some(f) = info.fd_count {
59 out.push_str(&format!("| Open FDs | {} |\n", f));
60 }
61 }
62
63 if let Some(k) = &info.kernel {
64 out.push_str("\n## Kernel\n\n| Field | Value |\n|---|---|\n");
65 out.push_str(&format!("| Scheduler | {} |\n", k.sched_policy));
66 out.push_str(&format!(
67 "| Nice / Priority | {} / {} |\n",
68 k.nice, k.priority
69 ));
70 out.push_str(&format!("| OOM Score | {} / 1000 |\n", k.oom_score));
71 out.push_str(&format!("| OOM Adj | {} |\n", k.oom_score_adj));
72 out.push_str(&format!("| Cgroup | `{}` |\n", k.cgroup));
73 out.push_str(&format!("| Seccomp | {} |\n", k.seccomp));
74 out.push_str(&format!("| Cap Permitted | {} |\n", k.cap_permitted));
75 out.push_str(&format!("| Cap Effective | {} |\n", k.cap_effective));
76 }
77
78 if let Some(net) = &info.network {
79 out.push_str("\n## Network\n\n");
80 if let (Some(rx), Some(tx)) = (net.traffic_rx_bytes_per_sec, net.traffic_tx_bytes_per_sec) {
81 out.push_str(&format!(
82 "**Traffic:** RX {} / s, TX {} / s\n\n",
83 format_bytes_per_sec_md(rx),
84 format_bytes_per_sec_md(tx)
85 ));
86 }
87 if !net.listening_tcp.is_empty() {
88 out.push_str("**Listening (TCP):**\n\n");
89 for s in &net.listening_tcp {
90 out.push_str(&format!(
91 "- `{}` {}:{}\n",
92 s.protocol, s.local_addr, s.local_port
93 ));
94 }
95 }
96 if !net.listening_udp.is_empty() {
97 out.push_str("**Listening (UDP):**\n\n");
98 for s in &net.listening_udp {
99 out.push_str(&format!(
100 "- `{}` {}:{}\n",
101 s.protocol, s.local_addr, s.local_port
102 ));
103 }
104 }
105 if let Some(unix) = &net.unix_sockets {
106 if !unix.is_empty() {
107 out.push_str("**Unix sockets:**\n\n");
108 for u in unix.iter().take(20) {
109 let path = if u.path.is_empty() {
110 "<anonymous>"
111 } else {
112 u.path.as_str()
113 };
114 out.push_str(&format!("- `{}`\n", path));
115 }
116 }
117 }
118 if !net.connections.is_empty() {
119 out.push_str(&format!(
120 "\n**Connections ({}):**\n\n",
121 net.connections.len()
122 ));
123 out.push_str("| Proto | Local | Remote | State |\n|---|---|---|---|\n");
124 for c in net.connections.iter().take(30) {
125 out.push_str(&format!(
126 "| {} | {}:{} | {}:{} | {} |\n",
127 c.protocol, c.local_addr, c.local_port, c.remote_addr, c.remote_port, c.state
128 ));
129 }
130 }
131 }
132
133 if let Some(files) = &info.open_files {
134 out.push_str(&format!("\n## Open Files ({} total)\n\n", files.len()));
135 out.push_str("| FD | Type | Path |\n|---|---|---|\n");
136 for f in files.iter().take(50) {
137 out.push_str(&format!(
138 "| {} | {} | `{}` |\n",
139 f.fd, f.fd_type, f.description
140 ));
141 }
142 }
143
144 if let Some(env_vars) = &info.env_vars {
145 let secrets = env_vars.iter().filter(|v| v.redacted).count();
146 out.push_str(&format!(
147 "\n## Environment ({} vars, {} redacted)\n\n",
148 env_vars.len(),
149 secrets
150 ));
151 out.push_str("| Key | Value |\n|---|---|\n");
152 for v in env_vars {
153 out.push_str(&format!("| `{}` | {} |\n", v.key, v.value));
154 }
155 }
156
157 if let Some(gpus) = &info.gpu {
158 if !gpus.is_empty() {
159 out.push_str(
160 "\n## GPU\n\n| Index | Name | Util% | Mem Used | Mem Total | Process (MB) |\n|---|---|---|---|---|---|\n",
161 );
162 for g in gpus {
163 let process_mb = g
164 .process_used_mb
165 .map(|p| format!("{:.0}", p))
166 .unwrap_or_else(|| "-".to_string());
167 out.push_str(&format!(
168 "| {} | {} | {:.1} | {:.0} MB | {:.0} MB | {} |\n",
169 g.index,
170 g.name,
171 g.utilization_percent.unwrap_or(0.0),
172 g.memory_used_mb.unwrap_or(0.0),
173 g.memory_total_mb.unwrap_or(0.0),
174 process_mb,
175 ));
176 }
177 }
178 }
179
180 if let Some(tree) = &info.process_tree {
181 out.push_str("\n## Process Tree\n\n```text\n");
182 append_process_tree_md(&mut out, tree, "", true, info.pid);
183 out.push_str("```\n");
184 }
185
186 out
187}
188
189fn append_process_tree_md(
190 out: &mut String,
191 node: &peek_core::ProcessNode,
192 prefix: &str,
193 is_last: bool,
194 target: i32,
195) {
196 let conn = if is_last { "└── " } else { "├── " };
197 let name = if node.pid == target {
198 format!("{} (this)", node.name)
199 } else {
200 node.name.clone()
201 };
202 out.push_str(&format!(
203 " {}{}{} ({}) [{} MB]\n",
204 prefix,
205 conn,
206 name,
207 node.pid,
208 node.rss_kb / 1024
209 ));
210 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
211 for (i, child) in node.children.iter().enumerate() {
212 append_process_tree_md(
213 out,
214 child,
215 &child_prefix,
216 i == node.children.len() - 1,
217 target,
218 );
219 }
220}
221
222fn format_bytes_per_sec_md(b: u64) -> String {
223 if b >= 1_000_000 {
224 format!("{:.1} MB/s", b as f64 / 1_000_000.0)
225 } else if b >= 1000 {
226 format!("{:.1} KB/s", b as f64 / 1000.0)
227 } else {
228 format!("{} B/s", b)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::render_markdown;
235 use crate::snapshot::ProcessSnapshot;
236 use chrono::{TimeZone, Utc};
237 use peek_core::ProcessInfo;
238
239 #[test]
240 fn markdown_includes_basic_sections() {
241 let info = ProcessInfo {
242 pid: 1234,
243 name: "test-proc".to_string(),
244 cmdline: "test-proc --flag".to_string(),
245 exe: Some("/usr/bin/test-proc".to_string()),
246 state: "Running".to_string(),
247 ppid: 1,
248 uid: 0,
249 gid: 0,
250 started_at: None,
251 threads: 1,
252 vm_size_kb: 0,
253 rss_kb: 0,
254 pss_kb: None,
255 swap_kb: None,
256 cpu_percent: None,
257 io_read_bytes: None,
258 io_write_bytes: None,
259 fd_count: None,
260 kernel: None,
261 network: None,
262 open_files: None,
263 env_vars: None,
264 process_tree: None,
265 gpu: None,
266 };
267 let snapshot = ProcessSnapshot {
268 captured_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
269 peek_version: "test-version".to_string(),
270 process: info,
271 };
272
273 let md = render_markdown(&snapshot);
274 assert!(md.contains("## Process"));
275 assert!(md.contains("peek report"));
276 }
277}