forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! `forge-guard notify` — send webhook notifications.

use crate::core::{AuditResult, ProjectConfig, Severity};
use crate::notify::{self, AuditNotification, NotificationKind};

use super::NotifyArgs;
use anyhow::{bail, Result};
use serde_json::json;

/// Send an audit summary to Slack/Discord webhooks.
pub fn run(args: &NotifyArgs) -> Result<()> {
    let config = ProjectConfig::from_default_location();

    // Build the notification from an audit result, or a simple message.
    let notification = if let Some(path) = &args.findings {
        let content = std::fs::read_to_string(path)
            .map_err(|e| anyhow::anyhow!("Could not read {}: {}", path.display(), e))?;
        let result: AuditResult = serde_json::from_str(&content).map_err(|e| {
            anyhow::anyhow!(
                "{} is not a valid forge-guard audit result: {}",
                path.display(),
                e
            )
        })?;
        AuditNotification {
            title: args.title.clone(),
            project: result.project_name,
            chain: result.chain,
            overall_score: Some(result.overall_score),
            risk: Some(result.risk_level.to_string()),
            success: result.deployment_approved,
            findings: result.findings,
        }
    } else {
        AuditNotification {
            title: args.title.clone(),
            project: config.project_root.to_string_lossy().to_string(),
            chain: config.chain.clone(),
            overall_score: None,
            risk: None,
            success: true,
            findings: Vec::new(),
        }
    };

    // Resolve platform and webhook.
    let kind = match &args.kind {
        Some(k) => NotificationKind::parse(k)?,
        None => {
            let probe = args
                .webhook
                .as_deref()
                .or(config.notifications.slack.webhook.as_deref())
                .or(config.notifications.discord.webhook.as_deref());
            match probe {
                Some(url) => NotificationKind::detect(url),
                None => NotificationKind::Slack,
            }
        }
    };

    let webhook = match &args.webhook {
        Some(url) => Some(url.clone()),
        None => notify::resolve_webhook(kind, None, &config),
    };
    let Some(webhook) = webhook else {
        eprintln!(
            "⚠️  No {} webhook configured. Use --webhook <URL> or set [notifications.{}] in forge-guard.toml.",
            kind.as_str(),
            kind.as_str()
        );
        return Ok(());
    };

    // Severity gating: explicit flag > configured minimum.
    let mut min_severity = notify::configured_min_severity(kind, &config);
    if let Some(sev) = &args.severity {
        min_severity = notify::severity_from_str(sev).ok_or_else(|| {
            anyhow::anyhow!(
                "Invalid severity '{}'. Use informational|low|medium|high|critical.",
                sev
            )
        })?;
    }

    if args.on_critical {
        if notification.max_severity() != Severity::Critical {
            eprintln!("🔕 No critical findings — skipping notification");
            return Ok(());
        }
    } else if args.on_high {
        if notification.max_severity() < Severity::High {
            eprintln!("🔕 No high-or-worse findings — skipping notification");
            return Ok(());
        }
    } else if !notify::meets_severity_gate(&notification.findings, min_severity) {
        eprintln!(
            "🔕 Findings below {} threshold — skipping notification",
            min_severity.label()
        );
        return Ok(());
    }

    // Build the platform payload (optionally attaching a free-form message).
    let mut payload = match kind {
        NotificationKind::Slack => notify::build_slack_payload(&notification),
        NotificationKind::Discord => notify::build_discord_payload(&notification),
    };
    if let Some(msg) = &args.message {
        match kind {
            NotificationKind::Slack => payload["blocks"]
                .as_array_mut()
                .expect("slack payload has blocks")
                .push(json!({ "type": "section", "text": { "type": "mrkdwn", "text": msg } })),
            NotificationKind::Discord => {
                payload["content"] = json!(format!("{}\n{}", args.title, msg));
            }
        }
    }

    if args.dry_run {
        println!("{}", serde_json::to_string_pretty(&payload)?);
        return Ok(());
    }

    match notify::send(&webhook, &payload) {
        Ok(()) => {
            eprintln!("📢 {} notification sent", kind.as_str());
            Ok(())
        }
        Err(e) => bail!("Notification failed: {}", e),
    }
}