Skip to main content

actions_rs/
input.rs

1//! Typed access to action inputs.
2//!
3//! An action input named `foo-bar` is passed to the process as the environment variable `INPUT_FOO-BAR`:
4//! the rule is `INPUT_` + uppercased name with spaces replaced by underscores (hyphens are **kept**).
5//! This matches `@actions/core`'s `getInput`.
6//!
7//! The `name → key` transform and the strict boolean parser are pure functions so they are
8//! unit-tested without mutating the global environment.
9
10use std::fmt::Display;
11use std::str::FromStr;
12
13use crate::error::{Error, Result};
14use crate::log;
15
16/// Options controlling how an input is read.
17///
18/// # Examples
19///
20/// ```
21/// use actions_rs::InputOptions;
22///
23/// // Required, but keep surrounding whitespace verbatim.
24/// let opts = InputOptions { required: true, trim: false };
25/// assert!(opts.required);
26/// assert_eq!(InputOptions::default().trim, true);
27/// ```
28#[derive(Debug, Clone, Copy)]
29pub struct InputOptions {
30    /// Error with [`Error::MissingRequiredInput`] if the input is absent/empty.
31    pub required: bool,
32    /// Trim leading/trailing whitespace (default `true`, as in `@actions/core`).
33    pub trim: bool,
34}
35
36impl Default for InputOptions {
37    fn default() -> Self {
38        Self {
39            required: false,
40            trim: true,
41        }
42    }
43}
44
45/// Compute the environment-variable key for an input name.
46///
47/// `INPUT_` + `name.to_uppercase()` with ASCII spaces → `_`.
48///
49/// # Examples
50///
51/// ```
52/// use actions_rs::input::input_env_key;
53///
54/// assert_eq!(input_env_key("my input"), "INPUT_MY_INPUT");
55/// assert_eq!(input_env_key("my-input"), "INPUT_MY-INPUT"); // hyphen kept
56/// ```
57#[must_use]
58pub fn input_env_key(name: &str) -> String {
59    format!("INPUT_{}", name.replace(' ', "_").to_uppercase())
60}
61
62fn raw(name: &str) -> Option<String> {
63    std::env::var(input_env_key(name)).ok()
64}
65
66/// Read an input with explicit [`InputOptions`].
67///
68/// # Errors
69/// [`Error::MissingRequiredInput`] when `options.required` and the **raw** input is absent or empty.\
70/// The required check runs *before* trimming (matching `@actions/core`): a whitespace-only required
71/// input passes the check and then trims to `""`.
72///
73/// # Examples
74///
75/// ```
76/// use actions_rs::{InputOptions, input::input_with};
77///
78/// // Unset + not required -> Ok("").
79/// let v = input_with("nope", InputOptions::default()).unwrap();
80/// assert_eq!(v, "");
81/// ```
82pub fn input_with(name: &str, options: InputOptions) -> Result<String> {
83    let value = raw(name).unwrap_or_default();
84    if options.required && value.is_empty() {
85        return Err(Error::MissingRequiredInput(name.to_owned()));
86    }
87    let value = if options.trim {
88        value.trim().to_owned()
89    } else {
90        value
91    };
92    Ok(value)
93}
94
95/// Read an optional input, trimmed. Returns `""` when unset.
96///
97/// An action input `foo-bar` arrives as the env var `INPUT_FOO-BAR` (uppercased, spaces → `_`, hyphens kept).
98///
99/// # Examples
100///
101/// ```
102/// // No `INPUT_NOPE` is set, so this is the empty string, not an error.
103/// assert_eq!(actions_rs::input::input("nope"), "");
104/// ```
105#[must_use]
106pub fn input(name: &str) -> String {
107    // Infallible: required is false, so `input_with` cannot error here.
108    input_with(name, InputOptions::default()).unwrap_or_default()
109}
110
111/// Read a required input, trimmed.
112///
113/// # Errors
114/// [`Error::MissingRequiredInput`] when absent or empty.
115///
116/// # Examples
117///
118/// ```
119/// // `target` was never provided -> a typed error, not a panic.
120/// let err = actions_rs::input::input_required("target").unwrap_err();
121/// assert!(matches!(err, actions_rs::Error::MissingRequiredInput(_)));
122/// ```
123pub fn input_required(name: &str) -> Result<String> {
124    input_with(
125        name,
126        InputOptions {
127            required: true,
128            trim: true,
129        },
130    )
131}
132
133/// Strict YAML 1.2 core-schema boolean parse of `value` for input `name`.
134fn parse_bool(name: &str, value: &str) -> Result<bool> {
135    match value {
136        "true" | "True" | "TRUE" => Ok(true),
137        "false" | "False" | "FALSE" => Ok(false),
138        _ => Err(Error::InvalidBool {
139            name: name.to_owned(),
140            value: value.to_owned(),
141        }),
142    }
143}
144
145/// Read a boolean input using the strict YAML 1.2 core schema
146/// (`true|True|TRUE|false|False|FALSE`).
147///
148/// # Errors
149/// [`Error::InvalidBool`] for any other value, including absent/empty (matching `@actions/core`'s `getBooleanInput`).
150///
151/// # Examples
152///
153/// ```no_run
154/// // `with: { verbose: true }` -> INPUT_VERBOSE=true
155/// let verbose = actions_rs::input::bool_input("verbose").unwrap_or(false);
156/// if verbose {
157///     actions_rs::log::info("verbose mode");
158/// }
159/// ```
160pub fn bool_input(name: &str) -> Result<bool> {
161    let value = input_with(
162        name,
163        InputOptions {
164            required: false,
165            trim: true,
166        },
167    )?;
168    parse_bool(name, &value)
169}
170
171/// Split a multiline input on `\n`, dropping empty lines.
172/// Each retained line is trimmed.
173///
174/// # Examples
175///
176/// ```
177/// // `paths` unset -> empty vec, never an error.
178/// assert!(actions_rs::input::multiline_input("paths").is_empty());
179/// ```
180#[must_use]
181pub fn multiline_input(name: &str) -> Vec<String> {
182    multiline_input_with(name, InputOptions::default()).unwrap_or_default()
183}
184
185/// Read a multiline input with explicit [`InputOptions`].
186/// Empty raw lines are dropped before optional trimming, matching `@actions/core`.
187///
188/// # Errors
189/// [`Error::MissingRequiredInput`] when `options.required` and the input is absent or empty.
190///
191/// # Examples
192///
193/// ```
194/// use actions_rs::{InputOptions, input::multiline_input_with};
195///
196/// // Optional + unset -> Ok(empty).
197/// let lines = multiline_input_with("globs", InputOptions::default()).unwrap();
198/// assert!(lines.is_empty());
199/// ```
200pub fn multiline_input_with(name: &str, options: InputOptions) -> Result<Vec<String>> {
201    let value = input_with(
202        name,
203        InputOptions {
204            required: options.required,
205            trim: false,
206        },
207    )?;
208    Ok(split_multiline(&value, options.trim))
209}
210
211fn split_multiline(value: &str, trim: bool) -> Vec<String> {
212    let items = value
213        .split('\n')
214        .filter(|line| !line.is_empty())
215        .map(ToOwned::to_owned);
216    if trim {
217        items.map(|line| line.trim().to_owned()).collect()
218    } else {
219        items.collect()
220    }
221}
222
223/// Read an input and parse it via [`FromStr`].
224///
225/// # Errors
226/// [`Error::ParseInput`] if parsing fails (the type's `FromStr::Err` is rendered via [`Display`]).
227///
228/// # Examples
229///
230/// ```no_run
231/// // INPUT_RETRIES=3
232/// let retries: u32 = actions_rs::input::input_as("retries")?;
233/// # Ok::<(), actions_rs::Error>(())
234/// ```
235pub fn input_as<T>(name: &str) -> Result<T>
236where
237    T: FromStr,
238    T::Err: Display,
239{
240    let value = input_required(name)?;
241    value.parse::<T>().map_err(|e| Error::ParseInput {
242        name: name.to_owned(),
243        reason: e.to_string(),
244    })
245}
246
247/// Mask the (untrimmed) raw value of input `name` in subsequent logs.
248///
249/// No-op when the input is unset.
250///
251/// # Examples
252///
253/// ```
254/// // Redact whatever was passed as the `token` input from later logs.
255/// actions_rs::input::mask_input("token");
256/// ```
257pub fn mask_input(name: &str) {
258    if let Some(value) = raw(name).filter(|v| !v.is_empty()) {
259        log::mask(value);
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn key_transform() {
269        assert_eq!(input_env_key("my input"), "INPUT_MY_INPUT");
270        assert_eq!(input_env_key("my-input"), "INPUT_MY-INPUT");
271        assert_eq!(input_env_key("myInput"), "INPUT_MYINPUT");
272        assert_eq!(input_env_key("a b-c d"), "INPUT_A_B-C_D");
273    }
274
275    #[test]
276    fn strict_bool_accepts_canonical() {
277        for v in ["true", "True", "TRUE"] {
278            assert!(parse_bool("x", v).unwrap());
279        }
280        for v in ["false", "False", "FALSE"] {
281            assert!(!parse_bool("x", v).unwrap());
282        }
283    }
284
285    #[test]
286    fn strict_bool_rejects_others() {
287        for v in ["yes", "1", "TrUe", "", " true", "0"] {
288            let e = parse_bool("flag", v).unwrap_err();
289            assert!(
290                matches!(e, Error::InvalidBool { .. }),
291                "{v:?} should be invalid"
292            );
293        }
294    }
295
296    #[test]
297    fn multiline_splits_and_trims_and_drops_empty() {
298        assert_eq!(
299            split_multiline("a\n  b  \n\n c\n", true),
300            vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]
301        );
302        assert!(split_multiline("", true).is_empty());
303    }
304
305    #[test]
306    fn multiline_keeps_whitespace_only_entries_until_after_filter() {
307        assert_eq!(
308            split_multiline("a\n   \n\n b\n", true),
309            vec!["a".to_owned(), "".to_owned(), "b".to_owned()]
310        );
311    }
312}