Skip to main content

actions_rs/
command.rs

1//! Low-level workflow-command construction and emission.
2//!
3//! Most users want the ergonomic helpers in [`crate::log`] / [`crate::annotation`].\
4//! This module exposes the underlying [`WorkflowCommand`] for power users who need to emit a command
5//! the higher-level API does not cover.
6
7use std::fmt;
8use std::io::{self, Write};
9
10use crate::escape::{escape_data, escape_property};
11
12/// A single GitHub Actions workflow command: `::name key=val,...::message`.
13///
14/// Properties are kept in insertion order to produce deterministic output
15/// (which the test-suite and `@actions/core` both rely on).\
16/// The [`Display`] implementation performs all required percent-encoding, so the rendered string is
17/// always safe to write to stdout.
18///
19/// [`Display`]: std::fmt::Display
20///
21/// # Examples
22///
23/// ```
24/// use actions_rs::WorkflowCommand;
25///
26/// let cmd = WorkflowCommand::new("error")
27///     .property("file", "src/a,b.rs") // `,` is property-encoded
28///     .message("bad: x\ny");          // `\n` is data-encoded
29/// assert_eq!(cmd.to_string(), "::error file=src/a%2Cb.rs::bad: x%0Ay");
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct WorkflowCommand {
33    name: &'static str,
34    properties: Vec<(&'static str, String)>,
35    message: String,
36}
37
38impl WorkflowCommand {
39    /// Create a command with the given name and an empty message.
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// use actions_rs::WorkflowCommand;
45    /// assert_eq!(WorkflowCommand::new("endgroup").to_string(), "::endgroup::");
46    /// ```
47    #[must_use]
48    pub fn new(name: &'static str) -> Self {
49        Self {
50            name,
51            properties: Vec::new(),
52            message: String::new(),
53        }
54    }
55
56    /// Set the command message (the segment after the final `::`).
57    ///
58    /// # Examples
59    ///
60    /// ```
61    /// use actions_rs::WorkflowCommand;
62    /// let c = WorkflowCommand::new("warning").message("low disk");
63    /// assert_eq!(c.to_string(), "::warning::low disk");
64    /// ```
65    #[must_use]
66    pub fn message(mut self, message: impl Into<String>) -> Self {
67        self.message = message.into();
68        self
69    }
70
71    /// Append a property.
72    /// The value is percent-encoded on render.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use actions_rs::WorkflowCommand;
78    /// let c = WorkflowCommand::new("notice").property("line", "42").message("m");
79    /// assert_eq!(c.to_string(), "::notice line=42::m");
80    /// ```
81    #[must_use]
82    pub fn property(mut self, key: &'static str, value: impl Into<String>) -> Self {
83        self.properties.push((key, value.into()));
84        self
85    }
86
87    /// Append a property only when `value` is `Some`.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use actions_rs::WorkflowCommand;
93    /// let c = WorkflowCommand::new("notice")
94    ///     .property_opt("file", Option::<String>::None) // skipped
95    ///     .property_opt("line", Some("10"))
96    ///     .message("m");
97    /// assert_eq!(c.to_string(), "::notice line=10::m");
98    /// ```
99    #[must_use]
100    pub fn property_opt(self, key: &'static str, value: Option<impl Into<String>>) -> Self {
101        match value {
102            Some(v) => self.property(key, v),
103            None => self,
104        }
105    }
106
107    /// Render and write this command followed by a newline to `w`.
108    ///
109    /// Used by the test-suite to capture output; the convenience helpers use [`WorkflowCommand::issue`].
110    ///
111    /// # Errors
112    /// Propagates any write error from `w`.
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// use actions_rs::WorkflowCommand;
118    /// let mut buf = Vec::new();
119    /// WorkflowCommand::new("debug").message("d").issue_to(&mut buf).unwrap();
120    /// assert_eq!(buf, b"::debug::d\n");
121    /// ```
122    pub fn issue_to<W: Write>(&self, mut w: W) -> io::Result<()> {
123        writeln!(w, "{self}")
124    }
125
126    /// Render and write this command to stdout followed by a newline.
127    ///
128    /// Stdout is the runner's command channel;
129    /// a failed write here cannot be meaningfully recovered from inside an action,
130    /// so the result is dropped deliberately (matching `@actions/core` behaviour).
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use actions_rs::WorkflowCommand;
136    /// // Emit a command the higher-level API does not cover.
137    /// WorkflowCommand::new("add-matcher").message(".github/pm.json").issue();
138    /// ```
139    pub fn issue(&self) {
140        let _ = self.issue_to(io::stdout().lock());
141    }
142}
143
144impl fmt::Display for WorkflowCommand {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(f, "::{}", self.name)?;
147        for (i, (key, value)) in self.properties.iter().enumerate() {
148            let sep = if i == 0 { ' ' } else { ',' };
149            write!(f, "{sep}{key}={}", escape_property(value))?;
150        }
151        write!(f, "::{}", escape_data(&self.message))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn no_properties() {
161        let c = WorkflowCommand::new("warning").message("hello");
162        assert_eq!(c.to_string(), "::warning::hello");
163    }
164
165    #[test]
166    fn bare_command() {
167        assert_eq!(WorkflowCommand::new("endgroup").to_string(), "::endgroup::");
168    }
169
170    #[test]
171    fn properties_are_ordered_and_escaped() {
172        let c = WorkflowCommand::new("error")
173            .property("title", "Type: bad")
174            .property("file", "a,b.rs")
175            .message("oops\nsecond");
176        assert_eq!(
177            c.to_string(),
178            "::error title=Type%3A bad,file=a%2Cb.rs::oops%0Asecond"
179        );
180    }
181
182    #[test]
183    fn property_opt_skips_none() {
184        let c = WorkflowCommand::new("notice")
185            .property_opt("file", Option::<String>::None)
186            .property_opt("line", Some("10"))
187            .message("m");
188        assert_eq!(c.to_string(), "::notice line=10::m");
189    }
190
191    #[test]
192    fn issue_to_appends_newline() {
193        let mut buf = Vec::new();
194        WorkflowCommand::new("debug")
195            .message("d")
196            .issue_to(&mut buf)
197            .unwrap();
198        assert_eq!(buf, b"::debug::d\n");
199    }
200}