action_core/
lib.rs

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
31/// Prepare key value message.
32///
33/// # Errors
34/// If the value contains the randomly generated delimiter.
35pub 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    // These should realistically never happen,
40    // but just in case someone finds a way to exploit
41    // uuid generation let's not allow keys or values that
42    // contain the delimiter.
43    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
53/// Sets env variable for this action and future actions in the job.
54///
55/// # Errors
56/// If the file command fails.
57pub 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
79/// Registers a secret which will get masked from logs.
80pub fn set_secret(secret: impl Into<String>) {
81    issue(&CommandBuilder::new("add-mask", secret).build());
82}
83
84/// Prepends a path to the `PATH` environment variable.
85///
86/// # Errors
87/// If the paths can not be joined.
88fn 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
114/// Enables or disables the echoing of commands into stdout for the rest of the step.
115///
116/// Echoing is disabled by default if `ACTIONS_STEP_DEBUG` is not set.
117pub 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    /// A code indicating that the action was successful
124    Success = 0,
125    /// A code indicating that the action was a failure
126    Failure = 1,
127}
128
129/// Sets the action status to failed.
130///
131/// When the action exits it will be with an exit code of 1.
132pub fn fail(message: impl std::fmt::Display) {
133    error!("{}", message);
134    std::process::exit(ExitCode::Failure as i32);
135}
136
137/// Gets whether Actions Step Debug is on or not.
138#[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
260/// Issue a file command.
261///
262/// # Errors
263/// When no env variable for the file command exists or writing fails.
264pub 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
289/// Prepends a path to the `PATH` environment variable.
290///
291/// Persisted for this action and future actions.
292///
293/// # Errors
294/// If the file command
295pub 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// pub fn issue_command(
311//     command: impl AsRef<str>,
312//     message: impl std::fmt::Display,
313//     props: HashMap<String, String>,
314// ) {
315//     let cmd= Command::new(command.as_ref(), message.to_string(), props);
316//     issue();
317// }
318
319#[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
360/// Adds an error issue.
361pub 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// /// Writes debug message to user log.
375// pub fn debug(message: impl std::fmt::Display) {
376//     issue_command("debug", message, HashMap::new())
377// }
378
379// /// Adds an error issue.
380// pub fn error(message: impl ToString, props: AnnotationProperties) {
381//     issue_level(LogLevel::Error, message, props);
382// }
383
384#[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
433// /// Adds a warning issue.
434// pub fn issue_warning(message: impl ToString, props: AnnotationProperties) {
435//     issue_level(LogLevel::Warning, message, props);
436// }
437//
438// /// Adds a notice issue
439// pub fn notice(message: impl std::fmt::Display, props: AnnotationProperties) {
440//     issue_level(LogLevel::Notice, message, props);
441// }
442
443/// Begin an output group.
444///
445/// Output until the next `group_end` will be foldable in this group.
446pub fn start_group(name: impl Into<String>) {
447    issue(&CommandBuilder::new("group", name).build());
448}
449
450/// End an output group.
451pub fn end_group() {
452    issue(&CommandBuilder::new("endgroup", "").build());
453}
454
455/// Saves state for current action, the state can only be retrieved by this action's post job execution.
456///
457/// # Errors
458/// If the file command fails.
459pub 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/// Gets the value of an state set by this action's main execution.
479#[must_use]
480pub fn get_state(name: impl AsRef<str>) -> Option<String> {
481    std::env::var(format!("STATE_{}", name.as_ref())).ok()
482}
483
484/// Wrap an asynchronous function call in a group.
485///
486/// Returns the same type as the function itself.
487pub 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}