automaat_processor_string_regex/
lib.rs

1//! An [Automaat] processor to match and replace strings using regex patterns.
2//!
3//! This processor allows you to match against strings in an Automaat workflow,
4//! and either show an error if the pattern doesn't match, or replace the
5//! pattern with a replacement string.
6//!
7//! It is great for transforming the output of the previous processor into
8//! something that is more readable for the user, before printing it to the
9//! screen using the [`PrintOutput`] processor.
10//!
11//! [Automaat]: automaat_core
12//! [`PrintOutput`]: https://docs.rs/automaat-processor-print-output
13//!
14//! # Examples
15//!
16//! ## Replace input based on regex pattern
17//!
18//! One common example of this processor is to use it after another processor
19//! ran, which provided some output that needs to be rewritten before it is used
20//! by the next processor (or presented to the user).
21//!
22//! In this example, we get a string `Failure #233 - email does not exist`. We
23//! want to rewrite this output to show `error: email does not exist`.
24//!
25//! ```rust
26//! # fn main() -> Result<(), Box<std::error::Error>> {
27//! use automaat_core::{Context, Processor};
28//! use automaat_processor_string_regex::StringRegex;
29//!
30//! let context = Context::new()?;
31//!
32//! let processor = StringRegex {
33//!     input: "Failure #233 - email does not exist".to_owned(),
34//!     regex: r"\A[^-]+ - (.*)\z".to_owned(),
35//!     mismatch_error: None,
36//!     replace: Some("error: $1".to_owned())
37//! };
38//!
39//! let output = processor.run(&context)?;
40//!
41//! assert_eq!(output, Some("error: email does not exist".to_owned()));
42//! #     Ok(())
43//! # }
44//! ```
45//!
46//! ## Return error on regex mismatch
47//!
48//! Another common use-case is to match against some input, and return an error
49//! if the pattern does not match.
50//!
51//! In this case, we want the string to be a valid UUIDv4 format, and return an
52//! understandable error to the user if it does not match.
53//!
54//! ```rust
55//! # fn main() -> Result<(), Box<std::error::Error>> {
56//! use automaat_core::{Context, Processor};
57//! use automaat_processor_string_regex::StringRegex;
58//!
59//! let context = Context::new()?;
60//!
61//! let processor = StringRegex {
62//!     input: "This is not a valid UUID".to_owned(),
63//!     regex: r"\A([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\z".to_owned(),
64//!     mismatch_error: Some("provided value is not in a valid UUIDv4 format".to_owned()),
65//!     replace: None
66//! };
67//!
68//! let error = processor.run(&context).unwrap_err();
69//!
70//! assert_eq!(error.to_string(), "provided value is not in a valid UUIDv4 format".to_owned());
71//! #     Ok(())
72//! # }
73//! ```
74//!
75//! # Package Features
76//!
77//! * `juniper` – creates a set of objects to be used in GraphQL-based
78//!   requests/responses.
79#![deny(
80    clippy::all,
81    clippy::cargo,
82    clippy::nursery,
83    clippy::pedantic,
84    deprecated_in_future,
85    future_incompatible,
86    missing_docs,
87    nonstandard_style,
88    rust_2018_idioms,
89    rustdoc,
90    warnings,
91    unused_results,
92    unused_qualifications,
93    unused_lifetimes,
94    unused_import_braces,
95    unsafe_code,
96    unreachable_pub,
97    trivial_casts,
98    trivial_numeric_casts,
99    missing_debug_implementations,
100    missing_copy_implementations
101)]
102#![warn(variant_size_differences)]
103#![allow(clippy::multiple_crate_versions, missing_doc_code_examples)]
104#![doc(html_root_url = "https://docs.rs/automaat-processor-string-regex/0.1.0")]
105
106use automaat_core::{Context, Processor};
107use regex::{Error as RegexError, Regex};
108use serde::{Deserialize, Serialize};
109use std::{error, fmt};
110
111/// The processor configuration.
112#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
113#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
114pub struct StringRegex {
115    /// The string that will be matched against the provided `regex`, and
116    /// optionally replaced by the `replace` pattern.
117    pub input: String,
118
119    /// The regular expression used to match the `input`. See the regex crate
120    /// [syntax documentation] for more details.
121    ///
122    /// [syntax documentation]: https://docs.rs/regex/latest/regex/#syntax
123    pub regex: String,
124
125    /// If the `regex` pattern does not match the `input` value, an error is
126    /// returned. By default, a generic mismatch error is returned.
127    ///
128    /// You can set this value to have it be returned as the error instead.
129    pub mismatch_error: Option<String>,
130
131    /// Optionally use the `regex` pattern and the `input` to construct a
132    /// replacement string to return as this processors output.
133    ///
134    /// You can use variables such as `$1` and `$2` to match against the
135    /// patterns in the regex.
136    pub replace: Option<String>,
137}
138
139/// The GraphQL [Input Object][io] used to initialize the processor via an API.
140///
141/// [`StringRegex`] implements `From<Input>`, so you can directly initialize the
142/// processor using this type.
143///
144/// _requires the `juniper` package feature to be enabled_
145///
146/// [io]: https://graphql.github.io/graphql-spec/June2018/#sec-Input-Objects
147#[cfg(feature = "juniper")]
148#[graphql(name = "StringRegexInput")]
149#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, juniper::GraphQLInputObject)]
150pub struct Input {
151    input: String,
152    regex: String,
153    mismatch_error: Option<String>,
154    replace: Option<String>,
155}
156
157#[cfg(feature = "juniper")]
158impl From<Input> for StringRegex {
159    fn from(input: Input) -> Self {
160        Self {
161            input: input.input,
162            regex: input.regex,
163            mismatch_error: input.mismatch_error,
164            replace: input.replace,
165        }
166    }
167}
168
169impl<'a> Processor<'a> for StringRegex {
170    const NAME: &'static str = "String Regex";
171
172    type Error = Error;
173    type Output = String;
174
175    /// Validate that the provided [`regex`] pattern is valid.
176    ///
177    /// # Errors
178    ///
179    /// If the regex syntax is invalid, the [`Error::Syntax`] error variant is
180    /// returned.
181    ///
182    /// If the regex pattern is too big (highly unlikely), the
183    /// [`Error::CompiledTooBig`] error variant is returned.
184    ///
185    /// Both variants wrap the original [Regex crate errors].
186    ///
187    /// [`regex`]: StringRegex::regex
188    /// [Regex crate errors]: regex::Error
189    fn validate(&self) -> Result<(), Self::Error> {
190        Regex::new(self.regex.as_str())
191            .map(|_| ())
192            .map_err(Into::into)
193    }
194
195    /// Do a regex match (and replace), based on the processor configuration.
196    ///
197    /// # Output
198    ///
199    /// If [`replace`] is set to `None`, the output of the processor will be
200    /// `Ok(None)` if no error occurred.
201    ///
202    /// If [`replace`] is set to `Some`, then `Some` is returned, matching the
203    /// final replaced output value in [`Processor::Output`].
204    ///
205    /// # Errors
206    ///
207    /// If the [`regex`] pattern does not match the [`input`] input, the
208    /// [`Error::Match`] error variant is returned. If [`mismatch_error`] is
209    /// set, the error will contain the provided message. If not, a default
210    /// message is provided.
211    ///
212    /// If the regex pattern is invalid, the same errors are returned as
213    /// [`validate`].
214    ///
215    /// [`replace`]: StringRegex::replace
216    /// [`regex`]: StringRegex::regex
217    /// [`input`]: StringRegex::input
218    /// [`mismatch_error`]: StringRegex::mismatch_error
219    /// [`validate`]: #method.validate
220    fn run(&self, _context: &Context) -> Result<Option<Self::Output>, Self::Error> {
221        let re = Regex::new(self.regex.as_str()).map_err(Into::<Self::Error>::into)?;
222
223        if re.is_match(self.input.as_str()) {
224            match &self.replace {
225                None => Ok(None),
226                Some(replace) => {
227                    let out = re
228                        .replace(self.input.as_str(), replace.as_str())
229                        .into_owned();
230
231                    if out.is_empty() {
232                        Ok(None)
233                    } else {
234                        Ok(Some(out))
235                    }
236                }
237            }
238        } else if let Some(msg) = &self.mismatch_error {
239            Err(Error::Match(msg.to_owned()))
240        } else {
241            Err(Error::Match(format!(
242                "Match error: \"{}\" does not match pattern: {}",
243                self.input, self.regex
244            )))
245        }
246    }
247}
248
249/// Represents all the ways that [`StringRegex`] can fail.
250///
251/// This type is not intended to be exhaustively matched, and new variants may
252/// be added in the future without a major version bump.
253#[derive(Debug)]
254pub enum Error {
255    /// A syntax error.
256    Syntax(RegexError),
257
258    /// The compiled program exceeded the set size limit. The argument is the
259    /// size limit imposed.
260    CompiledTooBig(RegexError),
261
262    /// The regex pattern did not match the provided [`StringRegex::input`].
263    ///
264    /// The contained string value is either a default mismatch error, or a
265    /// custom error, based on the [`StringRegex::mismatch_error`] value.
266    Match(String),
267
268    #[doc(hidden)]
269    __Unknown, // Match against _ instead, more variants may be added in the future.
270}
271
272impl fmt::Display for Error {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match *self {
275            Error::Syntax(ref err) | Error::CompiledTooBig(ref err) => {
276                write!(f, "Regex error: {}", err)
277            }
278            Error::Match(ref string) => write!(f, "{}", string),
279            Error::__Unknown => unreachable!(),
280        }
281    }
282}
283
284impl error::Error for Error {
285    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
286        match *self {
287            Error::Syntax(ref err) | Error::CompiledTooBig(ref err) => Some(err),
288            Error::Match(_) => None,
289            Error::__Unknown => unreachable!(),
290        }
291    }
292}
293
294impl From<RegexError> for Error {
295    fn from(err: RegexError) -> Self {
296        match err {
297            RegexError::Syntax(_) => Error::Syntax(err),
298            RegexError::CompiledTooBig(_) => Error::CompiledTooBig(err),
299
300            // Regex crate has a non-exhaustive error enum, similar to this
301            // crates. Should they ever add an error in an upgrade, we will know
302            // because compilation failed, and we'll have to add it as well.
303            RegexError::__Nonexhaustive => unreachable!(),
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    fn processor_stub() -> StringRegex {
313        StringRegex {
314            input: "hello world".to_owned(),
315            regex: r"\Ahello world\z".to_owned(),
316            mismatch_error: None,
317            replace: None,
318        }
319    }
320
321    mod run {
322        use super::*;
323
324        #[test]
325        fn test_match_pattern() {
326            let mut processor = processor_stub();
327            processor.input = "hello world".to_owned();
328            processor.regex = r"hello \w+".to_owned();
329
330            let context = Context::new().unwrap();
331            let output = processor.run(&context).unwrap();
332
333            assert!(output.is_none())
334        }
335
336        #[test]
337        fn test_mismatch_pattern_default_error() {
338            let mut processor = processor_stub();
339            processor.input = "hello world".to_owned();
340            processor.regex = r"hi \w+".to_owned();
341
342            let context = Context::new().unwrap();
343            let error = processor.run(&context).unwrap_err();
344
345            assert_eq!(
346                error.to_string(),
347                r#"Match error: "hello world" does not match pattern: hi \w+"#.to_owned()
348            )
349        }
350
351        #[test]
352        fn test_mismatch_pattern_custom_error() {
353            let mut processor = processor_stub();
354            processor.input = "hello world".to_owned();
355            processor.regex = r"hi \w+".to_owned();
356            processor.mismatch_error = Some("invalid!".to_owned());
357
358            let context = Context::new().unwrap();
359            let error = processor.run(&context).unwrap_err();
360
361            assert_eq!(error.to_string(), "invalid!".to_owned())
362        }
363
364        #[test]
365        fn test_replace_pattern() {
366            let mut processor = processor_stub();
367            processor.input = "hello world".to_owned();
368            processor.regex = r"hello (\w+)".to_owned();
369            processor.replace = Some("hi $1!".to_owned());
370
371            let context = Context::new().unwrap();
372            let output = processor.run(&context).unwrap().expect("Some");
373
374            assert_eq!(output, "hi world!".to_owned())
375        }
376    }
377
378    mod validate {
379        use super::*;
380
381        #[test]
382        fn test_valid_syntax() {
383            let mut processor = processor_stub();
384            processor.regex = r"hello \w+".to_owned();
385
386            processor.validate().unwrap()
387        }
388
389        #[test]
390        #[should_panic]
391        fn test_invalid_syntax() {
392            let mut processor = processor_stub();
393            processor.regex = r"hello \NO".to_owned();
394
395            processor.validate().unwrap()
396        }
397    }
398
399    #[test]
400    fn test_readme_deps() {
401        version_sync::assert_markdown_deps_updated!("README.md");
402    }
403
404    #[test]
405    fn test_html_root_url() {
406        version_sync::assert_html_root_url_updated!("src/lib.rs");
407    }
408}