use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldKind {
Bool,
Integer,
Text,
Choice(Vec<String>),
Suggested(Vec<String>),
Map,
List(ListItem),
Opaque,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListItem {
Text,
Integer,
}
#[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 secret: bool,
pub editable: bool,
}
#[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()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
fn resolve<'a>(schema: &'a Value, defs: &'a Map<String, Value>) -> &'a Value {
schema
.get("$ref")
.and_then(Value::as_str)
.and_then(|r| r.strip_prefix("#/$defs/"))
.and_then(|name| defs.get(name))
.unwrap_or(schema)
}
fn ref_name(schema: &Value) -> Option<&str> {
let target = schema.get("$ref").or_else(|| {
schema
.get("anyOf")?
.as_array()?
.iter()
.find_map(|arm| arm.get("$ref"))
})?;
target.as_str()?.strip_prefix("#/$defs/")
}
fn value_kind_of(schema: &Value) -> Option<ValueKind> {
match ref_name(schema)? {
"MemSize" => Some(ValueKind::MemSize),
"UpDuration" => Some(ValueKind::UpDuration),
_ => None,
}
}
fn strip_nullable<'a>(schema: &'a Value, defs: &'a Map<String, Value>) -> &'a Value {
let Some(arms) = schema.get("anyOf").and_then(Value::as_array) else {
return schema;
};
let non_null: Vec<&Value> = arms
.iter()
.filter(|arm| arm.get("type").and_then(Value::as_str) != Some("null"))
.collect();
match non_null.as_slice() {
[one] => resolve(one, defs),
_ => schema,
}
}
fn items(schema: &Value) -> &Value {
schema.get("items").unwrap_or(&Value::Null)
}
fn type_of(schema: &Value) -> Option<&str> {
match schema.get("type")? {
Value::String(s) => Some(s.as_str()),
Value::Array(arr) => arr.iter().filter_map(Value::as_str).find(|t| *t != "null"),
_ => None,
}
}
fn kind_of(schema: &Value, defs: &Map<String, Value>) -> FieldKind {
let schema = strip_nullable(resolve(schema, defs), defs);
if let Some(consts) = schema.get("oneOf").and_then(Value::as_array) {
let names: Vec<String> = consts
.iter()
.filter_map(|arm| arm.get("const").and_then(Value::as_str))
.map(str::to_owned)
.collect();
if !names.is_empty() && names.len() == consts.len() {
return FieldKind::Choice(names);
}
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
let names: Vec<String> = values
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
if names.len() == values.len() {
return FieldKind::Choice(names);
}
}
match type_of(schema) {
Some("boolean") => FieldKind::Bool,
Some("integer") => FieldKind::Integer,
Some("string") => FieldKind::Text,
Some("array") => match type_of(strip_nullable(resolve(items(schema), defs), defs)) {
Some("string") => FieldKind::List(ListItem::Text),
Some("integer") => FieldKind::List(ListItem::Integer),
_ => FieldKind::Opaque,
},
Some("object")
if schema.get("additionalProperties").is_some()
&& schema.get("properties").is_none() =>
{
FieldKind::Map
}
_ => FieldKind::Opaque,
}
}
fn render_default(value: Option<&Value>) -> Option<String> {
match value? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
other => Some(other.to_string()),
}
}
fn suggestions(init: Option<&Value>) -> Option<Vec<String>> {
let values = init?.get("suggest")?.as_array()?;
let names: Vec<String> = values
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
(names.len() == values.len()).then_some(names)
}
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 = kind != FieldKind::Opaque;
Field {
key: key.to_owned(),
help,
group,
kind,
value_kind,
default: render_default(schema.get("default")),
secret: schema
.get(shep_core::dogs::SECRET_KEY)
.and_then(Value::as_bool)
.unwrap_or(false),
editable,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use serde_json::json;
fn props(v: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
v.as_object().unwrap().clone()
}
fn real_field_set() -> FieldSet {
let schema = shep_core::config::flockfile_schema_json().to_value();
let defs = schema["$defs"].as_object().unwrap();
let props = defs["AppConfig"]["properties"].as_object().unwrap();
FieldSet::from_properties(props, defs, shep_core::config::GROUP_ORDER)
}
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 a_bool_an_integer_and_a_string_get_their_kinds() {
let p = props(json!({
"watch": { "type": "boolean", "default": false },
"max_restarts": { "type": "integer", "format": "uint32", "default": 16 },
"cwd": { "type": ["string", "null"], "default": null },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(set.by_key("watch").unwrap().kind, FieldKind::Bool);
assert_eq!(set.by_key("max_restarts").unwrap().kind, FieldKind::Integer);
assert_eq!(set.by_key("cwd").unwrap().kind, FieldKind::Text);
}
#[test]
fn a_ref_into_defs_takes_the_named_types_kind() {
let p = props(json!({
"kill_timeout": { "$ref": "#/$defs/UpDuration", "default": "1600" },
}));
let d = props(json!({
"UpDuration": { "type": "string", "pattern": "^\\d+(ms|h|m|s)?$" },
}));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(set.by_key("kill_timeout").unwrap().kind, FieldKind::Text);
assert_eq!(
set.by_key("kill_timeout").unwrap().default.as_deref(),
Some("1600")
);
}
#[test]
fn any_of_with_null_is_the_other_arm() {
let p = props(json!({
"max_memory": {
"anyOf": [{ "$ref": "#/$defs/MemSize" }, { "type": "null" }],
"default": null,
},
}));
let d = props(json!({ "MemSize": { "type": "string" } }));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(set.by_key("max_memory").unwrap().kind, FieldKind::Text);
assert_eq!(set.by_key("max_memory").unwrap().default, None);
}
#[test]
fn one_of_consts_is_a_choice_in_schema_order() {
let p = props(json!({
"kind": {
"oneOf": [
{ "type": "string", "const": "http" },
{ "type": "string", "const": "tcp" },
{ "type": "string", "const": "exec" },
],
},
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(
set.by_key("kind").unwrap().kind,
FieldKind::Choice(vec!["http".into(), "tcp".into(), "exec".into()])
);
}
#[test]
fn a_text_field_records_which_shep_core_grammar_it_holds() {
let d = props(json!({
"MemSize": { "type": "string" },
"UpDuration": { "type": "string" },
}));
let p = props(json!({
"max_memory": {
"anyOf": [{ "$ref": "#/$defs/MemSize" }, { "type": "null" }],
},
"kill_timeout": { "$ref": "#/$defs/UpDuration" },
"cwd": { "type": "string" },
}));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(
set.by_key("max_memory").unwrap().value_kind,
Some(ValueKind::MemSize)
);
assert_eq!(
set.by_key("kill_timeout").unwrap().value_kind,
Some(ValueKind::UpDuration)
);
assert_eq!(set.by_key("cwd").unwrap().value_kind, None);
}
#[test]
fn a_string_map_is_a_map_and_a_nested_object_is_opaque() {
let p = props(json!({
"env": { "type": "object", "additionalProperties": { "type": "string" } },
"liveness_probe": {
"anyOf": [{ "$ref": "#/$defs/ProbeConfig" }, { "type": "null" }],
},
}));
let d = props(json!({
"ProbeConfig": { "type": "object", "properties": { "kind": {} } },
}));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(set.by_key("env").unwrap().kind, FieldKind::Map);
assert_eq!(
set.by_key("liveness_probe").unwrap().kind,
FieldKind::Opaque
);
assert!(!set.by_key("liveness_probe").unwrap().editable);
}
#[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_default_is_rendered_the_way_the_pane_will_show_it() {
let p = props(json!({
"b": { "type": "boolean", "default": true },
"n": { "type": "integer", "default": 16 },
"s": { "type": "string", "default": "1s" },
"l": { "type": "array", "default": [], "items": { "type": "string" } },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(set.by_key("b").unwrap().default.as_deref(), Some("true"));
assert_eq!(set.by_key("n").unwrap().default.as_deref(), Some("16"));
assert_eq!(set.by_key("s").unwrap().default.as_deref(), Some("1s"));
assert_eq!(set.by_key("l").unwrap().default.as_deref(), Some("[]"));
}
#[test]
fn the_real_flockfile_schema_yields_thirty_nine_fields_in_eight_groups() {
let set = real_field_set();
assert_eq!(set.len(), 39);
assert_eq!(
groups_of(&set),
[
"process",
"logging",
"inputs",
"restart",
"readiness",
"shutdown",
"watch",
"cron"
]
);
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 the_real_flockfile_schema_marks_every_mem_size_and_up_duration_field() {
let set = real_field_set();
assert_eq!(
set.by_key("max_memory").unwrap().value_kind,
Some(ValueKind::MemSize)
);
for key in [
"kill_timeout",
"listen_timeout",
"min_uptime",
"graceful_timeout",
"action_timeout",
"restart_delay",
"watch_delay",
"exp_backoff_restart_delay",
] {
assert_eq!(
set.by_key(key).unwrap().value_kind,
Some(ValueKind::UpDuration),
"{key}"
);
}
assert_eq!(set.by_key("cwd").unwrap().value_kind, None);
}
#[test]
fn a_field_with_init_suggest_cycles_and_still_types() {
let schema = json!({
"type": ["string", "null"],
"init": { "suggest": ["SIGTERM", "SIGINT"] }
});
let field = field_from("kill_signal", &schema, &Map::new());
assert_eq!(
field.kind,
FieldKind::Suggested(vec!["SIGTERM".into(), "SIGINT".into()])
);
assert!(field.editable, "a suggestion is not a constraint");
}
#[test]
fn kill_signal_and_cron_restart_both_carry_suggestions() {
let set = real_field_set();
for key in ["kill_signal", "cron_restart"] {
let field = set.by_key(key).unwrap_or_else(|| panic!("no {key}"));
assert!(
matches!(field.kind, FieldKind::Suggested(ref names) if !names.is_empty()),
"{key}: {:?}",
field.kind
);
}
}
#[test]
fn an_array_of_strings_is_a_list_and_an_array_of_integers_knows_its_item() {
let set = real_field_set();
assert_eq!(
set.by_key("args").map(|f| f.kind.clone()),
Some(FieldKind::List(ListItem::Text))
);
assert_eq!(
set.by_key("stop_exit_codes").map(|f| f.kind.clone()),
Some(FieldKind::List(ListItem::Integer))
);
assert!(
set.by_key("args").is_some_and(|f| f.editable),
"an array is editable now"
);
}
}