feat(config): support human-readable durations with legacy compatibility

This commit is contained in:
lda committed 2026-10-10 23:37:04 +07:00 Verified
1 parent f91186a7b1
commit f098d85621
22 files changed
+715 -151

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
//! Human-readable config durations and the unit-aware legacy key adapter.
//! Protocol numbers and metric units deliberately do not use this adapter.
use std::time::Duration;
use serde::{Deserialize, Deserializer, de::Error};
use serde_json::Value;
#[derive(Clone, Copy)]
pub struct LegacyDuration {
pub old: &'static str,
pub new: &'static str,
pub unit: Duration,
}
/// Normalize one config table before normal serde decoding. The returned keys
/// let loaders warn only after validation, even before tracing is initialized.
pub fn normalize(
value: &mut Value,
keys: &[LegacyDuration],
) -> Result<Vec<LegacyDuration>, String> {
let table = value.as_object_mut().ok_or("expected a config table")?;
let mut deprecated = Vec::new();
for key in keys {
if table.contains_key(key.old) && table.contains_key(key.new) {
return Err(format!(
"configure only one of `{}` and `{}`",
key.old, key.new
));
}
if let Some(old) = table.remove(key.old) {
let duration = if let Some(number) = old.as_u64() {
// u64 multiplication must not wrap for large legacy day values.
let nanos = key
.unit
.as_nanos()
.checked_mul(u128::from(number))
.ok_or_else(|| format!("`{}` is too large", key.old))?;
let seconds = u64::try_from(nanos / 1_000_000_000)
.map_err(|_| format!("`{}` is too large", key.old))?;
Duration::new(seconds, (nanos % 1_000_000_000) as u32)
} else if let Some(text) = old.as_str() {
humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.old))?
} else {
return Err(format!(
"`{}` requires a non-negative integer or duration string",
key.old
));
};
table.insert(
key.new.into(),
Value::String(humantime::format_duration(duration).to_string()),
);
deprecated.push(*key);
}
if let Some(value) = table.get(key.new) {
let text = value
.as_str()
.ok_or_else(|| format!("`{}` requires a duration string with units", key.new))?;
humantime::parse_duration(text).map_err(|err| format!("`{}`: {err}", key.new))?;
}
}
Ok(deprecated)
}
/// Decode through the canonical serde definition after translating old keys.
/// The callback is a serde-generated associated function (`remote = "Self"`),
/// avoiding a second copy of every unrelated config field in a legacy DTO.
pub fn deserialize_legacy<'de, D, T>(
deserializer: D,
keys: &[LegacyDuration],
decode: impl FnOnce(Value) -> Result<T, serde_json::Error>,
) -> Result<T, D::Error>
where
D: Deserializer<'de>,
{
let mut value = Value::deserialize(deserializer)?;
normalize(&mut value, keys).map_err(D::Error::custom)?;
decode(value).map_err(D::Error::custom)
}
#[cfg(test)]
mod tests {
use super::*;
const KEYS: &[LegacyDuration] = &[LegacyDuration {
old: "interval_ms",
new: "interval",
unit: Duration::from_millis(1),
}];
#[test]
fn legacy_units_are_exact_and_conflicts_are_rejected() {
let mut value = serde_json::json!({"interval_ms": 1234});
assert_eq!(normalize(&mut value, KEYS).unwrap().len(), 1);
assert_eq!(
humantime::parse_duration(value["interval"].as_str().unwrap()).unwrap(),
Duration::from_millis(1234)
);
assert!(
normalize(
&mut serde_json::json!({"interval_ms": 1, "interval": "1s"}),
KEYS
)
.is_err()
);
for invalid in [
serde_json::json!({"interval": 5}),
serde_json::json!({"interval_ms": -1}),
serde_json::json!({"interval": "potato"}),
] {
assert!(normalize(&mut invalid.clone(), KEYS).is_err());
}
}
}
+1
View File
@@ -1,5 +1,6 @@
//! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows.
pub mod config_duration;
pub mod model;
pub mod parse;