use crate::FaucetError;
use crate::replication::{BindFormat, BindTarget, format_instant};
use chrono::{DateTime, Duration, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const WINDOW_PLACEHOLDER: &str = "${window}";
fn default_window_template() -> String {
WINDOW_PLACEHOLDER.to_owned()
}
pub const DEFAULT_MAX_WINDOWS: usize = 10_000;
fn default_max_windows() -> usize {
DEFAULT_MAX_WINDOWS
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Window {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WindowBind {
#[serde(default)]
pub into: BindTarget,
pub name: String,
#[serde(default = "default_window_template")]
pub template: String,
#[serde(default)]
pub format: BindFormat,
}
impl WindowBind {
pub fn validate(&self, side: &str) -> Result<(), FaucetError> {
if self.name.trim().is_empty() {
return Err(FaucetError::Config(format!(
"window slicing: `{side}.name` must not be empty"
)));
}
if !self.template.contains(WINDOW_PLACEHOLDER) {
return Err(FaucetError::Config(format!(
"window slicing: `{side}.template` must contain the `{WINDOW_PLACEHOLDER}` placeholder"
)));
}
Ok(())
}
pub fn render(&self, boundary: DateTime<Utc>) -> String {
let formatted = format_instant(boundary, self.format);
self.template.replace(WINDOW_PLACEHOLDER, &formatted)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WindowSpec {
pub step: String,
pub lower: WindowBind,
pub upper: WindowBind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granularity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lookback: Option<String>,
#[serde(default = "default_max_windows")]
pub max_windows: usize,
}
pub fn parse_step(s: &str) -> Result<Duration, FaucetError> {
let s = s.trim();
let err = || {
FaucetError::Config(format!(
"window slicing: '{s}' is not a valid duration — use e.g. 45s, 30m, 6h, 30d"
))
};
let (num, unit) = match s.chars().last() {
Some(c) if c.is_ascii_digit() => (s, "s"),
Some(c) => (&s[..s.len() - c.len_utf8()], &s[s.len() - c.len_utf8()..]),
None => return Err(err()),
};
let n: i64 = num.parse().map_err(|_| err())?;
if n <= 0 {
return Err(FaucetError::Config(format!(
"window slicing: duration '{s}' must be positive"
)));
}
Ok(match unit {
"s" => Duration::seconds(n),
"m" => Duration::minutes(n),
"h" => Duration::hours(n),
"d" => Duration::days(n),
_ => return Err(err()),
})
}
impl WindowSpec {
pub fn validate(&self) -> Result<(), FaucetError> {
parse_step(&self.step)?;
if let Some(g) = &self.granularity {
parse_step(g)?;
}
if let Some(l) = &self.lookback {
parse_step(l)?;
}
self.lower.validate("lower")?;
self.upper.validate("upper")?;
if self.max_windows == 0 {
return Err(FaucetError::Config(
"window slicing: `max_windows` must be greater than zero".to_owned(),
));
}
Ok(())
}
pub fn step_duration(&self) -> Result<Duration, FaucetError> {
parse_step(&self.step)
}
pub fn granularity_duration(&self) -> Result<Option<Duration>, FaucetError> {
self.granularity.as_deref().map(parse_step).transpose()
}
pub fn lookback_duration(&self) -> Result<Option<Duration>, FaucetError> {
self.lookback.as_deref().map(parse_step).transpose()
}
pub fn render_lower(&self, w: &Window) -> String {
self.lower.render(w.start)
}
pub fn render_upper(&self, w: &Window) -> Result<String, FaucetError> {
let end = match self.granularity_duration()? {
Some(g) => w.end - g,
None => w.end,
};
Ok(self.upper.render(end))
}
}
pub fn enumerate_windows(
start: DateTime<Utc>,
now: DateTime<Utc>,
step: Duration,
lookback: Option<Duration>,
max_windows: usize,
) -> (Vec<Window>, bool) {
let mut cur = match lookback {
Some(lb) => start - lb,
None => start,
};
let mut out = Vec::new();
let mut truncated = false;
while cur < now {
if out.len() >= max_windows {
truncated = true;
break;
}
let end = std::cmp::min(cur + step, now);
if end <= cur {
break;
}
out.push(Window { start: cur, end });
cur = end;
}
(out, truncated)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use serde_json::json;
fn dt(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[test]
fn parse_step_units() {
assert_eq!(parse_step("45s").unwrap(), Duration::seconds(45));
assert_eq!(parse_step("30m").unwrap(), Duration::minutes(30));
assert_eq!(parse_step("6h").unwrap(), Duration::hours(6));
assert_eq!(parse_step("30d").unwrap(), Duration::days(30));
assert_eq!(parse_step("3600").unwrap(), Duration::seconds(3600));
}
#[test]
fn parse_step_rejects_bad() {
assert!(parse_step("0d").is_err());
assert!(parse_step("-1h").is_err());
assert!(parse_step("").is_err());
assert!(parse_step("10y").is_err());
assert!(parse_step("abc").is_err());
}
#[test]
fn enumerate_contiguous_half_open() {
let (ws, trunc) = enumerate_windows(
dt("2024-01-01T00:00:00Z"),
dt("2024-01-04T00:00:00Z"),
Duration::days(1),
None,
100,
);
assert!(!trunc);
assert_eq!(ws.len(), 3);
assert_eq!(ws[0].start, dt("2024-01-01T00:00:00Z"));
assert_eq!(ws[0].end, dt("2024-01-02T00:00:00Z"));
assert_eq!(ws[0].end, ws[1].start);
assert_eq!(ws[2].end, dt("2024-01-04T00:00:00Z"));
}
#[test]
fn last_window_clamps_to_now() {
let (ws, _) = enumerate_windows(
dt("2024-01-01T00:00:00Z"),
dt("2024-01-02T06:00:00Z"),
Duration::days(1),
None,
100,
);
assert_eq!(ws.len(), 2);
assert_eq!(ws[1].start, dt("2024-01-02T00:00:00Z"));
assert_eq!(ws[1].end, dt("2024-01-02T06:00:00Z")); }
#[test]
fn empty_when_start_at_or_after_now() {
let (ws, trunc) = enumerate_windows(
dt("2024-06-01T00:00:00Z"),
dt("2024-06-01T00:00:00Z"),
Duration::days(1),
None,
100,
);
assert!(ws.is_empty());
assert!(!trunc);
}
#[test]
fn lookback_extends_the_first_window_backwards() {
let (ws, _) = enumerate_windows(
dt("2024-01-02T00:00:00Z"),
dt("2024-01-03T00:00:00Z"),
Duration::days(1),
Some(Duration::hours(6)),
100,
);
assert_eq!(ws[0].start, dt("2024-01-01T18:00:00Z"));
}
#[test]
fn max_windows_truncates_and_flags() {
let (ws, trunc) = enumerate_windows(
dt("2024-01-01T00:00:00Z"),
dt("2024-12-31T00:00:00Z"),
Duration::days(1),
None,
5,
);
assert_eq!(ws.len(), 5);
assert!(trunc);
assert_eq!(ws[4].end, dt("2024-01-06T00:00:00Z"));
}
#[test]
fn render_lower_and_upper_with_granularity() {
let spec = WindowSpec {
step: "1d".into(),
lower: WindowBind {
into: BindTarget::Query,
name: "start".into(),
template: "${window}".into(),
format: BindFormat::Date,
},
upper: WindowBind {
into: BindTarget::Query,
name: "end".into(),
template: "${window}".into(),
format: BindFormat::Date,
},
granularity: Some("1d".into()),
lookback: None,
max_windows: DEFAULT_MAX_WINDOWS,
};
let w = Window {
start: dt("2024-01-01T00:00:00Z"),
end: dt("2024-01-02T00:00:00Z"),
};
assert_eq!(spec.render_lower(&w), "2024-01-01");
assert_eq!(spec.render_upper(&w).unwrap(), "2024-01-01");
}
#[test]
fn render_template_and_epoch_format() {
let bind = WindowBind {
into: BindTarget::Query,
name: "since".into(),
template: "gte|${window}".into(),
format: BindFormat::EpochS,
};
let ts = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
assert_eq!(bind.render(ts), "gte|1700000000");
}
#[test]
fn validate_catches_misconfig() {
let ok = WindowSpec {
step: "1d".into(),
lower: WindowBind {
into: BindTarget::Query,
name: "start".into(),
template: "${window}".into(),
format: BindFormat::Iso8601,
},
upper: WindowBind {
into: BindTarget::Query,
name: "end".into(),
template: "${window}".into(),
format: BindFormat::Iso8601,
},
granularity: None,
lookback: None,
max_windows: DEFAULT_MAX_WINDOWS,
};
ok.validate().unwrap();
let mut bad_step = ok.clone();
bad_step.step = "0d".into();
assert!(bad_step.validate().is_err());
let mut empty_name = ok.clone();
empty_name.lower.name = " ".into();
assert!(empty_name.validate().is_err());
let mut no_placeholder = ok.clone();
no_placeholder.upper.template = "fixed".into();
assert!(no_placeholder.validate().is_err());
let mut zero_windows = ok.clone();
zero_windows.max_windows = 0;
assert!(zero_windows.validate().is_err());
}
#[test]
fn spec_deserializes_from_yaml_shape() {
let v = json!({
"step": "30d",
"lower": {"into": "query", "name": "start_date", "format": "date"},
"upper": {"into": "query", "name": "end_date", "format": "date"},
"lookback": "1d"
});
let spec: WindowSpec = serde_json::from_value(v).unwrap();
assert_eq!(spec.step, "30d");
assert_eq!(spec.lower.template, WINDOW_PLACEHOLDER); assert_eq!(spec.max_windows, DEFAULT_MAX_WINDOWS); spec.validate().unwrap();
}
}