1use 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)]
41 max_retries: Option<u32>,
42 #[arg(long)]
48 idempotency_key: Option<String>,
49 #[arg(long = "max-cost", value_name = "USD")]
52 max_cost: Option<f64>,
53 },
54 List {
56 #[arg(long)]
58 status: Option<String>,
59 #[arg(long)]
61 workflow: Option<String>,
62 #[arg(long)]
66 created_by: Option<Uuid>,
67 #[arg(long)]
69 page: Option<u32>,
70 #[arg(long)]
72 per_page: Option<u32>,
73 },
74 Get {
76 id: Uuid,
78 },
79 Cancel {
81 id: Uuid,
83 },
84 Approve {
86 id: Uuid,
88 },
89 Retry {
91 id: Uuid,
93 },
94}
95
96fn resolve_payload(
98 payload: Option<&str>,
99 payload_file: Option<&PathBuf>,
100) -> Result<serde_json::Value> {
101 match (payload, payload_file) {
102 (Some(raw), _) => serde_json::from_str(raw).context("invalid JSON in --payload"),
103 (_, Some(path)) => {
104 let content = fs::read_to_string(path)
105 .with_context(|| format!("cannot read payload file: {}", path.display()))?;
106 serde_json::from_str(&content)
107 .with_context(|| format!("invalid JSON in {}", path.display()))
108 }
109 (None, None) => Ok(serde_json::Value::Object(serde_json::Map::new())),
110 }
111}
112
113fn validate_max_cost(max_cost: Option<f64>) -> Result<()> {
122 match max_cost {
123 Some(value) if !value.is_finite() => {
124 anyhow::bail!("--max-cost must be a finite number, got {value}")
125 }
126 Some(value) if value < 0.0 => {
127 anyhow::bail!("--max-cost must be zero or positive, got {value}")
128 }
129 _ => Ok(()),
130 }
131}
132
133pub async fn execute(
139 client: &IronflowClient,
140 args: &RunArgs,
141 json_mode: bool,
142 _verbose: bool,
143) -> Result<()> {
144 match &args.command {
145 RunCommands::Create {
146 workflow,
147 payload,
148 payload_file,
149 max_retries,
150 idempotency_key,
151 max_cost,
152 } => {
153 validate_max_cost(*max_cost)?;
154 let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
155 let payload_map = payload_value
156 .as_object()
157 .context("payload must be a JSON object")?
158 .clone();
159 let request: CreateRunRequest = CreateRunRequest::builder()
160 .workflow(workflow.clone())
161 .payload(Some(payload_map))
162 .max_retries(max_retries.map(|n| n as i32))
165 .max_cost_usd(*max_cost)
166 .try_into()
167 .context("failed to build CreateRunRequest")?;
168
169 let response = match idempotency_key {
170 Some(key) => client.create_run_idempotent(&request, key).await?,
171 None => client.create_run(&request).await?,
172 };
173 output::print_output(json_mode, &response, || {
174 output::runs_table(slice::from_ref(&response.data))
175 })?;
176 }
177 RunCommands::List {
178 status,
179 workflow,
180 created_by,
181 page,
182 per_page,
183 } => {
184 let filter = ListRunsFilter {
185 status: status.as_deref(),
186 workflow: workflow.as_deref(),
187 created_by: *created_by,
188 page: *page,
189 per_page: *per_page,
190 ..Default::default()
191 };
192 let response = client.list_runs_filtered(&filter).await?;
193 output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
194 }
195 RunCommands::Get { id } => {
196 let response = client.get_run(*id).await?;
197 output::print_output(json_mode, &response, || {
198 output::run_detail_table(&response.data)
199 })?;
200
201 if !json_mode && !response.data.steps.is_empty() {
202 let mut out = std::io::stdout().lock();
203 writeln!(out)?;
204 writeln!(out, "Steps:")?;
205 writeln!(out, "{}", output::steps_table(&response.data.steps))?;
206 }
207 }
208 RunCommands::Cancel { id } => {
209 let response = client.cancel_run(*id).await?;
210 output::print_output(json_mode, &response, || {
211 output::runs_table(slice::from_ref(&response.data))
212 })?;
213 }
214 RunCommands::Approve { id } => {
215 let response = client.approve_run(*id).await?;
216 output::print_output(json_mode, &response, || {
217 output::runs_table(slice::from_ref(&response.data))
218 })?;
219 }
220 RunCommands::Retry { id } => {
221 let response = client.retry_run(*id).await?;
222 output::print_output(json_mode, &response, || {
223 output::runs_table(slice::from_ref(&response.data))
224 })?;
225 }
226 }
227 Ok(())
228}
229
230#[cfg(test)]
231mod tests {
232 use std::io::Write;
233
234 use tempfile::NamedTempFile;
235
236 use super::*;
237
238 #[test]
239 fn resolve_payload_none_returns_empty_object() {
240 let value = resolve_payload(None, None).unwrap();
241 assert!(value.is_object());
242 assert!(value.as_object().unwrap().is_empty());
243 }
244
245 #[test]
246 fn resolve_payload_inline_valid_json() {
247 let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
248 assert_eq!(value["key"], "value");
249 }
250
251 #[test]
252 fn resolve_payload_inline_invalid_json() {
253 let result = resolve_payload(Some("not json"), None);
254 assert!(result.is_err());
255 assert!(result.unwrap_err().to_string().contains("invalid JSON"));
256 }
257
258 #[test]
259 fn resolve_payload_file_valid() {
260 let mut tmp = NamedTempFile::new().unwrap();
261 write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
262 let path = tmp.path().to_path_buf();
263
264 let value = resolve_payload(None, Some(&path)).unwrap();
265 assert_eq!(value["workflow"], "test");
266 }
267
268 #[test]
269 fn resolve_payload_file_not_found() {
270 let path = PathBuf::from("/nonexistent/payload.json");
271 let result = resolve_payload(None, Some(&path));
272 assert!(result.is_err());
273 assert!(result.unwrap_err().to_string().contains("cannot read"));
274 }
275
276 #[test]
277 fn resolve_payload_file_invalid_json() {
278 let mut tmp = NamedTempFile::new().unwrap();
279 write!(tmp, "not valid json").unwrap();
280 let path = tmp.path().to_path_buf();
281
282 let result = resolve_payload(None, Some(&path));
283 assert!(result.is_err());
284 assert!(result.unwrap_err().to_string().contains("invalid JSON"));
285 }
286}