ironflow_cli/commands/
run.rs1use 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#[derive(Debug, Args)]
19pub struct RunArgs {
20 #[command(subcommand)]
22 pub command: RunCommands,
23}
24
25#[derive(Debug, Subcommand)]
27pub enum RunCommands {
28 Create {
30 workflow: String,
32 #[arg(long, group = "payload_source")]
34 payload: Option<String>,
35 #[arg(long, group = "payload_source")]
37 payload_file: Option<PathBuf>,
38 #[arg(long = "max-cost", value_name = "USD")]
41 max_cost: Option<f64>,
42 },
43 List {
45 #[arg(long)]
47 status: Option<String>,
48 #[arg(long)]
50 workflow: Option<String>,
51 #[arg(long)]
53 page: Option<u32>,
54 #[arg(long)]
56 per_page: Option<u32>,
57 },
58 Get {
60 id: Uuid,
62 },
63 Cancel {
65 id: Uuid,
67 },
68 Approve {
70 id: Uuid,
72 },
73 Retry {
75 id: Uuid,
77 },
78}
79
80fn 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
97fn 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
117pub 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}