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}