use serde_json::{Value, json};
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
const DESCRIPTION_SOFT_LIMIT: usize = 1900;
const COLOR_RED: u32 = 0xC0_39_2B; const COLOR_AMBER: u32 = 0xF3_9C_12; const COLOR_GREEN: u32 = 0x27_AE_60;
pub fn build_payload(
summary: &AlertSummary,
findings: &[&FindingReporterRecord],
include_secret: bool,
) -> Value {
let title = if summary.total == 0 {
if summary.unfiltered_total > 0 {
format!(
"Kingfisher: scan complete — 0 of {} finding{} matched this alert's filters",
summary.unfiltered_total,
plural(summary.unfiltered_total)
)
} else {
"Kingfisher: scan complete — no findings".to_string()
}
} else if summary.impacted_resources > 0 {
format!(
"Kingfisher: {} finding{} ({} active, {} inactive, {} unknown, {} impacted resource{})",
summary.total,
plural(summary.total),
summary.active,
summary.inactive,
summary.unknown,
summary.impacted_resources,
plural(summary.impacted_resources)
)
} else {
format!(
"Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)",
summary.total,
plural(summary.total),
summary.active,
summary.inactive,
summary.unknown
)
};
let color = if summary.active > 0 {
COLOR_RED
} else if summary.total > 0 || summary.unfiltered_total > 0 {
COLOR_AMBER
} else {
COLOR_GREEN
};
let mut fields: Vec<Value> = vec![
json!({ "name": "Active", "value": summary.active.to_string(), "inline": true }),
json!({ "name": "Inactive", "value": summary.inactive.to_string(), "inline": true }),
json!({ "name": "Unknown", "value": summary.unknown.to_string(), "inline": true }),
];
if summary.impacted_resources > 0 {
fields.push(json!({
"name": "Impacted resources",
"value": summary.impacted_resources.to_string(),
"inline": true,
}));
}
if let Some(t) = &summary.target {
fields.push(json!({
"name": "Target",
"value": format!("`{}`", escape_for_code_span(&truncate(t, 1000))),
"inline": false,
}));
}
if !summary.by_rule.is_empty() {
let lines: Vec<String> = summary
.by_rule
.iter()
.map(|(rule, count)| format!("• `{}` — {count}", escape_for_code_span(rule)))
.collect();
fields.push(json!({
"name": "Top rules",
"value": truncate(&lines.join("\n"), 1000),
"inline": false,
}));
}
let mut embed = json!({
"title": title,
"color": color,
"fields": fields,
"footer": { "text": format!("kingfisher v{}", summary.kingfisher_version) },
});
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut detail = String::new();
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.push_str(&format!(
"• `{}` at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
escape_for_code_span(&f.rule.id),
escape_for_code_span(&f.finding.path),
f.finding.line,
snippet,
escape_md(&f.finding.validation.status),
escape_for_code_span(&f.finding.fingerprint),
));
}
if findings.len() > take {
detail.push_str(&format!("…{} more findings omitted", findings.len() - take));
}
embed["description"] = Value::String(truncate(&detail, DESCRIPTION_SOFT_LIMIT));
} else if summary.detail == AlertDetail::Summary && summary.total > 0 {
embed["description"] = Value::String(format!(
"_{} findings — per-finding detail suppressed (summary mode). See full report for specifics._",
summary.total
));
}
if let Some(url) = &summary.report_url {
embed["url"] = Value::String(url.clone());
if let Some(fields_arr) = embed["fields"].as_array_mut() {
fields_arr.push(json!({
"name": "Full report",
"value": format!("[Open]({})", url),
"inline": false,
}));
}
}
json!({ "embeds": [embed] })
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
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_for_code_span(s: &str) -> String {
s.replace('`', "\u{02CB}").replace(['\n', '\r'], " ")
}
fn escape_md(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' | '*' | '_' | '~' | '`' | '|' | '>' | '<' | '[' | ']' | '(' | ')' => {
out.push('\\');
out.push(ch);
}
'\n' | '\r' => out.push(' '),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(total: usize, active: usize) -> AlertSummary {
AlertSummary {
total,
active,
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 color_red_when_active() {
let p = build_payload(&summary(3, 1), &[], false);
assert_eq!(p["embeds"][0]["color"], COLOR_RED);
}
#[test]
fn color_amber_when_findings_no_active() {
let p = build_payload(&summary(2, 0), &[], false);
assert_eq!(p["embeds"][0]["color"], COLOR_AMBER);
}
#[test]
fn color_green_when_empty() {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["embeds"][0]["color"], COLOR_GREEN);
assert_eq!(p["embeds"][0]["title"], "Kingfisher: scan complete — no findings");
}
#[test]
fn title_distinguishes_clean_scan_from_filtered_to_empty() {
let mut s = summary(0, 0);
s.unfiltered_total = 5;
let p = build_payload(&s, &[], false);
let title = p["embeds"][0]["title"].as_str().unwrap();
assert!(!title.contains("no findings"), "got: {title}");
assert!(title.contains("matched this alert's filters"), "got: {title}");
assert_eq!(p["embeds"][0]["color"], COLOR_AMBER);
}
#[test]
fn footer_carries_version() {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["embeds"][0]["footer"]["text"], "kingfisher vtest");
}
#[test]
fn empty_findings_has_no_description() {
let p = build_payload(&summary(0, 0), &[], false);
assert!(p["embeds"][0].get("description").is_none());
}
#[test]
fn report_url_renders_as_field_and_embed_url() {
let mut s = summary(0, 0);
s.report_url = Some("https://ci.example/run/9".to_string());
let p = build_payload(&s, &[], false);
assert_eq!(p["embeds"][0]["url"], "https://ci.example/run/9");
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("Full report"));
}
#[test]
fn summary_mode_emits_suppression_notice() {
let mut s = summary(50, 5);
s.detail = crate::alerts::AlertDetail::Summary;
let rec = crate::alerts::make_test_record("betterleaks.aws-access-token", "fp-x");
let p = build_payload(&s, &[&rec], false);
let desc = p["embeds"][0]["description"].as_str().unwrap();
assert!(desc.contains("per-finding detail suppressed"));
assert!(!desc.contains("betterleaks.aws-access-token"));
}
#[test]
fn detail_mode_includes_fingerprint() {
let s = summary(1, 1);
let rec = crate::alerts::make_test_record("betterleaks.aws-access-token", "fp-d-99");
let p = build_payload(&s, &[&rec], false);
let desc = p["embeds"][0]["description"].as_str().unwrap();
assert!(desc.contains("fp:`fp-d-99`"));
}
}