Repository: jacobdeichert/mask (language: rust)

Issue:
Add `number` type to options for input validation purposes
The port option below has `type: number` which would allow us to validate the input is correct.

Note that even though we can at least validate it, it still gets turned into an environment variable which is just a string. The benefit here is that the script can assume it's always a number and cast it as such.

```md
## serve

**OPTIONS:**
* port
    * flags: -p --port
    * type: number
    * desc: Which port to serve on

~~~node
const port = Number(process.env.port);
console.log(port * 2)
~~~
```


Discussion / hints:
Can `type` be a URL or a regular expression?
We can definitely add other `type`s for validation purposes. `type: number` would validate the input is a number... so `type: url` could validate the input is a correctly formatted url and `type: path` could also be handy. 

Allowing `type: /^some_regex$/` could be powerful but might be overkill if we can get away with mostly high-level types 👍 

Fix patch:
diff --git a/README.md b/README.md
index 8efdfb6..6289ed4 100644
--- a/README.md
+++ b/README.md
@@ -113,8 +113,7 @@ Important to note that `mask` auto injects a very common `boolean` flag called `
     * desc: Which port to serve on
 
 ~~~sh
-# Set a fallback port
-PORT=${port:-8080}
+PORT=${port:-8080} # Set a fallback port if not supplied
 
 if [[ "$verbose" == "true" ]]; then
     echo "Starting an http server on PORT: $PORT"
@@ -123,6 +122,27 @@ python -m SimpleHTTPServer $PORT
 ~~~
 ```
 
+You can also make your flag expect a numerical value by setting its `type` to `number`. This means `mask` will automatically validate it as a number for you. If it fails to validate, `mask` will exit with a helpful error message.
+
+**Example:**
+
+```markdown
+## purchase (price)
+
+> Calculate the total price of something.
+
+**OPTIONS**
+* tax
+    * flags: -t --tax
+    * type: number
+    * desc: What's the tax?
+
+~~~sh
+TAX=${tax:-1} # Fallback to 1 if not supplied
+echo "Total: $(($price * $TAX))"
+~~~
+```
+
 ### Subcommands
 
 Nested command structures can easily be created since they are simply defined by the level of markdown heading. H2 (`##`) is where you define your top-level commands. Every level after that is a subcommand. The only requirement is that subcommands must have all ancestor commands present in their heading.
@@ -341,7 +361,6 @@ This variable is an absolute path to the maskfile's parent directory. Having the
 
 * [ ] [Optional (non-required) positional arguments][2]
 * [ ] [Infinite positional args](https://github.com/jakedeichert/mask/issues/4)
-* [ ] [Option flag `number` type for input validation purposes](https://github.com/jakedeichert/mask/issues/3)
 
 
 
diff --git a/src/command.rs b/src/command.rs
index cc58117..096bacf 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -36,6 +36,7 @@ impl Command {
                 long: "verbose".to_string(),
                 multiple: false,
                 takes_value: false,
+                validate_as_number: false,
                 val: "".to_string(),
             });
         }
@@ -62,10 +63,11 @@ impl RequiredArg {
 pub struct OptionFlag {
     pub name: String,
     pub desc: String,
-    pub short: String,     // v        (used as -v)
-    pub long: String,      // verbose  (used as --verbose)
-    pub multiple: bool,    // Can it have multiple values? (-vvv OR -i one -i two)
-    pub takes_value: bool, // Does it take a value? (-i value)
+    pub short: String,            // v        (used as -v)
+    pub long: String,             // verbose  (used as --verbose)
+    pub multiple: bool,           // Can it have multiple values? (-vvv OR -i one -i two)
+    pub takes_value: bool,        // Does it take a value? (-i value)
+    pub validate_as_number: bool, // Should we validate it as a number?
     pub val: String,
 }
 
@@ -78,6 +80,7 @@ impl OptionFlag {
             long: "".to_string(),
             multiple: false,
             takes_value: false,
+            validate_as_number: false,
             val: "".to_string(),
         }
     }
