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}