use crate::version::SchemaVersion;
use greentic_types::EnvId;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OnNotifyAction {
RecordOnly,
#[default]
Stage,
}
impl OnNotifyAction {
pub fn as_str(&self) -> &'static str {
match self {
OnNotifyAction::RecordOnly => "record_only",
OnNotifyAction::Stage => "stage",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"record_only" | "record-only" => Some(OnNotifyAction::RecordOnly),
"stage" => Some(OnNotifyAction::Stage),
_ => None,
}
}
}
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 3600;
pub const MIN_POLL_INTERVAL_SECS: u64 = 60;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UpdateChannelConfig {
pub schema: SchemaVersion,
pub environment_id: EnvId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_notify: Option<OnNotifyAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll_interval_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_endpoint: Option<String>,
#[serde(flatten)]
pub unknown: serde_json::Map<String, serde_json::Value>,
}
impl UpdateChannelConfig {
pub fn schema_str() -> &'static str {
SchemaVersion::UPDATE_CHANNEL_V1
}
pub fn disabled(environment_id: EnvId) -> Self {
Self {
schema: SchemaVersion::new(SchemaVersion::UPDATE_CHANNEL_V1),
environment_id,
enabled: None,
on_notify: None,
poll_interval_secs: None,
plan_endpoint: None,
unknown: serde_json::Map::new(),
}
}
pub fn resolved_enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn resolved_on_notify(&self) -> OnNotifyAction {
self.on_notify.unwrap_or_default()
}
pub fn resolved_poll_interval_secs(&self) -> u64 {
self.poll_interval_secs
.unwrap_or(DEFAULT_POLL_INTERVAL_SECS)
.max(MIN_POLL_INTERVAL_SECS)
}
pub fn resolved_plan_endpoint(&self) -> Option<&str> {
self.plan_endpoint.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(id: &str) -> EnvId {
EnvId::try_from(id).unwrap()
}
#[test]
fn disabled_resolves_deny_by_default() {
let cfg = UpdateChannelConfig::disabled(env("local"));
assert!(!cfg.resolved_enabled());
assert_eq!(cfg.resolved_on_notify(), OnNotifyAction::Stage);
assert_eq!(
cfg.resolved_poll_interval_secs(),
DEFAULT_POLL_INTERVAL_SECS
);
}
#[test]
fn poll_interval_floored_at_minimum() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.poll_interval_secs = Some(1);
assert_eq!(cfg.resolved_poll_interval_secs(), MIN_POLL_INTERVAL_SECS);
}
#[test]
fn on_notify_round_trips_and_parses() {
assert_eq!(OnNotifyAction::parse("stage"), Some(OnNotifyAction::Stage));
assert_eq!(
OnNotifyAction::parse("record-only"),
Some(OnNotifyAction::RecordOnly)
);
assert_eq!(
OnNotifyAction::parse("record_only"),
Some(OnNotifyAction::RecordOnly)
);
assert_eq!(OnNotifyAction::parse("apply"), None);
let json = serde_json::to_string(&OnNotifyAction::RecordOnly).unwrap();
assert_eq!(json, "\"record_only\"");
}
#[test]
fn json_round_trip_omits_unset_fields() {
let cfg = UpdateChannelConfig::disabled(env("local"));
let json = serde_json::to_value(&cfg).unwrap();
assert!(json.get("enabled").is_none());
assert!(json.get("on_notify").is_none());
assert!(json.get("poll_interval_secs").is_none());
assert!(json.get("plan_endpoint").is_none());
let back: UpdateChannelConfig = serde_json::from_value(json).unwrap();
assert_eq!(back, cfg);
}
#[test]
fn plan_endpoint_round_trips() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.plan_endpoint = Some("https://updates.example.com/plans/latest".into());
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(
json.get("plan_endpoint").and_then(|v| v.as_str()),
Some("https://updates.example.com/plans/latest")
);
let back: UpdateChannelConfig = serde_json::from_value(json).unwrap();
assert_eq!(back.plan_endpoint, cfg.plan_endpoint);
assert_eq!(
back.resolved_plan_endpoint(),
Some("https://updates.example.com/plans/latest")
);
}
#[test]
fn unknown_fields_survive_read_modify_write() {
let on_disk = serde_json::json!({
"schema": UpdateChannelConfig::schema_str(),
"environment_id": "local",
"enabled": true,
"future_field": { "nested": [1, 2, 3] },
});
let mut cfg: UpdateChannelConfig = serde_json::from_value(on_disk).unwrap();
assert_eq!(cfg.enabled, Some(true));
assert_eq!(
cfg.unknown.get("future_field"),
Some(&serde_json::json!({ "nested": [1, 2, 3] }))
);
assert!(!cfg.unknown.contains_key("enabled"));
cfg.on_notify = Some(OnNotifyAction::RecordOnly);
let rewritten = serde_json::to_value(&cfg).unwrap();
assert_eq!(
rewritten.get("future_field"),
Some(&serde_json::json!({ "nested": [1, 2, 3] }))
);
assert_eq!(
rewritten.get("on_notify").and_then(|v| v.as_str()),
Some("record_only")
);
assert_eq!(
rewritten.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test]
fn no_unknown_fields_serializes_clean() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.enabled = Some(true);
let json = serde_json::to_value(&cfg).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 3, "unexpected keys: {obj:?}");
}
}