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