autumn-macros 0.9.0

Proc macros for the Autumn web framework
Documentation
//! `#[scheduled]` proc macro implementation.
//!
//! Generates a companion `__autumn_task_info_{name}()` function that returns
//! a `TaskInfo` struct for the scheduler.

use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::parse::Parser as _;
use syn::{ItemFn, LitStr};

struct ScheduledAttrs {
    every: Option<String>,
    cron: Option<String>,
    name: Option<String>,
    tz: Option<String>,
    coordination: Option<String>,
    /// Wait while the cost signal is high (issue #1720).
    deferrable: bool,
}

fn parse_scheduled_args(attr: TokenStream) -> syn::Result<ScheduledAttrs> {
    let mut result = ScheduledAttrs {
        every: None,
        cron: None,
        name: None,
        tz: None,
        coordination: None,
        deferrable: false,
    };

    syn::meta::parser(|meta| {
        if meta.path.is_ident("every") {
            let value: LitStr = meta.value()?.parse()?;
            result.every = Some(value.value());
            Ok(())
        } else if meta.path.is_ident("cron") {
            let value: LitStr = meta.value()?.parse()?;
            result.cron = Some(value.value());
            Ok(())
        } else if meta.path.is_ident("name") {
            let value: LitStr = meta.value()?.parse()?;
            result.name = Some(value.value());
            Ok(())
        } else if meta.path.is_ident("tz") {
            let value: LitStr = meta.value()?.parse()?;
            result.tz = Some(value.value());
            Ok(())
        } else if meta.path.is_ident("coordination") {
            let value: LitStr = meta.value()?.parse()?;
            result.coordination = Some(value.value());
            Ok(())
        } else if meta.path.is_ident("deferrable") {
            result.deferrable = crate::job::parse_flag(&meta)?;
            Ok(())
        } else {
            Err(meta.error(
                "unsupported attribute: expected every, cron, name, tz, coordination, or deferrable",
            ))
        }
    })
    .parse2(attr)?;

    if result.every.is_none() && result.cron.is_none() {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "#[scheduled] requires either `every` or `cron` parameter",
        ));
    }

    if result.every.is_some() && result.cron.is_some() {
        return Err(syn::Error::new(
            proc_macro2::Span::call_site(),
            "#[scheduled] cannot have both `every` and `cron`",
        ));
    }

    Ok(result)
}

