use crate::core::{Finding, ProjectConfig, Severity};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationKind {
Slack,
Discord,
}
impl NotificationKind {
pub fn parse(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"slack" => Ok(Self::Slack),
"discord" => Ok(Self::Discord),
other => bail!(
"Unknown notification platform: '{}'. Use 'slack' or 'discord'.",
other
),
}
}
pub fn detect(url: &str) -> Self {
let lower = url.to_lowercase();
if lower.contains("discord.com/api/webhooks")
|| lower.contains("discordapp.com/api/webhooks")
{
Self::Discord
} else {
Self::Slack
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Slack => "slack",
Self::Discord => "discord",
}
}
}
#[derive(Debug, Clone)]
pub struct AuditNotification {
pub title: String,
pub project: String,
pub chain: String,
pub overall_score: Option<u8>,
pub risk: Option<String>,
pub success: bool,
pub findings: Vec<Finding>,
}
impl AuditNotification {
pub fn max_severity(&self) -> Severity {
max_severity(&self.findings)
}
}
pub fn max_severity(findings: &[Finding]) -> Severity {
findings
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(Severity::Informational)
}
pub fn severity_from_str(s: &str) -> Option<Severity> {
match s.to_lowercase().as_str() {
"critical" | "crit" => Some(Severity::Critical),
"high" => Some(Severity::High),
"medium" | "med" => Some(Severity::Medium),
"low" => Some(Severity::Low),
"informational" | "info" => Some(Severity::Informational),
_ => None,
}
}
pub fn meets_severity_gate(findings: &[Finding], min: Severity) -> bool {
max_severity(findings) >= min
}
fn severity_color(sev: Severity) -> &'static str {
match sev {
Severity::Critical => "#e01e5a",
Severity::High => "#eb4d4b",
Severity::Medium => "#f39c12",
Severity::Low => "#3498db",
Severity::Informational => "#95a5a6",
}
}
fn count_by_severity(findings: &[Finding], sev: Severity) -> usize {
findings.iter().filter(|f| f.severity == sev).count()
}
pub fn build_slack_payload(n: &AuditNotification) -> Value {
let critical = count_by_severity(&n.findings, Severity::Critical);
let high = count_by_severity(&n.findings, Severity::High);
let medium = count_by_severity(&n.findings, Severity::Medium);
let low = count_by_severity(&n.findings, Severity::Low);
let info = count_by_severity(&n.findings, Severity::Informational);
let mut blocks = vec![
json!({
"type": "header",
"text": {
"type": "plain_text",
"text": format!("{} — {}", n.title, if n.success { "✅" } else { "❌" })
}
}),
json!({
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": format!("*Project:*\n{}", n.project) },
{ "type": "mrkdwn", "text": format!("*Chain:*\n{}", n.chain) }
]
}),
];
let mut score_text = String::new();
if let Some(score) = n.overall_score {
score_text.push_str(&format!("*Score:* {}/100\n", score));
}
if let Some(risk) = &n.risk {
score_text.push_str(&format!("*Risk:* {}\n", risk));
}
if !score_text.is_empty() {
blocks.push(json!({
"type": "section",
"text": { "type": "mrkdwn", "text": score_text.trim_end() }
}));
}
blocks.push(json!({
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": format!("🛑 Critical: {}\n🔴 High: {}\n🟡 Medium: {}", critical, high, medium) },
{ "type": "mrkdwn", "text": format!("🔵 Low: {}\n⚪ Info: {}\n*Total:* {}", low, info, n.findings.len()) }
]
}));
let top: Vec<&Finding> = n
.findings
.iter()
.filter(|f| f.severity >= Severity::High)
.take(5)
.collect();
if !top.is_empty() {
let mut text = String::from("*Top findings:*\n");
for f in &top {
let location = match (&f.file, f.line) {
(Some(file), Some(line)) => format!("{}:{}", file, line),
(Some(file), None) => file.clone(),
(None, _) => "unknown".into(),
};
text.push_str(&format!("• [{}] {} ({})\n", f.severity, f.title, location));
}
blocks.push(json!({
"type": "section",
"text": { "type": "mrkdwn", "text": text.trim_end() }
}));
}
json!({ "blocks": blocks })
}
pub fn build_discord_payload(n: &AuditNotification) -> Value {
let max = n.max_severity();
let critical = count_by_severity(&n.findings, Severity::Critical);
let high = count_by_severity(&n.findings, Severity::High);
let medium = count_by_severity(&n.findings, Severity::Medium);
let low = count_by_severity(&n.findings, Severity::Low);
let mut description = format!(
"🛑 Critical: {} | 🔴 High: {} | 🟡 Medium: {} | 🔵 Low: {}\n*Total findings:* {}",
critical,
high,
medium,
low,
n.findings.len()
);
if let Some(score) = n.overall_score {
description.push_str(&format!("\n**Overall score:** {}/100", score));
}
if let Some(risk) = &n.risk {
description.push_str(&format!("\n**Risk level:** {}", risk));
}
let top: Vec<&Finding> = n
.findings
.iter()
.filter(|f| f.severity >= Severity::High)
.take(5)
.collect();
if !top.is_empty() {
let mut list = String::new();
for f in &top {
let location = match (&f.file, f.line) {
(Some(file), Some(line)) => format!("{}:{}", file, line),
(Some(file), None) => file.clone(),
(None, _) => "unknown".into(),
};
list.push_str(&format!(
"`[{}]` **{}** — {}\n",
f.severity, f.title, location
));
}
description.push_str(&format!("\n\n**Top findings:**\n{}", list.trim_end()));
}
json!({
"username": "Forge Guard",
"content": format!("{} {}", n.title, if n.success { "✅" } else { "❌" }),
"embeds": [{
"title": format!("{} — {}", n.project, n.chain),
"description": description,
"color": u32::from_str_radix(severity_color(max).trim_start_matches('#'), 16).unwrap_or(0x95a5a6)
}]
})
}
pub fn send(webhook: &str, payload: &Value) -> Result<()> {
let client = reqwest::blocking::Client::new();
let response = client
.post(webhook)
.json(payload)
.send()
.with_context(|| format!("Failed to reach webhook {}", webhook))?;
if !response.status().is_success() {
let status = response.status();
bail!("Webhook returned HTTP {} for {}", status, webhook);
}
Ok(())
}
pub fn resolve_webhook(
kind: NotificationKind,
cli_webhook: Option<&str>,
config: &ProjectConfig,
) -> Option<String> {
if let Some(url) = cli_webhook {
return Some(url.to_string());
}
let endpoint = match kind {
NotificationKind::Slack => &config.notifications.slack,
NotificationKind::Discord => &config.notifications.discord,
};
endpoint.webhook.clone()
}
pub fn configured_min_severity(kind: NotificationKind, config: &ProjectConfig) -> Severity {
let endpoint = match kind {
NotificationKind::Slack => &config.notifications.slack,
NotificationKind::Discord => &config.notifications.discord,
};
severity_from_str(&endpoint.min_severity).unwrap_or(Severity::High)
}
pub fn notify_from_config(
config: &ProjectConfig,
n: &AuditNotification,
force: bool,
) -> Result<()> {
for kind in [NotificationKind::Slack, NotificationKind::Discord] {
let Some(webhook) = resolve_webhook(kind, None, config) else {
continue;
};
let min = configured_min_severity(kind, config);
if !force && !meets_severity_gate(&n.findings, min) {
eprintln!(
"🔕 {} webhook configured but findings are below the {} threshold — skipping",
kind.as_str(),
min.label()
);
continue;
}
let payload = match kind {
NotificationKind::Slack => build_slack_payload(n),
NotificationKind::Discord => build_discord_payload(n),
};
match send(&webhook, &payload) {
Ok(()) => eprintln!("📢 {} notification sent", kind.as_str()),
Err(e) => eprintln!("⚠️ Could not send {} notification: {}", kind.as_str(), e),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(sev: Severity, title: &str, file: &str, line: usize) -> Finding {
Finding::builder()
.id(&format!("FA-H-001-{}", line))
.title(title)
.description("desc")
.severity(sev)
.file(file)
.location(line, 0)
.recommendation("fix")
.category("Security")
.build()
}
fn notification() -> AuditNotification {
AuditNotification {
title: "Forge Guard Audit".into(),
project: "demo".into(),
chain: "ethereum".into(),
overall_score: Some(62),
risk: Some("HIGH".into()),
success: false,
findings: vec![
finding(Severity::Critical, "Reentrancy", "Vault.sol", 42),
finding(Severity::High, "TX Origin", "Vault.sol", 60),
finding(Severity::Medium, "Unchecked send", "Vault.sol", 80),
],
}
}
#[test]
fn test_kind_detect() {
assert_eq!(
NotificationKind::detect("https://hooks.slack.com/services/T000/B000/xxx"),
NotificationKind::Slack
);
assert_eq!(
NotificationKind::detect("https://discord.com/api/webhooks/123/abc"),
NotificationKind::Discord
);
assert_eq!(
NotificationKind::detect("https://discordapp.com/api/webhooks/123/abc"),
NotificationKind::Discord
);
}
#[test]
fn test_kind_from_str() {
assert_eq!(
NotificationKind::parse("slack").unwrap(),
NotificationKind::Slack
);
assert_eq!(
NotificationKind::parse("Discord").unwrap(),
NotificationKind::Discord
);
assert!(NotificationKind::parse("teams").is_err());
}
#[test]
fn test_severity_gate() {
let n = notification();
assert!(meets_severity_gate(&n.findings, Severity::High));
assert!(meets_severity_gate(&n.findings, Severity::Critical));
assert!(!meets_severity_gate(&[], Severity::High));
assert!(meets_severity_gate(&[], Severity::Informational));
}
#[test]
fn test_severity_from_str() {
assert_eq!(severity_from_str("critical"), Some(Severity::Critical));
assert_eq!(severity_from_str("HIGH"), Some(Severity::High));
assert_eq!(severity_from_str("info"), Some(Severity::Informational));
assert_eq!(severity_from_str("bogus"), None);
}
#[test]
fn test_slack_payload_structure() {
let payload = build_slack_payload(¬ification());
let blocks = payload["blocks"].as_array().unwrap();
assert!(blocks.iter().any(|b| b["type"] == "header"));
assert!(payload.to_string().contains("Forge Guard Audit"));
assert!(payload.to_string().contains("Vault.sol:42"));
}
#[test]
fn test_discord_payload_structure() {
let payload = build_discord_payload(¬ification());
assert_eq!(payload["username"], "Forge Guard");
let embed = &payload["embeds"][0];
assert!(embed["description"]
.as_str()
.unwrap()
.contains("Critical: 1"));
assert!(embed["description"].as_str().unwrap().contains("62/100"));
assert_eq!(embed["color"], u32::from_str_radix("e01e5a", 16).unwrap());
}
#[test]
fn test_discord_payload_clean_run_color() {
let mut n = notification();
n.success = true;
n.findings = vec![finding(Severity::Low, "Naming", "Vault.sol", 1)];
let payload = build_discord_payload(&n);
assert_eq!(
payload["embeds"][0]["color"],
u32::from_str_radix("3498db", 16).unwrap()
);
}
#[test]
fn test_slack_payload_no_findings() {
let n = AuditNotification {
title: "All clear".into(),
project: "demo".into(),
chain: "ethereum".into(),
overall_score: Some(95),
risk: None,
success: true,
findings: vec![],
};
let payload = build_slack_payload(&n);
assert!(payload["blocks"].as_array().unwrap().len() >= 3);
let body = payload.to_string();
assert!(body.contains("Score:") && body.contains("95/100"));
assert!(body.contains("*Total:*"));
}
#[test]
fn test_notify_from_config_no_webhook_is_ok() {
let config = ProjectConfig::default();
assert!(notify_from_config(&config, ¬ification(), false).is_ok());
}
#[test]
fn test_resolve_webhook_cli_overrides_config() {
let mut config = ProjectConfig::default();
config.notifications.slack.webhook = Some("https://hooks.slack.com/services/AAA".into());
assert_eq!(
resolve_webhook(
NotificationKind::Slack,
Some("https://cli.example/webhook"),
&config
),
Some("https://cli.example/webhook".into())
);
assert_eq!(
resolve_webhook(NotificationKind::Slack, None, &config),
Some("https://hooks.slack.com/services/AAA".into())
);
assert_eq!(
resolve_webhook(NotificationKind::Discord, None, &config),
None
);
}
#[test]
fn test_config_roundtrip() {
let toml_str = r#"
[notifications.slack]
webhook = "https://hooks.slack.com/services/T/B/X"
min_severity = "critical"
"#;
let config: ProjectConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.notifications.slack.webhook.as_deref(),
Some("https://hooks.slack.com/services/T/B/X")
);
assert_eq!(config.notifications.slack.min_severity, "critical");
assert_eq!(config.notifications.discord.webhook, None);
assert_eq!(config.notifications.discord.min_severity, "high");
}
}