Skip to main content

ironflow_cli/commands/
signal.rs

1//! Signal subcommands: send, list.
2
3use std::fs::read_to_string;
4
5use anyhow::{Context, Result, bail};
6use clap::{Args, Subcommand};
7use comfy_table::{ContentArrangement, Table};
8use ironflow_sdk::IronflowClient;
9use ironflow_sdk::client::ListSignalsFilter;
10use ironflow_sdk::types::{SendSignalRequest, SignalDeliveryResponse, SignalResponse};
11use serde_json::{Value, from_str, from_value, json};
12
13use crate::output;
14
15/// Arguments for the `signal` command group.
16#[derive(Debug, Args)]
17pub struct SignalArgs {
18    /// Signal subcommand.
19    #[command(subcommand)]
20    pub command: SignalCommands,
21}
22
23/// Available signal subcommands.
24#[derive(Debug, Subcommand)]
25pub enum SignalCommands {
26    /// Send a signal, resuming the runs waiting for it.
27    Send {
28        /// Signal name, e.g. `ci.pipeline_finished`.
29        name: String,
30        /// Occurrence key, e.g. a commit SHA.
31        #[arg(long)]
32        key: String,
33        /// JSON payload, or `@path` to read it from a file. Defaults to `{}`.
34        #[arg(long)]
35        payload: Option<String>,
36        /// Deduplication ID: sending it again delivers nothing.
37        #[arg(long)]
38        idempotency_id: Option<String>,
39    },
40    /// List received signals, newest first.
41    List {
42        /// Only signals with this exact name.
43        #[arg(long)]
44        name: Option<String>,
45        /// Only signals with this exact key.
46        #[arg(long)]
47        key: Option<String>,
48        /// Page number (1-based).
49        #[arg(long)]
50        page: Option<u32>,
51        /// Items per page (max 100).
52        #[arg(long)]
53        per_page: Option<u32>,
54    },
55}
56
57/// Read a payload file named by `--payload @path`.
58fn read_payload_file(path: &str) -> Result<String> {
59    read_to_string(path).with_context(|| format!("cannot read payload file '{path}'"))
60}
61
62/// Parse the `--payload` value: inline JSON, `@path` to a JSON file, or `{}`
63/// when absent.
64fn parse_payload(raw: Option<&str>) -> Result<Value> {
65    let Some(raw) = raw else {
66        return Ok(json!({}));
67    };
68    let text = match raw.strip_prefix('@') {
69        Some(path) => read_payload_file(path)?,
70        None => raw.to_string(),
71    };
72    let payload: Value = from_str(&text).context("--payload must be valid JSON")?;
73    if !payload.is_object() {
74        bail!("--payload must be a JSON object");
75    }
76    Ok(payload)
77}
78
79fn delivery_table(delivery: &SignalDeliveryResponse) -> Table {
80    let mut table = Table::new();
81    table.set_content_arrangement(ContentArrangement::Dynamic);
82    table.set_header(vec!["SIGNAL ID", "DUPLICATE", "RESUMED", "REJECTED"]);
83    table.add_row(vec![
84        delivery.signal_id.to_string(),
85        delivery.duplicate.to_string(),
86        delivery.resumed.len().to_string(),
87        delivery.rejected.len().to_string(),
88    ]);
89    table
90}
91
92fn signals_table(items: &[SignalResponse]) -> Table {
93    let mut table = Table::new();
94    table.set_content_arrangement(ContentArrangement::Dynamic);
95    table.set_header(vec!["ID", "NAME", "KEY", "RECEIVED AT"]);
96    for s in items {
97        table.add_row(vec![
98            s.id.to_string(),
99            s.name.clone(),
100            s.key.clone(),
101            s.received_at.to_string(),
102        ]);
103    }
104    table
105}
106
107/// Execute a signal subcommand.
108///
109/// # Errors
110///
111/// Returns an error on API failure or an invalid `--payload`.
112pub async fn execute(client: &IronflowClient, args: &SignalArgs, json_mode: bool) -> Result<()> {
113    match &args.command {
114        SignalCommands::Send {
115            name,
116            key,
117            payload,
118            idempotency_id,
119        } => {
120            let payload = parse_payload(payload.as_deref())?;
121            let request: SendSignalRequest = from_value(json!({
122                "name": name,
123                "key": key,
124                "payload": payload,
125                "idempotency_id": idempotency_id,
126            }))
127            .context("cannot build the signal request")?;
128            let response = client.send_signal(&request).await?;
129            output::print_output(json_mode, &response, || delivery_table(&response.data))
130        }
131        SignalCommands::List {
132            name,
133            key,
134            page,
135            per_page,
136        } => {
137            let filter = ListSignalsFilter {
138                name: name.clone(),
139                key: key.clone(),
140                page: *page,
141                per_page: *per_page,
142            };
143            let response = client.list_signals(&filter).await?;
144            output::print_output(json_mode, &response, || signals_table(&response.data))
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use std::fs::write;
152
153    use tempfile::tempdir;
154
155    use super::*;
156
157    #[test]
158    fn parse_payload_defaults_to_an_empty_object() {
159        assert_eq!(parse_payload(None).unwrap(), json!({}));
160    }
161
162    #[test]
163    fn parse_payload_accepts_inline_json() {
164        let payload = parse_payload(Some(r#"{"status":"success"}"#)).unwrap();
165        assert_eq!(payload, json!({"status": "success"}));
166    }
167
168    #[test]
169    fn parse_payload_reads_a_file() {
170        let dir = tempdir().unwrap();
171        let path = dir.path().join("payload.json");
172        write(&path, r#"{"sha":"abc"}"#).unwrap();
173        let payload = parse_payload(Some(&format!("@{}", path.display()))).unwrap();
174        assert_eq!(payload, json!({"sha": "abc"}));
175    }
176
177    #[test]
178    fn parse_payload_rejects_invalid_json_and_non_objects() {
179        assert!(parse_payload(Some("{not json")).is_err());
180        assert!(parse_payload(Some("[1, 2]")).is_err());
181        assert!(parse_payload(Some("@/nonexistent/payload.json")).is_err());
182    }
183}