Skip to main content

ironflow_cli/commands/
schedule.rs

1//! Schedule subcommands: list, create, pause, resume, delete, trigger.
2
3use 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/// Arguments for the `schedule` command group.
14#[derive(Debug, Args)]
15pub struct ScheduleArgs {
16    /// Schedule subcommand.
17    #[command(subcommand)]
18    pub command: ScheduleCommands,
19}
20
21/// Available schedule subcommands.
22#[derive(Debug, Subcommand)]
23pub enum ScheduleCommands {
24    /// List all schedules.
25    List,
26    /// Create a new schedule.
27    Create {
28        /// Workflow name.
29        workflow: String,
30        /// Cron expression (5 or 6 field format).
31        cron: String,
32        /// JSON inputs for the workflow (defaults to `{}`).
33        #[arg(long, default_value = "{}")]
34        inputs: String,
35        /// Queue priority, from -100 to 100, given to every run the schedule
36        /// creates. Defaults to the workflow priority.
37        #[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 a schedule (disable automatic triggers).
45    Pause {
46        /// Schedule ID.
47        id: Uuid,
48    },
49    /// Resume a paused schedule.
50    Resume {
51        /// Schedule ID.
52        id: Uuid,
53    },
54    /// Delete a schedule.
55    Delete {
56        /// Schedule ID.
57        id: Uuid,
58        /// Skip the interactive confirmation.
59        #[arg(long)]
60        yes: bool,
61    },
62    /// Trigger a schedule manually, creating a run immediately.
63    Trigger {
64        /// Schedule ID.
65        id: Uuid,
66    },
67}
68
69/// `active`, `paused` by a user, or `disabled: <reason>` when Ironflow
70/// disabled the schedule on an error.
71fn 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
109/// Execute a schedule subcommand.
110///
111/// # Errors
112///
113/// Returns an error on API failure, invalid JSON inputs, or an unconfirmed
114/// destructive command.
115pub 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}