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