Skip to main content

otto_cli/
notify.rs

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}