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};
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    },
36    /// Pause a schedule (disable automatic triggers).
37    Pause {
38        /// Schedule ID.
39        id: Uuid,
40    },
41    /// Resume a paused schedule.
42    Resume {
43        /// Schedule ID.
44        id: Uuid,
45    },
46    /// Delete a schedule.
47    Delete {
48        /// Schedule ID.
49        id: Uuid,
50        /// Skip the interactive confirmation.
51        #[arg(long)]
52        yes: bool,
53    },
54    /// Trigger a schedule manually, creating a run immediately.
55    Trigger {
56        /// Schedule ID.
57        id: Uuid,
58    },
59}
60
61/// `active`, `paused` by a user, or `disabled: <reason>` when Ironflow
62/// disabled the schedule on an error.
63fn schedule_state(s: &ScheduleResponse) -> String {
64    match (&s.disabled_at, &s.last_error) {
65        (None, _) => "active".to_string(),
66        (Some(_), Some(error)) => format!("disabled: {error}"),
67        (Some(_), None) => "paused".to_string(),
68    }
69}
70
71fn schedules_table(schedules: &[ScheduleResponse]) -> Table {
72    let mut table = Table::new();
73    table.set_content_arrangement(ContentArrangement::Dynamic);
74    table.set_header(vec![
75        "ID",
76        "WORKFLOW",
77        "CRON",
78        "SOURCE",
79        "ENABLED",
80        "NEXT TRIGGER",
81    ]);
82    for s in schedules {
83        let source = format!("{:?}", s.source).to_lowercase();
84        table.add_row(vec![
85            s.id.to_string(),
86            s.workflow_name.clone(),
87            s.cron_expression.clone(),
88            source.to_string(),
89            schedule_state(s),
90            s.next_trigger_at
91                .as_ref()
92                .map(|d| d.to_string())
93                .unwrap_or_else(|| "-".to_string()),
94        ]);
95    }
96    table
97}
98
99/// Execute a schedule subcommand.
100///
101/// # Errors
102///
103/// Returns an error on API failure, invalid JSON inputs, or an unconfirmed
104/// destructive command.
105pub async fn execute(client: &IronflowClient, args: &ScheduleArgs, json_mode: bool) -> Result<()> {
106    match &args.command {
107        ScheduleCommands::List => {
108            let response = client.list_schedules().await?;
109            if json_mode {
110                output::print_json(&response)?;
111            } else {
112                println!("{}", schedules_table(&response.data));
113            }
114            Ok(())
115        }
116        ScheduleCommands::Create {
117            workflow,
118            cron,
119            inputs,
120        } => {
121            let parsed_inputs: serde_json::Value =
122                serde_json::from_str(inputs).map_err(|e| anyhow::anyhow!("invalid JSON: {e}"))?;
123            let response = client
124                .create_schedule(&CreateScheduleRequest {
125                    workflow_name: workflow.clone(),
126                    cron_expression: cron.clone(),
127                    inputs: Some(parsed_inputs),
128                })
129                .await?;
130            if json_mode {
131                output::print_json(&response)?;
132            } else {
133                println!("Schedule {} created", response.data.id);
134            }
135            Ok(())
136        }
137        ScheduleCommands::Pause { id } => {
138            let response = client.pause_schedule(*id).await?;
139            if json_mode {
140                output::print_json(&response)?;
141            } else {
142                println!("Schedule {} paused", response.data.id);
143            }
144            Ok(())
145        }
146        ScheduleCommands::Resume { id } => {
147            let response = client.resume_schedule(*id).await?;
148            if json_mode {
149                output::print_json(&response)?;
150            } else {
151                println!("Schedule {} resumed", response.data.id);
152            }
153            Ok(())
154        }
155        ScheduleCommands::Delete { id, yes } => {
156            let prompt = format!("Delete schedule {id}?");
157            confirm(&prompt, *yes)?;
158            client.delete_schedule(*id).await?;
159            if json_mode {
160                output::print_json(&serde_json::json!({"deleted": id.to_string()}))?;
161            } else {
162                println!("Schedule {id} deleted");
163            }
164            Ok(())
165        }
166        ScheduleCommands::Trigger { id } => {
167            let response = client.trigger_schedule(*id).await?;
168            if json_mode {
169                output::print_json(&response)?;
170            } else {
171                println!("Schedule {} triggered", response.data.id);
172            }
173            Ok(())
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use serde_json::{from_value, json};
181
182    use super::*;
183
184    fn schedule(disabled_at: Option<&str>, last_error: Option<&str>) -> ScheduleResponse {
185        from_value(json!({
186            "id": "01a10fdb-f467-7982-826b-0c99470c7264",
187            "workflow_name": "deploy",
188            "cron_expression": "0 0 30 2 *",
189            "inputs": {},
190            "source": "api",
191            "disabled_at": disabled_at,
192            "last_triggered_at": null,
193            "next_trigger_at": null,
194            "last_error": last_error,
195            "created_by_user_id": null,
196            "created_at": "2026-10-06T08:00:00Z",
197            "updated_at": "2026-10-06T08:00:00Z"
198        }))
199        .unwrap()
200    }
201
202    #[test]
203    fn schedule_state_shows_why_ironflow_disabled_a_schedule() {
204        let s = schedule(
205            Some("2026-10-06T08:00:00Z"),
206            Some("cannot compute next trigger"),
207        );
208        assert_eq!(schedule_state(&s), "disabled: cannot compute next trigger");
209        assert!(
210            schedules_table(&[s])
211                .to_string()
212                .contains("disabled: cannot compute next trigger")
213        );
214    }
215
216    #[test]
217    fn schedule_state_tells_a_user_pause_from_an_active_schedule() {
218        assert_eq!(
219            schedule_state(&schedule(Some("2026-10-06T08:00:00Z"), None)),
220            "paused"
221        );
222        assert_eq!(schedule_state(&schedule(None, None)), "active");
223    }
224}