1#![allow(warnings)]
3
4use std::collections::HashMap;
5use std::path::Path;
6
7#[cfg(feature = "derive")]
8pub use action_derive::Action;
9
10#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
11pub struct Input<'a> {
12 pub description: Option<&'a str>,
13 pub deprecation_message: Option<&'a str>,
14 pub default: Option<&'a str>,
15 pub required: Option<bool>,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)]
19pub enum LogLevel {
20 Debug,
21 Error,
22 Warning,
23 Notice,
24}
25
26impl std::fmt::Display for LogLevel {
27 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
28 match self {
29 LogLevel::Debug => write!(f, "debug"),
30 LogLevel::Error => write!(f, "error"),
31 LogLevel::Warning => write!(f, "warning"),
32 LogLevel::Notice => write!(f, "notice"),
33 }
34 }
35}
36
37pub fn input_env_var(name: impl Into<String>) -> String {
38 let mut var: String = name.into();
39 if !var.starts_with("INPUT_") {
40 var = format!("INPUT_{var}");
41 }
42 var = var.replace(' ', "_").to_uppercase();
43 var
44}
45
46pub mod env {
47 use std::collections::HashMap;
48
49 #[derive(Debug, Default)]
50 pub struct Env(pub HashMap<String, String>);
51
52 impl FromIterator<(String, String)> for Env {
53 fn from_iter<I: IntoIterator<Item = (String, String)>>(iter: I) -> Self {
54 Self::new(HashMap::from_iter(iter))
55 }
56 }
57
58 impl Env {
59 #[must_use]
60 pub fn new(values: HashMap<String, String>) -> Self {
61 let inner = values
62 .into_iter()
63 .map(|(k, v)| (super::input_env_var(k), v))
64 .collect();
65 Self(inner)
66 }
67
68 #[cfg(feature = "serde")]
73 pub fn from_reader(reader: impl std::io::Read) -> Result<Self, serde_yaml::Error> {
74 Ok(Self::new(serde_yaml::from_reader(reader)?))
75 }
76 }
77
78 #[cfg(feature = "serde")]
79 impl std::str::FromStr for Env {
80 type Err = serde_yaml::Error;
81
82 fn from_str(env: &str) -> Result<Self, Self::Err> {
83 Ok(Self::new(serde_yaml::from_str(env)?))
84 }
85 }
86
87 impl std::borrow::Borrow<HashMap<String, String>> for Env {
88 fn borrow(&self) -> &HashMap<String, String> {
89 &self.0
90 }
91 }
92
93 impl std::ops::Deref for Env {
94 type Target = HashMap<String, String>;
95
96 fn deref(&self) -> &Self::Target {
97 &self.0
98 }
99 }
100
101 impl std::ops::DerefMut for Env {
102 fn deref_mut(&mut self) -> &mut Self::Target {
103 &mut self.0
104 }
105 }
106
107 pub trait Read {
108 fn get(&self, key: &str) -> Result<String, std::env::VarError>;
113 }
114
115 pub trait Write {
116 fn set(&mut self, key: String, value: String);
118 }
119
120 impl<T> Read for T
121 where
122 T: std::borrow::Borrow<HashMap<String, String>>,
123 {
124 fn get(&self, key: &str) -> Result<String, std::env::VarError> {
125 self.borrow()
126 .get(key)
127 .ok_or(std::env::VarError::NotPresent)
128 .cloned()
129 }
130 }
131
132 impl<T> Write for T
133 where
134 T: std::borrow::BorrowMut<HashMap<String, String>>,
135 {
136 fn set(&mut self, key: String, value: String) {
137 self.borrow_mut().insert(key, value);
138 }
139 }
140
141 pub struct Std;
142
143 pub static ENV: Std = Std {};
144
145 impl Read for Std {
146 fn get(&self, key: &str) -> Result<String, std::env::VarError> {
147 std::env::var(key)
148 }
149 }
150
151 impl Write for Std {
152 fn set(&mut self, key: String, value: String) {
153 std::env::set_var(key, value);
154 }
155 }
156
157 pub trait Parse {
158 type Error: std::error::Error;
159
160 fn from_str(config: &str) -> Result<HashMap<String, String>, Self::Error>;
165
166 fn from_reader(reader: impl std::io::Read) -> Result<HashMap<String, String>, Self::Error>;
171 }
172}
173
174pub mod utils {
175 pub fn to_posix_path(path: impl AsRef<str>) -> String {
179 path.as_ref().replace('\\', "/")
180 }
181
182 pub fn to_win32_path(path: impl AsRef<str>) -> String {
186 path.as_ref().replace('/', "\\")
187 }
188
189 pub fn to_platform_path(path: impl AsRef<str>) -> String {
194 path.as_ref()
195 .replace(['/', '\\'], std::path::MAIN_SEPARATOR_STR)
196 }
197
198 pub fn escape_data(data: impl AsRef<str>) -> String {
199 data.as_ref()
200 .replace('%', "%25")
201 .replace('\r', "%0D")
202 .replace('\n', "%0A")
203 }
204
205 pub fn escape_property(prop: impl AsRef<str>) -> String {
206 prop.as_ref()
207 .replace('%', "%25")
208 .replace('\r', "%0D")
209 .replace('\n', "%0A")
210 .replace(':', "%3A")
211 .replace(',', "%2C")
212 }
213}
214
215pub mod summary {
216 pub const ENV_VAR: &str = "GITHUB_STEP_SUMMARY";
217 pub const DOCS_URL: &str = "https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary";
218
219 #[derive(Debug, PartialEq, Eq, Hash, Clone)]
220 pub struct TableCell {
221 pub data: String,
223 pub header: bool,
225 pub colspan: usize,
227 pub rowspan: usize,
229 }
230
231 impl TableCell {
232 #[must_use]
233 pub fn new(data: String) -> Self {
234 Self {
235 data,
236 ..Self::default()
237 }
238 }
239
240 #[must_use]
241 pub fn header(data: String) -> Self {
242 Self {
243 data,
244 header: true,
245 ..Self::default()
246 }
247 }
248 }
249
250 impl Default for TableCell {
251 fn default() -> Self {
252 Self {
253 data: String::new(),
254 header: false,
255 colspan: 1,
256 rowspan: 1,
257 }
258 }
259 }
260
261 #[derive(Default, Debug, PartialEq, Eq, Hash, Clone)]
262 pub struct ImageOptions {
263 width: Option<usize>,
265
266 height: Option<usize>,
268 }
269
270 }
274
275pub fn prepare_kv_message(key: &str, value: &str) -> Result<String, ValueError> {
280 use uuid::Uuid;
281 let delimiter = format!("ghadelimiter_{}", Uuid::new_v4());
282
283 if key.contains(&delimiter) {
288 return Err(ValueError::ContainsDelimiter { delimiter });
289 }
290
291 if value.contains(&delimiter) {
292 return Err(ValueError::ContainsDelimiter { delimiter });
293 }
294 Ok(format!("{key}<<{delimiter}\n{value}\n{delimiter}"))
295}
296
297pub fn export_var(name: impl AsRef<str>, value: impl Into<String>) -> Result<(), CommandError> {
302 let value = value.into();
303 std::env::set_var(name.as_ref(), &value);
304
305 if std::env::var("GITHUB_ENV").and_then(not_empty).is_ok() {
306 let message = prepare_kv_message(name.as_ref(), &value)?;
307 issue_file_command("ENV", message)?;
308 return Ok(());
309 }
310
311 issue(
312 &CommandBuilder::new("set-env", value)
313 .property("name", name.as_ref())
314 .build(),
315 );
316 Ok(())
317}
318
319pub fn set_secret(secret: impl Into<String>) {
321 issue(&CommandBuilder::new("add-mask", secret).build());
322}
323
324fn prepend_to_path(path: impl AsRef<Path>) -> Result<(), std::env::JoinPathsError> {
329 if let Some(old_path) = std::env::var_os("PATH") {
330 let paths = [path.as_ref().to_path_buf()]
331 .into_iter()
332 .chain(std::env::split_paths(&old_path));
333 let new_path = std::env::join_paths(paths)?;
334 std::env::set_var("PATH", new_path);
335 }
336 Ok(())
337}
338
339#[derive(thiserror::Error, Debug)]
340pub enum AddPathError {
341 #[error(transparent)]
342 File(#[from] FileCommandError),
343
344 #[error(transparent)]
345 Join(#[from] std::env::JoinPathsError),
346}
347
348pub fn add_path(path: impl AsRef<Path>) -> Result<(), AddPathError> {
355 let path_string = path.as_ref().to_string_lossy();
356 prepend_to_path(path.as_ref())?;
357
358 if std::env::var("GITHUB_PATH").and_then(not_empty).is_ok() {
359 issue_file_command("PATH", &path_string)?;
360 } else {
361 issue(&CommandBuilder::new("add-path", path_string).build());
362 }
363 Ok(())
364}
365
366pub trait Parse {
367 type Input;
368
369 #[must_use]
370 fn parse() -> HashMap<Self::Input, Option<String>> {
371 Self::parse_from(&env::Std)
372 }
373
374 #[must_use]
375 fn parse_from<E: env::Read>(env: &E) -> HashMap<Self::Input, Option<String>>;
376}
377
378pub trait ParseInput: Sized {
379 type Error: std::error::Error;
380
381 fn parse(value: String) -> Result<Self, Self::Error>;
386}
387
388#[derive(thiserror::Error, Debug, PartialEq, Eq, Hash, Clone)]
389pub enum ParseError {
390 #[error("invalid boolean value \"{0}\"")]
391 Bool(String),
392}
393
394impl ParseInput for String {
395 type Error = std::convert::Infallible;
396 fn parse(value: String) -> Result<Self, Self::Error> {
397 Ok(value)
398 }
399}
400
401impl ParseInput for bool {
402 type Error = ParseError;
403 fn parse(value: String) -> Result<Self, Self::Error> {
404 match value.to_ascii_lowercase().as_str() {
405 "yes" | "true" | "t" => Ok(true),
406 "no" | "false" | "f" => Ok(false),
407 _ => Err(ParseError::Bool(value)),
408 }
409 }
410}
411
412pub fn get_input<T>(name: impl AsRef<str>) -> Result<Option<T>, <T as ParseInput>::Error>
419where
420 T: ParseInput,
421{
422 match get_raw_input(&env::ENV, name) {
423 Ok(input) => Some(T::parse(input)).transpose(),
424 Err(_) => Ok(None),
425 }
426}
427
428pub fn not_empty(value: String) -> Result<String, std::env::VarError> {
433 if value.is_empty() {
434 Err(std::env::VarError::NotPresent)
435 } else {
436 Ok(value)
437 }
438}
439
440pub fn get_raw_input(
445 env: &impl env::Read,
446 name: impl AsRef<str>,
447) -> Result<String, std::env::VarError> {
448 env.get(&input_env_var(name.as_ref())).and_then(not_empty)
449}
450
451pub fn get_input_from<T>(
458 env: &impl env::Read,
459 name: impl AsRef<str>,
460) -> Result<Option<T>, <T as ParseInput>::Error>
461where
462 T: ParseInput,
463{
464 match get_raw_input(env, name) {
465 Ok(input) => Some(T::parse(input)).transpose(),
466 Err(_) => Ok(None),
467 }
468}
469
470pub fn get_multiline_input(name: impl AsRef<str>) -> Result<Vec<String>, std::env::VarError> {
475 let value = get_raw_input(&env::ENV, name)?;
476 Ok(value.lines().map(ToOwned::to_owned).collect())
477}
478
479pub fn set_command_echo(enabled: bool) {
483 issue(&CommandBuilder::new("echo", if enabled { "on" } else { "off" }).build());
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)]
487pub enum ExitCode {
488 Success = 0,
490 Failure = 1,
492}
493
494pub fn fail(message: impl std::fmt::Display) {
498 error!("{}", message);
499 std::process::exit(ExitCode::Failure as i32);
500}
501
502#[must_use]
504pub fn is_debug() -> bool {
505 std::env::var("RUNNER_DEBUG")
506 .map(|v| v.trim() == "1")
507 .unwrap_or(false)
508}
509
510#[derive(Debug)]
511pub struct CommandBuilder {
512 command: String,
513 message: String,
514 props: HashMap<String, String>,
515}
516
517impl CommandBuilder {
518 #[must_use]
519 pub fn new(command: impl Into<String>, message: impl Into<String>) -> Self {
520 Self {
521 command: command.into(),
522 message: message.into(),
523 props: HashMap::new(),
524 }
525 }
526
527 #[must_use]
528 pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
529 self.props.insert(key.into(), value.into());
530 self
531 }
532
533 #[must_use]
534 pub fn properties(mut self, props: HashMap<String, String>) -> Self {
535 self.props.extend(props.into_iter());
536 self
537 }
538
539 #[must_use]
540 pub fn build(self) -> Command {
541 let Self {
542 command,
543 message,
544 props,
545 } = self;
546 Command {
547 command,
548 message,
549 props,
550 }
551 }
552}
553
554#[derive(Debug, PartialEq, Eq, Clone)]
555pub struct Command {
556 command: String,
557 message: String,
558 props: HashMap<String, String>,
559}
560
561impl Command {
562 #[must_use]
563 pub fn new(command: String, message: String, props: HashMap<String, String>) -> Self {
564 Self {
565 command,
566 message,
567 props,
568 }
569 }
570}
571
572impl std::fmt::Display for Command {
573 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574 const CMD_STRING: &str = "::";
575 write!(f, "{}{}", CMD_STRING, self.command)?;
576 if !self.props.is_empty() {
577 write!(f, " ")?;
578 }
579 for (i, (k, v)) in self.props.iter().enumerate() {
580 if i > 0 {
581 write!(f, ",")?;
582 }
583 if v.is_empty() {
584 continue;
585 }
586 write!(f, "{k}={}", utils::escape_property(v))?;
587 }
588 write!(f, "{}{}", CMD_STRING, self.message)
589 }
590}
591
592pub fn issue(cmd: &Command) {
593 println!("{cmd}");
594}
595
596#[derive(thiserror::Error, Debug)]
597pub enum ValueError {
598 #[error("should not contain delimiter `{delimiter}`")]
599 ContainsDelimiter { delimiter: String },
600}
601
602#[derive(thiserror::Error, Debug)]
603pub enum FileCommandError {
604 #[error("missing env variable for file command {cmd}")]
605 Missing {
606 source: std::env::VarError,
607 cmd: String,
608 },
609 #[error(transparent)]
610 Io(#[from] std::io::Error),
611
612 #[error(transparent)]
613 Value(#[from] ValueError),
614}
615
616#[derive(thiserror::Error, Debug)]
617pub enum CommandError {
618 #[error(transparent)]
619 File(#[from] FileCommandError),
620
621 #[error(transparent)]
622 Value(#[from] ValueError),
623}
624
625pub fn issue_file_command(
630 command: impl AsRef<str>,
631 message: impl AsRef<str>,
632) -> Result<(), FileCommandError> {
633 use std::io::Write;
634 let key = format!("GITHUB_{}", command.as_ref());
635 let file_path = std::env::var(key).map_err(|source| FileCommandError::Missing {
636 source,
637 cmd: command.as_ref().to_string(),
638 })?;
639 let file = std::fs::OpenOptions::new()
640 .append(true)
641 .write(true)
642 .open(file_path)?;
643 let mut file = std::io::BufWriter::new(file);
644 writeln!(file, "{}", message.as_ref())?;
645 Ok(())
646}
647
648#[derive(Default, Debug, Hash, PartialEq, Eq)]
658pub struct AnnotationProperties {
659 pub title: Option<String>,
660 pub file: Option<String>,
661 pub start_line: Option<usize>,
662 pub end_line: Option<usize>,
663 pub start_column: Option<usize>,
664 pub end_column: Option<usize>,
665}
666
667impl<H> From<AnnotationProperties> for HashMap<String, String, H>
668where
669 H: std::hash::BuildHasher + Default,
670{
671 fn from(props: AnnotationProperties) -> Self {
672 [
673 ("title".to_string(), props.title),
674 ("file".to_string(), props.file),
675 (
676 "line".to_string(),
677 props.start_line.map(|line| line.to_string()),
678 ),
679 (
680 "endLine".to_string(),
681 props.end_line.map(|line| line.to_string()),
682 ),
683 (
684 "col".to_string(),
685 props.start_column.map(|col| col.to_string()),
686 ),
687 (
688 "endColumn".to_string(),
689 props.end_column.map(|col| col.to_string()),
690 ),
691 ]
692 .into_iter()
693 .filter_map(|(k, v)| v.map(|v| (k, v)))
694 .collect()
695 }
696}
697
698pub fn issue_level(
700 level: LogLevel,
701 message: impl Into<String>,
702 props: Option<AnnotationProperties>,
703) {
704 let props = props.unwrap_or_default();
705 issue(
706 &CommandBuilder::new(level.to_string(), message)
707 .properties(props.into())
708 .build(),
709 );
710}
711
712#[macro_export]
723macro_rules! debug {
724 ($($arg:tt)*) => {{
725 $crate::issue_level(
726 $crate::LogLevel::Debug,
727 format!($($arg)*),
728 None,
729 );
730 }};
731 }
732
733#[macro_export]
734macro_rules! warning {
735 ($($arg:tt)*) => {{
736 $crate::issue_level(
737 $crate::LogLevel::Warning,
738 format!($($arg)*),
739 None,
740 );
741 }};
742}
743
744#[macro_export]
745macro_rules! error {
746 ($($arg:tt)*) => {{
747 $crate::issue_level(
748 $crate::LogLevel::Error,
749 format!($($arg)*),
750 None,
751 );
752 }};
753}
754
755#[macro_export]
756macro_rules! notice {
757 ($($arg:tt)*) => {{
758 $crate::issue_level(
759 $crate::LogLevel::Notice,
760 format!($($arg)*),
761 None,
762 );
763 }};
764}
765
766#[macro_export]
767macro_rules! info {
768 ($($arg:tt)*) => { println!($($arg)*); };
769}
770
771pub fn start_group(name: impl Into<String>) {
785 issue(&CommandBuilder::new("group", name).build());
786}
787
788pub fn end_group() {
790 issue(&CommandBuilder::new("endgroup", "").build());
791}
792
793pub fn save_state(name: impl AsRef<str>, value: impl Into<String>) -> Result<(), CommandError> {
798 if std::env::var("GITHUB_STATE").and_then(not_empty).is_ok() {
799 let message = prepare_kv_message(name.as_ref(), &value.into())?;
800 issue_file_command("STATE", message)?;
801 return Ok(());
802 }
803
804 issue(
805 &CommandBuilder::new("save-state", value)
806 .property("name", name.as_ref())
807 .build(),
808 );
809 Ok(())
810}
811
812#[must_use]
814pub fn get_state(name: impl AsRef<str>) -> Option<String> {
815 std::env::var(format!("STATE_{}", name.as_ref())).ok()
816}
817
818pub async fn group<T>(name: impl Into<String>, fut: impl std::future::Future<Output = T>) -> T {
822 start_group(name);
823 let res: T = fut.await;
824
825 end_group();
826 res
827}
828
829#[cfg(test)]
830mod tests {
831 use super::env::Env;
832
833 #[test]
834 fn test_env() {
835 let input_name = "SOME_NAME";
836 let env = Env::from_iter([(input_name.to_string(), "SET".to_string())]);
837 dbg!(&env);
838 assert_eq!(env.get("INPUT_SOME_NAME"), Some(&"SET".to_string()));
839 }
840
841 #[test]
842 fn test_get_non_empty_input() {
843 let input_name = "SOME_NAME";
844 let env = Env::from_iter([(input_name.to_string(), "SET".to_string())]);
845 dbg!(&env);
846 assert_eq!(
847 super::get_input_from::<String>(&env, input_name),
848 Ok(Some("SET".to_string()))
849 );
850 }
851
852 #[test]
853 fn test_get_empty_input() {
854 let input_name = "SOME_NAME";
855 let mut env = Env::from_iter([]);
856 assert_eq!(super::get_input_from::<String>(&env, input_name), Ok(None),);
857
858 env.insert(input_name.to_string(), String::new());
859 assert_eq!(super::get_input_from::<String>(&env, input_name), Ok(None),);
860 }
861}