use serde::{Deserialize, Serialize};
use crate::config::MailSection;
pub const MAX_AGENT_PRUNE_DAYS: u32 = 36_500;
pub const DEFAULT_COLLECT_RETENTION_DAYS: u32 = 30;
pub const MAX_COLLECT_RETENTION_DAYS: u32 = 3650;
pub const DEFAULT_SESSION_TTL_HOURS: u32 = 24;
pub const MAX_SESSION_TTL_HOURS: u32 = 8_760;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(default)]
pub struct ServerSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_prune_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub controller_group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mail: Option<MailSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub collect_retention_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_ttl_hours: Option<u32>,
}
impl ServerSettings {
pub fn defaults() -> Self {
Self {
agent_prune_days: None,
controller_group: None,
mail: None,
collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
}
}
pub fn effective_controller_group(&self) -> Option<&str> {
self.controller_group
.as_deref()
.map(str::trim)
.filter(|g| !g.is_empty())
}
pub fn effective_agent_prune_days(&self) -> u32 {
self.agent_prune_days
.or(Self::defaults().agent_prune_days)
.unwrap_or(0)
.min(MAX_AGENT_PRUNE_DAYS)
}
pub fn effective_collect_retention_days(&self) -> u32 {
self.collect_retention_days
.or(Self::defaults().collect_retention_days)
.unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
.clamp(1, MAX_COLLECT_RETENTION_DAYS)
}
pub fn effective_session_ttl_hours(&self) -> u32 {
self.session_ttl_hours
.or(Self::defaults().session_ttl_hours)
.unwrap_or(DEFAULT_SESSION_TTL_HOURS)
.clamp(1, MAX_SESSION_TTL_HOURS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_unset() {
assert_eq!(ServerSettings::default().agent_prune_days, None);
}
#[test]
fn unset_resolves_to_disabled() {
assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
}
#[test]
fn stored_value_wins_over_default() {
let s = ServerSettings {
agent_prune_days: Some(30),
..Default::default()
};
assert_eq!(s.effective_agent_prune_days(), 30);
}
#[test]
fn effective_clamps_to_max() {
let s = ServerSettings {
agent_prune_days: Some(u32::MAX),
..Default::default()
};
assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
}
#[test]
fn round_trips_through_json() {
let s = ServerSettings {
agent_prune_days: Some(30),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"agent_prune_days":30}"#);
let back: ServerSettings = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn unset_serialises_to_empty_object() {
let s = ServerSettings::default();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, "{}");
let back: ServerSettings = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn explicit_null_decodes_to_unset() {
let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
assert_eq!(s.agent_prune_days, None);
}
#[test]
fn empty_object_decodes_to_default() {
let s: ServerSettings = serde_json::from_str("{}").unwrap();
assert_eq!(s, ServerSettings::default());
}
#[test]
fn controller_group_effective_trims_and_blank_is_unset() {
assert_eq!(ServerSettings::default().effective_controller_group(), None);
let s = ServerSettings {
controller_group: Some(" feed-runners ".into()),
..Default::default()
};
assert_eq!(s.effective_controller_group(), Some("feed-runners"));
let blank = ServerSettings {
controller_group: Some(" ".into()),
..Default::default()
};
assert_eq!(blank.effective_controller_group(), None);
}
#[test]
fn controller_group_round_trips_and_omits_when_unset() {
let s = ServerSettings {
controller_group: Some("infra".into()),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"controller_group":"infra"}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert_eq!(
serde_json::to_string(&ServerSettings::default()).unwrap(),
"{}"
);
}
#[test]
fn mail_round_trips_and_omits_when_unset() {
use crate::config::{MailEncryption, MailSection};
assert_eq!(
serde_json::to_string(&ServerSettings::default()).unwrap(),
"{}"
);
let s = ServerSettings {
mail: Some(MailSection {
host: "smtp.example.com".into(),
port: 587,
encryption: MailEncryption::Starttls,
from: "kanade-noreply@example.com".into(),
username: Some("kanade-noreply".into()),
}),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
assert!(!json.contains("password"), "password must never serialise");
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
}
#[test]
fn mail_defaults_to_unset() {
assert_eq!(ServerSettings::default().mail, None);
let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
assert_eq!(s.mail, None);
assert_eq!(s.agent_prune_days, Some(7));
}
#[test]
fn collect_retention_unset_resolves_to_builtin_default() {
assert_eq!(ServerSettings::default().collect_retention_days, None);
assert_eq!(
ServerSettings::default().effective_collect_retention_days(),
DEFAULT_COLLECT_RETENTION_DAYS,
);
assert_eq!(
ServerSettings::defaults().collect_retention_days,
Some(DEFAULT_COLLECT_RETENTION_DAYS),
);
}
#[test]
fn collect_retention_stored_value_wins() {
let s = ServerSettings {
collect_retention_days: Some(90),
..Default::default()
};
assert_eq!(s.effective_collect_retention_days(), 90);
}
#[test]
fn collect_retention_effective_clamps_out_of_band_writes() {
let big = ServerSettings {
collect_retention_days: Some(u32::MAX),
..Default::default()
};
assert_eq!(
big.effective_collect_retention_days(),
MAX_COLLECT_RETENTION_DAYS,
);
let zero = ServerSettings {
collect_retention_days: Some(0),
..Default::default()
};
assert_eq!(zero.effective_collect_retention_days(), 1);
}
#[test]
fn collect_retention_round_trips_and_omits_when_unset() {
let s = ServerSettings {
collect_retention_days: Some(90),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"collect_retention_days":90}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert!(
!serde_json::to_string(&ServerSettings::default())
.unwrap()
.contains("collect_retention_days")
);
}
#[test]
fn session_ttl_unset_resolves_to_builtin_default() {
assert_eq!(ServerSettings::default().session_ttl_hours, None);
assert_eq!(
ServerSettings::default().effective_session_ttl_hours(),
DEFAULT_SESSION_TTL_HOURS,
);
assert_eq!(
ServerSettings::defaults().session_ttl_hours,
Some(DEFAULT_SESSION_TTL_HOURS),
);
}
#[test]
fn session_ttl_stored_value_wins() {
let s = ServerSettings {
session_ttl_hours: Some(72),
..Default::default()
};
assert_eq!(s.effective_session_ttl_hours(), 72);
}
#[test]
fn session_ttl_effective_clamps_out_of_band_writes() {
let zero = ServerSettings {
session_ttl_hours: Some(0),
..Default::default()
};
assert_eq!(zero.effective_session_ttl_hours(), 1);
let huge = ServerSettings {
session_ttl_hours: Some(u32::MAX),
..Default::default()
};
assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
}
#[test]
fn session_ttl_round_trips_and_omits_when_unset() {
let s = ServerSettings {
session_ttl_hours: Some(48),
..Default::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"session_ttl_hours":48}"#);
assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
assert!(
!serde_json::to_string(&ServerSettings::default())
.unwrap()
.contains("session_ttl_hours")
);
}
#[test]
fn accepts_unknown_fields_for_forward_compat() {
let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
let s: ServerSettings = serde_json::from_str(json).unwrap();
assert_eq!(s.agent_prune_days, Some(7));
}
}