use crate::config::{HookConfig, NotificationConfig, NotifyOutcome};
use crate::executor::ExecutionResult;
use crate::interpolation::interpolate_command;
use crate::models::ExecutionStatus;
use crate::models::execution::Execution;
fn notification_context(
hook: &HookConfig,
result: &ExecutionResult,
execution: &Execution,
) -> serde_json::Value {
let duration = compute_duration(execution);
serde_json::json!({
"hook_name": hook.name,
"hook_slug": hook.slug,
"status": result.status.to_string(),
"exit_code": result.exit_code.map(|c| c.to_string()).unwrap_or_default(),
"execution_id": execution.id,
"duration": duration,
"trigger_source": execution.trigger_source,
})
}
fn compute_duration(execution: &Execution) -> String {
match (&execution.started_at, &execution.completed_at) {
(Some(started), Some(completed)) => {
let start = chrono::DateTime::parse_from_rfc3339(started).ok();
let end = chrono::DateTime::parse_from_rfc3339(completed).ok();
match (start, end) {
(Some(s), Some(e)) => {
let secs = (e - s).num_seconds();
format!("{secs}s")
}
_ => "unknown".into(),
}
}
_ => "unknown".into(),
}
}
fn status_to_outcome(status: &ExecutionStatus) -> Option<NotifyOutcome> {
match status {
ExecutionStatus::Success => Some(NotifyOutcome::Success),
ExecutionStatus::Failed => Some(NotifyOutcome::Failure),
ExecutionStatus::TimedOut => Some(NotifyOutcome::Timeout),
_ => None,
}
}
pub async fn send_notification(
client: &reqwest::Client,
config: &NotificationConfig,
hook: &HookConfig,
result: &ExecutionResult,
execution: &Execution,
) {
let Some(outcome) = status_to_outcome(&result.status) else {
return;
};
if !config.on.contains(&outcome) {
return;
}
let context = notification_context(hook, result, execution);
let body = interpolate_command(&config.body, &context).into_owned();
let mut builder = client
.post(&config.url)
.timeout(std::time::Duration::from_secs(10))
.body(body);
for (k, v) in &config.headers {
builder = builder.header(k.as_str(), v.as_str());
}
match builder.send().await {
Ok(resp) => tracing::info!(
hook_slug = %hook.slug,
status = resp.status().as_u16(),
"notification sent"
),
Err(e) => tracing::warn!(
hook_slug = %hook.slug,
error = %e,
"notification failed"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::executor::ExecutionResult;
use crate::models::ExecutionStatus;
fn make_hook() -> HookConfig {
use crate::config::ExecutorConfig;
use std::collections::HashMap;
HookConfig {
name: "Test Hook".into(),
slug: "test-hook".into(),
description: String::new(),
enabled: true,
auth: None,
executor: ExecutorConfig::Shell {
command: "echo ok".into(),
},
env: HashMap::new(),
cwd: None,
timeout: None,
retries: None,
rate_limit: None,
payload: None,
trigger_rules: None,
concurrency: None,
approval: None,
notification: None,
}
}
fn make_execution() -> Execution {
Execution {
id: "exec-1".into(),
hook_slug: "test-hook".into(),
triggered_at: "2026-04-12T10:00:00Z".into(),
started_at: Some("2026-04-12T10:00:01Z".into()),
completed_at: Some("2026-04-12T10:00:04Z".into()),
status: ExecutionStatus::Success,
exit_code: Some(0),
log_path: "data/logs/exec-1".into(),
trigger_source: "webhook".into(),
request_payload: "{}".into(),
retry_count: 0,
retry_of: None,
approved_at: None,
approved_by: None,
}
}
fn make_result(status: ExecutionStatus) -> ExecutionResult {
ExecutionResult {
status,
exit_code: Some(0),
log_dir: "data/logs/exec-1".into(),
}
}
#[test]
fn notification_context_builds_all_fields() {
let hook = make_hook();
let execution = make_execution();
let result = make_result(ExecutionStatus::Success);
let ctx = notification_context(&hook, &result, &execution);
assert_eq!(ctx["hook_name"], "Test Hook");
assert_eq!(ctx["hook_slug"], "test-hook");
assert_eq!(ctx["status"], "success");
assert_eq!(ctx["execution_id"], "exec-1");
assert_eq!(ctx["trigger_source"], "webhook");
assert_eq!(ctx["duration"], "3s");
}
#[test]
fn status_to_outcome_maps_correctly() {
assert_eq!(
status_to_outcome(&ExecutionStatus::Success),
Some(NotifyOutcome::Success)
);
assert_eq!(
status_to_outcome(&ExecutionStatus::Failed),
Some(NotifyOutcome::Failure)
);
assert_eq!(
status_to_outcome(&ExecutionStatus::TimedOut),
Some(NotifyOutcome::Timeout)
);
assert!(status_to_outcome(&ExecutionStatus::Rejected).is_none());
assert!(status_to_outcome(&ExecutionStatus::Pending).is_none());
assert!(status_to_outcome(&ExecutionStatus::Running).is_none());
assert!(status_to_outcome(&ExecutionStatus::Expired).is_none());
}
#[tokio::test]
async fn notification_not_sent_when_outcome_not_in_on() {
use std::collections::HashMap;
let config = NotificationConfig {
url: "http://127.0.0.1:1".into(), on: vec![NotifyOutcome::Failure, NotifyOutcome::Timeout],
headers: HashMap::new(),
body: "done".into(),
};
let hook = make_hook();
let execution = make_execution();
let result = make_result(ExecutionStatus::Success);
let client = reqwest::Client::new();
send_notification(&client, &config, &hook, &result, &execution).await;
}
}