1use serde::Deserialize;
7use serde::Serialize;
8use thiserror::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
15#[error("invalid automation plan")]
16pub enum AutomationPlanError {
17 Invalid,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct AutomationPlan {
24 commands: Vec<Vec<String>>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct AutomationCommandResult {
30 pub index: usize,
32 pub command: String,
34 pub status: String,
36 pub created_ids: Vec<String>,
38 pub updated_ids: Vec<String>,
40 pub error: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct AutomationReport {
47 pub status: String,
49 pub dry_run: bool,
51 pub commands: Vec<AutomationCommandResult>,
53}
54
55#[derive(Deserialize)]
56#[serde(deny_unknown_fields)]
57struct RawPlan {
58 commands: Vec<Vec<String>>,
59}
60
61impl AutomationPlan {
62 pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
67 let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
68 if raw.commands.is_empty()
69 || raw.commands.iter().any(std::vec::Vec::is_empty)
70 || raw.commands.iter().any(|command| {
71 matches!(
72 command.first().map(String::as_str),
73 Some("automate" | "shell" | "kanban" | "completion")
74 )
75 })
76 {
77 return Err(AutomationPlanError::Invalid);
78 }
79 Ok(Self {
80 commands: raw.commands,
81 })
82 }
83
84 #[must_use]
86 pub fn commands(&self) -> &[Vec<String>] {
87 &self.commands
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::AutomationPlan;
94
95 #[test]
96 fn accepts_a_sequence_of_commands() {
97 let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
98 .expect("valid plan");
99 assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
100 }
101
102 #[test]
103 fn rejects_unknown_fields_including_api_keys() {
104 assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
105 }
106
107 #[test]
108 fn rejects_recursive_or_interactive_commands() {
109 for command in ["automate", "shell", "kanban", "completion"] {
110 assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
111 }
112 }
113}