use std::fmt;
use chrono::{DateTime, Utc};
pub(crate) const ID_BYTES: usize = 16;
const HEX: [u8; 16] = *b"0123456789abcdef";
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[usize::from(byte >> 4)] as char);
out.push(HEX[usize::from(byte & 0x0f)] as char);
}
out
}
fn hex_decode(text: &str, out: &mut [u8]) -> bool {
let bytes = text.as_bytes();
if bytes.len() != out.len() * 2 {
return false;
}
let (pairs, _) = bytes.as_chunks::<2>();
for (slot, pair) in out.iter_mut().zip(pairs) {
let (Some(high), Some(low)) = (nibble(pair[0]), nibble(pair[1])) else {
return false;
};
*slot = (high << 4) | low;
}
true
}
fn nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct NotificationId([u8; ID_BYTES]);
impl NotificationId {
pub(crate) fn from_bytes(bytes: [u8; ID_BYTES]) -> Self {
Self(bytes)
}
#[must_use]
pub fn from_hex(text: &str) -> Option<Self> {
let mut bytes = [0u8; ID_BYTES];
hex_decode(text, &mut bytes).then_some(Self(bytes))
}
#[must_use]
pub fn to_hex(&self) -> String {
hex_encode(&self.0)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl fmt::Display for NotificationId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for NotificationId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "NotificationId({})", self.to_hex())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct StoredNotification {
pub(crate) id: NotificationId,
pub(crate) notifiable_key: String,
pub(crate) kind: String,
pub(crate) data: serde_json::Value,
pub(crate) read_at: Option<DateTime<Utc>>,
pub(crate) created_at: DateTime<Utc>,
}
impl StoredNotification {
#[must_use]
pub fn id(&self) -> NotificationId {
self.id
}
#[must_use]
pub fn notifiable_key(&self) -> &str {
&self.notifiable_key
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn data(&self) -> &serde_json::Value {
&self.data
}
#[must_use]
pub fn read_at(&self) -> Option<DateTime<Utc>> {
self.read_at
}
#[must_use]
pub fn is_read(&self) -> bool {
self.read_at.is_some()
}
#[must_use]
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_id_round_trips_through_hex() {
let id = NotificationId::from_bytes([0xab; ID_BYTES]);
let text = id.to_hex();
assert_eq!(text.len(), ID_BYTES * 2);
assert_eq!(NotificationId::from_hex(&text), Some(id));
}
#[test]
fn an_id_is_not_parsed_from_anything_that_is_not_one() {
for text in [
"",
"abc",
"0123456789abcdef0123456789abcde", "0123456789abcdef0123456789abcdeff", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
"0123456789abcdef0123456789abcde ",
] {
assert!(
NotificationId::from_hex(text).is_none(),
"{text:?} parsed as an id"
);
}
}
#[test]
fn uppercase_hex_parses_to_the_same_id() {
let lower = NotificationId::from_hex("0123456789abcdef0123456789abcdef");
let upper = NotificationId::from_hex("0123456789ABCDEF0123456789ABCDEF");
assert_eq!(lower, upper);
assert_eq!(upper.unwrap().to_hex(), "0123456789abcdef0123456789abcdef");
}
#[test]
fn a_notification_is_unread_until_it_has_a_read_time() {
let mut row = StoredNotification {
id: NotificationId::from_bytes([0; ID_BYTES]),
notifiable_key: "user:42".to_owned(),
kind: "invoice.paid".to_owned(),
data: serde_json::json!({}),
read_at: None,
created_at: Utc::now(),
};
assert!(!row.is_read());
row.read_at = Some(Utc::now());
assert!(row.is_read());
}
}