capsula-notify-slack 0.9.2

A Capsula hook that sends notifications to Slack.
Documentation
mod error;
use crate::error::SlackNotifyError;
use capsula_core::captured::Captured;
use capsula_core::error::CapsulaResult;
use capsula_core::hook::{Hook, PostRun, PreRun, RuntimeParams};
use capsula_core::run::PreparedRun;
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SlackNotifyHookConfig {
    channel: String,
    #[serde(default = "token_from_env")]
    token: String,
}

fn token_from_env() -> String {
    std::env::var("SLACK_BOT_TOKEN").unwrap_or_default()
}

#[derive(Debug)]
pub struct SlackNotifyHook {
    config: SlackNotifyHookConfig,
}

#[derive(Debug, Serialize)]
pub struct SlackNotifyCaptured {
    message: String,
    response: Option<String>,
}

impl Captured for SlackNotifyCaptured {
    fn serialize_json(&self) -> Result<serde_json::Value, serde_json::Error> {
        serde_json::to_value(self)
    }
}

impl Hook<PreRun> for SlackNotifyHook {
    const ID: &'static str = "notify-slack";

    type Config = SlackNotifyHookConfig;
    type Output = SlackNotifyCaptured;

    fn from_config(
        config: &serde_json::Value,
        _project_root: &std::path::Path,
    ) -> CapsulaResult<Self> {
        let config = serde_json::from_value::<SlackNotifyHookConfig>(config.clone())?;
        if config.token.is_empty() {
            return Err(SlackNotifyError::MissingToken.into());
        }
        Ok(Self { config })
    }

    fn config(&self) -> &Self::Config {
        &self.config
    }

    fn run(
        &self,
        metadata: &PreparedRun,
        _params: &RuntimeParams<PreRun>,
    ) -> CapsulaResult<Self::Output> {
        let client = reqwest::blocking::Client::new();
        let payload = json!({
            "channel": self.config.channel,
            "text": format!("Run `{}` (ID: `{}`) is starting.", metadata.name, metadata.id),
        });

        let res = client
            .post("https://slack.com/api/chat.postMessage")
            .bearer_auth(&self.config.token)
            .json(&payload)
            .send()
            .map_err(SlackNotifyError::from)?;

        if !res.status().is_success() {
            return Err(SlackNotifyError::SlackApi {
                message: format!("Failed to send Slack notification: {}", res.status()),
            }
            .into());
        }
        let res_json = res.text().ok();

        Ok(SlackNotifyCaptured {
            message: "Slack notification sent successfully".to_string(),
            response: res_json,
        })
    }
}

impl Hook<PostRun> for SlackNotifyHook {
    const ID: &'static str = "notify-slack";

    type Config = SlackNotifyHookConfig;
    type Output = SlackNotifyCaptured;

    fn from_config(
        config: &serde_json::Value,
        _project_root: &std::path::Path,
    ) -> CapsulaResult<Self> {
        let config = serde_json::from_value::<SlackNotifyHookConfig>(config.clone())?;
        if config.token.is_empty() {
            return Err(SlackNotifyError::MissingToken.into());
        }
        Ok(Self { config })
    }

    fn config(&self) -> &Self::Config {
        &self.config
    }

    fn run(
        &self,
        metadata: &PreparedRun,
        _params: &RuntimeParams<PostRun>,
    ) -> CapsulaResult<Self::Output> {
        let client = reqwest::blocking::Client::new();
        let payload = json!({
            "channel": self.config.channel,
            "text": format!("Run `{}` (ID: `{}`) has completed.", metadata.name, metadata.id),
        });

        let res = client
            .post("https://slack.com/api/chat.postMessage")
            .bearer_auth(&self.config.token)
            .json(&payload)
            .send()
            .map_err(SlackNotifyError::from)?;

        if !res.status().is_success() {
            return Err(SlackNotifyError::SlackApi {
                message: format!("Failed to send Slack notification: {}", res.status()),
            }
            .into());
        }

        Ok(SlackNotifyCaptured {
            message: "Slack notification sent successfully".to_string(),
            response: res.text().ok(),
        })
    }
}