1pub mod env;
2pub mod input;
3pub mod summary;
4pub mod utils;
5
6use std::collections::HashMap;
7use std::path::Path;
8
9#[cfg(feature = "derive")]
10pub use action_derive::Action;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)]
13pub enum LogLevel {
14 Debug,
15 Error,
16 Warning,
17 Notice,
18}
19
20impl std::fmt::Display for LogLevel {
21 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
22 match self {
23 LogLevel::Debug => write!(f, "debug"),
24 LogLevel::Error => write!(f, "error"),
25 LogLevel::Warning => write!(f, "warning"),
26 LogLevel::Notice => write!(f, "notice"),
27 }
28 }
29}
30
31pub fn prepare_kv_message(key: &str, value: &str) -> Result<String, ValueError> {
36 use uuid::Uuid;
37 let delimiter = format!("ghadelimiter_{}", Uuid::new_v4());
38
39 if key.contains(&delimiter) {
44 return Err(ValueError::ContainsDelimiter { delimiter });
45 }
46
47 if value.contains(&delimiter) {
48 return Err(ValueError::ContainsDelimiter { delimiter });
49 }
50 Ok(format!("{key}<<{delimiter}\n{value}\n{delimiter}"))
51}
52
53pub fn export_var(
58 env: &(impl env::Read + env::Write),
59 name: impl AsRef<str>,
60 value: impl Into<String>,
61) -> Result<(), CommandError> {
62 let value = value.into();
63 env.set(name.as_ref(), &value);
64
65 if env.get("GITHUB_ENV").is_some() {
66 let message = prepare_kv_message(name.as_ref(), &value)?;
67 issue_file_command("ENV", message)?;
68 return Ok(());
69 }
70
71 issue(
72 &CommandBuilder::new("set-env", value)
73 .property("name", name.as_ref())
74 .build(),
75 );
76 Ok(())
77}
78
79pub fn set_secret(secret: impl Into<String>) {
81 issue(&CommandBuilder::new("add-mask", secret).build());
82}
83
84fn prepend_to_path(
89 env: &impl env::Write,
90 path: impl AsRef<Path>,
91) -> Result<(), std::env::JoinPathsError> {
92 if let Some(old_path) = std::env::var_os("PATH") {
93 let paths = [path.as_ref().to_path_buf()]
94 .into_iter()
95 .chain(std::env::split_paths(&old_path));
96 let new_path = std::env::join_paths(paths)?;
97 env.set("PATH", new_path);
98 }
99 Ok(())
100}
101
102pub trait Parse {
103 type Input;
104
105 #[must_use]
106 fn parse() -> HashMap<Self::Input, Option<String>> {
107 Self::parse_from(&env::OsEnv)
108 }
109
110 #[must_use]
111 fn parse_from<E: env::Read>(env: &E) -> HashMap<Self::Input, Option<String>>;
112}
113
114pub fn set_command_echo(enabled: bool) {
118 issue(&CommandBuilder::new("echo", if enabled { "on" } else { "off" }).build());
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)]
122pub enum ExitCode {
123 Success = 0,
125 Failure = 1,
127}
128
129pub fn fail(message: impl std::fmt::Display) {
133 error!("{}", message);
134 std::process::exit(ExitCode::Failure as i32);
135}
136
137#[must_use]
139pub fn is_debug() -> bool {
140 std::env::var("RUNNER_DEBUG")
141 .map(|v| v.trim() == "1")
142 .unwrap_or(false)
143}
144
145#[derive(Debug)]
146pub struct CommandBuilder {
147 command: String,
148 message: String,
149 props: HashMap<String, String>,
150}
151
152impl CommandBuilder {
153 #[must_use]
154 pub fn new(command: impl Into<String>, message: impl Into<String>) -> Self {
155 Self {
156 command: command.into(),
157 message: message.into(),
158 props: HashMap::new(),
159 }
160 }
161
162 #[must_use]
163 pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
164 self.props.insert(key.into(), value.into());
165 self
166 }
167
168 #[must_use]
169 pub fn properties(mut self, props: HashMap<String, String>) -> Self {
170 self.props.extend(props);
171 self
172 }
173
174 #[must_use]
175 pub fn build(self) -> Command {
176 let Self {
177 command,
178 message,
179 props,
180 } = self;
181 Command {
182 command,
183 message,
184 props,
185 }
186 }
187}
188
189#[derive(Debug, PartialEq, Eq, Clone)]
190pub struct Command {
191 command: String,
192 message: String,
193 props: HashMap<String, String>,
194}
195
196impl Command {
197 #[must_use]
198 pub fn new(command: String, message: String, props: HashMap<String, String>) -> Self {
199 Self {
200 command,
201 message,
202 props,
203 }
204 }
205}
206
207impl std::fmt::Display for Command {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 const CMD_STRING: &str = "::";
210 write!(f, "{}{}", CMD_STRING, self.command)?;
211 if !self.props.is_empty() {
212 write!(f, " ")?;
213 }
214 for (i, (k, v)) in self.props.iter().enumerate() {
215 if i > 0 {
216 write!(f, ",")?;
217 }
218 if v.is_empty() {
219 continue;
220 }
221 write!(f, "{k}={}", utils::escape_property(v))?;
222 }
223 write!(f, "{}{}", CMD_STRING, self.message)
224 }
225}
226
227pub fn issue(cmd: &Command) {
228 println!("{cmd}");
229}
230
231#[derive(thiserror::Error, Debug)]
232pub enum ValueError {
233 #[error("should not contain delimiter `{delimiter}`")]
234 ContainsDelimiter { delimiter: String },
235}
236
237#[derive(thiserror::Error, Debug)]
238pub enum FileCommandError {
239 #[error("missing env variable for file command {cmd}")]
240 Missing {
241 source: std::env::VarError,
242 cmd: String,
243 },
244 #[error(transparent)]
245 Io(#[from] std::io::Error),
246
247 #[error(transparent)]
248 Value(#[from] ValueError),
249}
250
251#[derive(thiserror::Error, Debug)]
252pub enum CommandError {
253 #[error(transparent)]
254 File(#[from] FileCommandError),
255
256 #[error(transparent)]
257 Value(#[from] ValueError),
258}
259
260pub fn issue_file_command(
265 command: impl AsRef<str>,
266 message: impl AsRef<str>,
267) -> Result<(), FileCommandError> {
268 use std::io::Write;
269 let key = format!("GITHUB_{}", command.as_ref());
270 let file_path = std::env::var(key).map_err(|source| FileCommandError::Missing {
271 source,
272 cmd: command.as_ref().to_string(),
273 })?;
274 let file = std::fs::OpenOptions::new().append(true).open(file_path)?;
275 let mut file = std::io::BufWriter::new(file);
276 writeln!(file, "{}", message.as_ref())?;
277 Ok(())
278}
279
280#[derive(thiserror::Error, Debug)]
281pub enum AddPathError {
282 #[error(transparent)]
283 File(#[from] FileCommandError),
284
285 #[error(transparent)]
286 Join(#[from] std::env::JoinPathsError),
287}
288
289pub fn add_path(
296 env: &(impl env::Read + env::Write),
297 path: impl AsRef<Path>,
298) -> Result<(), AddPathError> {
299 let path_string = path.as_ref().to_string_lossy();
300 prepend_to_path(env, path.as_ref())?;
301
302 if env.get("GITHUB_PATH").is_some() {
303 issue_file_command("PATH", &path_string)?;
304 } else {
305 issue(&CommandBuilder::new("add-path", path_string).build());
306 }
307 Ok(())
308}
309
310#[derive(Default, Debug, Hash, PartialEq, Eq)]
320pub struct AnnotationProperties {
321 pub title: Option<String>,
322 pub file: Option<String>,
323 pub start_line: Option<usize>,
324 pub end_line: Option<usize>,
325 pub start_column: Option<usize>,
326 pub end_column: Option<usize>,
327}
328
329impl<H> From<AnnotationProperties> for HashMap<String, String, H>
330where
331 H: std::hash::BuildHasher + Default,
332{
333 fn from(props: AnnotationProperties) -> Self {
334 [
335 ("title".to_string(), props.title),
336 ("file".to_string(), props.file),
337 (
338 "line".to_string(),
339 props.start_line.map(|line| line.to_string()),
340 ),
341 (
342 "endLine".to_string(),
343 props.end_line.map(|line| line.to_string()),
344 ),
345 (
346 "col".to_string(),
347 props.start_column.map(|col| col.to_string()),
348 ),
349 (
350 "endColumn".to_string(),
351 props.end_column.map(|col| col.to_string()),
352 ),
353 ]
354 .into_iter()
355 .filter_map(|(k, v)| v.map(|v| (k, v)))
356 .collect()
357 }
358}
359
360pub fn issue_level(
362 level: LogLevel,
363 message: impl Into<String>,
364 props: Option<AnnotationProperties>,
365) {
366 let props = props.unwrap_or_default();
367 issue(
368 &CommandBuilder::new(level.to_string(), message)
369 .properties(props.into())
370 .build(),
371 );
372}
373
374#[macro_export]
385macro_rules! debug {
386 ($($arg:tt)*) => {{
387 $crate::issue_level(
388 $crate::LogLevel::Debug,
389 format!($($arg)*),
390 None,
391 );
392 }};
393 }
394
395#[macro_export]
396macro_rules! warning {
397 ($($arg:tt)*) => {{
398 $crate::issue_level(
399 $crate::LogLevel::Warning,
400 format!($($arg)*),
401 None,
402 );
403 }};
404}
405
406#[macro_export]
407macro_rules! error {
408 ($($arg:tt)*) => {{
409 $crate::issue_level(
410 $crate::LogLevel::Error,
411 format!($($arg)*),
412 None,
413 );
414 }};
415}
416
417#[macro_export]
418macro_rules! notice {
419 ($($arg:tt)*) => {{
420 $crate::issue_level(
421 $crate::LogLevel::Notice,
422 format!($($arg)*),
423 None,
424 );
425 }};
426}
427
428#[macro_export]
429macro_rules! info {
430 ($($arg:tt)*) => { println!($($arg)*); };
431}
432
433pub fn start_group(name: impl Into<String>) {
447 issue(&CommandBuilder::new("group", name).build());
448}
449
450pub fn end_group() {
452 issue(&CommandBuilder::new("endgroup", "").build());
453}
454
455pub fn save_state(
460 env: &impl env::Read,
461 name: impl AsRef<str>,
462 value: impl Into<String>,
463) -> Result<(), CommandError> {
464 if env.get("GITHUB_STATE").is_some() {
465 let message = prepare_kv_message(name.as_ref(), &value.into())?;
466 issue_file_command("STATE", message)?;
467 return Ok(());
468 }
469
470 issue(
471 &CommandBuilder::new("save-state", value)
472 .property("name", name.as_ref())
473 .build(),
474 );
475 Ok(())
476}
477
478#[must_use]
480pub fn get_state(name: impl AsRef<str>) -> Option<String> {
481 std::env::var(format!("STATE_{}", name.as_ref())).ok()
482}
483
484pub async fn group<T>(name: impl Into<String>, fut: impl std::future::Future<Output = T>) -> T {
488 start_group(name);
489 let res: T = fut.await;
490
491 end_group();
492 res
493}