use serde_json::{Value, json};
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
pub fn build_payload(
summary: &AlertSummary,
findings: &[&FindingReporterRecord],
include_secret: bool,
) -> Value {
let header_text = if summary.total == 0 {
if summary.unfiltered_total > 0 {
format!(
"Kingfisher: scan complete — 0 of {} finding{} matched this alert's filters",
summary.unfiltered_total,
if summary.unfiltered_total == 1 { "" } else { "s" }
)
} else {
"Kingfisher: scan complete — no findings".to_string()
}
} else if summary.impacted_resources > 0 {
format!(
"Kingfisher: {} finding{} ({} active, {} inactive, {} unknown, {} impacted resource{})",
summary.total,
if summary.total == 1 { "" } else { "s" },
summary.active,
summary.inactive,
summary.unknown,
summary.impacted_resources,
if summary.impacted_resources == 1 { "" } else { "s" }
)
} else {
format!(
"Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)",
summary.total,
if summary.total == 1 { "" } else { "s" },
summary.active,
summary.inactive,
summary.unknown
)
};
let mut blocks: Vec<Value> = vec![json!({
"type": "header",
"text": { "type": "plain_text", "text": header_text }
})];
if let Some(target) = &summary.target {
blocks.push(json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!("*Target:* `{}`", escape_for_code_span(target))
}
}));
}
if !summary.by_rule.is_empty() {
let lines: Vec<String> = summary
.by_rule
.iter()
.map(|(rule_id, count)| format!("• `{}` — {}", escape_for_code_span(rule_id), count))
.collect();
blocks.push(json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!("*Top rules*\n{}", lines.join("\n"))
}
}));
}
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut detail_lines: Vec<String> = Vec::with_capacity(take);
for f in findings.iter().take(take) {
let snippet = if include_secret {
escape_for_code_span(&truncate(&f.finding.snippet, 32))
} else {
"redacted".to_string()
};
detail_lines.push(format!(
"• `{}` at `{}:{}` — `{}` (validation: {}) — fp:`{}`",
escape_for_code_span(&f.rule.id),
escape_for_code_span(&f.finding.path),
f.finding.line,
snippet,
escape_mrkdwn(&f.finding.validation.status),
escape_for_code_span(&f.finding.fingerprint),
));
}
if findings.len() > take {
detail_lines.push(format!("_…{} more findings omitted_", findings.len() - take));
}
blocks.push(json!({
"type": "section",
"text": { "type": "mrkdwn", "text": detail_lines.join("\n") }
}));
} else if summary.detail == AlertDetail::Summary && summary.total > 0 {
blocks.push(json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(
"_{} findings — per-finding detail suppressed (summary mode). See full report for specifics._",
summary.total
)
}
}));
}
if let Some(url) = &summary.report_url {
blocks.push(json!({
"type": "actions",
"elements": [{
"type": "button",
"text": { "type": "plain_text", "text": "Full report" },
"url": url,
}]
}));
}
blocks.push(json!({
"type": "context",
"elements": [{
"type": "mrkdwn",
"text": format!("kingfisher v{}", summary.kingfisher_version)
}]
}));
json!({ "text": header_text, "blocks": blocks })
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let prefix: String = s.chars().take(n).collect();
format!("{prefix}…")
}
fn escape_mrkdwn(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
fn escape_for_code_span(s: &str) -> String {
escape_mrkdwn(s).replace('`', "\u{02CB}").replace(['\n', '\r'], " ")
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_summary() -> AlertSummary {
AlertSummary {
total: 0,
active: 0,
inactive: 0,
unknown: 0,
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
impacted_resources: 0,
unfiltered_total: 0,
}
}
#[test]
fn empty_payload_has_no_finding_block() {
let p = build_payload(&empty_summary(), &[], false);
let blocks = p["blocks"].as_array().unwrap();
let header = &blocks[0]["text"]["text"].as_str().unwrap();
assert!(header.contains("no findings"));
}
#[test]
fn header_distinguishes_clean_scan_from_filtered_to_empty() {
let p = build_payload(&empty_summary(), &[], false);
let header = p["blocks"][0]["text"]["text"].as_str().unwrap();
assert!(header.contains("no findings"));
let mut s = empty_summary();
s.unfiltered_total = 5;
let p2 = build_payload(&s, &[], false);
let header2 = p2["blocks"][0]["text"]["text"].as_str().unwrap();
assert!(!header2.contains("no findings"), "got: {header2}");
assert!(header2.contains("matched this alert's filters"), "got: {header2}");
}
#[test]
fn header_pluralization() {
let summary = AlertSummary {
total: 1,
active: 1,
inactive: 0,
unknown: 0,
by_rule: vec![("betterleaks.aws-access-token".into(), 1)],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
impacted_resources: 0,
unfiltered_total: 0,
};
let p = build_payload(&summary, &[], false);
let header = p["blocks"][0]["text"]["text"].as_str().unwrap();
assert!(header.contains("1 finding"));
assert!(!header.contains("findings"), "should be singular");
}
#[test]
fn report_url_renders_link_block() {
let mut s = empty_summary();
s.report_url = Some("https://ci.example/run/42".to_string());
let p = build_payload(&s, &[], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("https://ci.example/run/42"));
assert!(serialized.contains("Full report"));
}
#[test]
fn summary_mode_suppresses_findings_with_notice() {
let mut s = empty_summary();
s.detail = crate::alerts::AlertDetail::Summary;
s.total = 50;
let rec = crate::alerts::make_test_record("betterleaks.aws-access-token", "fp-123");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(
!serialized.contains("betterleaks.aws-access-token"),
"summary mode must not render the per-finding rule id"
);
assert!(serialized.contains("per-finding detail suppressed"));
assert!(serialized.contains("50 findings"));
}
#[test]
fn detail_mode_includes_fingerprint() {
let mut s = empty_summary();
s.total = 1;
let rec = crate::alerts::make_test_record("betterleaks.aws-access-token", "fp-abc-123");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("fp:`fp-abc-123`"), "fingerprint must appear in detail block");
}
}