pinto-cli 0.1.1

A lightweight, local-first, Git-friendly Scrum backlog and Kanban board for the CLI and TUI
Documentation
//! A format for safely receiving operation plans generated by AI agents.
//!
//! pinto does not configure or store AI providers or API keys. An external
//! agent or API creates this JSON; the CLI only executes the existing commands.

use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;

/// Why an automation plan cannot be run safely.
///
/// The detailed input is omitted so callers can log an error without leaking
/// secret values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("invalid automation plan")]
pub enum AutomationPlanError {
    /// The JSON format, schema, or permitted command is invalid.
    Invalid,
}

/// A JSON plan of pinto commands to execute sequentially.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutomationPlan {
    commands: Vec<Vec<String>>,
}

/// Per-command result returned by `pinto automate --json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AutomationCommandResult {
    /// One-based position in the submitted plan.
    pub index: usize,
    /// Command name without argument values.
    pub command: String,
    /// `valid`, `succeeded`, `failed`, or `skipped`.
    pub status: String,
    /// IDs created by this command, when they can be determined.
    pub created_ids: Vec<String>,
    /// IDs targeted by an update command, when they can be determined.
    pub updated_ids: Vec<String>,
    /// Sanitized error detail for failed or invalid commands.
    pub error: Option<String>,
}

/// Structured automation summary returned by `pinto automate --json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AutomationReport {
    /// `completed`, `partial_failure`, `invalid`, or `dry_run`.
    pub status: String,
    /// Whether the plan was validated without applying changes.
    pub dry_run: bool,
    /// Results in the same order as the submitted commands.
    pub commands: Vec<AutomationCommandResult>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPlan {
    commands: Vec<Vec<String>>,
}

impl AutomationPlan {
    /// Validate and read the JSON object `{\"commands\":[[\"add\", \"...\"], ...]}`.
    ///
    /// Reject unknown fields, so don't accept or store connection information like API keys.
    /// Detailed JSON parsing errors may include input content and are not returned to the caller.
    pub fn parse(input: &str) -> Result<Self, AutomationPlanError> {
        let raw: RawPlan = serde_json::from_str(input).map_err(|_| AutomationPlanError::Invalid)?;
        if raw.commands.is_empty()
            || raw.commands.iter().any(std::vec::Vec::is_empty)
            || raw.commands.iter().any(|command| {
                matches!(
                    command.first().map(String::as_str),
                    Some("automate" | "shell" | "kanban" | "completion")
                )
            })
        {
            return Err(AutomationPlanError::Invalid);
        }
        Ok(Self {
            commands: raw.commands,
        })
    }

    /// Return argv lists in execution order.
    #[must_use]
    pub fn commands(&self) -> &[Vec<String>] {
        &self.commands
    }
}

#[cfg(test)]
mod tests {
    use super::AutomationPlan;

    #[test]
    fn accepts_a_sequence_of_commands() {
        let plan = AutomationPlan::parse(r#"{"commands":[["add","A"],["move","T-1","done"]]}"#)
            .expect("valid plan");
        assert_eq!(plan.commands()[1], ["move", "T-1", "done"]);
    }

    #[test]
    fn rejects_unknown_fields_including_api_keys() {
        assert!(AutomationPlan::parse(r#"{"api_key":"secret","commands":[["list"]]}"#).is_err());
    }

    #[test]
    fn rejects_recursive_or_interactive_commands() {
        for command in ["automate", "shell", "kanban", "completion"] {
            assert!(AutomationPlan::parse(&format!(r#"{{"commands":[["{command}"]]}}"#)).is_err());
        }
    }
}