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}