Skip to main content

agentsec_core/output/
markdown.rs

1//! Markdown renderers.
2//!
3//! Two entry points:
4//!
5//! - [`render`] — full scan report from a [`ScanOutcome`].
6//! - [`render_diff`] — standalone diff section from a
7//!   [`crate::scan::diff::DiffReport`], used by `agentsec diff` and the
8//!   `scan_diff` MCP tool.
9//!
10//! ## `render` output sections (always in this order)
11//!
12//! 1. `# AgentSec scan — <RFC 3339 UTC>` header + total path count.
13//! 2. `## Inventory` table with one row per [`crate::scan::inventory::PathEntry`].
14//! 3. `## Diff vs previous snapshot` — `_no previous snapshot — this is the
15//!    baseline._` on first run, `_no changes._` when the diff is empty,
16//!    otherwise three subsections (`### Added`, `### Modified`,
17//!    `### Removed`).
18//! 4. Footer line with the absolute snapshot path.
19//!
20//! Pure functions: no I/O, no allocation beyond the output string.
21
22use crate::scan::unknown::{UnknownVerdict, UnknownVerdictKind};
23use crate::scan::{ScanOutcome, ScanReport, diff::DiffReport};
24use std::fmt::Write;
25
26/// Render `outcome` as a Markdown report (see module docs §`render` output
27/// sections for the schema).
28pub fn render(outcome: &ScanOutcome) -> String {
29    let mut s = String::new();
30    write_header(&mut s, &outcome.report);
31    write_inventory(&mut s, &outcome.report);
32    write_diff(&mut s, outcome);
33    write_footer(&mut s, outcome);
34    s
35}
36
37fn write_header(s: &mut String, report: &ScanReport) {
38    let _ = writeln!(s, "# AgentSec scan — {}", report.scanned_at.to_rfc3339());
39    let _ = writeln!(s);
40    let _ = writeln!(s, "Total paths inventoried: **{}**", report.paths.len());
41    let _ = writeln!(s);
42}
43
44fn write_inventory(s: &mut String, report: &ScanReport) {
45    let _ = writeln!(s, "## Inventory");
46    let _ = writeln!(s);
47    if report.paths.is_empty() {
48        let _ = writeln!(s, "_no targets present._");
49        let _ = writeln!(s);
50        return;
51    }
52    let _ = writeln!(s, "| category | path | size | sha256 |");
53    let _ = writeln!(s, "|---|---|---|---|");
54    for p in &report.paths {
55        let _ = writeln!(
56            s,
57            "| {} | `{}` | {} | `{}` |",
58            p.category,
59            p.path.display(),
60            p.size,
61            &p.sha256[..16]
62        );
63    }
64    let _ = writeln!(s);
65}
66
67fn write_diff(s: &mut String, outcome: &ScanOutcome) {
68    let Some(diff) = &outcome.diff else {
69        let _ = writeln!(s, "## Diff vs previous snapshot");
70        let _ = writeln!(s);
71        let _ = writeln!(s, "_no previous snapshot — this is the baseline._");
72        let _ = writeln!(s);
73        return;
74    };
75    let _ = writeln!(s, "## Diff vs previous snapshot");
76    let _ = writeln!(s);
77    if diff.is_empty() {
78        let _ = writeln!(s, "_no changes._");
79        let _ = writeln!(s);
80        return;
81    }
82    if !diff.added.is_empty() {
83        let _ = writeln!(s, "### Added ({})", diff.added.len());
84        for e in &diff.added {
85            let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
86        }
87        let _ = writeln!(s);
88    }
89    if !diff.modified.is_empty() {
90        let _ = writeln!(s, "### Modified ({})", diff.modified.len());
91        for c in &diff.modified {
92            let _ = writeln!(
93                s,
94                "- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
95                c.path.display(),
96                c.prev_size,
97                c.curr_size,
98                &c.prev_sha256[..8],
99                &c.curr_sha256[..8]
100            );
101        }
102        let _ = writeln!(s);
103    }
104    if !diff.removed.is_empty() {
105        let _ = writeln!(s, "### Removed ({})", diff.removed.len());
106        for e in &diff.removed {
107            let _ = writeln!(s, "- `{}`", e.path.display());
108        }
109        let _ = writeln!(s);
110    }
111}
112
113/// Render a standalone diff report as Markdown.
114///
115/// Schema:
116///
117/// - `# AgentSec diff vs <baseline-path-or-"latest snapshot">` — header.
118/// - If the diff is empty, a single `_no changes._` line and nothing else.
119/// - Otherwise the three `### Added` / `### Modified` / `### Removed`
120///   subsections, each omitted when its vector is empty.
121///
122/// Pass `baseline_hint` as `Some("/path/to/snapshot")` to put the
123/// snapshot path in the header, or `None` for the generic phrasing.
124pub fn render_diff(diff: &DiffReport, baseline_hint: Option<&str>) -> String {
125    let mut s = String::new();
126    let header = match baseline_hint {
127        Some(p) => format!("# AgentSec diff vs `{p}`\n\n"),
128        None => "# AgentSec diff vs latest snapshot\n\n".to_string(),
129    };
130    s.push_str(&header);
131    if diff.is_empty() {
132        let _ = writeln!(s, "_no changes._");
133        return s;
134    }
135    if !diff.added.is_empty() {
136        let _ = writeln!(s, "### Added ({})", diff.added.len());
137        for e in &diff.added {
138            let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
139        }
140        let _ = writeln!(s);
141    }
142    if !diff.modified.is_empty() {
143        let _ = writeln!(s, "### Modified ({})", diff.modified.len());
144        for c in &diff.modified {
145            let _ = writeln!(
146                s,
147                "- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
148                c.path.display(),
149                c.prev_size,
150                c.curr_size,
151                &c.prev_sha256[..8],
152                &c.curr_sha256[..8]
153            );
154        }
155        let _ = writeln!(s);
156    }
157    if !diff.removed.is_empty() {
158        let _ = writeln!(s, "### Removed ({})", diff.removed.len());
159        for e in &diff.removed {
160            let _ = writeln!(s, "- `{}`", e.path.display());
161        }
162        let _ = writeln!(s);
163    }
164    s
165}
166
167/// Render BlackList classify verdicts as Markdown.
168///
169/// Empty input ⇒ a single `_no MCP servers found in .mcp.json / .claude.json._`
170/// notice. Otherwise three subsections (`### Known-good`, `### Typosquat
171/// candidates`, `### Unknown`) in that order. Each subsection is omitted
172/// when its bucket is empty.
173pub fn render_blacklist(verdicts: &[UnknownVerdict], registry_size: usize) -> String {
174    let mut s = String::new();
175    let _ = writeln!(s, "# AgentSec BlackList check");
176    let _ = writeln!(s);
177    let _ = writeln!(s, "Registry: {registry_size} known-good entries.");
178    let _ = writeln!(s);
179    if verdicts.is_empty() {
180        let _ = writeln!(s, "_no MCP servers found in .mcp.json / .claude.json._");
181        return s;
182    }
183    let mut good = Vec::new();
184    let mut typo = Vec::new();
185    let mut unk = Vec::new();
186    for v in verdicts {
187        match &v.verdict {
188            UnknownVerdictKind::KnownGood => good.push(v),
189            UnknownVerdictKind::Typosquat { .. } => typo.push(v),
190            UnknownVerdictKind::Unknown => unk.push(v),
191        }
192    }
193    if !good.is_empty() {
194        let _ = writeln!(s, "### Known-good ({})", good.len());
195        for v in good {
196            let _ = writeln!(s, "- `{}` — {} (from `{}`)", v.name, v.reason, v.path);
197        }
198        let _ = writeln!(s);
199    }
200    if !typo.is_empty() {
201        let _ = writeln!(s, "### Typosquat candidates ({})", typo.len());
202        for v in typo {
203            let _ = writeln!(s, "- `{}` — {} (from `{}`)", v.name, v.reason, v.path);
204        }
205        let _ = writeln!(s);
206    }
207    if !unk.is_empty() {
208        let _ = writeln!(s, "### Unknown ({})", unk.len());
209        for v in unk {
210            let _ = writeln!(s, "- `{}` — {} (from `{}`)", v.name, v.reason, v.path);
211        }
212        let _ = writeln!(s);
213    }
214    s
215}
216
217/// Short one-line diff summary for hook stderr output.
218///
219/// Format: `N added, M modified, K removed` (omits zero-count terms).
220/// Returns `"no changes"` for an empty diff.
221///
222/// # Examples
223///
224/// ```
225/// use agentsec_core::output::markdown::diff_summary_line;
226/// use agentsec_core::scan::diff::DiffReport;
227///
228/// let empty = DiffReport { added: vec![], modified: vec![], removed: vec![] };
229/// assert_eq!(diff_summary_line(&empty), "no changes");
230/// ```
231pub fn diff_summary_line(diff: &DiffReport) -> String {
232    if diff.is_empty() {
233        return "no changes".to_string();
234    }
235    let mut parts = Vec::new();
236    if !diff.added.is_empty() {
237        parts.push(format!("{} added", diff.added.len()));
238    }
239    if !diff.modified.is_empty() {
240        parts.push(format!("{} modified", diff.modified.len()));
241    }
242    if !diff.removed.is_empty() {
243        parts.push(format!("{} removed", diff.removed.len()));
244    }
245    parts.join(", ")
246}
247
248fn write_footer(s: &mut String, outcome: &ScanOutcome) {
249    let _ = writeln!(
250        s,
251        "_snapshot written to: `{}`_",
252        outcome.snapshot_path.display()
253    );
254}