pub fn scheduled_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
    let attrs = match parse_scheduled_args(attr) {
        Ok(a) => a,
        Err(err) => return err.to_compile_error(),
    };

    let input_fn: ItemFn = match syn::parse2(item) {
        Ok(f) => f,
        Err(err) => return err.to_compile_error(),
    };

    if input_fn.sig.asyncness.is_none() {
        return syn::Error::new_spanned(
            input_fn.sig.fn_token,
            "#[scheduled] functions must be async",
        )
        .to_compile_error();
    }

    let fn_name = &input_fn.sig.ident;
    let task_name = attrs.name.unwrap_or_else(|| fn_name.to_string());
    let companion_name = format_ident!("__autumn_task_info_{fn_name}");

    // Build the schedule expression
    let schedule_expr = if let Some(every) = &attrs.every {
        // Validate the duration at compile time
        let every_str = every.clone();
        quote! {
            ::autumn_web::task::Schedule::FixedDelay(
                ::autumn_web::task::parse_duration(#every_str)
                    .expect(concat!("invalid duration in #[scheduled(every = \"", #every_str, "\")]"))
            )
        }
    } else if let Some(cron) = &attrs.cron {
        let tz = attrs.tz.as_deref();
        let tz_expr = tz.map_or_else(|| quote! { None }, |tz| quote! { Some(#tz.to_string()) });
        quote! {
            ::autumn_web::task::Schedule::Cron {
                expression: #cron.to_string(),
                timezone: #tz_expr,
            }
        }
    } else {
        unreachable!()
    };

    let task_name_str = task_name;
    let mark_deferrable = attrs.deferrable.then(|| {
        quote! {
            ::autumn_web::cost::mark_deferrable(::autumn_web::cost::WorkKind::Task, #task_name_str);
        }
    });
    let coordination_expr = match attrs.coordination.as_deref() {
        None | Some("fleet") => quote! { ::autumn_web::task::TaskCoordination::Fleet },
        Some("per_replica") => quote! { ::autumn_web::task::TaskCoordination::PerReplica },
        Some(other) => {
            return syn::Error::new(
                proc_macro2::Span::call_site(),
                format!(
                    "invalid #[scheduled(coordination = {other:?})]; expected \"fleet\" or \"per_replica\""
                ),
            )
            .to_compile_error();
        }
    };

    // ── Architecture-graph node (#1747) ─────────────────────────
    // The schedule is carried as the literal the author wrote (`every = "15m"`
    // or the cron expression), not the parsed `Schedule`: the graph reports the
    // declaration, and a parsed value would need the runtime type to render.
    // A cron with a timezone reads `<cron> (<tz>)`. A fixed delay has no zone.
    let graph_schedule = attrs.every.clone().unwrap_or_else(|| {
        let cron = attrs.cron.clone().unwrap_or_default();
        match attrs.tz.as_deref() {
            Some(tz) => format!("{cron} ({tz})"),
            None => cron,
        }
    });
    let graph_descriptor = crate::graph::emit_job_descriptor(
        &input_fn,
        &quote! { #task_name_str },
        "Scheduled",
        &quote! { #graph_schedule },
    );

    quote! {
        #input_fn

        #graph_descriptor

        #[doc(hidden)]
        pub fn #companion_name() -> ::autumn_web::task::TaskInfo {
            #mark_deferrable
            ::autumn_web::task::TaskInfo {
                name: #task_name_str.to_string(),
                schedule: #schedule_expr,
                coordination: #coordination_expr,
                handler: |state: ::autumn_web::AppState| {
                    Box::pin(async move {
                        #fn_name(state).await
                    })
                },
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use quote::quote;

    #[test]
    fn parses_deferrable_flag() {
        let attrs = parse_scheduled_args(quote! { every = "1m" }).expect("parse");
        assert!(!attrs.deferrable);
        let attrs = parse_scheduled_args(quote! { every = "1m", deferrable }).expect("parse");
        assert!(attrs.deferrable);
        let attrs = parse_scheduled_args(quote! { cron = "0 * * * * *", deferrable = false })
            .expect("parse");
        assert!(!attrs.deferrable);
    }

    #[test]
    fn deferrable_task_marks_itself_in_the_info_fn() {
        let out = scheduled_macro(
            quote! { every = "1m", deferrable },
            quote! { async fn sweep(state: AppState) -> AutumnResult<()> { Ok(()) } },
        )
        .to_string();
        assert!(out.contains("mark_deferrable"), "{out}");
        assert!(out.contains("WorkKind :: Task"), "{out}");
    }

    fn graph_schedule_of(attr: TokenStream) -> String {
        scheduled_macro(
            attr,
            quote! { async fn sweep(state: AppState) -> AutumnResult<()> { Ok(()) } },
        )
        .to_string()
    }

    #[test]
    fn graph_schedule_names_the_cron_timezone() {
        let out = graph_schedule_of(quote! { cron = "0 3 * * *", tz = "America/New_York" });
        assert!(
            out.contains(r#"schedule : "0 3 * * * (America/New_York)""#),
            "{out}"
        );
    }

    #[test]
    fn graph_schedule_without_a_timezone_is_the_bare_cron() {
        let out = graph_schedule_of(quote! { cron = "0 3 * * *" });
        assert!(out.contains(r#"schedule : "0 3 * * *""#), "{out}");
    }

    #[test]
    fn graph_schedule_ignores_tz_for_a_fixed_delay() {
        let out = graph_schedule_of(quote! { every = "15m", tz = "UTC" });
        assert!(out.contains(r#"schedule : "15m""#), "{out}");
    }
}