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] = &["automate", "auto", "shell", "kanban", "k", "completion"];
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
58#[error("invalid automation plan")]
59pub enum AutomationPlanError {
60 Invalid,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct AutomationPlan {
67 commands: Vec<Vec<String>>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct AutomationCommandResult {
73 pub index: usize,
75 pub command: String,
77 pub status: String,
79 pub created_ids: Vec<String>,
81 pub updated_ids: Vec<String>,
83 pub error: Option<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89pub struct AutomationReport {
90 pub status: String,
92 pub dry_run: bool,
94 pub commands: Vec<AutomationCommandResult>,
96}
97
98#[derive(Deserialize)]
99#[serde(deny_unknown_fields)]
100struct RawPlan {
101 commands: Vec<Vec<String>>,
102}
103
104impl AutomationPlan {
105 pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
110 let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
111 if raw.commands.is_empty()
112 || raw.commands.iter().any(std::vec::Vec::is_empty)
113 || raw.commands.iter().any(|command| {
114 command
115 .first()
116 .is_some_and(|name| UNSAFE_COMMAND_NAMES.contains(&name.as_str()))
117 })
118 {
119 return Err(AutomationPlanError::Invalid);
120 }
121 Ok(Self {
122 commands: raw.commands,
123 })
124 }
125
126 #[must_use]
128 pub fn commands(&self) -> &[Vec<String>] {
129 &self.commands
130 }
131
132 #[must_use]
139 pub fn json_schema() -> serde_json::Value {
140 serde_json::json!({
141 "$schema": "https://json-schema.org/draft/2020-12/schema",
142 "title": "pinto automation plan",
143 "description": "A non-empty sequence of safe pinto command argv arrays.",
144 "type": "object",
145 "additionalProperties": false,
146 "required": ["commands"],
147 "properties": {
148 "commands": {
149 "type": "array",
150 "description": "Commands are executed in order after clap validation.",
151 "minItems": 1,
152 "items": {"$ref": "#/$defs/command"}
153 }
154 },
155 "$defs": {
156 "command": {
157 "type": "array",
158 "description": "An argv-style pinto command; arguments are strings.",
159 "minItems": 1,
160 "prefixItems": [{
161 "type": "string",
162 "enum": SAFE_COMMAND_NAMES
163 }],
164 "items": {"type": "string"}
165 }
166 }
167 })
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::AutomationPlan;
174
175 #[test]
176 fn accepts_a_sequence_of_commands() {
177 let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
178 .expect("valid plan");
179 assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
180 }
181
182 #[test]
183 fn rejects_unknown_fields_including_api_keys() {
184 assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
185 }
186
187 #[test]
188 fn rejects_recursive_or_interactive_commands() {
189 for command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
190 assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
191 }
192 }
193
194 #[test]
195 fn exposes_a_strict_schema_for_safe_command_plans() {
196 let schema = AutomationPlan::json_schema();
197
198 assert_eq!(
199 schema["$schema"],
200 "https://json-schema.org/draft/2020-12/schema"
201 );
202 assert_eq!(schema["type"], "object");
203 assert_eq!(schema["additionalProperties"], false);
204 assert_eq!(schema["required"], serde_json::json!(["commands"]));
205 assert_eq!(schema["properties"]["commands"]["type"], "array");
206 assert_eq!(schema["properties"]["commands"]["minItems"], 1);
207
208 let command = &schema["$defs"]["command"];
209 assert_eq!(command["type"], "array");
210 assert_eq!(command["minItems"], 1);
211 let command_names = command["prefixItems"][0]["enum"]
212 .as_array()
213 .expect("schema command names are an array");
214 for unsafe_command in ["automate", "auto", "shell", "kanban", "k", "completion"] {
215 assert!(
216 !command_names.iter().any(|name| name == unsafe_command),
217 "unsafe command must not be schema-valid: {unsafe_command}"
218 );
219 }
220 assert!(command_names.iter().any(|name| name == "add"));
221 assert_eq!(command["items"]["type"], "string");
222 }
223}