use serde_json::Value;
use super::numbers::{integer_of, range_refusal};
use super::patterns::pattern_refusal;
use super::{Refusal, enum_refusal, shown_value, takes_one_of};
use crate::lookout::field::{Bounds, ListItem};
#[must_use]
pub fn item_refusal(
key: &str,
item: &ListItem,
bounds: &Bounds,
typed: &str,
secret: bool,
) -> Option<Refusal> {
let shown = shown_value(secret, typed);
let value = match item {
ListItem::Integer => match integer_of(key, bounds, typed, shown) {
Ok(number) => {
if let Some(refused) = range_refusal(key, bounds, shown, number) {
return Some(refused);
}
Value::from(number)
}
Err(refused) => return Some(refused),
},
ListItem::Choice(names) => {
if !names.iter().any(|name| name == typed) {
return Some(takes_one_of(key, shown, names));
}
Value::String(typed.to_owned())
}
ListItem::Text => {
if let Some(refused) = min_length_refusal(key, bounds, typed, shown) {
return Some(refused);
}
if let Some(refused) =
pattern_refusal(key, bounds.pattern.as_deref(), &[], typed, shown)
{
return Some(refused);
}
Value::String(typed.to_owned())
}
};
enum_refusal(key, bounds, &value, shown)
}
fn min_length_refusal(key: &str, bounds: &Bounds, typed: &str, shown: &str) -> Option<Refusal> {
let least = bounds.min_length?;
(typed.chars().count() < least).then(|| Refusal {
text: format!("{key} is \"{shown}\"; {key} needs at least {least} characters"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_item_rule_refuses_one_element_and_takes_the_next() {
let min_length = Bounds {
min_length: Some(3),
..Default::default()
};
let pattern = Bounds {
pattern: Some("^[a-z]+$".to_owned()),
..Default::default()
};
let words = Bounds {
choices: vec![serde_json::json!("ask"), serde_json::json!("auto")],
..Default::default()
};
let numbers = Bounds {
choices: vec![
serde_json::json!(1),
serde_json::json!(2),
serde_json::json!(3),
],
..Default::default()
};
let range = Bounds {
minimum: Some(1),
maximum: Some(64),
..Default::default()
};
let cases = [
(
"minLength",
ListItem::Text,
&min_length,
"abc",
"ab",
"tags is \"ab\"; tags needs at least 3 characters",
),
(
"pattern",
ListItem::Text,
&pattern,
"abc",
"Ab1",
"tags is \"Ab1\", which it does not take; try it matches ^[a-z]+$",
),
(
"enum",
ListItem::Text,
&words,
"auto",
"never",
"tags is \"never\"; tags takes one of ask, auto",
),
(
"integer enum",
ListItem::Integer,
&numbers,
"2",
"4",
"tags is \"4\"; tags takes one of 1, 2, 3",
),
(
"minimum",
ListItem::Integer,
&range,
"1",
"0",
"tags is \"0\"; tags starts at 1",
),
(
"maximum",
ListItem::Integer,
&range,
"64",
"65",
"tags is \"65\"; tags stops at 64",
),
];
for (rule, item, bounds, accepted, refused, sentence) in cases {
assert_eq!(
item_refusal("tags", &item, bounds, accepted, false),
None,
"{rule}: {accepted} is the value the rule takes"
);
let refused = item_refusal("tags", &item, bounds, refused, false).expect(rule);
assert_eq!(refused.text, sentence, "{rule}");
}
}
#[test]
fn an_enum_number_written_with_a_decimal_point_still_takes_the_integer() {
let bounds = Bounds {
choices: vec![serde_json::json!(1.0), serde_json::json!(2.0)],
..Default::default()
};
for typed in ["1", "2"] {
assert_eq!(
item_refusal("levels", &ListItem::Integer, &bounds, typed, false),
None,
"{typed} is the value the schema wrote"
);
}
let refused = item_refusal("levels", &ListItem::Integer, &bounds, "3", false)
.expect("3 is not in the set");
assert_eq!(
refused.text,
"levels is \"3\"; levels takes one of 1.0, 2.0"
);
let halves = Bounds {
choices: vec![serde_json::json!(1.5)],
..Default::default()
};
let refused =
item_refusal("levels", &ListItem::Integer, &halves, "1", false).expect("1 is not 1.5");
assert_eq!(refused.text, "levels is \"1\"; levels takes one of 1.5");
}
#[test]
fn an_integer_element_that_is_not_a_number_is_refused_out_loud() {
let bounds = Bounds::default();
let refused = item_refusal(
"stop_exit_codes",
&ListItem::Integer,
&bounds,
"lots",
false,
)
.expect("lots is not a number");
assert_eq!(
refused.text,
"stop_exit_codes is \"lots\", which is not a whole number shep accepts"
);
assert_eq!(
item_refusal("stop_exit_codes", &ListItem::Integer, &bounds, "-1", false),
None,
"a negative exit code is a whole number"
);
}
#[test]
fn a_list_with_no_item_rules_refuses_nothing() {
let bounds = Bounds::default();
for typed in ["a", "!!! anything", ""] {
assert_eq!(
item_refusal("args", &ListItem::Text, &bounds, typed, false),
None,
"{typed}"
);
}
}
#[test]
fn a_min_length_counts_characters_and_not_bytes() {
let bounds = Bounds {
min_length: Some(4),
..Default::default()
};
assert_eq!(
item_refusal("tags", &ListItem::Text, &bounds, "abc\u{00e9}", false),
None,
"four characters, five bytes"
);
let refused = item_refusal("tags", &ListItem::Text, &bounds, "ab\u{00e9}", false)
.expect("three characters is under four");
assert_eq!(
refused.text,
"tags is \"ab\u{00e9}\"; tags needs at least 4 characters"
);
}
#[test]
fn a_list_pattern_that_does_not_compile_refuses_nothing() {
let broken = ["^[", "a-z"].concat();
assert!(
regex::Regex::new(&broken).is_err(),
"the fixture must be a pattern regex really refuses"
);
let bounds = Bounds {
pattern: Some(broken),
..Default::default()
};
assert_eq!(
item_refusal("tags", &ListItem::Text, &bounds, "ab", false),
None,
"a pattern that does not compile bounds nothing"
);
}
#[test]
fn a_secret_lists_refusal_names_the_marker_and_never_the_element() {
const CREDENTIAL: &str = "tok_HUNTER2";
let pattern = Bounds {
pattern: Some("^sk-".to_owned()),
..Default::default()
};
let refused = item_refusal("tokens", &ListItem::Text, &pattern, CREDENTIAL, true)
.expect("the pattern refuses it");
assert_eq!(
refused.text,
"tokens is \"<set>\", which it does not take; try it matches ^sk-"
);
let min_length = Bounds {
min_length: Some(32),
..Default::default()
};
let refused = item_refusal("tokens", &ListItem::Text, &min_length, CREDENTIAL, true)
.expect("too short");
assert!(!refused.text.contains(CREDENTIAL), "{}", refused.text);
assert!(refused.text.contains("<set>"), "{}", refused.text);
let choices = Bounds {
choices: vec![serde_json::json!("ask"), serde_json::json!("auto")],
..Default::default()
};
let refused = item_refusal("tokens", &ListItem::Text, &choices, CREDENTIAL, true)
.expect("not a member");
assert!(!refused.text.contains(CREDENTIAL), "{}", refused.text);
let range = Bounds {
minimum: Some(1000),
..Default::default()
};
let refused =
item_refusal("pins", &ListItem::Integer, &range, "7", true).expect("below the floor");
assert_eq!(refused.text, "pins is \"<set>\"; pins starts at 1000");
}
#[test]
fn an_ordinary_element_is_still_quoted_back() {
let bounds = Bounds {
pattern: Some("^eu-".to_owned()),
..Default::default()
};
assert!(
item_refusal("regions", &ListItem::Text, &bounds, "Europe", false)
.expect("refused")
.text
.contains("Europe"),
);
}
}
#[test]
fn a_choice_element_is_held_to_the_names_the_item_carries() {
let names = vec!["ask".to_owned(), "auto".to_owned()];
let bounds = Bounds::default();
for typed in ["ask", "auto"] {
assert_eq!(
item_refusal(
"modes",
&ListItem::Choice(names.clone()),
&bounds,
typed,
false
),
None,
"{typed} is one of the names"
);
}
let refused = item_refusal(
"modes",
&ListItem::Choice(names.clone()),
&bounds,
"never",
false,
)
.expect("never is not one of the names");
assert_eq!(
refused.text,
"modes is \"never\"; modes takes one of ask, auto"
);
let refused = item_refusal(
"modes",
&ListItem::Choice(names),
&bounds,
"tok_HUNTER2",
true,
)
.expect("a credential is not one of the names");
assert_eq!(
refused.text,
"modes is \"<set>\"; modes takes one of ask, auto"
);
}