#[cfg(test)]
mod fixtures;
mod forms;
mod items;
mod numbers;
mod patterns;
pub(crate) mod standard;
pub(crate) mod table;
#[cfg(test)]
mod table_tests;
pub(crate) mod validate;
#[cfg(test)]
pub(crate) mod validate_tests;
pub use forms::bullets;
pub use items::item_refusal;
pub(crate) use table::touched_refusal;
pub(crate) use validate::{AskFailure, ask_dog, dog_write};
use shep_core::values::{MemSize, UpDuration};
use serde_json::Value;
use crate::lookout::field::{Bounds, Field, FieldKind, ValueKind};
use forms::{DURATION_FORMS, Form, MEMORY_FORMS};
use numbers::{integer_refusal, number_refusal};
use patterns::pattern_refusal;
pub const REDACTED: &str = "<set>";
#[must_use]
pub(crate) fn shown_value(secret: bool, typed: &str) -> &str {
if secret { REDACTED } else { typed }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
pub text: String,
}
#[must_use]
pub fn refusal(field: &Field, typed: &str) -> Option<Refusal> {
if typed.is_empty() {
return None;
}
let shown = shown_value(field.secret, typed);
if field.kind == FieldKind::Integer {
return integer_refusal(&field.key, &field.bounds, typed, shown);
}
if field.kind == FieldKind::Number {
return number_refusal(&field.key, &field.bounds, typed, shown);
}
match field.value_kind {
Some(ValueKind::UpDuration) => typed
.parse::<UpDuration>()
.is_err()
.then(|| refuse(&field.key, shown, "a duration", DURATION_FORMS)),
Some(ValueKind::MemSize) => typed
.parse::<MemSize>()
.is_err()
.then(|| refuse(&field.key, shown, "a size", MEMORY_FORMS)),
None => pattern_refusal(
&field.key,
field.bounds.pattern.as_deref(),
&field.accepts,
typed,
shown,
),
}
}
pub(crate) fn not_a_whole_number(key: &str, shown: &str) -> Refusal {
Refusal {
text: format!("{key} is \"{shown}\", which is not a whole number shep accepts"),
}
}
pub(crate) fn not_a_number(key: &str, shown: &str) -> Refusal {
Refusal {
text: format!("{key} is \"{shown}\", which is not a number shep accepts"),
}
}
fn takes_no_value(key: &str) -> Refusal {
Refusal {
text: format!("{key} takes no value: its schema asks for a range with nothing in it"),
}
}
pub(crate) fn required_field(field: &str, tag: &str, arm: &str) -> Refusal {
Refusal {
text: format!("{field} is required when {tag} is {arm}; give it a value"),
}
}
pub(crate) fn takes_one_of(key: &str, shown: &str, names: &[String]) -> Refusal {
Refusal {
text: format!(
"{key} is \"{shown}\"; {key} takes one of {}",
names.join(", ")
),
}
}
fn enum_refusal(key: &str, bounds: &Bounds, value: &Value, shown: &str) -> Option<Refusal> {
if bounds.choices.is_empty()
|| bounds
.choices
.iter()
.any(|choice| choice_taken(choice, value))
{
return None;
}
let allowed: Vec<String> = bounds.choices.iter().map(choice_text).collect();
Some(Refusal {
text: format!(
"{key} is \"{shown}\"; {key} takes one of {}",
allowed.join(", ")
),
})
}
fn choice_taken(choice: &Value, value: &Value) -> bool {
match (choice.as_i64(), value.as_i64()) {
(Some(written), Some(typed)) => written == typed,
_ => match (choice.as_f64(), value.as_f64()) {
(Some(written), Some(typed)) => written == typed,
_ => choice == value,
},
}
}
fn choice_text(choice: &Value) -> String {
match choice {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
fn refuse(key: &str, shown: &str, what: &str, forms: &[Form]) -> Refusal {
let takes: Vec<&str> = forms.iter().map(|form| form.text).collect();
Refusal {
text: format!(
"{key} is \"{shown}\", which is not {what} shep accepts; try {}",
takes.join(", ")
),
}
}
#[cfg(test)]
mod tests {
use super::fixtures::text_field;
use super::forms::{DURATION_FORMS, DURATION_REFUSALS, MEMORY_FORMS};
use super::*;
use shep_core::values::UpDuration;
#[test]
fn the_shown_value_of_a_secret_is_the_marker_and_never_the_buffer() {
let mut field = text_field("token");
assert_eq!(
shown_value(field.secret, "sk_live_HUNTER2"),
"sk_live_HUNTER2"
);
field.secret = true;
assert_eq!(shown_value(field.secret, "sk_live_HUNTER2"), REDACTED);
assert!(
!not_a_whole_number(&field.key, shown_value(field.secret, "sk_live_HUNTER2"))
.text
.contains("HUNTER2")
);
}
#[test]
fn a_dogs_nullable_duration_refuses_a_week() {
let field = duration_field();
assert_eq!(
field.value_kind,
Some(ValueKind::UpDuration),
"the $ref must resolve through the anyOf or this test proves nothing"
);
assert!(
"1w".parse::<UpDuration>().is_err(),
"1w must be outside the grammar or there is nothing to refuse"
);
let refused = refusal(&field, "1w").expect("1w is not a duration shep parses");
assert_eq!(
refused.text,
"max_age is \"1w\", which is not a duration shep accepts; \
try 500ms, 2s, 5m, 1h, 7d, a bare number is milliseconds"
);
}
#[test]
fn the_spellings_the_panel_offers_are_the_spellings_it_takes() {
let field = duration_field();
for accepted in DURATION_FORMS.iter().flat_map(|form| form.examples) {
assert_eq!(
refusal(&field, accepted),
None,
"{accepted} is printed as accepted and must be filed"
);
}
for refused in DURATION_REFUSALS.iter().flat_map(|form| form.examples) {
assert!(
refusal(&field, refused).is_some(),
"{refused} is printed as refused and must not be filed"
);
}
}
#[test]
fn a_size_field_refuses_a_spelling_shep_does_not_take() {
let mut field = text_field("max_size");
field.value_kind = Some(ValueKind::MemSize);
assert_eq!(
refusal(&field, "10MB")
.expect("10MB is not shep's spelling")
.text,
"max_size is \"10MB\", which is not a size shep accepts; \
try 512M, 2G, a bare number is bytes"
);
for accepted in MEMORY_FORMS.iter().flat_map(|form| form.examples) {
assert_eq!(
refusal(&field, accepted),
None,
"{accepted} is printed as accepted and must be filed"
);
}
}
#[test]
fn a_secrets_value_never_reaches_the_sentence_or_the_debug() {
const CREDENTIAL: &str = "sk_live_HUNTER2";
let mut pattern_door = text_field("token");
pattern_door.secret = true;
pattern_door.bounds.pattern = Some("^sk-".to_owned());
let refused = refusal(&pattern_door, CREDENTIAL).expect("the pattern refuses it");
assert_eq!(
refused.text,
"token is \"<set>\", which it does not take; try it matches ^sk-"
);
assert_eq!(
format!("{refused:?}"),
"Refusal { text: \"token is \\\"<set>\\\", which it does not take; try it matches ^sk-\" }"
);
let mut grammar_door = text_field("api_key");
grammar_door.secret = true;
grammar_door.value_kind = Some(ValueKind::UpDuration);
let refused = refusal(&grammar_door, CREDENTIAL).expect("not a duration");
assert!(!refused.text.contains(CREDENTIAL), "{}", refused.text);
assert!(refused.text.contains("<set>"), "{}", refused.text);
let mut integer_door = text_field("pin");
integer_door.secret = true;
integer_door.kind = FieldKind::Integer;
let refused = refusal(&integer_door, CREDENTIAL).expect("not a number");
assert_eq!(
refused.text,
"pin is \"<set>\", which is not a whole number shep accepts"
);
let mut bounded = text_field("pin");
bounded.secret = true;
bounded.kind = FieldKind::Integer;
bounded.bounds.minimum = Some(1000);
let refused = refusal(&bounded, "7").expect("below the floor");
assert_eq!(refused.text, "pin is \"<set>\"; pin starts at 1000");
let mut decimal = text_field("rate");
decimal.secret = true;
decimal.kind = FieldKind::Number;
decimal.bounds.number_exclusive_minimum = Some(bound(0));
let refused = refusal(&decimal, CREDENTIAL).expect("not a number");
assert_eq!(
refused.text,
"rate is \"<set>\", which is not a number shep accepts"
);
let refused = refusal(&decimal, "0").expect("below the floor");
assert_eq!(refused.text, "rate is \"<set>\"; rate must be above 0");
}
#[test]
fn an_ordinary_field_still_quotes_what_was_typed() {
let mut field = text_field("region");
field.bounds.pattern = Some("^eu-".to_owned());
assert!(
refusal(&field, "Europe")
.expect("refused")
.text
.contains("Europe"),
);
}
#[test]
fn an_empty_buffer_is_not_a_refusal() {
assert_eq!(refusal(&duration_field(), ""), None);
}
#[test]
fn a_field_with_no_grammar_and_no_pattern_refuses_nothing() {
assert_eq!(refusal(&text_field("cwd"), "/srv/web"), None);
assert_eq!(refusal(&text_field("cwd"), "!!! anything"), None);
}
fn duration_field() -> Field {
let schema = serde_json::json!({
"$defs": {
"UpDuration": { "type": "string", "pattern": r"^\d+(ms|h|m|s)?$" },
},
"properties": {
"max_age": {
"anyOf": [{ "$ref": "#/$defs/UpDuration" }, { "type": "null" }],
"description": "Also rotate this long after the last rotation.",
},
},
});
crate::lookout::pane::ConfigPane::dog(
"log-rotate".to_owned(),
None,
schema,
String::new(),
Vec::new(),
)
.fields()
.by_key("max_age")
.expect("the schema declares max_age")
.clone()
}
fn bound(value: i64) -> serde_json::Number {
serde_json::json!(value)
.as_number()
.expect("a number")
.clone()
}
}