use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defschedule")]
pub struct ScheduleSpec {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub cron: String,
#[serde(default)]
pub interval_seconds: u64,
#[serde(default)]
pub idle_seconds: u64,
#[serde(default)]
pub at_startup: bool,
#[serde(default)]
pub command: String,
#[serde(default)]
pub workflow: String,
#[serde(default)]
pub action: String,
#[serde(default)]
pub filetype: String,
#[serde(default)]
pub keybind: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trigger {
Cron,
Interval,
Idle,
Startup,
Manual,
Invalid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dispatch {
Command,
Workflow,
Action,
Invalid,
}
impl ScheduleSpec {
#[must_use]
pub fn trigger(&self) -> Trigger {
let has_cron = !self.cron.is_empty();
let has_interval = self.interval_seconds > 0;
let has_idle = self.idle_seconds > 0;
let has_startup = self.at_startup;
match (has_cron, has_interval, has_idle, has_startup) {
(true, false, false, false) => Trigger::Cron,
(false, true, false, false) => Trigger::Interval,
(false, false, true, false) => Trigger::Idle,
(false, false, false, true) => Trigger::Startup,
(false, false, false, false) => Trigger::Manual,
_ => Trigger::Invalid,
}
}
#[must_use]
pub fn dispatch(&self) -> Dispatch {
let has_cmd = !self.command.is_empty();
let has_wf = !self.workflow.is_empty();
let has_action = !self.action.is_empty();
match (has_cmd, has_wf, has_action) {
(true, false, false) => Dispatch::Command,
(false, true, false) => Dispatch::Workflow,
(false, false, true) => Dispatch::Action,
_ => Dispatch::Invalid,
}
}
#[must_use]
pub fn has_well_shaped_cron(&self) -> bool {
let fields: Vec<&str> = self.cron.split_whitespace().collect();
fields.len() == 5 && fields.iter().all(|f| !f.is_empty())
}
#[must_use]
pub fn is_automatic(&self) -> bool {
!matches!(self.trigger(), Trigger::Manual | Trigger::Invalid)
}
#[must_use]
pub fn trigger_label(&self) -> String {
match self.trigger() {
Trigger::Cron => format!("cron:{}", self.cron),
Trigger::Interval => format!("interval:{}s", self.interval_seconds),
Trigger::Idle => format!("idle:{}s", self.idle_seconds),
Trigger::Startup => "startup".to_string(),
Trigger::Manual => "manual".to_string(),
Trigger::Invalid => "invalid".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bare(name: &str) -> ScheduleSpec {
ScheduleSpec { name: name.into(), ..Default::default() }
}
#[test]
fn trigger_classifies_single_field_as_canonical() {
let mut s = bare("s");
s.cron = "0 * * * *".into();
assert_eq!(s.trigger(), Trigger::Cron);
let mut s = bare("s");
s.interval_seconds = 60;
assert_eq!(s.trigger(), Trigger::Interval);
let mut s = bare("s");
s.idle_seconds = 30;
assert_eq!(s.trigger(), Trigger::Idle);
let mut s = bare("s");
s.at_startup = true;
assert_eq!(s.trigger(), Trigger::Startup);
assert_eq!(bare("s").trigger(), Trigger::Manual);
}
#[test]
fn trigger_rejects_multiple_fields_as_invalid() {
let mut s = bare("s");
s.cron = "0 * * * *".into();
s.interval_seconds = 60;
assert_eq!(s.trigger(), Trigger::Invalid);
let mut s = bare("s");
s.idle_seconds = 30;
s.at_startup = true;
assert_eq!(s.trigger(), Trigger::Invalid);
}
#[test]
fn dispatch_classifies_each_mode() {
let mut s = bare("s");
s.command = "save".into();
assert_eq!(s.dispatch(), Dispatch::Command);
let mut s = bare("s");
s.workflow = "ship".into();
assert_eq!(s.dispatch(), Dispatch::Workflow);
let mut s = bare("s");
s.action = "picker.files".into();
assert_eq!(s.dispatch(), Dispatch::Action);
assert_eq!(bare("s").dispatch(), Dispatch::Invalid);
let mut s = bare("s");
s.command = "a".into();
s.action = "b".into();
assert_eq!(s.dispatch(), Dispatch::Invalid);
}
#[test]
fn cron_shape_check_counts_five_fields() {
let mut s = bare("s");
s.cron = "0 * * * *".into();
assert!(s.has_well_shaped_cron());
s.cron = "0 0 * *".into(); assert!(!s.has_well_shaped_cron());
s.cron = "0 0 * * * *".into(); assert!(!s.has_well_shaped_cron());
s.cron = "garbage".into();
assert!(!s.has_well_shaped_cron());
s.cron = " 0 * * * * ".into();
assert!(s.has_well_shaped_cron());
}
#[test]
fn is_automatic_is_false_for_manual() {
let mut s = bare("s");
assert!(!s.is_automatic());
s.cron = "0 * * * *".into();
assert!(s.is_automatic());
s.cron = String::new();
s.interval_seconds = 0;
s.idle_seconds = 0;
s.at_startup = false;
assert!(!s.is_automatic());
}
#[test]
fn trigger_label_renders_payload_for_each_kind() {
let mut s = bare("s");
s.cron = "*/5 * * * *".into();
assert_eq!(s.trigger_label(), "cron:*/5 * * * *");
s = bare("s");
s.interval_seconds = 120;
assert_eq!(s.trigger_label(), "interval:120s");
s = bare("s");
s.idle_seconds = 45;
assert_eq!(s.trigger_label(), "idle:45s");
s = bare("s");
s.at_startup = true;
assert_eq!(s.trigger_label(), "startup");
assert_eq!(bare("s").trigger_label(), "manual");
let mut bad = bare("bad");
bad.cron = "0 * * * *".into();
bad.interval_seconds = 60;
assert_eq!(bad.trigger_label(), "invalid");
}
}