Skip to main content

pinto/
automation.rs

1//! A format for safely receiving operation plans generated by AI agents.
2//!
3//! pinto does not configure or store AI providers or API keys. An external
4//! agent or API creates this JSON; the CLI only executes the existing commands.
5
6use 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/// Why an automation plan cannot be run safely.
54///
55/// The detailed input is omitted so callers can log an error without leaking
56/// secret values.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
58#[error("invalid automation plan")]
59pub enum AutomationPlanError {
60    /// The JSON format, schema, or permitted command is invalid.
61    Invalid,
62}
63
64/// A JSON plan of pinto commands to execute sequentially.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct AutomationPlan {
67    commands: Vec<Vec<String>>,
68}
69
70/// Per-command result returned by `pinto automate --json`.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct AutomationCommandResult {
73    /// One-based position in the submitted plan.
74    pub index: usize,
75    /// Command name without argument values.
76    pub command: String,
77    /// `valid`, `succeeded`, `failed`, or `skipped`.
78    pub status: String,
79    /// IDs created by this command, when they can be determined.
80    pub created_ids: Vec<String>,
81    /// IDs targeted by an update command, when they can be determined.
82    pub updated_ids: Vec<String>,
83    /// Sanitized error detail for failed or invalid commands.
84    pub error: Option<String>,
85}
86
87/// Structured automation summary returned by `pinto automate --json`.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89pub struct AutomationReport {
90    /// `completed`, `partial_failure`, `invalid`, or `dry_run`.
91    pub status: String,
92    /// Whether the plan was validated without applying changes.
93    pub dry_run: bool,
94    /// Results in the same order as the submitted commands.
95    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    /// Validate and read the JSON object `{\"commands\":[[\"add\", \"...\"], ...]}`.
106    ///
107    /// Reject unknown fields, so don't accept or store connection information like API keys.
108    /// Detailed JSON parsing errors may include input content and are not returned to the caller.
109    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    /// Return argv lists in execution order.
127    #[must_use]
128    pub fn commands(&self) -> &[Vec<String>] {
129        &self.commands
130    }
131
132    /// Return the JSON Schema for the validated automation-plan envelope.
133    ///
134    /// The schema covers the JSON structure and excludes commands that would recurse into
135    /// automation or start an interactive session. Arguments after the command name remain
136    /// ordinary strings because the CLI's existing `clap` parser is the source of truth for each
137    /// command's complete argument grammar.
138    #[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}