Skip to main content

notify_core/
message.rs

1use std::{
2    fmt,
3    fs::File,
4    io::Read,
5    path::{Path, PathBuf},
6    str::FromStr,
7};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use time::OffsetDateTime;
12use ulid::Ulid;
13
14use crate::{NotifyError, Result};
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Priority {
19    #[default]
20    Info,
21    Success,
22    Warning,
23    Error,
24    Critical,
25}
26
27impl fmt::Display for Priority {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        let value = match self {
30            Self::Info => "info",
31            Self::Success => "success",
32            Self::Warning => "warning",
33            Self::Error => "error",
34            Self::Critical => "critical",
35        };
36        f.write_str(value)
37    }
38}
39
40impl FromStr for Priority {
41    type Err = NotifyError;
42
43    fn from_str(value: &str) -> Result<Self> {
44        match value {
45            "info" => Ok(Self::Info),
46            "success" => Ok(Self::Success),
47            "warning" => Ok(Self::Warning),
48            "error" => Ok(Self::Error),
49            "critical" => Ok(Self::Critical),
50            _ => Err(NotifyError::InvalidInput(format!(
51                "invalid priority \"{value}\""
52            ))),
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum MessageFormat {
60    #[default]
61    Text,
62    Markdown,
63}
64
65impl fmt::Display for MessageFormat {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        let value = match self {
68            Self::Text => "text",
69            Self::Markdown => "markdown",
70        };
71        f.write_str(value)
72    }
73}
74
75impl FromStr for MessageFormat {
76    type Err = NotifyError;
77
78    fn from_str(value: &str) -> Result<Self> {
79        match value {
80            "text" => Ok(Self::Text),
81            "markdown" => Ok(Self::Markdown),
82            _ => Err(NotifyError::InvalidInput(format!(
83                "invalid format \"{value}\""
84            ))),
85        }
86    }
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct NotifyMessage {
91    pub id: String,
92    #[serde(with = "time::serde::rfc3339")]
93    pub timestamp: OffsetDateTime,
94    pub title: String,
95    pub body: Option<String>,
96    pub format: MessageFormat,
97    pub priority: Priority,
98    pub tags: Vec<String>,
99    pub attachments: Vec<Attachment>,
100}
101
102impl NotifyMessage {
103    pub fn new(
104        title: String,
105        body: Option<String>,
106        format: MessageFormat,
107        priority: Priority,
108        tags: Vec<String>,
109        attachments: Vec<Attachment>,
110    ) -> Result<Self> {
111        if title.trim().is_empty() {
112            return Err(NotifyError::InvalidInput("title required".to_string()));
113        }
114        if body.as_deref().unwrap_or("").is_empty() && attachments.is_empty() {
115            return Err(NotifyError::InvalidInput(
116                "body or file required".to_string(),
117            ));
118        }
119
120        Ok(Self {
121            id: Ulid::new().to_string(),
122            timestamp: OffsetDateTime::now_utc(),
123            title,
124            body,
125            format,
126            priority,
127            tags,
128            attachments,
129        })
130    }
131}
132
133#[derive(Debug, Clone, Serialize)]
134pub struct Attachment {
135    pub path: PathBuf,
136    pub name: String,
137    pub mime_type: String,
138    pub size_bytes: u64,
139    pub sha256: String,
140}
141
142impl Attachment {
143    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
144        let path = path.as_ref();
145        let metadata = std::fs::metadata(path).map_err(|source| NotifyError::Io {
146            path: path.to_path_buf(),
147            source,
148        })?;
149
150        if metadata.is_dir() {
151            return Err(NotifyError::InvalidInput(format!(
152                "{} is a directory, not a file",
153                path.display()
154            )));
155        }
156
157        let name = path
158            .file_name()
159            .and_then(|value| value.to_str())
160            .ok_or_else(|| {
161                NotifyError::InvalidInput(format!("invalid attachment path {}", path.display()))
162            })?
163            .to_string();
164        let mime_type = mime_guess::from_path(path)
165            .first_or_octet_stream()
166            .essence_str()
167            .to_string();
168        let sha256 = sha256_file(path)?;
169
170        Ok(Self {
171            path: path.to_path_buf(),
172            name,
173            mime_type,
174            size_bytes: metadata.len(),
175            sha256,
176        })
177    }
178}
179
180fn sha256_file(path: &Path) -> Result<String> {
181    let mut file = File::open(path).map_err(|source| NotifyError::Io {
182        path: path.to_path_buf(),
183        source,
184    })?;
185    let mut hasher = Sha256::new();
186    let mut buffer = [0_u8; 8192];
187
188    loop {
189        let read = file.read(&mut buffer).map_err(|source| NotifyError::Io {
190            path: path.to_path_buf(),
191            source,
192        })?;
193        if read == 0 {
194            break;
195        }
196        hasher.update(&buffer[..read]);
197    }
198
199    Ok(format!("{:x}", hasher.finalize()))
200}
201
202#[cfg(test)]
203mod tests {
204    use std::fs;
205
206    use tempfile::tempdir;
207
208    use super::*;
209
210    #[test]
211    fn message_rejects_blank_title() {
212        let error = NotifyMessage::new(
213            "   ".to_string(),
214            Some("body".to_string()),
215            MessageFormat::Text,
216            Priority::Info,
217            Vec::new(),
218            Vec::new(),
219        )
220        .unwrap_err();
221
222        assert_eq!(error.code(), "INVALID_INPUT");
223        assert_eq!(error.to_string(), "title required");
224    }
225
226    #[test]
227    fn attachment_from_path_records_metadata() {
228        let dir = tempdir().unwrap();
229        let path = dir.path().join("report.txt");
230        fs::write(&path, "hello").unwrap();
231
232        let attachment = Attachment::from_path(&path).unwrap();
233
234        assert_eq!(attachment.name, "report.txt");
235        assert_eq!(attachment.mime_type, "text/plain");
236        assert_eq!(attachment.size_bytes, 5);
237        assert_eq!(
238            attachment.sha256,
239            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
240        );
241    }
242
243    #[test]
244    fn attachment_rejects_directories() {
245        let dir = tempdir().unwrap();
246
247        let error = Attachment::from_path(dir.path()).unwrap_err();
248
249        assert_eq!(error.code(), "INVALID_INPUT");
250        assert!(error.to_string().contains("is a directory"));
251    }
252}