1use std::collections::BTreeMap;
7
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12pub type WebhookConfig = shipper_webhook::WebhookConfig;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "event", rename_all = "snake_case")]
18pub enum WebhookEvent {
19 PublishStarted {
21 plan_id: String,
22 package_count: usize,
23 registry: String,
24 },
25 PublishSucceeded {
27 plan_id: String,
28 package_name: String,
29 package_version: String,
30 duration_ms: u64,
31 },
32 PublishFailed {
34 plan_id: String,
35 package_name: String,
36 package_version: String,
37 error_class: String,
38 message: String,
39 },
40 PublishCompleted {
42 plan_id: String,
43 total_packages: usize,
44 success_count: usize,
45 failure_count: usize,
46 skipped_count: usize,
47 result: String,
48 },
49}
50
51#[derive(Debug, Serialize, Deserialize)]
53pub struct WebhookPayload {
54 pub timestamp: DateTime<Utc>,
56 pub event: WebhookEvent,
58}
59
60#[derive(Clone)]
62pub struct WebhookClient {
63 config: WebhookConfig,
64}
65
66impl WebhookClient {
67 pub fn new(config: &WebhookConfig) -> Result<Self> {
69 if config.url.trim().is_empty() {
70 anyhow::bail!("webhook URL is required when webhooks are enabled");
71 }
72 Ok(Self {
73 config: config.clone(),
74 })
75 }
76
77 pub fn send_event(&self, event: WebhookEvent) {
79 let payload = WebhookPayload {
80 timestamp: Utc::now(),
81 event,
82 };
83
84 let client = self.clone();
85 let _ = std::thread::spawn(move || {
86 if let Err(e) =
87 shipper_webhook::send_webhook(&client.config, &to_micro_payload(&payload))
88 {
89 eprintln!("[warn] webhook delivery failed (non-blocking): {:#}", e);
90 }
91 });
92 }
93}
94
95pub fn maybe_send_event(config: &WebhookConfig, event: WebhookEvent) {
97 if config.url.trim().is_empty() {
98 return;
99 }
100
101 let client = match WebhookClient::new(config) {
102 Ok(client) => client,
103 Err(e) => {
104 eprintln!("[warn] failed to build webhook client: {:#}", e);
105 return;
106 }
107 };
108
109 let _ = std::thread::spawn(move || {
110 let payload = WebhookPayload {
111 timestamp: Utc::now(),
112 event,
113 };
114
115 if let Err(e) = shipper_webhook::send_webhook(&client.config, &to_micro_payload(&payload)) {
116 eprintln!("[warn] webhook delivery failed (non-blocking): {:#}", e);
117 }
118 });
119}
120
121pub(crate) fn to_micro_payload(payload: &WebhookPayload) -> shipper_webhook::WebhookPayload {
122 let (message, title, success, package, version, registry, error, extra) = match &payload.event {
123 WebhookEvent::PublishStarted {
124 plan_id,
125 package_count,
126 registry,
127 } => (
128 format!("publish started for plan {plan_id} ({package_count} packages) on {registry}"),
129 Some("Publish Started".to_string()),
130 true,
131 None,
132 None,
133 Some(registry.clone()),
134 None,
135 serde_json::json!({
136 "event": "publish_started",
137 "plan_id": plan_id,
138 "package_count": package_count,
139 "registry": registry,
140 }),
141 ),
142 WebhookEvent::PublishSucceeded {
143 plan_id,
144 package_name,
145 package_version,
146 duration_ms,
147 ..
148 } => (
149 format!(
150 "publish succeeded for package {package_name} version {package_version} in {duration_ms}ms (plan {plan_id})"
151 ),
152 Some("Publish Succeeded".to_string()),
153 true,
154 Some(package_name.clone()),
155 Some(package_version.clone()),
156 None,
157 None,
158 serde_json::json!({
159 "event": "publish_succeeded",
160 "plan_id": plan_id,
161 "duration_ms": duration_ms,
162 }),
163 ),
164 WebhookEvent::PublishFailed {
165 plan_id,
166 package_name,
167 package_version,
168 error_class,
169 message,
170 ..
171 } => (
172 format!(
173 "publish failed for package {package_name} version {package_version} ({error_class}): {message}"
174 ),
175 Some("Publish Failed".to_string()),
176 false,
177 Some(package_name.clone()),
178 Some(package_version.clone()),
179 None,
180 Some(message.clone()),
181 serde_json::json!({
182 "event": "publish_failed",
183 "plan_id": plan_id,
184 "error_class": error_class,
185 }),
186 ),
187 WebhookEvent::PublishCompleted {
188 plan_id,
189 total_packages,
190 success_count,
191 failure_count,
192 skipped_count,
193 result,
194 } => (
195 format!(
196 "publish completed: {success_count}/{total_packages} succeeded, {failure_count} failed, {skipped_count} skipped (plan {plan_id}, result: {result})"
197 ),
198 Some("Publish Completed".to_string()),
199 *failure_count == 0,
200 None,
201 None,
202 None,
203 None,
204 serde_json::json!({
205 "event": "publish_completed",
206 "plan_id": plan_id,
207 "total_packages": total_packages,
208 "success_count": success_count,
209 "failure_count": failure_count,
210 "skipped_count": skipped_count,
211 "result": result,
212 }),
213 ),
214 };
215
216 let mut extra_fields = BTreeMap::new();
217 extra_fields.insert("legacy".to_string(), extra);
218
219 shipper_webhook::WebhookPayload {
220 message,
221 title,
222 success,
223 package,
224 version,
225 registry,
226 error,
227 extra: extra_fields,
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use shipper_webhook::WebhookType;
235
236 fn sample_config(url: &str) -> WebhookConfig {
237 WebhookConfig {
238 url: url.to_string(),
239 webhook_type: WebhookType::Generic,
240 secret: None,
241 timeout_secs: 30,
242 }
243 }
244
245 fn started_event() -> WebhookEvent {
246 WebhookEvent::PublishStarted {
247 plan_id: "plan-1".to_string(),
248 package_count: 3,
249 registry: "crates-io".to_string(),
250 }
251 }
252
253 fn succeeded_event() -> WebhookEvent {
254 WebhookEvent::PublishSucceeded {
255 plan_id: "plan-1".to_string(),
256 package_name: "pkg-a".to_string(),
257 package_version: "1.2.3".to_string(),
258 duration_ms: 1500,
259 }
260 }
261
262 fn failed_event() -> WebhookEvent {
263 WebhookEvent::PublishFailed {
264 plan_id: "plan-1".to_string(),
265 package_name: "pkg-b".to_string(),
266 package_version: "0.1.0".to_string(),
267 error_class: "Permanent".to_string(),
268 message: "auth failed".to_string(),
269 }
270 }
271
272 fn completed_event(failure_count: usize) -> WebhookEvent {
273 WebhookEvent::PublishCompleted {
274 plan_id: "plan-1".to_string(),
275 total_packages: 4,
276 success_count: 4 - failure_count,
277 failure_count,
278 skipped_count: 0,
279 result: if failure_count == 0 {
280 "success".to_string()
281 } else {
282 "partial".to_string()
283 },
284 }
285 }
286
287 fn sample_payload(event: WebhookEvent) -> WebhookPayload {
288 WebhookPayload {
289 timestamp: Utc::now(),
290 event,
291 }
292 }
293
294 #[test]
295 fn webhook_client_new_rejects_empty_url() {
296 let cfg = sample_config("");
297 let err = match WebhookClient::new(&cfg) {
298 Err(e) => e,
299 Ok(_) => panic!("empty url must be rejected"),
300 };
301 let msg = format!("{err:#}");
302 assert!(msg.contains("required"), "unexpected error: {msg}");
303 }
304
305 #[test]
306 fn webhook_client_new_rejects_whitespace_only_url() {
307 let cfg = sample_config(" ");
308 let err = match WebhookClient::new(&cfg) {
309 Err(e) => e,
310 Ok(_) => panic!("whitespace url must be rejected"),
311 };
312 assert!(format!("{err:#}").contains("required"));
313 }
314
315 #[test]
316 fn webhook_client_new_accepts_valid_url() {
317 let cfg = sample_config("https://example.invalid/hook");
318 WebhookClient::new(&cfg).expect("valid url must be accepted");
319 }
320
321 #[test]
322 fn webhook_client_is_clone() {
323 let cfg = sample_config("https://example.invalid/hook");
324 let client = WebhookClient::new(&cfg).expect("ok");
325 let _ = client.clone();
326 }
327
328 #[test]
329 fn maybe_send_event_returns_early_on_empty_url() {
330 let cfg = sample_config("");
331 maybe_send_event(&cfg, started_event());
332 }
333
334 #[test]
335 fn maybe_send_event_returns_early_on_whitespace_url() {
336 let cfg = sample_config(" \t ");
337 maybe_send_event(&cfg, succeeded_event());
338 }
339
340 #[test]
341 fn to_micro_payload_publish_started_fields() {
342 let micro = to_micro_payload(&sample_payload(started_event()));
343 assert!(micro.success);
344 assert_eq!(micro.title.as_deref(), Some("Publish Started"));
345 assert!(micro.package.is_none());
346 assert!(micro.version.is_none());
347 assert_eq!(micro.registry.as_deref(), Some("crates-io"));
348 assert!(micro.error.is_none());
349 assert!(micro.message.contains("plan-1"));
350 assert!(micro.message.contains("3 packages"));
351 assert!(micro.message.contains("crates-io"));
352 let legacy = micro.extra.get("legacy").expect("legacy");
353 assert_eq!(
354 legacy.get("event").and_then(|v| v.as_str()),
355 Some("publish_started")
356 );
357 assert_eq!(
358 legacy.get("plan_id").and_then(|v| v.as_str()),
359 Some("plan-1")
360 );
361 assert_eq!(
362 legacy.get("package_count").and_then(|v| v.as_u64()),
363 Some(3)
364 );
365 assert_eq!(
366 legacy.get("registry").and_then(|v| v.as_str()),
367 Some("crates-io")
368 );
369 }
370
371 #[test]
372 fn to_micro_payload_publish_succeeded_fields() {
373 let micro = to_micro_payload(&sample_payload(succeeded_event()));
374 assert!(micro.success);
375 assert_eq!(micro.title.as_deref(), Some("Publish Succeeded"));
376 assert_eq!(micro.package.as_deref(), Some("pkg-a"));
377 assert_eq!(micro.version.as_deref(), Some("1.2.3"));
378 assert!(micro.registry.is_none());
379 assert!(micro.error.is_none());
380 assert!(micro.message.contains("pkg-a"));
381 assert!(micro.message.contains("1.2.3"));
382 assert!(micro.message.contains("1500ms"));
383 let legacy = micro.extra.get("legacy").expect("legacy");
384 assert_eq!(
385 legacy.get("event").and_then(|v| v.as_str()),
386 Some("publish_succeeded")
387 );
388 assert_eq!(
389 legacy.get("duration_ms").and_then(|v| v.as_u64()),
390 Some(1500)
391 );
392 }
393
394 #[test]
395 fn to_micro_payload_publish_failed_fields() {
396 let micro = to_micro_payload(&sample_payload(failed_event()));
397 assert!(!micro.success);
398 assert_eq!(micro.title.as_deref(), Some("Publish Failed"));
399 assert_eq!(micro.package.as_deref(), Some("pkg-b"));
400 assert_eq!(micro.version.as_deref(), Some("0.1.0"));
401 assert!(micro.registry.is_none());
402 assert_eq!(micro.error.as_deref(), Some("auth failed"));
403 assert!(micro.message.contains("Permanent"));
404 assert!(micro.message.contains("auth failed"));
405 let legacy = micro.extra.get("legacy").expect("legacy");
406 assert_eq!(
407 legacy.get("event").and_then(|v| v.as_str()),
408 Some("publish_failed")
409 );
410 assert_eq!(
411 legacy.get("error_class").and_then(|v| v.as_str()),
412 Some("Permanent")
413 );
414 }
415
416 #[test]
417 fn to_micro_payload_publish_completed_success_when_no_failures() {
418 let micro = to_micro_payload(&sample_payload(completed_event(0)));
419 assert!(micro.success, "no failures -> success=true");
420 assert_eq!(micro.title.as_deref(), Some("Publish Completed"));
421 assert!(micro.package.is_none());
422 assert!(micro.version.is_none());
423 assert!(micro.registry.is_none());
424 assert!(micro.error.is_none());
425 assert!(micro.message.contains("4/4 succeeded"));
426 let legacy = micro.extra.get("legacy").expect("legacy");
427 assert_eq!(
428 legacy.get("total_packages").and_then(|v| v.as_u64()),
429 Some(4)
430 );
431 assert_eq!(
432 legacy.get("success_count").and_then(|v| v.as_u64()),
433 Some(4)
434 );
435 assert_eq!(
436 legacy.get("failure_count").and_then(|v| v.as_u64()),
437 Some(0)
438 );
439 assert_eq!(
440 legacy.get("result").and_then(|v| v.as_str()),
441 Some("success")
442 );
443 }
444
445 #[test]
446 fn to_micro_payload_publish_completed_failure_when_any_failed() {
447 let micro = to_micro_payload(&sample_payload(completed_event(2)));
448 assert!(!micro.success, "any failures -> success=false");
449 assert!(micro.message.contains("2/4 succeeded"));
450 assert!(micro.message.contains("2 failed"));
451 let legacy = micro.extra.get("legacy").expect("legacy");
452 assert_eq!(
453 legacy.get("failure_count").and_then(|v| v.as_u64()),
454 Some(2)
455 );
456 assert_eq!(
457 legacy.get("result").and_then(|v| v.as_str()),
458 Some("partial")
459 );
460 }
461
462 #[test]
463 fn to_micro_payload_includes_legacy_key() {
464 for event in [
465 started_event(),
466 succeeded_event(),
467 failed_event(),
468 completed_event(1),
469 ] {
470 let micro = to_micro_payload(&sample_payload(event));
471 assert!(
472 micro.extra.contains_key("legacy"),
473 "to_micro_payload must always include `legacy` key"
474 );
475 }
476 }
477
478 #[test]
479 fn webhook_event_serde_roundtrip_for_all_variants() {
480 for event in [
481 started_event(),
482 succeeded_event(),
483 failed_event(),
484 completed_event(1),
485 ] {
486 let json = serde_json::to_string(&event).expect("serialize");
487 let back: WebhookEvent = serde_json::from_str(&json).expect("deserialize");
488 assert_eq!(
490 json,
491 serde_json::to_string(&back).expect("re-serialize"),
492 "roundtrip must preserve event payload"
493 );
494 }
495 }
496
497 #[test]
498 fn webhook_event_publish_started_tag_in_json() {
499 let json = serde_json::to_string(&started_event()).expect("serialize");
500 assert!(
501 json.contains("\"event\":\"publish_started\""),
502 "expected tagged variant, got {json}"
503 );
504 }
505
506 #[test]
507 fn webhook_event_publish_failed_tag_in_json() {
508 let json = serde_json::to_string(&failed_event()).expect("serialize");
509 assert!(
510 json.contains("\"event\":\"publish_failed\""),
511 "expected tagged variant, got {json}"
512 );
513 }
514
515 #[test]
516 fn webhook_payload_serializes_with_event_and_timestamp() {
517 let payload = sample_payload(started_event());
518 let json = serde_json::to_string(&payload).expect("serialize");
519 assert!(json.contains("\"timestamp\""));
520 assert!(json.contains("\"event\":\"publish_started\""));
521 }
522}