use std::collections::BTreeMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(default)]
pub struct ConfigScope {
#[serde(skip_serializing_if = "Option::is_none")]
pub target_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inventory_interval: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inventory_jitter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inventory_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heartbeat_interval: Option<String>,
}
impl ConfigScope {
pub fn is_empty(&self) -> bool {
self.target_version.is_none()
&& self.inventory_interval.is_none()
&& self.inventory_jitter.is_none()
&& self.inventory_enabled.is_none()
&& self.heartbeat_interval.is_none()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct EffectiveConfig {
pub target_version: Option<String>,
pub inventory_interval: String,
pub inventory_jitter: String,
pub inventory_enabled: bool,
pub heartbeat_interval: String,
}
impl EffectiveConfig {
pub fn builtin_defaults() -> Self {
Self {
target_version: None,
inventory_interval: "24h".to_string(),
inventory_jitter: "10m".to_string(),
inventory_enabled: true,
heartbeat_interval: "30s".to_string(),
}
}
pub fn heartbeat_duration(&self) -> Duration {
humantime::parse_duration(&self.heartbeat_interval).unwrap_or(Duration::from_secs(30))
}
pub fn inventory_interval_duration(&self) -> Duration {
humantime::parse_duration(&self.inventory_interval)
.unwrap_or(Duration::from_secs(24 * 60 * 60))
}
pub fn inventory_jitter_duration(&self) -> Duration {
humantime::parse_duration(&self.inventory_jitter).unwrap_or(Duration::from_secs(600))
}
}
impl Default for EffectiveConfig {
fn default() -> Self {
Self::builtin_defaults()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolutionWarning {
MultiGroupConflict {
field: &'static str,
groups: Vec<String>,
},
}
pub fn resolve(
global: Option<&ConfigScope>,
group_scopes: &BTreeMap<String, ConfigScope>,
pc_scope: Option<&ConfigScope>,
my_groups: &[String],
) -> (EffectiveConfig, Vec<ResolutionWarning>) {
let mut out = EffectiveConfig::builtin_defaults();
let mut warnings = Vec::new();
if let Some(g) = global {
apply_scope(&mut out, g);
}
let mut sorted_groups: Vec<&str> = my_groups.iter().map(String::as_str).collect();
sorted_groups.sort();
sorted_groups.dedup();
let mut setters: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
for g in &sorted_groups {
let Some(scope) = group_scopes.get(*g) else {
continue;
};
if scope.target_version.is_some() {
setters
.entry("target_version")
.or_default()
.push(g.to_string());
}
if scope.inventory_interval.is_some() {
setters
.entry("inventory_interval")
.or_default()
.push(g.to_string());
}
if scope.inventory_jitter.is_some() {
setters
.entry("inventory_jitter")
.or_default()
.push(g.to_string());
}
if scope.inventory_enabled.is_some() {
setters
.entry("inventory_enabled")
.or_default()
.push(g.to_string());
}
if scope.heartbeat_interval.is_some() {
setters
.entry("heartbeat_interval")
.or_default()
.push(g.to_string());
}
}
for (field, groups) in setters {
if groups.len() > 1 {
warnings.push(ResolutionWarning::MultiGroupConflict { field, groups });
}
}
for g in &sorted_groups {
if let Some(scope) = group_scopes.get(*g) {
apply_scope(&mut out, scope);
}
}
if let Some(p) = pc_scope {
apply_scope(&mut out, p);
}
(out, warnings)
}
fn apply_scope(out: &mut EffectiveConfig, s: &ConfigScope) {
if let Some(v) = &s.target_version {
out.target_version = Some(v.clone());
}
if let Some(v) = &s.inventory_interval {
out.inventory_interval = v.clone();
}
if let Some(v) = &s.inventory_jitter {
out.inventory_jitter = v.clone();
}
if let Some(v) = s.inventory_enabled {
out.inventory_enabled = v;
}
if let Some(v) = &s.heartbeat_interval {
out.heartbeat_interval = v.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scope() -> ConfigScope {
ConfigScope::default()
}
#[test]
fn empty_stack_gives_builtin_defaults() {
let (eff, warns) = resolve(None, &BTreeMap::new(), None, &[]);
assert_eq!(eff, EffectiveConfig::builtin_defaults());
assert!(warns.is_empty());
}
#[test]
fn global_only() {
let g = ConfigScope {
inventory_interval: Some("12h".into()),
heartbeat_interval: Some("60s".into()),
..scope()
};
let (eff, _) = resolve(Some(&g), &BTreeMap::new(), None, &[]);
assert_eq!(eff.inventory_interval, "12h");
assert_eq!(eff.heartbeat_interval, "60s");
assert_eq!(eff.inventory_jitter, "10m");
assert!(eff.inventory_enabled);
assert!(eff.target_version.is_none());
}
#[test]
fn group_overrides_global() {
let global = ConfigScope {
inventory_interval: Some("24h".into()),
..scope()
};
let mut groups = BTreeMap::new();
groups.insert(
"canary".into(),
ConfigScope {
inventory_interval: Some("1h".into()),
..scope()
},
);
let (eff, warns) = resolve(Some(&global), &groups, None, &["canary".into()]);
assert_eq!(eff.inventory_interval, "1h");
assert!(warns.is_empty());
}
#[test]
fn pc_overrides_group() {
let mut groups = BTreeMap::new();
groups.insert(
"wave1".into(),
ConfigScope {
inventory_interval: Some("12h".into()),
..scope()
},
);
let pc = ConfigScope {
inventory_interval: Some("5m".into()),
..scope()
};
let (eff, _) = resolve(None, &groups, Some(&pc), &["wave1".into()]);
assert_eq!(eff.inventory_interval, "5m");
}
#[test]
fn pc_overrides_global_when_no_group_match() {
let global = ConfigScope {
inventory_interval: Some("24h".into()),
..scope()
};
let pc = ConfigScope {
inventory_interval: Some("30m".into()),
..scope()
};
let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
assert_eq!(eff.inventory_interval, "30m");
}
#[test]
fn partial_override_only_changes_named_fields() {
let global = ConfigScope {
inventory_interval: Some("24h".into()),
heartbeat_interval: Some("30s".into()),
..scope()
};
let pc = ConfigScope {
heartbeat_interval: Some("15s".into()),
..scope()
};
let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
assert_eq!(eff.inventory_interval, "24h"); assert_eq!(eff.heartbeat_interval, "15s"); }
#[test]
fn multi_group_conflict_emits_warning() {
let mut groups = BTreeMap::new();
groups.insert(
"wave1".into(),
ConfigScope {
inventory_interval: Some("12h".into()),
..scope()
},
);
groups.insert(
"dept-eng".into(),
ConfigScope {
inventory_interval: Some("24h".into()),
..scope()
},
);
let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "dept-eng".into()]);
assert_eq!(eff.inventory_interval, "12h");
assert_eq!(warns.len(), 1);
match &warns[0] {
ResolutionWarning::MultiGroupConflict { field, groups } => {
assert_eq!(*field, "inventory_interval");
assert_eq!(groups, &vec!["dept-eng".to_string(), "wave1".to_string()]);
}
}
}
#[test]
fn group_alphabetical_last_wins_no_conflict_when_only_one_sets() {
let mut groups = BTreeMap::new();
groups.insert(
"wave1".into(),
ConfigScope {
inventory_interval: Some("12h".into()),
..scope()
},
);
groups.insert(
"dept-eng".into(),
ConfigScope {
heartbeat_interval: Some("15s".into()),
..scope()
},
);
let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "dept-eng".into()]);
assert_eq!(eff.inventory_interval, "12h");
assert_eq!(eff.heartbeat_interval, "15s");
assert!(warns.is_empty());
}
#[test]
fn unknown_group_is_silently_ignored() {
let mut groups = BTreeMap::new();
groups.insert(
"canary".into(),
ConfigScope {
inventory_interval: Some("1h".into()),
..scope()
},
);
let (eff, warns) = resolve(
None,
&groups,
None,
&["canary".into(), "ghost-group".into()],
);
assert_eq!(eff.inventory_interval, "1h");
assert!(warns.is_empty());
}
#[test]
fn group_scope_not_applied_when_pc_not_in_group() {
let mut groups = BTreeMap::new();
groups.insert(
"canary".into(),
ConfigScope {
target_version: Some("0.3.0".into()),
..scope()
},
);
let (eff, _) = resolve(None, &groups, None, &["dept-eng".into()]);
assert!(eff.target_version.is_none());
}
#[test]
fn duplicate_group_names_dedup_silently() {
let mut groups = BTreeMap::new();
groups.insert(
"wave1".into(),
ConfigScope {
inventory_interval: Some("12h".into()),
..scope()
},
);
let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "wave1".into()]);
assert_eq!(eff.inventory_interval, "12h");
assert!(warns.is_empty());
}
#[test]
fn config_scope_serde_round_trip() {
let s = ConfigScope {
target_version: Some("0.3.0".into()),
heartbeat_interval: Some("15s".into()),
..scope()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(
json,
r#"{"target_version":"0.3.0","heartbeat_interval":"15s"}"#
);
let back: ConfigScope = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn empty_config_scope_round_trips_as_empty_json() {
let s = ConfigScope::default();
assert!(s.is_empty());
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, "{}");
let back: ConfigScope = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn deserialize_tolerates_unknown_fields_for_forward_compat() {
let json = r#"{"target_version":"0.3.0","future_knob":"future_value"}"#;
let s: ConfigScope = serde_json::from_str(json).unwrap();
assert_eq!(s.target_version.as_deref(), Some("0.3.0"));
}
#[test]
fn pc_does_not_override_other_pcs() {
let mut groups = BTreeMap::new();
groups.insert(
"wave1".into(),
ConfigScope {
inventory_interval: Some("12h".into()),
..scope()
},
);
let pc = ConfigScope {
inventory_interval: Some("5m".into()),
..scope()
};
let (eff, _) = resolve(None, &groups, Some(&pc), &["wave1".into()]);
assert_eq!(eff.inventory_interval, "5m");
}
}