use shep_core::values::{MemSize, UpDuration};
use super::field::{Field, FieldKind, ValueKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Form {
pub text: &'static str,
pub examples: &'static [&'static str],
}
pub const DURATION_FORMS: &[Form] = &[
Form {
text: "500ms, 2s, 5m, 1h",
examples: &["500ms", "2s", "5m", "1h"],
},
Form {
text: "a bare number is milliseconds",
examples: &["250"],
},
];
pub const DURATION_REFUSALS: &[Form] = &[
Form {
text: "a negative",
examples: &["-1s"],
},
Form {
text: "a unit shep does not know",
examples: &["3 fortnights"],
},
];
pub const MEMORY_FORMS: &[Form] = &[
Form {
text: "512M, 2G",
examples: &["512M", "2G"],
},
Form {
text: "a bare number is bytes",
examples: &["1048576"],
},
];
pub const MEMORY_REFUSALS: &[Form] = &[
Form {
text: "a two-letter unit",
examples: &["10MB", "2GB"],
},
Form {
text: "a lowercase unit",
examples: &["512m", "2g"],
},
Form {
text: "a fraction",
examples: &["1.5G"],
},
];
pub const BOOL_FORMS: &[Form] = &[Form {
text: "true or false",
examples: &["true", "false"],
}];
pub const INTEGER_FORMS: &[Form] = &[Form {
text: "a whole number",
examples: &["3"],
}];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Bullets {
pub accepts: Vec<String>,
pub refuses: Vec<String>,
}
impl Bullets {
#[must_use]
pub fn is_empty(&self) -> bool {
self.accepts.is_empty() && self.refuses.is_empty()
}
}
fn table_for(field: &Field) -> (&'static [Form], &'static [Form]) {
match field.value_kind {
Some(ValueKind::UpDuration) => return (DURATION_FORMS, DURATION_REFUSALS),
Some(ValueKind::MemSize) => return (MEMORY_FORMS, MEMORY_REFUSALS),
None => {}
}
match field.kind {
FieldKind::Bool => (BOOL_FORMS, &[]),
FieldKind::Integer => (INTEGER_FORMS, &[]),
_ => (&[], &[]),
}
}
#[must_use]
pub fn bullets(field: &Field) -> Bullets {
if !field.accepts.is_empty() || !field.refuses.is_empty() {
return Bullets {
accepts: field.accepts.clone(),
refuses: field.refuses.clone(),
};
}
let (accepts, refuses) = table_for(field);
Bullets {
accepts: accepts.iter().map(|f| f.text.to_owned()).collect(),
refuses: refuses.iter().map(|f| f.text.to_owned()).collect(),
}
}
pub const REDACTED: &str = "<set>";
#[must_use]
pub(super) fn shown_value<'a>(field: &Field, typed: &'a str) -> &'a str {
if field.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, typed);
if field.kind == FieldKind::Integer {
return integer_refusal(field, 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, typed, shown),
}
}
fn integer_refusal(field: &Field, typed: &str, shown: &str) -> Option<Refusal> {
let key = &field.key;
if field.bounds.unsatisfiable {
return Some(Refusal {
text: format!("{key} takes no value: its schema asks for a range with nothing in it"),
});
}
let Ok(number) = typed.parse::<i64>() else {
return Some(not_a_whole_number(key, shown));
};
match (field.bounds.minimum, field.bounds.maximum) {
(Some(min), _) if number < min => Some(Refusal {
text: format!("{key} is \"{shown}\"; {key} starts at {min}"),
}),
(_, Some(max)) if number > max => Some(Refusal {
text: format!("{key} is \"{shown}\"; {key} stops at {max}"),
}),
_ => None,
}
}
fn pattern_refusal(field: &Field, typed: &str, shown: &str) -> Option<Refusal> {
let pattern = field.bounds.pattern.as_deref()?;
let compiled = regex::Regex::new(pattern).ok()?;
if compiled.is_match(typed) {
return None;
}
let takes = if field.accepts.is_empty() {
format!("it matches {pattern}")
} else {
field.accepts.join(", ")
};
Some(Refusal {
text: format!(
"{} is \"{shown}\", which it does not take; try {takes}",
field.key
),
})
}
pub(super) fn not_a_whole_number(key: &str, shown: &str) -> Refusal {
Refusal {
text: format!("{key} is \"{shown}\", which is not a whole number shep accepts"),
}
}
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::*;
use shep_core::values::{MemSize, UpDuration};
#[test]
fn every_accepted_duration_form_parses() {
for form in DURATION_FORMS {
for example in form.examples {
assert!(
example.parse::<UpDuration>().is_ok(),
"{} is listed as accepted but does not parse",
example
);
}
}
}
#[test]
fn every_refused_duration_form_fails_to_parse() {
for form in DURATION_REFUSALS {
for example in form.examples {
assert!(
example.parse::<UpDuration>().is_err(),
"{} is listed as refused but parses",
example
);
}
}
}
#[test]
fn every_accepted_memory_form_parses() {
for form in MEMORY_FORMS {
for example in form.examples {
assert!(
example.parse::<MemSize>().is_ok(),
"{} is listed as accepted but does not parse",
example
);
}
}
}
#[test]
fn every_refused_memory_form_fails_to_parse() {
for form in MEMORY_REFUSALS {
for example in form.examples {
assert!(
example.parse::<MemSize>().is_err(),
"{} is listed as refused but parses",
example
);
}
}
}
fn forms_named(text: &str) -> usize {
text.split(',').flat_map(|part| part.split(" or ")).count()
}
#[test]
fn every_form_the_text_names_has_an_example() {
for (name, forms) in [
("DURATION_FORMS", DURATION_FORMS),
("DURATION_REFUSALS", DURATION_REFUSALS),
("MEMORY_FORMS", MEMORY_FORMS),
("MEMORY_REFUSALS", MEMORY_REFUSALS),
("BOOL_FORMS", BOOL_FORMS),
("INTEGER_FORMS", INTEGER_FORMS),
] {
for form in forms {
assert!(
forms_named(form.text) <= form.examples.len(),
"{name}: text \"{}\" names {} forms but examples has only {}",
form.text,
forms_named(form.text),
form.examples.len()
);
}
}
}
#[test]
fn a_field_with_its_own_accepts_replaces_the_type_table() {
let mut field = text_field("cwd");
field.value_kind = Some(ValueKind::UpDuration);
field.accepts = vec!["only this".to_owned()];
let bullets = bullets(&field);
assert_eq!(bullets.accepts, vec!["only this".to_owned()]);
}
#[test]
fn a_field_with_no_accepts_takes_the_type_table() {
let mut field = text_field("min_uptime");
field.value_kind = Some(ValueKind::UpDuration);
let bullets = bullets(&field);
assert!(bullets.accepts.len() > 1);
}
#[test]
fn a_plain_text_field_with_no_annotation_has_no_bullets() {
let bullets = bullets(&text_field("fold"));
assert!(bullets.is_empty());
}
#[test]
fn every_type_table_reaches_the_panel() {
let cases = [
(FieldKind::Bool, None, BOOL_FORMS, &[] as &[Form]),
(FieldKind::Integer, None, INTEGER_FORMS, &[]),
(
FieldKind::Text,
Some(ValueKind::MemSize),
MEMORY_FORMS,
MEMORY_REFUSALS,
),
(
FieldKind::Text,
Some(ValueKind::UpDuration),
DURATION_FORMS,
DURATION_REFUSALS,
),
];
for (kind, value_kind, accepts, refuses) in cases {
let mut field = text_field("max_memory");
field.kind = kind.clone();
field.value_kind = value_kind;
let bullets = bullets(&field);
let texts = |forms: &[Form]| -> Vec<String> {
forms.iter().map(|form| form.text.to_owned()).collect()
};
assert_eq!(bullets.accepts, texts(accepts), "{kind:?} accepts");
assert_eq!(bullets.refuses, texts(refuses), "{kind:?} refuses");
}
}
#[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, "sk_live_HUNTER2"), "sk_live_HUNTER2");
field.secret = true;
assert_eq!(shown_value(&field, "sk_live_HUNTER2"), REDACTED);
assert!(
!not_a_whole_number(&field.key, shown_value(&field, "sk_live_HUNTER2"))
.text
.contains("HUNTER2")
);
}
#[test]
fn a_dogs_nullable_duration_refuses_a_day() {
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!(
"1d".parse::<UpDuration>().is_err(),
"1d must be outside the grammar or there is nothing to refuse"
);
let refused = refusal(&field, "1d").expect("1d is not a duration shep parses");
assert_eq!(
refused.text,
"max_age is \"1d\", which is not a duration shep accepts; \
try 500ms, 2s, 5m, 1h, 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");
}
#[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 a_range_with_nothing_in_it_says_so() {
let mut field = text_field("workers");
field.kind = FieldKind::Integer;
field.bounds.minimum = Some(3);
field.bounds.maximum = Some(1);
field.bounds.unsatisfiable = true;
for typed in ["1", "2", "3", "not a number"] {
assert_eq!(
refusal(&field, typed).expect("nothing is acceptable").text,
"workers takes no value: its schema asks for a range with nothing in it",
"{typed}"
);
}
}
#[test]
fn an_empty_buffer_is_not_a_refusal() {
assert_eq!(refusal(&duration_field(), ""), None);
}
#[test]
fn a_dogs_own_pattern_refuses_what_it_does_not_match() {
let mut field = text_field("region");
field.bounds.pattern = Some("^[a-z]{2}-[a-z]+-[0-9]$".to_owned());
assert_eq!(
refusal(&field, "eu-west-1"),
None,
"the pattern's own shape"
);
let refused = refusal(&field, "Europe").expect("Europe does not match");
assert_eq!(
refused.text,
"region is \"Europe\", which it does not take; try it matches ^[a-z]{2}-[a-z]+-[0-9]$"
);
field.accepts = vec!["a region like eu-west-1".to_owned()];
let refused = refusal(&field, "Europe").expect("still refused");
assert_eq!(
refused.text,
"region is \"Europe\", which it does not take; try a region like eu-west-1",
"the author's own words win over the regex"
);
}
#[test]
fn a_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 mut field = text_field("region");
field.bounds.pattern = Some(broken);
assert_eq!(refusal(&field, "anything at all"), None);
}
#[test]
fn an_integers_schema_floor_and_ceiling_are_enforced() {
let mut field = text_field("keep");
field.kind = FieldKind::Integer;
field.bounds.minimum = Some(1);
field.bounds.maximum = Some(64);
assert_eq!(refusal(&field, "1"), None, "the floor itself is allowed");
assert_eq!(refusal(&field, "64"), None, "the ceiling itself is allowed");
let refused = refusal(&field, "0").expect("0 is below the floor");
assert_eq!(refused.text, "keep is \"0\"; keep starts at 1");
let refused = refusal(&field, "65").expect("65 is above the ceiling");
assert_eq!(refused.text, "keep is \"65\"; keep stops at 64");
}
#[test]
fn an_integer_field_refuses_text_out_loud() {
let mut field = text_field("max_restarts");
field.kind = FieldKind::Integer;
let refused = refusal(&field, "lots").expect("lots is not a number");
assert_eq!(
refused.text,
"max_restarts is \"lots\", which is not a whole number shep accepts"
);
}
#[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())
.fields()
.by_key("max_age")
.expect("the schema declares max_age")
.clone()
}
fn text_field(key: &str) -> Field {
Field {
key: key.to_owned(),
help: key.to_owned(),
group: None,
kind: FieldKind::Text,
value_kind: None,
default: None,
default_value: None,
secret: false,
editable: true,
example: None,
accepts: Vec::new(),
refuses: Vec::new(),
neighbours: Vec::new(),
bounds: crate::lookout::field::Bounds::default(),
}
}
}