Skip to main content

export_engine/
markdown.rs

1// Render report as Markdown. Logic moved from peek-cli `render_markdown`.
2
3use crate::snapshot::ProcessSnapshot;
4
5/// Render a process snapshot as a Markdown report (includes capture time and peek version).
6pub 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}