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