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