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        /// Maximum cumulative cost for this run, in USD. Overrides the
39        /// workflow and server defaults.
40        #[arg(long = "max-cost", value_name = "USD")]
41        max_cost: Option<f64>,
42    },
43    /// List runs with optional filters.
44    List {
45        /// Filter by run status (pending, running, completed, failed, etc.).
46        #[arg(long)]
47        status: Option<String>,
48        /// Filter by workflow name.
49        #[arg(long)]
50        workflow: Option<String>,
51        /// Page number (1-based).
52        #[arg(long)]
53        page: Option<u32>,
54        /// Items per page.
55        #[arg(long)]
56        per_page: Option<u32>,
57    },
58    /// Get details of a specific run.
59    Get {
60        /// Run UUID.
61        id: Uuid,
62    },
63    /// Cancel a pending or running run.
64    Cancel {
65        /// Run UUID.
66        id: Uuid,
67    },
68    /// Approve a run waiting for approval.
69    Approve {
70        /// Run UUID.
71        id: Uuid,
72    },
73    /// Retry a failed run.
74    Retry {
75        /// Run UUID.
76        id: Uuid,
77    },
78}
79
80/// Resolve the payload from inline string or file.
81fn resolve_payload(
82    payload: Option<&str>,
83    payload_file: Option<&PathBuf>,
84) -> Result<serde_json::Value> {
85    match (payload, payload_file) {
86        (Some(raw), _) => serde_json::from_str(raw).context("invalid JSON in --payload"),
87        (_, Some(path)) => {
88            let content = fs::read_to_string(path)
89                .with_context(|| format!("cannot read payload file: {}", path.display()))?;
90            serde_json::from_str(&content)
91                .with_context(|| format!("invalid JSON in {}", path.display()))
92        }
93        (None, None) => Ok(serde_json::Value::Object(serde_json::Map::new())),
94    }
95}
96
97/// Reject a `--max-cost` value the API would refuse anyway.
98///
99/// Catching it client-side turns a 400 round-trip into an immediate, readable
100/// error.
101///
102/// # Errors
103///
104/// Returns an error when the value is negative or not a finite number.
105fn validate_max_cost(max_cost: Option<f64>) -> Result<()> {
106    match max_cost {
107        Some(value) if !value.is_finite() => {
108            anyhow::bail!("--max-cost must be a finite number, got {value}")
109        }
110        Some(value) if value < 0.0 => {
111            anyhow::bail!("--max-cost must be zero or positive, got {value}")
112        }
113        _ => Ok(()),
114    }
115}
116
117/// Execute a run subcommand.
118///
119/// # Errors
120///
121/// Returns an error on API failure or invalid input.
122pub async fn execute(
123    client: &IronflowClient,
124    args: &RunArgs,
125    json_mode: bool,
126    _verbose: bool,
127) -> Result<()> {
128    match &args.command {
129        RunCommands::Create {
130            workflow,
131            payload,
132            payload_file,
133            max_cost,
134        } => {
135            validate_max_cost(*max_cost)?;
136            let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
137            let payload_map = payload_value
138                .as_object()
139                .context("payload must be a JSON object")?
140                .clone();
141            let request: CreateRunRequest = CreateRunRequest::builder()
142                .workflow(workflow.clone())
143                .payload(Some(payload_map))
144                .max_cost_usd(*max_cost)
145                .try_into()
146                .context("failed to build CreateRunRequest")?;
147
148            let response = client.create_run(&request).await?;
149            output::print_output(json_mode, &response, || {
150                output::runs_table(slice::from_ref(&response.data))
151            })?;
152        }
153        RunCommands::List {
154            status,
155            workflow,
156            page,
157            per_page,
158        } => {
159            let filter = ListRunsFilter {
160                status: status.as_deref(),
161                workflow: workflow.as_deref(),
162                page: *page,
163                per_page: *per_page,
164                ..Default::default()
165            };
166            let response = client.list_runs_filtered(&filter).await?;
167            output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
168        }
169        RunCommands::Get { id } => {
170            let response = client.get_run(*id).await?;
171            output::print_output(json_mode, &response, || {
172                output::run_detail_table(&response.data)
173            })?;
174
175            if !json_mode && !response.data.steps.is_empty() {
176                let mut out = std::io::stdout().lock();
177                writeln!(out)?;
178                writeln!(out, "Steps:")?;
179                writeln!(out, "{}", output::steps_table(&response.data.steps))?;
180            }
181        }
182        RunCommands::Cancel { id } => {
183            let response = client.cancel_run(*id).await?;
184            output::print_output(json_mode, &response, || {
185                output::runs_table(slice::from_ref(&response.data))
186            })?;
187        }
188        RunCommands::Approve { id } => {
189            let response = client.approve_run(*id).await?;
190            output::print_output(json_mode, &response, || {
191                output::runs_table(slice::from_ref(&response.data))
192            })?;
193        }
194        RunCommands::Retry { id } => {
195            let response = client.retry_run(*id).await?;
196            output::print_output(json_mode, &response, || {
197                output::runs_table(slice::from_ref(&response.data))
198            })?;
199        }
200    }
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use std::io::Write;
207
208    use tempfile::NamedTempFile;
209
210    use super::*;
211
212    #[test]
213    fn resolve_payload_none_returns_empty_object() {
214        let value = resolve_payload(None, None).unwrap();
215        assert!(value.is_object());
216        assert!(value.as_object().unwrap().is_empty());
217    }
218
219    #[test]
220    fn resolve_payload_inline_valid_json() {
221        let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
222        assert_eq!(value["key"], "value");
223    }
224
225    #[test]
226    fn resolve_payload_inline_invalid_json() {
227        let result = resolve_payload(Some("not json"), None);
228        assert!(result.is_err());
229        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
230    }
231
232    #[test]
233    fn resolve_payload_file_valid() {
234        let mut tmp = NamedTempFile::new().unwrap();
235        write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
236        let path = tmp.path().to_path_buf();
237
238        let value = resolve_payload(None, Some(&path)).unwrap();
239        assert_eq!(value["workflow"], "test");
240    }
241
242    #[test]
243    fn resolve_payload_file_not_found() {
244        let path = PathBuf::from("/nonexistent/payload.json");
245        let result = resolve_payload(None, Some(&path));
246        assert!(result.is_err());
247        assert!(result.unwrap_err().to_string().contains("cannot read"));
248    }
249
250    #[test]
251    fn resolve_payload_file_invalid_json() {
252        let mut tmp = NamedTempFile::new().unwrap();
253        write!(tmp, "not valid json").unwrap();
254        let path = tmp.path().to_path_buf();
255
256        let result = resolve_payload(None, Some(&path));
257        assert!(result.is_err());
258        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
259    }
260}