diff --git a/src/main.rs b/src/main.rs
index 905da3e..880d481 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -149,11 +149,25 @@ fn get_command_options(mut cmd: Command, matches: &ArgMatches) -> Command {
     for flag in &mut cmd.option_flags {
         flag.val = if flag.takes_value {
             // Extract the value
-            matches
+            let raw_value = matches
                 .value_of(flag.name.clone())
                 .or(Some(""))
                 .unwrap()
-                .to_string()
+                .to_string();
+
+            if flag.validate_as_number && raw_value != "" {
+                // Try converting to an integer or float to validate it
+                if raw_value.parse::<isize>().is_err() && raw_value.parse::<f32>().is_err() {
+                    eprintln!(
+                        "{} flag `{}` expects a numerical value",
+                        "ERROR:".red(),
+                        flag.name
+                    );
+                    std::process::exit(1);
+                }
+            }
+
+            raw_value
         } else {
             // Check if the boolean flag is present and set to "true".
             // It's a string since it's set as an environment variable.
diff --git a/src/parser.rs b/src/parser.rs
index 75f92b4..1f767c4 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -81,11 +81,14 @@ pub fn build_command_structure(maskfile_contents: String) -> Command {
                     let val = config_split.next().unwrap_or("").trim();
                     match param {
                         "desc" => current_option_flag.desc = val.to_string(),
-                        // TODO: allow "number" type for input validation purposes https://github.com/jakedeichert/mask/issues/3
                         "type" => {
-                            if val == "string" {
+                            if val == "string" || val == "number" {
                                 current_option_flag.takes_value = true;
                             }
+
+                            if val == "number" {
+                                current_option_flag.validate_as_number = true;
+                            }
                         }
                         // Parse out the short and long flag names
                         "flags" => {


Test patch:
diff --git a/tests/arguments_and_flags_test.rs b/tests/arguments_and_flags_test.rs
index af10533..3e64105 100644
--- a/tests/arguments_and_flags_test.rs
+++ b/tests/arguments_and_flags_test.rs
@@ -1,5 +1,6 @@
 use assert_cmd::prelude::*;
 use clap::{crate_name, crate_version};
+use colored::*;
 use predicates::str::contains;
 
 mod common;
@@ -85,6 +86,133 @@ fi
         .success();
 }
 
+mod numerical_option_flag {
+    use super::*;
+
+    #[test]
+    fn properly_validates_flag_with_type_number() {
+        let (_temp, maskfile_path) = common::maskfile(
+            r#"
+## integer
+
+**OPTIONS**
+* val
+    * flags: --val
+    * type: number
+
+~~~bash
+echo "Value: $val"
+~~~
+"#,
+        );
+
+        common::run_mask(&maskfile_path)
+            .cli("integer --val 1111112222")
+            .assert()
+            .stdout(contains("Value: 1111112222"))
+            .success();
+    }
+
+    #[test]
+    fn properly_validates_negative_numbers() {
+        let (_temp, maskfile_path) = common::maskfile(
+            r#"
+## negative
+
+**OPTIONS**
+* val
+    * flags: --val
+    * type: number
+
+~~~bash
+echo "Value: $val"
+~~~
+"#,
+        );
+
+        common::run_mask(&maskfile_path)
+            .cli("negative --val -123")
+            .assert()
+            .stdout(contains("Value: -123"))
+            .success();
+    }
+
+    #[test]
+    fn properly_validates_decimal_numbers() {
+        let (_temp, maskfile_path) = common::maskfile(
+            r#"
+## decimal
+
+**OPTIONS**
+* val
+    * flags: --val
+    * type: number
+
+~~~bash
+echo "Value: $val"
+~~~
+"#,
+        );
+
+        common::run_mask(&maskfile_path)
+            .cli("decimal --val 123.3456789")
+            .assert()
+            .stdout(contains("Value: 123.3456789"))
+            .success();
+    }
+
+    #[test]
+    fn errors_when_value_is_not_a_number() {
+        let (_temp, maskfile_path) = common::maskfile(
+            r#"
+## notanumber
+
+**OPTIONS**
+* val
+    * flags: --val
+    * type: number
+
+~~~bash
+echo "This shouldn't render"
+~~~
+"#,
+        );
+
+        common::run_mask(&maskfile_path)
+            .cli("notanumber --val a234")
+            .assert()
+            .stderr(contains(format!(
+                "{} flag `val` expects a numerical value",
+                "ERROR:".red()
+            )))
+            .failure();
+    }
+
+    #[test]
+    fn ignores_the_option_if_not_supplied() {
+        let (_temp, maskfile_path) = common::maskfile(
+            r#"
+## nooption
+
+**OPTIONS**
+* val
+    * flags: --val
+    * type: number
+
+~~~bash
+echo "No arg this time"
+~~~
+"#,
+        );
+
+        common::run_mask(&maskfile_path)
+            .cli("nooption")
+            .assert()
+            .stdout(contains("No arg this time"))
+            .success();
+    }
+}
+
 mod version_flag {
     use super::*;
 
diff --git a/tests/subcommands_test.rs b/tests/subcommands_test.rs
index f3bbcf8..aab6bd6 100644
--- a/tests/subcommands_test.rs
+++ b/tests/subcommands_test.rs
@@ -62,8 +62,7 @@ mod when_entering_negative_numbers {
     fn allows_entering_negative_numbers_as_values() {
         let (_temp, maskfile_path) = common::maskfile(
             r#"
-## math
-### math add (a) (b)
+## add (a) (b)
 ~~~bash
 echo $(($a + $b))
 ~~~
@@ -71,7 +70,7 @@ echo $(($a + $b))
         );
 
         common::run_mask(&maskfile_path)
-            .cli("math add -1 -3")
+            .cli("add -1 -3")
             .assert()
             .stdout(contains("-4"))
             .success();
@@ -81,8 +80,7 @@ echo $(($a + $b))
     fn allows_entering_negative_numbers_as_flag_values() {
         let (_temp, maskfile_path) = common::maskfile(
             r#"
-## math
-### math add
+## add
 
 **OPTIONS**
 * a
@@ -99,7 +97,7 @@ echo $(($a + $b))
         );
 
         common::run_mask(&maskfile_path)
-            .cli("math add --a -33 --b 17")
+            .cli("add --a -33 --b 17")
             .assert()
             .stdout(contains("-16"))
             .success();