use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("invalid automation plan")]
pub enum AutomationPlanError {
Invalid,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutomationPlan {
commands: Vec<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AutomationCommandResult {
pub index: usize,
pub command: String,
pub status: String,
pub created_ids: Vec<String>,
pub updated_ids: Vec<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AutomationReport {
pub status: String,
pub dry_run: bool,
pub commands: Vec<AutomationCommandResult>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPlan {
commands: Vec<Vec<String>>,
}
impl AutomationPlan {
pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
if raw.commands.is_empty()
|| raw.commands.iter().any(std::vec::Vec::is_empty)
|| raw.commands.iter().any(|command| {
matches!(
command.first().map(String::as_str),
Some("automate" | "shell" | "kanban" | "completion")
)
})
{
return Err(AutomationPlanError::Invalid);
}
Ok(Self {
commands: raw.commands,
})
}
#[must_use]
pub fn commands(&self) -> &[Vec<String>] {
&self.commands
}
}
#[cfg(test)]
mod tests {
use super::AutomationPlan;
#[test]
fn accepts_a_sequence_of_commands() {
let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
.expect("valid plan");
assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
}
#[test]
fn rejects_unknown_fields_including_api_keys() {
assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
}
#[test]
fn rejects_recursive_or_interactive_commands() {
for command in ["automate", "shell", "kanban", "completion"] {
assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
}
}
}