1use reqwest::blocking::Client;
2use serde::Serialize;
3use std::process::Command;
4use std::time::Duration;
5use time::OffsetDateTime;
6
7#[derive(Debug, Clone)]
8pub struct Event {
9 pub name: String,
10 pub source: String,
11 pub status: String,
12 pub exit_code: i32,
13 pub duration: Duration,
14 pub started_at: OffsetDateTime,
15 pub command_preview: String,
16 pub stderr_tail: Option<String>,
17}
18
19#[derive(Debug, Clone)]
20pub struct Manager {
21 pub desktop_enabled: bool,
22 pub webhook_url: String,
23 pub webhook_timeout: Duration,
24}
25
26impl Manager {
27 pub fn notify(&self, event: &Event) -> Result<(), String> {
28 let mut errors = Vec::new();
29
30 if self.desktop_enabled
31 && let Err(err) = desktop_notify(event)
32 {
33 errors.push(format!("desktop: {err}"));
34 }
35
36 if !self.webhook_url.is_empty()
37 && let Err(err) = webhook_notify(&self.webhook_url, self.webhook_timeout, event)
38 {
39 errors.push(format!("webhook: {err}"));
40 }
41
42 if errors.is_empty() {
43 Ok(())
44 } else {
45 Err(errors.join("; "))
46 }
47 }
48}
49
50fn desktop_notify(event: &Event) -> Result<(), String> {
51 let title = format!("{} {}", event.name, event.status);
52 let body = format!(
53 "exit {}, duration {}",
54 event.exit_code,
55 format_duration(event.duration)
56 );
57
58 if cfg!(target_os = "macos") {
59 let script = format!("display notification {:?} with title {:?}", body, title);
60
61 let status = Command::new("osascript")
62 .arg("-e")
63 .arg(script)
64 .status()
65 .map_err(|e| e.to_string())?;
66
67 if status.success() {
68 return Ok(());
69 }
70
71 return Err(format!("osascript exited with status {status}"));
72 }
73
74 if cfg!(target_os = "linux") {
75 let status = Command::new("notify-send")
76 .arg(&title)
77 .arg(&body)
78 .status()
79 .map_err(|e| e.to_string())?;
80
81 if status.success() {
82 return Ok(());
83 }
84
85 return Err(format!("notify-send exited with status {status}"));
86 }
87
88 Ok(())
89}
90
91#[derive(Debug, Serialize)]
92struct WebhookPayload<'a> {
93 name: &'a str,
94 source: &'a str,
95 status: &'a str,
96 exit_code: i32,
97 duration_ms: i128,
98 started_at: String,
99 command_preview: &'a str,
100 stderr_tail: &'a str,
101}
102
103fn webhook_notify(webhook_url: &str, timeout: Duration, event: &Event) -> Result<(), String> {
104 let timeout = if timeout.is_zero() {
105 Duration::from_secs(5)
106 } else {
107 timeout
108 };
109
110 let client = Client::builder()
111 .timeout(timeout)
112 .build()
113 .map_err(|e| format!("build client: {e}"))?;
114
115 let payload = WebhookPayload {
116 name: &event.name,
117 source: &event.source,
118 status: &event.status,
119 exit_code: event.exit_code,
120 duration_ms: event.duration.as_millis() as i128,
121 started_at: event
122 .started_at
123 .format(&time::format_description::well_known::Rfc3339)
124 .map_err(|e| format!("format started_at: {e}"))?,
125 command_preview: &event.command_preview,
126 stderr_tail: event.stderr_tail.as_deref().unwrap_or(""),
127 };
128
129 let response = client
130 .post(webhook_url)
131 .header(reqwest::header::CONTENT_TYPE, "application/json")
132 .json(&payload)
133 .send()
134 .map_err(|e| format!("send request: {e}"))?;
135
136 if response.status().is_success() {
137 Ok(())
138 } else {
139 Err(format!("unexpected status {}", response.status().as_u16()))
140 }
141}
142
143fn format_duration(duration: Duration) -> String {
144 let ms = duration.as_millis();
145 if ms < 1_000 {
146 return format!("{ms}ms");
147 }
148
149 if ms.is_multiple_of(1_000) {
150 return format!("{}s", ms / 1_000);
151 }
152
153 format!("{:.3}s", duration.as_secs_f64())
154}