use cratefield_core::{LocKeys, Notification};
use cratefield_i18n::Args;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
Rendered(Box<Notification>),
Localizable(Box<Localizable>),
}
impl From<Notification> for Message {
fn from(notification: Notification) -> Self {
Message::Rendered(Box::new(notification))
}
}
impl From<Localizable> for Message {
fn from(localizable: Localizable) -> Self {
Message::Localizable(Box::new(localizable))
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Localizable {
pub key: String,
#[serde(default, skip_serializing_if = "Args::is_empty")]
pub args: Args,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub data: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collapse_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loc: Option<LocKeys>,
}
impl Localizable {
#[must_use]
pub fn new(key: impl Into<String>) -> Self {
Self {
key: key.into(),
..Self::default()
}
}
#[must_use]
pub fn arg(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
self.args.insert(name.into(), value.into());
self
}
#[must_use]
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
#[must_use]
pub fn data(mut self, data: Value) -> Self {
self.data = data;
self
}
#[must_use]
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
#[must_use]
pub fn badge(mut self, badge: u32) -> Self {
self.badge = Some(badge);
self
}
#[must_use]
pub fn collapse_id(mut self, id: impl Into<String>) -> Self {
self.collapse_id = Some(id.into());
self
}
#[must_use]
pub fn loc(mut self, loc: LocKeys) -> Self {
self.loc = Some(loc);
self
}
pub(crate) fn shape(&self, title: String, body: String) -> Notification {
let mut notification = Notification::new(title, body);
notification.url.clone_from(&self.url);
notification.icon.clone_from(&self.icon);
notification.data = self.data.clone();
notification.badge = self.badge;
notification.collapse_id.clone_from(&self.collapse_id);
notification
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RenderMode {
#[default]
Server,
Native,
Both,
}
impl RenderMode {
#[must_use]
pub fn sends_loc_keys(self) -> bool {
matches!(self, RenderMode::Native | RenderMode::Both)
}
#[must_use]
pub fn renders_for_recipient(self) -> bool {
matches!(self, RenderMode::Server | RenderMode::Both)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
RenderMode::Server => "server",
RenderMode::Native => "native",
RenderMode::Both => "both",
}
}
}
impl std::fmt::Display for RenderMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rendered_notification_converts_without_the_caller_saying_so() {
let message: Message = Notification::new("Booked", "See you Tuesday").into();
match message {
Message::Rendered(notification) => {
assert_eq!(notification.title, "Booked");
}
Message::Localizable(_) => panic!("a Notification is rendered text"),
}
}
#[test]
fn the_builder_keeps_a_number_a_number() {
let message = Localizable::new("booking-confirmed")
.arg("count", 2)
.arg("coach", "Sari");
assert!(
message.args["count"].is_number(),
"a string never selects a plural category"
);
assert_eq!(message.args["coach"], "Sari");
}
#[test]
fn a_payload_round_trips_and_omits_what_was_not_set() {
let message = Localizable::new("booking-confirmed").arg("coach", "Sari");
let json = serde_json::to_string(&message).expect("serialises");
assert!(!json.contains("\"url\""), "{json}");
assert!(!json.contains("\"loc\""), "{json}");
let back: Localizable = serde_json::from_str(&json).expect("round trips");
assert_eq!(back, message);
}
#[test]
fn the_modes_say_what_each_one_does() {
assert!(!RenderMode::Server.sends_loc_keys());
assert!(RenderMode::Native.sends_loc_keys());
assert!(RenderMode::Both.sends_loc_keys());
assert!(RenderMode::Server.renders_for_recipient());
assert!(
!RenderMode::Native.renders_for_recipient(),
"native means the app owns the language; the text is only a fallback"
);
assert!(RenderMode::Both.renders_for_recipient());
assert_eq!(RenderMode::default(), RenderMode::Server);
}
#[test]
fn the_shape_carries_everything_that_is_not_language() {
let message = Localizable::new("booking-confirmed")
.url("https://example.test/b/1")
.icon("bell")
.badge(3)
.collapse_id("b-1")
.data(serde_json::json!({ "booking_id": "b-1" }));
let notification = message.shape("Booked".to_owned(), "Tuesday".to_owned());
assert_eq!(notification.title, "Booked");
assert_eq!(
notification.url.as_deref(),
Some("https://example.test/b/1")
);
assert_eq!(notification.icon.as_deref(), Some("bell"));
assert_eq!(notification.badge, Some(3));
assert_eq!(notification.collapse_id.as_deref(), Some("b-1"));
assert_eq!(notification.data["booking_id"], "b-1");
}
}