mod bounds;
#[cfg(test)]
mod fixtures;
mod flatten;
mod init;
mod schema;
mod union;
pub use bounds::Bounds;
pub(crate) use flatten::{Child, Flattened, Outline, flatten_values, flattened, segment};
pub(crate) use union::KEY_SEP;
pub use union::Union;
use serde_json::{Map, Value};
use bounds::bounds_of;
use init::{neighbours, strings, suggestions};
use schema::{holds_secret, kind_of, render_default, value_kind_of};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldKind {
Bool,
Integer,
Number,
Text,
Choice(Vec<String>),
Suggested(Vec<String>),
Map,
List(ListItem),
Entries(Entries),
Union(Union),
Opaque,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Entries {
Array,
Map,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListItem {
Text,
Integer,
Choice(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueKind {
MemSize,
UpDuration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Field {
pub key: String,
pub help: String,
pub group: Option<String>,
pub kind: FieldKind,
pub value_kind: Option<ValueKind>,
pub default: Option<String>,
pub default_value: Option<Value>,
pub secret: bool,
pub editable: bool,
pub example: Option<String>,
pub accepts: Vec<String>,
pub refuses: Vec<String>,
pub neighbours: Vec<Neighbour>,
pub bounds: Bounds,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Neighbour {
pub field: String,
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldSet {
fields: Vec<Field>,
}
impl FieldSet {
#[must_use]
pub fn from_properties(
properties: &Map<String, Value>,
defs: &Map<String, Value>,
group_order: &[&str],
) -> Self {
let fields = properties
.iter()
.map(|(key, schema)| field_from(key, schema, defs))
.collect();
Self::from_fields(fields, group_order)
}
#[must_use]
pub fn from_fields(mut fields: Vec<Field>, group_order: &[&str]) -> Self {
let mut unknown: Vec<String> = Vec::new();
for field in &fields {
if let Some(group) = field.group.as_deref()
&& !group_order.contains(&group)
&& !unknown.iter().any(|seen| seen == group)
{
unknown.push(group.to_owned());
}
}
let rank = |group: Option<&str>| -> (usize, usize) {
match group {
None => (2, 0),
Some(g) => match group_order.iter().position(|known| *known == g) {
Some(i) => (0, i),
None => (
1,
unknown
.iter()
.position(|seen| seen == g)
.unwrap_or(usize::MAX),
),
},
}
};
fields.sort_by_key(|f| rank(f.group.as_deref()));
Self { fields }
}
#[must_use]
pub fn fields(&self) -> &[Field] {
&self.fields
}
#[must_use]
pub fn by_key(&self, key: &str) -> Option<&Field> {
self.fields.iter().find(|f| f.key == key)
}
#[must_use]
pub fn len(&self) -> usize {
self.fields.len()
}
}
fn field_from(key: &str, schema: &Value, defs: &Map<String, Value>) -> Field {
let init = schema.get("init");
let help = init
.and_then(|i| i.get("blurb"))
.or_else(|| schema.get("description"))
.and_then(Value::as_str)
.map_or_else(|| key.to_owned(), str::to_owned);
let group = init
.and_then(|i| i.get("group"))
.and_then(Value::as_str)
.map(str::to_owned);
let kind = kind_of(schema, defs);
let value_kind = (kind == FieldKind::Text)
.then(|| value_kind_of(schema))
.flatten();
let kind = match (kind, suggestions(init)) {
(FieldKind::Text, Some(names)) if !names.is_empty() => FieldKind::Suggested(names),
(kind, _) => kind,
};
let editable = !matches!(kind, FieldKind::Opaque | FieldKind::Entries(_));
let example = init
.and_then(|i| i.get("example"))
.and_then(Value::as_str)
.map(str::to_owned);
Field {
key: key.to_owned(),
help,
group,
kind,
value_kind,
default: render_default(schema.get("default")),
default_value: schema
.get("default")
.filter(|value| !value.is_null())
.cloned(),
secret: holds_secret(schema, defs),
editable,
example,
accepts: strings(init, "accepts"),
refuses: strings(init, "refuses"),
neighbours: neighbours(init),
bounds: bounds_of(schema, defs),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::fixtures::{props, real_field_set};
use super::*;
use serde_json::json;
fn groups_of(set: &FieldSet) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for field in set.fields() {
if let Some(group) = &field.group
&& seen.last() != Some(group)
{
seen.push(group.clone());
}
}
seen
}
#[test]
fn help_prefers_the_blurb_then_the_description_then_the_key() {
let p = props(json!({
"a": { "type": "boolean", "description": "desc", "init": { "blurb": "blurb" } },
"b": { "type": "boolean", "description": "desc" },
"c": { "type": "boolean" },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(set.by_key("a").unwrap().help, "blurb");
assert_eq!(set.by_key("b").unwrap().help, "desc");
assert_eq!(set.by_key("c").unwrap().help, "c");
}
#[test]
fn two_groups_the_order_does_not_name_still_come_out_contiguous() {
let p = props(json!({
"a": { "type": "boolean", "init": { "group": "zebra" } },
"b": { "type": "boolean", "init": { "group": "aardvark" } },
"c": { "type": "boolean", "init": { "group": "zebra" } },
"d": { "type": "boolean", "init": { "group": "aardvark" } },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
let keys: Vec<&str> = set.fields().iter().map(|f| f.key.as_str()).collect();
assert_eq!(
keys,
["a", "c", "b", "d"],
"first appearance wins, and neither group is split"
);
assert_eq!(
groups_of(&set),
["zebra", "aardvark"],
"each name appears once, which is what a header per change needs"
);
}
#[test]
fn fields_sort_by_group_rank_then_schema_order_and_groups_lists_those_present() {
let p = props(json!({
"zeta": { "type": "boolean", "init": { "group": "control" } },
"alpha": { "type": "boolean", "init": { "group": "process" } },
"beta": { "type": "boolean", "init": { "group": "control" } },
"nogroup": { "type": "boolean" },
"odd": { "type": "boolean", "init": { "group": "unknown" } },
}));
let set =
FieldSet::from_properties(&p, &Default::default(), &["process", "inputs", "control"]);
let keys: Vec<&str> = set.fields().iter().map(|f| f.key.as_str()).collect();
assert_eq!(keys, ["alpha", "beta", "zeta", "odd", "nogroup"]);
assert_eq!(
groups_of(&set),
["process", "control", "unknown"],
"and each group is contiguous, so a renderer draws its header once"
);
}
#[test]
fn the_secret_marker_is_read_off_the_extension_key() {
let p = props(json!({
"url": { "type": "string", "x-shep-secret": true },
"path": { "type": "string" },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert!(set.by_key("url").unwrap().secret);
assert!(!set.by_key("path").unwrap().secret);
}
#[test]
fn a_secret_anywhere_in_a_value_masks_its_row() {
let defs = props(json!({
"Db": {
"type": "object",
"properties": {
"host": { "type": "string" },
"password": { "type": "string", "x-shep-secret": true },
},
},
"Token": { "type": "string", "x-shep-secret": true },
}));
let p = props(json!({
"db": { "$ref": "#/$defs/Db" },
"token": { "$ref": "#/$defs/Token" },
"replicas": { "type": "array", "items": { "$ref": "#/$defs/Db" } },
"name": { "type": "string" },
}));
let set = FieldSet::from_properties(&p, &defs, &[]);
for key in ["db", "token", "replicas"] {
assert!(set.by_key(key).unwrap().secret, "{key}");
}
assert!(!set.by_key("name").unwrap().secret);
}
#[test]
fn the_real_flockfile_schema_yields_forty_four_fields_in_nine_groups() {
let set = real_field_set();
assert_eq!(set.len(), 44);
assert_eq!(
groups_of(&set),
[
"process",
"logging",
"inputs",
"restart",
"readiness",
"shutdown",
"watch",
"cron",
"dogs"
]
);
assert!(
set.fields().iter().all(|f| f.group.is_some()),
"every field carries a group"
);
assert_eq!(set.by_key("env").unwrap().kind, FieldKind::Map);
assert_eq!(set.by_key("autorestart").unwrap().kind, FieldKind::Bool);
}
#[test]
fn no_group_holds_more_than_a_third_of_the_fields() {
let set = real_field_set();
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for field in set.fields() {
*counts
.entry(field.group.as_deref().unwrap_or(""))
.or_default() += 1;
}
let (worst, count) = counts
.iter()
.max_by_key(|(_, n)| **n)
.expect("fields exist");
assert!(*count <= 13, "{worst} holds {count} of {}", set.len());
}
#[test]
fn every_neighbour_in_the_real_schema_names_a_real_field() {
let schema = shep_core::config::flockfile_schema_json().to_value();
let props = schema
.pointer("/$defs/AppConfig/properties")
.and_then(serde_json::Value::as_object)
.expect("app config properties must exist");
let defs = schema
.pointer("/$defs")
.and_then(serde_json::Value::as_object)
.expect("defs must exist");
let set = FieldSet::from_properties(props, defs, shep_core::config::GROUP_ORDER);
for field in set.fields() {
for neighbour in &field.neighbours {
assert!(
set.by_key(&neighbour.field).is_some(),
"{} names {}, which is not a field",
field.key,
neighbour.field
);
}
}
}
}