use crate::diagnostics::{
DoctorReport, DoctorStatus, EnvSource, InfoReport, RecentActivityReport, StatusReport,
};
use crate::platform::PlatformProbe;
use crate::scan::unknown::{UnknownVerdict, UnknownVerdictKind};
use crate::scan::{ScanOutcome, ScanReport, diff::DiffReport};
use std::collections::HashSet;
use std::fmt::Write;
pub fn render(outcome: &ScanOutcome) -> String {
let mut s = String::new();
write_header(&mut s, &outcome.report);
write_inventory(&mut s, &outcome.report);
write_diff(&mut s, outcome);
write_footer(&mut s, outcome);
s
}
fn write_header(s: &mut String, report: &ScanReport) {
let _ = writeln!(s, "# AgentSec scan — {}", report.scanned_at.to_rfc3339());
let _ = writeln!(s);
let _ = writeln!(s, "Total paths inventoried: **{}**", report.paths.len());
let _ = writeln!(s);
}
fn write_inventory(s: &mut String, report: &ScanReport) {
let _ = writeln!(s, "## Inventory");
let _ = writeln!(s);
if report.paths.is_empty() {
let _ = writeln!(s, "_no targets present._");
let _ = writeln!(s);
return;
}
let _ = writeln!(s, "| category | path | size | sha256 |");
let _ = writeln!(s, "|---|---|---|---|");
for p in &report.paths {
let _ = writeln!(
s,
"| {} | `{}` | {} | `{}` |",
p.category,
p.path.display(),
p.size,
&p.sha256[..16]
);
}
let _ = writeln!(s);
}
fn write_diff(s: &mut String, outcome: &ScanOutcome) {
let Some(diff) = &outcome.diff else {
let _ = writeln!(s, "## Diff vs previous snapshot");
let _ = writeln!(s);
let _ = writeln!(s, "_no previous snapshot — this is the baseline._");
let _ = writeln!(s);
return;
};
let _ = writeln!(s, "## Diff vs previous snapshot");
let _ = writeln!(s);
if diff.is_empty() {
let _ = writeln!(s, "_no changes._");
let _ = writeln!(s);
return;
}
if !diff.added.is_empty() {
let _ = writeln!(s, "### Added ({})", diff.added.len());
for e in &diff.added {
let _ = writeln!(s, "- `{}` ({} bytes)", e.path.display(), e.size);
}
let _ = writeln!(s);
}
if !diff.modified.is_empty() {
let _ = writeln!(s, "### Modified ({})", diff.modified.len());
for c in &diff.modified {
let _ = writeln!(
s,
"- `{}` ({} → {} bytes, sha256 `{}` → `{}`)",
c.path.display(),
c.prev_size,
c.curr_size,
&c.prev_sha256[..8],
&c.curr_sha256[..8]
);
}
let _ = writeln!(s);
}
if !diff.removed.is_empty() {
let _ = writeln!(s, "### Removed ({})", diff.removed.len());
for e in &diff.removed {
let _ = writeln!(s, "- `{}`", e.path.display());
}
let _ = writeln!(s);
}
}
fn critical_set<'a>(probes: &'a [&dyn PlatformProbe]) -> HashSet<&'a str> {
let mut set = HashSet::new();
for probe in probes {
for cat in probe.critical_categories() {
set.insert(*cat);
}
}
set
}
pub fn render_diff(
diff: &DiffReport,
baseline_hint: Option<&str>,
probes: &[&dyn PlatformProbe],
) -> String {
let crit = critical_set(probes);
let is_crit_cat = |c: &str| crit.contains(c);
let mut s = String::new();
let header = match baseline_hint {
Some(p) => format!("# AgentSec diff vs `{p}`\n\n"),
None => "# AgentSec diff vs latest snapshot\n\n".to_string(),
};
s.push_str(&header);
if diff.is_empty() {
let _ = writeln!(s, "_no changes._");
return s;
}
if !diff.added.is_empty() {
let _ = writeln!(s, "### Added ({})", diff.added.len());
let mut sorted: Vec<_> = diff.added.iter().collect();
sorted.sort_by(|a, b| {
let a_crit = is_crit_cat(&a.category);
let b_crit = is_crit_cat(&b.category);
b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
});
for e in sorted {
let prefix = if is_crit_cat(&e.category) {
"🚨 **CRITICAL** "
} else {
""
};
let _ = writeln!(s, "- {prefix}`{}` ({} bytes)", e.path.display(), e.size);
}
let _ = writeln!(s);
}
if !diff.modified.is_empty() {
let _ = writeln!(s, "### Modified ({})", diff.modified.len());
let mut sorted_modified: Vec<_> = diff.modified.iter().collect();
sorted_modified.sort_by(|a, b| {
let a_crit = is_crit_cat(&a.category);
let b_crit = is_crit_cat(&b.category);
b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
});
for c in sorted_modified {
let prefix = if is_crit_cat(&c.category) {
"🚨 **CRITICAL** "
} else {
""
};
let _ = writeln!(
s,
"- {prefix}`{}` ({} → {} bytes, sha256 `{}` → `{}`)",
c.path.display(),
c.prev_size,
c.curr_size,
&c.prev_sha256[..8],
&c.curr_sha256[..8]
);
}
let _ = writeln!(s);
}
if !diff.removed.is_empty() {
let _ = writeln!(s, "### Removed ({})", diff.removed.len());
let mut sorted: Vec<_> = diff.removed.iter().collect();
sorted.sort_by(|a, b| {
let a_crit = is_crit_cat(&a.category);
let b_crit = is_crit_cat(&b.category);
b_crit.cmp(&a_crit).then_with(|| a.path.cmp(&b.path))
});
for e in sorted {
let prefix = if is_crit_cat(&e.category) {
"🚨 **CRITICAL** "
} else {
""
};
let _ = writeln!(s, "- {prefix}`{}`", e.path.display());
}
let _ = writeln!(s);
}
s
}
pub fn render_blacklist(verdicts: &[UnknownVerdict], registry_size: usize) -> String {
let mut s = String::new();
let _ = writeln!(s, "# AgentSec BlackList check");
let _ = writeln!(s);
let _ = writeln!(s, "Registry: {registry_size} known-good entries.");
let _ = writeln!(s);
if verdicts.is_empty() {
let _ = writeln!(s, "_no MCP servers found in any platform config._");
return s;
}
let mut good = Vec::new();
let mut likely = Vec::new();
let mut informational = Vec::new();
let mut unk = Vec::new();
for v in verdicts {
match &v.verdict {
UnknownVerdictKind::KnownGood => good.push(v),
UnknownVerdictKind::LikelyTyposquat { .. } => likely.push(v),
UnknownVerdictKind::InformationalTyposquat { .. } => informational.push(v),
UnknownVerdictKind::Unknown => unk.push(v),
}
}
if !good.is_empty() {
let _ = writeln!(s, "### Known-good ({})", good.len());
for v in good {
let paths_line = v.paths.join(", ");
let _ = writeln!(
s,
"- `{}` ×{} — {} (from `{}`)",
v.name, v.count, v.reason, paths_line
);
}
let _ = writeln!(s);
}
if !likely.is_empty() {
let _ = writeln!(s, "### Likely typosquat (warn) ({})", likely.len());
for v in likely {
let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
for p in &v.paths {
let _ = writeln!(s, " - `{p}`");
}
}
let _ = writeln!(s);
}
if !informational.is_empty() {
let _ = writeln!(
s,
"### Informational typosquat (info) ({})",
informational.len()
);
for v in informational {
let _ = writeln!(s, "- `{}` ×{} — {}", v.name, v.count, v.reason);
for p in &v.paths {
let _ = writeln!(s, " - `{p}`");
}
}
let _ = writeln!(s);
}
if !unk.is_empty() {
let _ = writeln!(s, "### Unknown ({})", unk.len());
for v in unk {
let paths_line = v.paths.join(", ");
let _ = writeln!(s, "- `{}` ×{} (from `{}`)", v.name, v.count, paths_line);
}
let _ = writeln!(s);
}
s
}
pub fn render_info(report: &InfoReport) -> String {
let mut s = String::new();
let _ = writeln!(s, "# AgentSec — info");
let _ = writeln!(s);
let _ = writeln!(s, "## Version");
let _ = writeln!(s);
let _ = writeln!(s, "- agentsec-core: `{}`", report.version);
let _ = writeln!(s);
let _ = writeln!(s, "## Paths");
let _ = writeln!(s);
let p = &report.paths;
let _ = writeln!(s, "- home: `{}`", p.home.display());
let _ = writeln!(s, "- user_home: `{}`", p.user_home.display());
let _ = writeln!(s, "- snapshots: `{}`", p.snapshots.display());
let _ = writeln!(s, "- scans: `{}`", p.scans.display());
let _ = writeln!(s, "- web_log: `{}`", p.web_log.display());
let _ = writeln!(s, "- paste_log: `{}`", p.paste_log.display());
let _ = writeln!(s);
let _ = writeln!(s, "## Environment");
let _ = writeln!(s);
let _ = writeln!(s, "| Key | Source | Value |");
let _ = writeln!(s, "|---|---|---|");
for ev in &report.env {
let source = render_env_source(&ev.source);
let value = ev.value_display.clone().unwrap_or_else(|| "—".into());
let _ = writeln!(s, "| `{}` | {source} | `{value}` |", ev.key);
}
s
}
fn render_env_source(src: &EnvSource) -> String {
match src {
EnvSource::Process => "process env".into(),
EnvSource::DotenvFile { path, line } => {
format!("dotenv: `{}:{}`", path.display(), line)
}
EnvSource::DotenvFileShadowed { path, line } => {
format!(
"dotenv `{}:{}` (shadowed by process env)",
path.display(),
line
)
}
EnvSource::Default(_) => "builtin default".into(),
EnvSource::Unset => "unset".into(),
}
}
pub fn render_status(report: &StatusReport) -> String {
let mut s = String::new();
let _ = writeln!(s, "# AgentSec — status");
let _ = writeln!(s);
let _ = writeln!(s, "| Metric | Value |");
let _ = writeln!(s, "|---|---|");
let _ = writeln!(s, "| snapshots | {} |", report.snapshots_count);
let _ = writeln!(
s,
"| latest snapshot | {} |",
report.latest_snapshot.as_deref().unwrap_or("—")
);
let _ = writeln!(s, "| paste_log entries | {} |", report.paste_log_count);
let _ = writeln!(s, "| web_log entries | {} |", report.web_log_count);
let _ = writeln!(
s,
"| plain mode active | {} |",
if report.plain_mode_active {
"yes"
} else {
"no"
}
);
let _ = writeln!(s, "| plain mode entries | {} |", report.plain_mode_entries);
let _ = writeln!(s, "| registry entries | {} |", report.registry_entries);
if !report.storage.is_empty() {
let _ = writeln!(s);
let _ = writeln!(s, "## Storage");
let _ = writeln!(s);
let _ = writeln!(s, "| Kind | Path | Count | Size | Latest |");
let _ = writeln!(s, "|---|---|---|---|---|");
for item in &report.storage {
let _ = writeln!(
s,
"| {} | `{}` | {} | {} | {} |",
item.kind,
item.path.display(),
item.count,
crate::diagnostics::format_size(item.size_bytes),
item.latest.as_deref().unwrap_or("—"),
);
}
}
s
}
pub fn render_recent(report: &RecentActivityReport) -> String {
let mut s = String::new();
let _ = writeln!(s, "# AgentSec — recent activity");
let _ = writeln!(s);
write_audit_section(&mut s, "paste_log", &report.paste);
write_audit_section(&mut s, "web_log", &report.web);
s
}
fn write_audit_section(s: &mut String, label: &str, rows: &[crate::diagnostics::AuditTail]) {
let _ = writeln!(s, "## {label} ({} rows)", rows.len());
let _ = writeln!(s);
if rows.is_empty() {
let _ = writeln!(s, "_empty._");
let _ = writeln!(s);
return;
}
for row in rows {
let _ = writeln!(s, "### `{}` ({} bytes)", row.filename, row.size);
let _ = writeln!(s);
let _ = writeln!(s, "```");
let _ = writeln!(s, "{}", row.excerpt);
let _ = writeln!(s, "```");
let _ = writeln!(s);
}
}
pub fn render_doctor(report: &DoctorReport) -> String {
let mut s = String::new();
let _ = writeln!(s, "# AgentSec — doctor");
let _ = writeln!(s);
let mut pass = 0usize;
let mut warn = 0usize;
let mut fail = 0usize;
for c in &report.checks {
match c.status {
DoctorStatus::Pass => pass += 1,
DoctorStatus::Warn => warn += 1,
DoctorStatus::Fail => fail += 1,
}
}
let _ = writeln!(
s,
"Summary: **{pass} PASS / {warn} WARN / {fail} FAIL** ({} checks)",
report.checks.len()
);
let _ = writeln!(s);
let _ = writeln!(s, "| Status | Check | Detail |");
let _ = writeln!(s, "|---|---|---|");
for c in &report.checks {
let badge = match c.status {
DoctorStatus::Pass => "✅ PASS",
DoctorStatus::Warn => "⚠️ WARN",
DoctorStatus::Fail => "❌ FAIL",
};
let detail = c.message.replace('\n', " ").replace('|', "\\|");
let _ = writeln!(s, "| {badge} | {} | {detail} |", c.name);
}
s
}
pub fn diff_summary_line(diff: &DiffReport) -> String {
if diff.is_empty() {
return "no changes".to_string();
}
let mut parts = Vec::new();
if !diff.added.is_empty() {
parts.push(format!("{} added", diff.added.len()));
}
if !diff.modified.is_empty() {
parts.push(format!("{} modified", diff.modified.len()));
}
if !diff.removed.is_empty() {
parts.push(format!("{} removed", diff.removed.len()));
}
parts.join(", ")
}
fn write_footer(s: &mut String, outcome: &ScanOutcome) {
let _ = writeln!(
s,
"_snapshot written to: `{}`_",
outcome.snapshot_path.display()
);
}