1use anyhow::Result;
4use clap::{Args, Subcommand, value_parser};
5use comfy_table::{ContentArrangement, Table};
6use ironflow_sdk::IronflowClient;
7use ironflow_sdk::types::{CreateScheduleRequest, ScheduleResponse};
8use uuid::Uuid;
9
10use crate::confirm::confirm;
11use crate::output;
12
13#[derive(Debug, Args)]
15pub struct ScheduleArgs {
16 #[command(subcommand)]
18 pub command: ScheduleCommands,
19}
20
21#[derive(Debug, Subcommand)]
23pub enum ScheduleCommands {
24 List,
26 Create {
28 workflow: String,
30 cron: String,
32 #[arg(long, default_value = "{}")]
34 inputs: String,
35 #[arg(
38 long,
39 allow_negative_numbers = true,
40 value_parser = value_parser!(i16).range(-100..=100)
41 )]
42 priority: Option<i16>,
43 },
44 Pause {
46 id: Uuid,
48 },
49 Resume {
51 id: Uuid,
53 },
54 Delete {
56 id: Uuid,
58 #[arg(long)]
60 yes: bool,
61 },
62 Trigger {
64 id: Uuid,
66 },
67}
68
69fn schedule_state(s: &ScheduleResponse) -> String {
72 match (&s.disabled_at, &s.last_error) {
73 (None, _) => "active".to_string(),
74 (Some(_), Some(error)) => format!("disabled: {error}"),
75 (Some(_), None) => "paused".to_string(),
76 }
77}
78
79fn schedules_table(schedules: &[ScheduleResponse]) -> Table {
80 let mut table = Table::new();
81 table.set_content_arrangement(ContentArrangement::Dynamic);
82 table.set_header(vec![
83 "ID",
84 "WORKFLOW",
85 "CRON",
86 "SOURCE",
87 "PRIORITY",
88 "ENABLED",
89 "NEXT TRIGGER",
90 ]);
91 for s in schedules {
92 let source = format!("{:?}", s.source).to_lowercase();
93 table.add_row(vec![
94 s.id.to_string(),
95 s.workflow_name.clone(),
96 s.cron_expression.clone(),
97 source.to_string(),
98 s.priority.to_string(),
99 schedule_state(s),
100 s.next_trigger_at
101 .as_ref()
102 .map(|d| d.to_string())
103 .unwrap_or_else(|| "-".to_string()),
104 ]);
105 }
106 table
107}
108
109pub async fn execute(client: &IronflowClient, args: &ScheduleArgs, json_mode: bool) -> Result<()> {
116 match &args.command {
117 ScheduleCommands::List => {
118 let response = client.list_schedules().await?;
119 if json_mode {
120 output::print_json(&response)?;
121 } else {
122 println!("{}", schedules_table(&response.data));
123 }
124 Ok(())
125 }
126 ScheduleCommands::Create {
127 workflow,
128 cron,
129 inputs,
130 priority,
131 } => {
132 let parsed_inputs: serde_json::Value =
133 serde_json::from_str(inputs).map_err(|e| anyhow::anyhow!("invalid JSON: {e}"))?;
134 let response = client
135 .create_schedule(&CreateScheduleRequest {
136 workflow_name: workflow.clone(),
137 cron_expression: cron.clone(),
138 inputs: Some(parsed_inputs),
139 priority: priority.map(i32::from),
140 })
141 .await?;
142 if json_mode {
143 output::print_json(&response)?;
144 } else {
145 println!("Schedule {} created", response.data.id);
146 }
147 Ok(())
148 }
149 ScheduleCommands::Pause { id } => {
150 let response = client.pause_schedule(*id).await?;
151 if json_mode {
152 output::print_json(&response)?;
153 } else {
154 println!("Schedule {} paused", response.data.id);
155 }
156 Ok(())
157 }
158 ScheduleCommands::Resume { id } => {
159 let response = client.resume_schedule(*id).await?;
160 if json_mode {
161 output::print_json(&response)?;
162 } else {
163 println!("Schedule {} resumed", response.data.id);
164 }
165 Ok(())
166 }
167 ScheduleCommands::Delete { id, yes } => {
168 let prompt = format!("Delete schedule {id}?");
169 confirm(&prompt, *yes)?;
170 client.delete_schedule(*id).await?;
171 if json_mode {
172 output::print_json(&serde_json::json!({"deleted": id.to_string()}))?;
173 } else {
174 println!("Schedule {id} deleted");
175 }
176 Ok(())
177 }
178 ScheduleCommands::Trigger { id } => {
179 let response = client.trigger_schedule(*id).await?;
180 if json_mode {
181 output::print_json(&response)?;
182 } else {
183 println!("Schedule {} triggered", response.data.id);
184 }
185 Ok(())
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use serde_json::{from_value, json};
193
194 use super::*;
195
196 fn schedule(disabled_at: Option<&str>, last_error: Option<&str>) -> ScheduleResponse {
197 from_value(json!({
198 "id": "01a10fdb-f467-7982-826b-0c99470c7264",
199 "workflow_name": "deploy",
200 "cron_expression": "0 0 30 2 *",
201 "inputs": {},
202 "source": "api",
203 "priority": -10,
204 "disabled_at": disabled_at,
205 "last_triggered_at": null,
206 "next_trigger_at": null,
207 "last_error": last_error,
208 "created_by_user_id": null,
209 "created_at": "2026-10-06T08:00:00Z",
210 "updated_at": "2026-10-06T08:00:00Z"
211 }))
212 .unwrap()
213 }
214
215 #[test]
216 fn schedule_state_shows_why_ironflow_disabled_a_schedule() {
217 let s = schedule(
218 Some("2026-10-06T08:00:00Z"),
219 Some("cannot compute next trigger"),
220 );
221 assert_eq!(schedule_state(&s), "disabled: cannot compute next trigger");
222 assert!(
223 schedules_table(&[s])
224 .to_string()
225 .contains("disabled: cannot compute next trigger")
226 );
227 }
228
229 #[test]
230 fn schedule_state_tells_a_user_pause_from_an_active_schedule() {
231 assert_eq!(
232 schedule_state(&schedule(Some("2026-10-06T08:00:00Z"), None)),
233 "paused"
234 );
235 assert_eq!(schedule_state(&schedule(None, None)), "active");
236 }
237
238 #[test]
239 fn schedules_table_shows_the_priority() {
240 let output = schedules_table(&[schedule(None, None)]).to_string();
241 assert!(
242 output.contains("PRIORITY"),
243 "header missing from:\n{output}"
244 );
245 assert!(output.contains("-10"), "priority missing from:\n{output}");
246 }
247}