1use serde::Deserialize;
7use serde::Serialize;
8use thiserror::Error;
9
10const SAFE_COMMAND_NAMES: &[&str] = &[
11 "init",
12 "add",
13 "a",
14 "list",
15 "ls",
16 "next",
17 "n",
18 "show",
19 "s",
20 "move",
21 "mv",
22 "reorder",
23 "ro",
24 "edit",
25 "e",
26 "remove",
27 "rm",
28 "restore",
29 "rs",
30 "dep",
31 "d",
32 "link",
33 "ln",
34 "dod",
35 "dd",
36 "export",
37 "sprint",
38 "sp",
39 "board",
40 "b",
41 "cycletime",
42 "ct",
43 "rebalance",
44 "reb",
45 "migrate",
46 "mig",
47 "doctor",
48 "dr",
49];
50
51const UNSAFE_COMMAND_NAMES: &[&str] = &[
52 "automate",
53 "auto",
54 "shell",
55 "kanban",
56 "k",
57 "completion",
58 "import",
61 "undo",
64];
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
71#[error("invalid automation plan")]
72pub enum AutomationPlanError {
73 Invalid,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct AutomationPlan {
80 commands: Vec<Vec<String>>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct AutomationCommandResult {
86 pub index: usize,
88 pub command: String,
90 pub status: String,
92 pub created_ids: Vec<String>,
94 pub updated_ids: Vec<String>,
96 pub error: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102pub struct AutomationReport {
103 pub status: String,
105 pub dry_run: bool,
107 pub commands: Vec<AutomationCommandResult>,
109}
110
111#[derive(Deserialize)]
112#[serde(deny_unknown_fields)]
113struct RawPlan {
114 commands: Vec<Vec<String>>,
115}
116
117impl AutomationPlan {
118 pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
123 let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
124 if raw.commands.is_empty()
125 || raw.commands.iter().any(std::vec::Vec::is_empty)
126 || raw.commands.iter().any(|command| {
127 command
128 .first()
129 .is_some_and(|name| UNSAFE_COMMAND_NAMES.contains(&name.as_str()))
130 })
131 {
132 return Err(AutomationPlanError::Invalid);
133 }
134 Ok(Self {
135 commands: raw.commands,
136 })
137 }
138
139 #[must_use]
141 pub fn commands(&self) -> &[Vec<String>] {
142 &self.commands
143 }
144
145 #[must_use]
152 pub fn json_schema() -> serde_json::Value {
153 serde_json::json!({
154 "$schema": "https://json-schema.org/draft/2020-12/schema",
155 "title": "pinto automation plan",
156 "description": "A non-empty sequence of safe pinto command argv arrays.",
157 "type": "object",
158 "additionalProperties": false,
159 "required": ["commands"],
160 "properties": {
161 "commands": {
162 "type": "array",
163 "description": "Commands are executed in order after clap validation.",
164 "minItems": 1,
165 "items": {"$ref": "#/$defs/command"}
166 }
167 },
168 "$defs": {
169 "command": {
170 "type": "array",
171 "description": "An argv-style pinto command; arguments are strings.",
172 "minItems": 1,
173 "prefixItems": [{
174 "type": "string",
175 "enum": SAFE_COMMAND_NAMES
176 }],
177 "items": {"type": "string"}
178 }
179 }
180 })
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::AutomationPlan;
187
188 #[test]
189 fn accepts_a_sequence_of_commands() {
190 let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
191 .expect("valid plan");
192 assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
193 }
194
195 #[test]
196 fn rejects_unknown_fields_including_api_keys() {
197 assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
198 }
199
200 #[test]
201 fn rejects_recursive_or_interactive_commands() {
202 for command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
203 assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
204 }
205 }
206
207 #[test]
208 fn exposes_a_strict_schema_for_safe_command_plans() {
209 let schema = AutomationPlan::json_schema();
210
211 assert_eq!(
212 schema["$schema"],
213 "https://json-schema.org/draft/2020-12/schema"
214 );
215 assert_eq!(schema["type"], "object");
216 assert_eq!(schema["additionalProperties"], false);
217 assert_eq!(schema["required"], serde_json::json!(["commands"]));
218 assert_eq!(schema["properties"]["commands"]["type"], "array");
219 assert_eq!(schema["properties"]["commands"]["minItems"], 1);
220
221 let command = &schema["$defs"]["command"];
222 assert_eq!(command["type"], "array");
223 assert_eq!(command["minItems"], 1);
224 let command_names = command["prefixItems"][0]["enum"]
225 .as_array()
226 .expect("schema command names are an array");
227 for unsafe_command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
228 assert!(
229 !command_names.iter().any(|name| name == unsafe_command),
230 "unsafe command must not be schema-valid: {unsafe_command}"
231 );
232 }
233 assert!(command_names.iter().any(|name| name == "add"));
234 assert_eq!(command["items"]["type"], "string");
235 }
236}