use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApplyGroup {
Live,
NextSpawn,
NeedsRespawn,
Structural,
}
const FIELDS: &[(&str, ApplyGroup)] = &[
("autorestart", ApplyGroup::Live),
("max_restarts", ApplyGroup::Live),
("min_uptime", ApplyGroup::Live),
("restart_delay", ApplyGroup::Live),
("exp_backoff_restart_delay", ApplyGroup::Live),
("stop_exit_codes", ApplyGroup::Live),
("kill_timeout", ApplyGroup::Live),
("graceful_timeout", ApplyGroup::Live),
("max_memory", ApplyGroup::Live),
("watch", ApplyGroup::Live),
("ignore_watch", ApplyGroup::Live),
("watch_delay", ApplyGroup::Live),
("watch_options", ApplyGroup::Live),
("cron_restart", ApplyGroup::Live),
("cron_timezone", ApplyGroup::Live),
("liveness_probe", ApplyGroup::Live),
("fold", ApplyGroup::Live),
("reuse_port", ApplyGroup::Live),
("level_rules", ApplyGroup::Live),
("action_timeout", ApplyGroup::Live),
("kill_signal", ApplyGroup::NextSpawn),
("listen_timeout", ApplyGroup::NextSpawn),
("readiness_probe", ApplyGroup::NextSpawn),
("autostart", ApplyGroup::NextSpawn),
("depends_on", ApplyGroup::NextSpawn),
("script", ApplyGroup::NeedsRespawn),
("args", ApplyGroup::NeedsRespawn),
("cwd", ApplyGroup::NeedsRespawn),
("interpreter", ApplyGroup::NeedsRespawn),
("env", ApplyGroup::NeedsRespawn),
("environment", ApplyGroup::NeedsRespawn),
("user", ApplyGroup::NeedsRespawn),
("group", ApplyGroup::NeedsRespawn),
("out_file", ApplyGroup::NeedsRespawn),
("err_file", ApplyGroup::NeedsRespawn),
("merge_logs", ApplyGroup::NeedsRespawn),
("channel", ApplyGroup::NeedsRespawn),
("stdin", ApplyGroup::NeedsRespawn),
("wait_ready", ApplyGroup::NeedsRespawn),
("shutdown_with_message", ApplyGroup::NeedsRespawn),
("name", ApplyGroup::Structural),
("instances", ApplyGroup::Structural),
];
#[must_use]
pub fn apply_group(field: &str) -> ApplyGroup {
FIELDS
.iter()
.find(|(name, _)| *name == field)
.map_or(ApplyGroup::NeedsRespawn, |(_, group)| *group)
}
#[must_use]
pub fn reaches_running(field: &str) -> bool {
match apply_group(field) {
ApplyGroup::Live => true,
ApplyGroup::NextSpawn => matches!(field, "autostart" | "depends_on"),
ApplyGroup::NeedsRespawn | ApplyGroup::Structural => false,
}
}
#[must_use]
pub fn is_classified(field: &str) -> bool {
FIELDS.iter().any(|(name, _)| *name == field)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResetDepth {
#[default]
None,
File,
Policy,
Env,
All,
}
#[cfg(test)]
mod tests {
use super::{ApplyGroup, FIELDS, apply_group, is_classified, reaches_running};
use crate::config::AppConfig;
fn appconfig_fields() -> serde_json::Map<String, serde_json::Value> {
let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
else {
panic!("AppConfig must serialize as an object");
};
fields
}
#[test]
fn every_appconfig_field_has_a_group() {
let fields = appconfig_fields();
let missing: Vec<&String> = fields.keys().filter(|k| !is_classified(k)).collect();
assert!(
missing.is_empty(),
"unclassified AppConfig fields: {missing:?}"
);
}
#[test]
fn every_group_row_names_a_real_field() {
let fields = appconfig_fields();
let strays: Vec<&str> = FIELDS
.iter()
.map(|(name, _)| *name)
.filter(|name| !fields.contains_key(*name))
.collect();
assert!(strays.is_empty(), "FIELDS rows naming no field: {strays:?}");
}
#[test]
fn kill_signal_reaches_the_next_spawn_not_the_next_kill() {
assert_eq!(apply_group("kill_signal"), ApplyGroup::NextSpawn);
assert_eq!(apply_group("kill_timeout"), ApplyGroup::Live);
assert_eq!(apply_group("graceful_timeout"), ApplyGroup::Live);
}
#[test]
fn shutdown_with_message_is_baked_into_the_child() {
assert_eq!(
apply_group("shutdown_with_message"),
ApplyGroup::NeedsRespawn
);
}
#[test]
fn depends_on_applies_at_the_next_spawn() {
assert_eq!(apply_group("depends_on"), ApplyGroup::NextSpawn);
assert!(is_classified("depends_on"));
}
#[test]
fn the_split_is_20_5_15_2() {
let fields = appconfig_fields();
let count = |want: ApplyGroup| fields.keys().filter(|k| apply_group(k) == want).count();
assert_eq!(count(ApplyGroup::Live), 20, "Live");
assert_eq!(count(ApplyGroup::NextSpawn), 5, "NextSpawn");
assert_eq!(count(ApplyGroup::NeedsRespawn), 15, "NeedsRespawn");
assert_eq!(count(ApplyGroup::Structural), 2, "Structural");
}
#[test]
fn a_live_field_reaches_a_running_child() {
assert!(reaches_running("max_restarts"));
}
#[test]
fn autostart_and_depends_on_reach_one_without_a_respawn() {
assert!(reaches_running("autostart"));
assert!(reaches_running("depends_on"));
}
#[test]
fn the_other_next_spawn_fields_do_not() {
assert!(!reaches_running("kill_signal"));
assert!(!reaches_running("listen_timeout"));
assert!(!reaches_running("readiness_probe"));
}
#[test]
fn a_needs_respawn_field_does_not() {
assert!(!reaches_running("cwd"));
}
#[test]
fn an_unknown_field_does_not_reach_a_running_child() {
assert!(!reaches_running("a_field_from_a_later_shep"));
}
#[test]
fn reaches_running_agrees_with_the_group_table_for_every_field() {
for (field, group) in FIELDS {
let expected = match group {
ApplyGroup::Live => true,
ApplyGroup::NextSpawn => matches!(*field, "autostart" | "depends_on"),
_ => false,
};
assert_eq!(
reaches_running(field),
expected,
"{field} is classified {group:?}"
);
}
}
}