use std::fmt;
use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct TriggerSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub activation: TriggerActivation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TriggerActivation {
Schedule {
cron: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tz: Option<String>,
},
GroupEnd {
after: TriggerDuration,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(into = "String")]
pub struct TriggerDuration {
text: String,
seconds: u64,
}
const DURATION_PATTERN: &str = "^[1-9][0-9]{0,8}[smhd]$";
const DURATION_DESCRIPTION: &str = "Positive whole duration <n><unit> with unit s, m, h, or d, \
such as 30m or 24h, at most one year: 365d = 8760h = 525600m = 31536000s.";
impl TriggerDuration {
pub const MAX_SECONDS: u64 = 365 * 86_400;
pub fn parse(text: &str) -> Result<Self, String> {
let invalid = || format!("{text:?} is not a duration <n><s|m|h|d>, such as 30m");
let split = text.len().checked_sub(1).ok_or_else(invalid)?;
let (count, unit) = text.split_at_checked(split).ok_or_else(invalid)?;
let scale = match unit {
"s" => 1,
"m" => 60,
"h" => 3_600,
"d" => 86_400,
_ => return Err(invalid()),
};
if count.is_empty()
|| count.len() > 9
|| count.starts_with('0')
|| !count.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(invalid());
}
let count: u64 = count.parse().map_err(|_| invalid())?;
let seconds = count * scale;
if seconds > Self::MAX_SECONDS {
return Err(format!(
"{text:?} is longer than the maximum duration of one year (365d)"
));
}
Ok(Self {
text: text.to_owned(),
seconds,
})
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
pub const fn seconds(&self) -> u64 {
self.seconds
}
#[must_use]
pub const fn as_duration(&self) -> Duration {
Duration::from_secs(self.seconds)
}
}
impl fmt::Display for TriggerDuration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl From<TriggerDuration> for String {
fn from(value: TriggerDuration) -> Self {
value.text
}
}
impl<'de> Deserialize<'de> for TriggerDuration {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let text = String::deserialize(deserializer)?;
Self::parse(&text).map_err(serde::de::Error::custom)
}
}
impl schemars::JsonSchema for TriggerDuration {
fn schema_name() -> String {
"TriggerDuration".to_owned()
}
fn json_schema(_generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::{InstanceType, Metadata, SchemaObject, StringValidation};
SchemaObject {
metadata: Some(Box::new(Metadata {
description: Some(DURATION_DESCRIPTION.to_owned()),
..Metadata::default()
})),
instance_type: Some(InstanceType::String.into()),
string: Some(Box::new(StringValidation {
pattern: Some(DURATION_PATTERN.to_owned()),
..StringValidation::default()
})),
..SchemaObject::default()
}
.into()
}
}
#[cfg(feature = "server")]
impl utoipa::PartialSchema for TriggerDuration {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.pattern(Some(DURATION_PATTERN))
.description(Some(DURATION_DESCRIPTION))
.into()
}
}
#[cfg(feature = "server")]
impl utoipa::ToSchema for TriggerDuration {}
#[cfg(test)]
mod tests {
use super::{TriggerActivation, TriggerDuration, TriggerSpec};
use crate::envelope::{CardKind, Spec};
#[test]
fn schedule_decodes_with_optional_description() {
let spec: TriggerSpec = serde_json::from_value(serde_json::json!({
"kind": "schedule",
"cron": "0 * * * *",
"description": "hourly",
}))
.expect("description decodes beside the activation");
assert!(matches!(
spec.activation,
TriggerActivation::Schedule { .. }
));
assert_eq!(spec.description.as_deref(), Some("hourly"));
}
#[test]
fn observations_ready_is_not_an_activation() {
assert!(
serde_json::from_value::<TriggerSpec>(serde_json::json!({
"kind": "observations_ready",
}))
.is_err()
);
}
#[test]
fn schedule_rejects_unknown_fields() {
let error = serde_json::from_value::<TriggerSpec>(serde_json::json!({
"kind": "schedule",
"cron": "0 * * * *",
"threshhold": 0.2,
}))
.expect_err("a misspelled sibling key is refused");
assert!(error.to_string().contains("threshhold"), "{error}");
}
#[test]
fn trigger_card_spec_rejects_unknown_fields() {
let error = Spec::from_kind_and_value(
&CardKind::Trigger,
serde_json::json!({
"kind": "schedule",
"cron": "0 * * * *",
"api_token": "sk-live-not-a-real-token",
}),
)
.expect_err("a Trigger Card spec refuses the same unknown key");
assert!(error.to_string().contains("api_token"), "{error}");
}
#[test]
fn group_end_decodes_its_duration() {
let authored = serde_json::json!({"kind": "group_end", "after": "30m"});
let spec: TriggerSpec =
serde_json::from_value(authored.clone()).expect("group_end decodes");
let TriggerActivation::GroupEnd { after } = &spec.activation else {
panic!("decoded {:?}", spec.activation);
};
assert_eq!(after.seconds(), 1_800);
assert_eq!(serde_json::to_value(&spec).expect("encodes"), authored);
for refused in [
serde_json::json!({"kind": "group_end"}),
serde_json::json!({"kind": "group_end", "after": "30m", "idle": "30m"}),
serde_json::json!({"kind": "group_end", "after": "30m", "max_age": "24h"}),
serde_json::json!({"kind": "group_end", "after": "30m", "finality": "30d"}),
] {
assert!(
serde_json::from_value::<TriggerSpec>(refused.clone()).is_err(),
"{refused}"
);
}
for text in [
"",
"m",
"0m",
"07m",
"30",
"30w",
"-5s",
"+5s",
"1.5h",
"1234567890s",
] {
assert!(TriggerDuration::parse(text).is_err(), "{text:?}");
}
assert_eq!(
TriggerDuration::parse("45s").expect("seconds").seconds(),
45
);
for year in ["31536000s", "525600m", "8760h", "365d"] {
let after = TriggerDuration::parse(year).expect("one year is accepted");
assert_eq!(after.seconds(), 31_536_000, "{year}");
assert_eq!(after.as_str(), year);
let authored = serde_json::json!({"kind": "group_end", "after": year});
let spec: TriggerSpec =
serde_json::from_value(authored.clone()).expect("one year decodes");
assert_eq!(serde_json::to_value(&spec).expect("encodes"), authored);
}
for over in ["31536001s", "525601m", "8761h", "366d", "999999999d"] {
let error = TriggerDuration::parse(over).expect_err(over);
assert!(error.contains("365d"), "{over}: {error}");
let refused = serde_json::json!({"kind": "group_end", "after": over});
assert!(
serde_json::from_value::<TriggerSpec>(refused).is_err(),
"{over}"
);
}
}
}