Skip to main content

ironflow_cli/commands/
run.rs

1//! Run subcommands: create, list, get, cancel, approve, retry.
2
3use std::fs;
4use std::io::Write as _;
5use std::path::PathBuf;
6use std::slice;
7
8use anyhow::{Context, Result};
9use clap::{Args, Subcommand};
10use ironflow_sdk::IronflowClient;
11use ironflow_sdk::client::ListRunsFilter;
12use ironflow_sdk::types::CreateRunRequest;
13use uuid::Uuid;
14
15use crate::output;
16
17/// Arguments for the `run` command group.
18#[derive(Debug, Args)]
19pub struct RunArgs {
20    /// Run subcommand.
21    #[command(subcommand)]
22    pub command: RunCommands,
23}
24
25/// Available run subcommands.
26#[derive(Debug, Subcommand)]
27pub enum RunCommands {
28    /// Create a new run for a workflow.
29    Create {
30        /// Workflow name to trigger.
31        workflow: String,
32        /// JSON payload (inline string).
33        #[arg(long, group = "payload_source")]
34        payload: Option<String>,
35        /// Path to a JSON file containing the payload.
36        #[arg(long, group = "payload_source")]
37        payload_file: Option<PathBuf>,
38    },
39    /// List runs with optional filters.
40    List {
41        /// Filter by run status (pending, running, completed, failed, etc.).
42        #[arg(long)]
43        status: Option<String>,
44        /// Filter by workflow name.
45        #[arg(long)]
46        workflow: Option<String>,
47        /// Page number (1-based).
48        #[arg(long)]
49        page: Option<u32>,
50        /// Items per page.
51        #[arg(long)]
52        per_page: Option<u32>,
53    },
54    /// Get details of a specific run.
55    Get {
56        /// Run UUID.
57        id: Uuid,
58    },
59    /// Cancel a pending or running run.
60    Cancel {
61        /// Run UUID.
62        id: Uuid,
63    },
64    /// Approve a run waiting for approval.
65    Approve {
66        /// Run UUID.
67        id: Uuid,
68    },
69    /// Retry a failed run.
70    Retry {
71        /// Run UUID.
72        id: Uuid,
73    },
74}
75
76/// Resolve the payload from inline string or file.
77fn resolve_payload(
78    payload: Option<&str>,
79    payload_file: Option<&PathBuf>,
80) -> Result<serde_json::Value> {
81    match (payload, payload_file) {
82        (Some(raw), _) => serde_json::from_str(raw).context("invalid JSON in --payload"),
83        (_, Some(path)) => {
84            let content = fs::read_to_string(path)
85                .with_context(|| format!("cannot read payload file: {}", path.display()))?;
86            serde_json::from_str(&content)
87                .with_context(|| format!("invalid JSON in {}", path.display()))
88        }
89        (None, None) => Ok(serde_json::Value::Object(serde_json::Map::new())),
90    }
91}
92
93/// Execute a run subcommand.
94///
95/// # Errors
96///
97/// Returns an error on API failure or invalid input.
98pub async fn execute(
99    client: &IronflowClient,
100    args: &RunArgs,
101    json_mode: bool,
102    _verbose: bool,
103) -> Result<()> {
104    match &args.command {
105        RunCommands::Create {
106            workflow,
107            payload,
108            payload_file,
109        } => {
110            let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
111            let payload_map = payload_value
112                .as_object()
113                .context("payload must be a JSON object")?
114                .clone();
115            let request: CreateRunRequest = CreateRunRequest::builder()
116                .workflow(workflow.clone())
117                .payload(Some(payload_map))
118                .try_into()
119                .context("failed to build CreateRunRequest")?;
120
121            let response = client.create_run(&request).await?;
122            output::print_output(json_mode, &response, || {
123                output::runs_table(slice::from_ref(&response.data))
124            })?;
125        }
126        RunCommands::List {
127            status,
128            workflow,
129            page,
130            per_page,
131        } => {
132            let filter = ListRunsFilter {
133                status: status.as_deref(),
134                workflow: workflow.as_deref(),
135                page: *page,
136                per_page: *per_page,
137                ..Default::default()
138            };
139            let response = client.list_runs_filtered(&filter).await?;
140            output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
141        }
142        RunCommands::Get { id } => {
143            let response = client.get_run(*id).await?;
144            output::print_output(json_mode, &response, || {
145                output::run_detail_table(&response.data)
146            })?;
147
148            if !json_mode && !response.data.steps.is_empty() {
149                let mut out = std::io::stdout().lock();
150                writeln!(out)?;
151                writeln!(out, "Steps:")?;
152                writeln!(out, "{}", output::steps_table(&response.data.steps))?;
153            }
154        }
155        RunCommands::Cancel { id } => {
156            let response = client.cancel_run(*id).await?;
157            output::print_output(json_mode, &response, || {
158                output::runs_table(slice::from_ref(&response.data))
159            })?;
160        }
161        RunCommands::Approve { id } => {
162            let response = client.approve_run(*id).await?;
163            output::print_output(json_mode, &response, || {
164                output::runs_table(slice::from_ref(&response.data))
165            })?;
166        }
167        RunCommands::Retry { id } => {
168            let response = client.retry_run(*id).await?;
169            output::print_output(json_mode, &response, || {
170                output::runs_table(slice::from_ref(&response.data))
171            })?;
172        }
173    }
174    Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179    use std::io::Write;
180
181    use tempfile::NamedTempFile;
182
183    use super::*;
184
185    #[test]
186    fn resolve_payload_none_returns_empty_object() {
187        let value = resolve_payload(None, None).unwrap();
188        assert!(value.is_object());
189        assert!(value.as_object().unwrap().is_empty());
190    }
191
192    #[test]
193    fn resolve_payload_inline_valid_json() {
194        let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
195        assert_eq!(value["key"], "value");
196    }
197
198    #[test]
199    fn resolve_payload_inline_invalid_json() {
200        let result = resolve_payload(Some("not json"), None);
201        assert!(result.is_err());
202        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
203    }
204
205    #[test]
206    fn resolve_payload_file_valid() {
207        let mut tmp = NamedTempFile::new().unwrap();
208        write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
209        let path = tmp.path().to_path_buf();
210
211        let value = resolve_payload(None, Some(&path)).unwrap();
212        assert_eq!(value["workflow"], "test");
213    }
214
215    #[test]
216    fn resolve_payload_file_not_found() {
217        let path = PathBuf::from("/nonexistent/payload.json");
218        let result = resolve_payload(None, Some(&path));
219        assert!(result.is_err());
220        assert!(result.unwrap_err().to_string().contains("cannot read"));
221    }
222
223    #[test]
224    fn resolve_payload_file_invalid_json() {
225        let mut tmp = NamedTempFile::new().unwrap();
226        write!(tmp, "not valid json").unwrap();
227        let path = tmp.path().to_path_buf();
228
229        let result = resolve_payload(None, Some(&path));
230        assert!(result.is_err());
231        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
232    }
233}