use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
const SAFE_COMMAND_NAMES: &[&str] = &[
"init",
"add",
"a",
"list",
"ls",
"next",
"n",
"show",
"s",
"move",
"mv",
"reorder",
"ro",
"edit",
"e",
"remove",
"rm",
"restore",
"rs",
"dep",
"d",
"link",
"ln",
"dod",
"dd",
"export",
"sprint",
"sp",
"board",
"b",
"cycletime",
"ct",
"rebalance",
"reb",
"migrate",
"mig",
"doctor",
"dr",
];
const UNSAFE_COMMAND_NAMES: &[&str] = &[
"automate",
"auto",
"shell",
"kanban",
"k",
"completion",
"import",
"undo",
];
#[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| {
command
.first()
.is_some_and(|name| UNSAFE_COMMAND_NAMES.contains(&name.as_str()))
})
{
return Err(AutomationPlanError::Invalid);
}
Ok(Self {
commands: raw.commands,
})
}
#[must_use]
pub fn commands(&self) -> &[Vec<String>] {
&self.commands
}
#[must_use]
pub fn json_schema() -> serde_json::Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "pinto automation plan",
"description": "A non-empty sequence of safe pinto command argv arrays.",
"type": "object",
"additionalProperties": false,
"required": ["commands"],
"properties": {
"commands": {
"type": "array",
"description": "Commands are executed in order after clap validation.",
"minItems": 1,
"items": {"$ref": "#/$defs/command"}
}
},
"$defs": {
"command": {
"type": "array",
"description": "An argv-style pinto command; arguments are strings.",
"minItems": 1,
"prefixItems": [{
"type": "string",
"enum": SAFE_COMMAND_NAMES
}],
"items": {"type": "string"}
}
}
})
}
}
#[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", "auto", "shell", "kanban", "k", "completion"] {
assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
}
}
#[test]
fn exposes_a_strict_schema_for_safe_command_plans() {
let schema = AutomationPlan::json_schema();
assert_eq!(
schema["$schema"],
"https://json-schema.org/draft/2020-12/schema"
);
assert_eq!(schema["type"], "object");
assert_eq!(schema["additionalProperties"], false);
assert_eq!(schema["required"], serde_json::json!(["commands"]));
assert_eq!(schema["properties"]["commands"]["type"], "array");
assert_eq!(schema["properties"]["commands"]["minItems"], 1);
let command = &schema["$defs"]["command"];
assert_eq!(command["type"], "array");
assert_eq!(command["minItems"], 1);
let command_names = command["prefixItems"][0]["enum"]
.as_array()
.expect("schema command names are an array");
for unsafe_command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
assert!(
!command_names.iter().any(|name| name == unsafe_command),
"unsafe command must not be schema-valid: {unsafe_command}"
);
}
assert!(command_names.iter().any(|name| name == "add"));
assert_eq!(command["items"]["type"], "string");
}
}