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)]
44 idempotency_key: Option<String>,
45 #[arg(long = "max-cost", value_name = "USD")]
48 max_cost: Option<f64>,
49 },
50 List {
52 #[arg(long)]
54 status: Option<String>,
55 #[arg(long)]
57 workflow: Option<String>,
58 #[arg(long)]
60 page: Option<u32>,
61 #[arg(long)]
63 per_page: Option<u32>,
64 },
65 Get {
67 id: Uuid,
69 },
70 Cancel {
72 id: Uuid,
74 },
75 Approve {
77 id: Uuid,
79 },
80 Retry {
82 id: Uuid,
84 },
85}
86
87fn 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
104fn 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
124pub 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}