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
10/// Why an automation plan cannot be run safely.
11///
12/// The detailed input is omitted so callers can log an error without leaking
13/// secret values.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
15#[error("invalid automation plan")]
16pub enum AutomationPlanError {
17    /// The JSON format, schema, or permitted command is invalid.
18    Invalid,
19}
20
21/// A JSON plan of pinto commands to execute sequentially.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct AutomationPlan {
24    commands: Vec<Vec<String>>,
25}
26
27/// Per-command result returned by `pinto automate --json`.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct AutomationCommandResult {
30    /// One-based position in the submitted plan.
31    pub index: usize,
32    /// Command name without argument values.
33    pub command: String,
34    /// `valid`, `succeeded`, `failed`, or `skipped`.
35    pub status: String,
36    /// IDs created by this command, when they can be determined.
37    pub created_ids: Vec<String>,
38    /// IDs targeted by an update command, when they can be determined.
39    pub updated_ids: Vec<String>,
40    /// Sanitized error detail for failed or invalid commands.
41    pub error: Option<String>,
42}
43
44/// Structured automation summary returned by `pinto automate --json`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct AutomationReport {
47    /// `completed`, `partial_failure`, `invalid`, or `dry_run`.
48    pub status: String,
49    /// Whether the plan was validated without applying changes.
50    pub dry_run: bool,
51    /// Results in the same order as the submitted commands.
52    pub commands: Vec<AutomationCommandResult>,
53}
54
55#[derive(Deserialize)]
56#[serde(deny_unknown_fields)]
57struct RawPlan {
58    commands: Vec<Vec<String>>,
59}
60
61impl AutomationPlan {
62    /// Validate and read the JSON object `{\"commands\":[[\"add\", \"...\"], ...]}`.
63    ///
64    /// Reject unknown fields, so don't accept or store connection information like API keys.
65    /// Detailed JSON parsing errors may include input content and are not returned to the caller.
66    pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
67        let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
68        if raw.commands.is_empty()
69            || raw.commands.iter().any(std::vec::Vec::is_empty)
70            || raw.commands.iter().any(|command| {
71                matches!(
72                    command.first().map(String::as_str),
73                    Some("automate" | "shell" | "kanban" | "completion")
74                )
75            })
76        {
77            return Err(AutomationPlanError::Invalid);
78        }
79        Ok(Self {
80            commands: raw.commands,
81        })
82    }
83
84    /// Return argv lists in execution order.
85    #[must_use]
86    pub fn commands(&self) -> &[Vec<String>] {
87        &self.commands
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::AutomationPlan;
94
95    #[test]
96    fn accepts_a_sequence_of_commands() {
97        let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
98            .expect("valid plan");
99        assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
100    }
101
102    #[test]
103    fn rejects_unknown_fields_including_api_keys() {
104        assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
105    }
106
107    #[test]
108    fn rejects_recursive_or_interactive_commands() {
109        for command in ["automate", "shell", "kanban", "completion"] {
110            assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
111        }
112    }
113}