poet 0.7.0

Static site generator with optional MCP server and LLM SEO optimizations
Documentation
use anyhow::anyhow;
use chrono::DateTime;
use chrono::NaiveDate;
use chrono::NaiveDateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serializer;
use serde::de::Error;

const DATETIME_FORMAT_PATTERNS: &[&str] = &[
    "%Y-%m-%d %H:%M:%S%.f",
    "%Y-%m-%d %H:%M:%S",
    "%Y-%m-%d %H:%M",
    "%Y-%m-%d",
    "%Y/%m/%d %H:%M:%S",
    "%Y/%m/%d %H:%M",
    "%Y/%m/%d",
    "%d-%m-%Y %H:%M:%S",
    "%d-%m-%Y %H:%M",
    "%d-%m-%Y",
    "%Y-%m-%dT%H:%M:%S%.fZ",
    "%Y-%m-%dT%H:%M:%SZ",
    "%Y-%m-%dT%H:%M:%S",
];

pub fn serialize<TSerializer>(
    datetime_value: &Option<DateTime<Utc>>,
    serializer: TSerializer,
) -> Result<TSerializer::Ok, TSerializer::Error>
where
    TSerializer: Serializer,
{
    match datetime_value {
        Some(datetime_value) => serializer.serialize_str(&datetime_value.to_rfc3339()),
        None => serializer.serialize_none(),
    }
}

pub fn deserialize_string(input_string: String) -> anyhow::Result<DateTime<Utc>> {
    for datetime_format_pattern in DATETIME_FORMAT_PATTERNS {
        if let Ok(parsed_naive_datetime) =
            NaiveDateTime::parse_from_str(&input_string, datetime_format_pattern)
        {
            return Ok(DateTime::from_naive_utc_and_offset(
                parsed_naive_datetime,
                Utc,
            ));
        }

        if let Ok(parsed_naive_date) =
            NaiveDate::parse_from_str(&input_string, datetime_format_pattern)
        {
            let naive_datetime_at_midnight = parsed_naive_date
                .and_hms_opt(0, 0, 0)
                .ok_or_else(|| anyhow!("Invalid date"))?;

            return Ok(DateTime::from_naive_utc_and_offset(
                naive_datetime_at_midnight,
                Utc,
            ));
        }
    }

    if let Ok(rfc3339_parsed_datetime) = DateTime::parse_from_rfc3339(&input_string) {
        return Ok(rfc3339_parsed_datetime.with_timezone(&Utc));
    }

    if let Ok(rfc2822_parsed_datetime) = DateTime::parse_from_rfc2822(&input_string) {
        return Ok(rfc2822_parsed_datetime.with_timezone(&Utc));
    }

    Err(anyhow!(
        "Unable to parse '{input_string}' as datetime with any known format"
    ))
}

pub fn deserialize<'deserialization_lifetime, TDeserializer>(
    deserializer: TDeserializer,
) -> Result<Option<DateTime<Utc>>, TDeserializer::Error>
where
    TDeserializer: Deserializer<'deserialization_lifetime>,
{
    let input_option = Option::deserialize(deserializer)?;

    match input_option {
        None => Ok(None),
        Some(input_string) => {
            Ok(Some(deserialize_string(input_string).map_err(|err| {
                TDeserializer::Error::custom(format!("{err:#?}"))
            })?))
        }
    }
}

#[cfg(test)]
mod tests {
    use serde::Serialize;

    use super::*;

    #[derive(Debug, Serialize, Deserialize, PartialEq)]
    struct TestStruct {
        #[serde(default, with = "crate::flexible_datetime")]
        timestamp: Option<DateTime<Utc>>,
    }

    #[test]
    fn test_none() {
        let input_json = r#"{}"#;
        let deserialized: TestStruct = serde_json::from_str(input_json).unwrap();

        assert_eq!(deserialized.timestamp, None);
    }

    #[test]
    fn test_deserialize_date_only_converts_to_midnight() {
        let input_json = r#"{"timestamp":"2025-09-25"}"#;
        let deserialized: TestStruct = serde_json::from_str(input_json).unwrap();
        let expected_datetime = DateTime::parse_from_rfc3339("2025-09-25T00:00:00+00:00")
            .unwrap()
            .with_timezone(&Utc);
        assert_eq!(deserialized.timestamp, Some(expected_datetime));
    }

    #[test]
    fn test_serialize_always_outputs_rfc3339() {
        let test_struct = TestStruct {
            timestamp: Some(
                DateTime::parse_from_rfc3339("2025-09-25T14:30:45+00:00")
                    .unwrap()
                    .with_timezone(&Utc),
            ),
        };
        let serialized = serde_json::to_string(&test_struct).unwrap();
        assert_eq!(serialized, r#"{"timestamp":"2025-09-25T14:30:45+00:00"}"#);
    }

    #[test]
    fn test_serialize_none_outputs_null() {
        let serialized = serde_json::to_string(&TestStruct { timestamp: None }).unwrap();

        assert_eq!(serialized, r#"{"timestamp":null}"#);
    }

    #[test]
    fn parses_pattern_with_time_component() -> anyhow::Result<()> {
        let expected =
            DateTime::parse_from_rfc3339("2025-09-25T14:30:45+00:00")?.with_timezone(&Utc);

        assert_eq!(
            deserialize_string("2025-09-25 14:30:45".to_string())?,
            expected
        );

        Ok(())
    }

    #[test]
    fn parses_rfc3339_with_offset_as_utc() -> anyhow::Result<()> {
        let expected =
            DateTime::parse_from_rfc3339("2025-09-25T12:30:45+00:00")?.with_timezone(&Utc);

        assert_eq!(
            deserialize_string("2025-09-25T14:30:45+02:00".to_string())?,
            expected
        );

        Ok(())
    }

    #[test]
    fn parses_rfc2822_as_utc() -> anyhow::Result<()> {
        let expected =
            DateTime::parse_from_rfc3339("2003-07-01T08:52:37+00:00")?.with_timezone(&Utc);

        assert_eq!(
            deserialize_string("Tue, 01 Jul 2003 10:52:37 +0200".to_string())?,
            expected
        );

        Ok(())
    }

    #[test]
    fn errors_on_unparseable_input() {
        assert!(deserialize_string("not a date".to_string()).is_err());
    }

    #[test]
    fn deserializes_explicit_null_as_none() -> anyhow::Result<()> {
        let deserialized: TestStruct = serde_json::from_str(r#"{"timestamp":null}"#)?;

        assert_eq!(deserialized.timestamp, None);

        Ok(())
    }

    #[test]
    fn deserialization_fails_for_invalid_datetime_field() {
        assert!(serde_json::from_str::<TestStruct>(r#"{"timestamp":"not a date"}"#).is_err());
    }
}