use std::collections::BTreeMap;
use std::fmt;
use jiff::civil::Date;
use jiff::tz::Offset;
use jiff::{Span, Timestamp};
use crate::category::Category;
use crate::classify::Classification;
use crate::map::CategoryMap;
pub const FLOOR_YEARS: u16 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Years(u16);
impl Years {
pub const FLOOR: Self = Self(FLOOR_YEARS);
#[must_use]
pub const fn new(years: u16) -> Option<Self> {
if years < FLOOR_YEARS {
None
} else {
Some(Self(years))
}
}
#[must_use]
pub const fn get(self) -> u16 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Period {
Years(u16),
Days(u32),
Indefinite,
}
impl Period {
#[must_use]
pub fn years(self) -> Option<Years> {
match self {
Self::Years(years) => Years::new(years),
Self::Days(_) | Self::Indefinite => None,
}
}
#[must_use]
pub fn code(self) -> String {
match self {
Self::Years(years) => format!("P{years}Y"),
Self::Days(days) => format!("P{days}D"),
Self::Indefinite => "indefinite".to_owned(),
}
}
fn fewest_days(self) -> Option<u64> {
match self {
Self::Years(years) => Some(u64::from(years).saturating_mul(365)),
Self::Days(days) => Some(u64::from(days)),
Self::Indefinite => None,
}
}
fn most_days(self) -> Option<u64> {
match self {
Self::Years(years) => {
let years = u64::from(years);
Some(years.saturating_mul(365).saturating_add(years.div_ceil(4)))
}
Self::Days(days) => Some(u64::from(days)),
Self::Indefinite => None,
}
}
fn reaches(self, floor: Self) -> bool {
if let (Self::Years(kept), Self::Years(needed)) = (self, floor) {
return kept >= needed;
}
match (self.fewest_days(), floor.most_days()) {
(None, _) => true,
(Some(_), None) => false,
(Some(kept), Some(needed)) => kept >= needed,
}
}
fn within(self, ceiling: Self) -> bool {
if let (Self::Years(kept), Self::Years(limit)) = (self, ceiling) {
return kept <= limit;
}
match (self.most_days(), ceiling.fewest_days()) {
(_, None) => true,
(None, Some(_)) => false,
(Some(kept), Some(limit)) => kept <= limit,
}
}
fn ends(self, accessed: Date) -> Option<Date> {
let span = match self {
Self::Years(years) => Span::new()
.try_years(i64::from(years))
.and_then(|span| span.try_days(1)),
Self::Days(days) => Span::new().try_days(i64::from(days).saturating_add(1)),
Self::Indefinite => return None,
};
Some(span.map_or(Date::MAX, |span| accessed.saturating_add(span)))
}
fn outlasted_by(self, other: Self, accessed: Date) -> bool {
match (self.ends(accessed), other.ends(accessed)) {
(None, _) => false,
(Some(_), None) => true,
(Some(ours), Some(theirs)) => theirs > ours,
}
}
}
impl fmt::Display for Period {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.code())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FloorGround {
Union,
National(String),
OutsideUnion(String),
}
impl FloorGround {
#[must_use]
pub fn code(&self) -> String {
match self {
Self::Union => "eu-2025-327-art-9-2".to_owned(),
Self::National(citation) => format!("national:{citation}"),
Self::OutsideUnion(citation) => format!("outside-union:{citation}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bounds {
floor: Period,
floor_ground: FloorGround,
ceiling: Option<(Period, String)>,
}
impl Default for Bounds {
fn default() -> Self {
Self::union()
}
}
impl Bounds {
#[must_use]
pub fn union() -> Self {
Self {
floor: Period::Years(FLOOR_YEARS),
floor_ground: FloorGround::Union,
ceiling: None,
}
}
pub fn national(floor: Period, ground: String) -> Result<Self, RetentionError> {
if floor == Period::Indefinite {
return Err(RetentionError::FloorIndefinite);
}
if !floor.reaches(Period::Years(FLOOR_YEARS)) {
return Err(RetentionError::NationalUnderUnion { floor });
}
Ok(Self {
floor,
floor_ground: FloorGround::National(ground),
ceiling: None,
})
}
pub fn outside_union(floor: Period, ground: String) -> Result<Self, RetentionError> {
if floor == Period::Indefinite {
return Err(RetentionError::FloorIndefinite);
}
Ok(Self {
floor,
floor_ground: FloorGround::OutsideUnion(ground),
ceiling: None,
})
}
pub fn with_ceiling(mut self, ceiling: Period, ground: String) -> Result<Self, RetentionError> {
if !ceiling.reaches(self.floor) {
return Err(RetentionError::CeilingUnderFloor {
ceiling,
floor: self.floor,
});
}
self.ceiling = Some((ceiling, ground));
Ok(self)
}
#[must_use]
pub fn floor(&self) -> Period {
self.floor
}
#[must_use]
pub fn floor_ground(&self) -> &FloorGround {
&self.floor_ground
}
#[must_use]
pub fn ceiling(&self) -> Option<(Period, &str)> {
self.ceiling
.as_ref()
.map(|(period, ground)| (*period, ground.as_str()))
}
fn admit(&self, key: String, period: Period) -> Result<Period, RetentionError> {
if !period.reaches(self.floor) {
if let (Period::Years(years), FloorGround::Union) = (period, &self.floor_ground) {
return Err(RetentionError::UnderFloor { key, years });
}
return Err(RetentionError::UnderBound {
key,
period,
floor: self.floor,
ground: self.floor_ground.code(),
});
}
if let Some((ceiling, ground)) = &self.ceiling
&& !period.within(*ceiling)
{
return Err(RetentionError::OverCeiling {
key,
period,
ceiling: *ceiling,
ground: ground.clone(),
});
}
Ok(period)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Ground {
Default,
Unclassified,
Category(Category),
Origin(String),
}
impl Ground {
#[must_use]
pub fn code(&self) -> String {
match self {
Self::Default => "default".to_owned(),
Self::Unclassified => "unclassified".to_owned(),
Self::Category(category) => format!("category:{}", category.token()),
Self::Origin(key) => format!("origin:{key}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Retention {
period: Period,
ground: Ground,
floor: FloorGround,
ends: Option<Date>,
}
impl Retention {
#[must_use]
pub fn period(&self) -> Period {
self.period
}
#[must_use]
pub fn ground(&self) -> &Ground {
&self.ground
}
#[must_use]
pub fn floor(&self) -> &FloorGround {
&self.floor
}
#[must_use]
pub fn ends(&self) -> Option<Date> {
self.ends
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RetentionError {
#[error(
"{key} keeps a record {years} years, under the {FLOOR_YEARS} years from each date of access Regulation (EU) 2025/327 Art 9(2) asks for"
)]
UnderFloor {
key: String,
years: u16,
},
#[error(
"{key} keeps a record {period}, which does not reach the floor of {floor} ({ground}) from every date of access"
)]
UnderBound {
key: String,
period: Period,
floor: Period,
ground: String,
},
#[error("{key} keeps a record {period}, past the ceiling of {ceiling} ({ground})")]
OverCeiling {
key: String,
period: Period,
ceiling: Period,
ground: String,
},
#[error(
"a national floor of {floor} does not reach the {FLOOR_YEARS} years of Regulation (EU) 2025/327 Art 9(2); a deployment outside the Union declares its floor as such"
)]
NationalUnderUnion {
floor: Period,
},
#[error("a retention floor is a finite period")]
FloorIndefinite,
#[error("the ceiling of {ceiling} does not reach the floor of {floor}")]
CeilingUnderFloor {
ceiling: Period,
floor: Period,
},
#[error("categories key {code:?} is neither a priority category nor a declared national one")]
UnknownCategory {
code: String,
},
#[error("an origins key is empty or holds a control character")]
OriginKey,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetentionPolicy {
bounds: Bounds,
default: Period,
categories: BTreeMap<Category, Period>,
origins: BTreeMap<String, Period>,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
bounds: Bounds::union(),
default: Period::Years(FLOOR_YEARS),
categories: BTreeMap::new(),
origins: BTreeMap::new(),
}
}
}
impl RetentionPolicy {
pub fn declare(
years: u16,
categories: &BTreeMap<String, u16>,
origins: &BTreeMap<String, u16>,
map: &CategoryMap,
) -> Result<Self, RetentionError> {
let in_years = |table: &BTreeMap<String, u16>| {
table
.iter()
.map(|(key, years)| (key.clone(), Period::Years(*years)))
.collect::<BTreeMap<_, _>>()
};
Self::declare_within(
&Bounds::union(),
Period::Years(years),
&in_years(categories),
&in_years(origins),
map,
)
}
pub fn declare_within(
bounds: &Bounds,
default: Period,
categories: &BTreeMap<String, Period>,
origins: &BTreeMap<String, Period>,
map: &CategoryMap,
) -> Result<Self, RetentionError> {
let default = bounds.admit("years".to_owned(), default)?;
let categories = categories
.iter()
.map(|(code, period)| {
let category = map
.category(code)
.ok_or_else(|| RetentionError::UnknownCategory { code: code.clone() })?;
Ok((
category,
bounds.admit(format!("categories.{code}"), *period)?,
))
})
.collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
let origins = origins
.iter()
.map(|(key, period)| {
if key.is_empty() || key.chars().any(char::is_control) {
return Err(RetentionError::OriginKey);
}
Ok((
key.clone(),
bounds.admit(format!("origins.{key}"), *period)?,
))
})
.collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
Ok(Self {
bounds: bounds.clone(),
default,
categories,
origins,
})
}
#[must_use]
pub fn bounds(&self) -> &Bounds {
&self.bounds
}
pub fn origins(&self) -> impl Iterator<Item = &str> {
self.origins.keys().map(String::as_str)
}
#[must_use]
pub fn longest(&self) -> Period {
self.categories
.values()
.chain(self.origins.values())
.copied()
.fold(self.default, |kept, candidate| {
let longer = match (kept.fewest_days(), candidate.fewest_days()) {
(None, _) => false,
(Some(_), None) => true,
(Some(ours), Some(theirs)) => theirs > ours,
};
if longer { candidate } else { kept }
})
}
#[must_use]
pub fn retain<'a>(
&self,
recorded: Timestamp,
classified: &Classification,
origins: impl IntoIterator<Item = &'a str>,
) -> Retention {
let accessed = Offset::UTC.to_datetime(recorded).date();
let longest = |candidates: Vec<(Period, Ground)>| {
candidates
.into_iter()
.fold((self.default, Ground::Default), |kept, candidate| {
if kept.0.outlasted_by(candidate.0, accessed) {
candidate
} else {
kept
}
})
};
let (period, ground) = if classified.unclassified().is_some() {
let (period, _) = longest(
self.categories
.values()
.chain(self.origins.values())
.map(|period| (*period, Ground::Unclassified))
.collect(),
);
(period, Ground::Unclassified)
} else {
let categories = classified.categories().keys().filter_map(|category| {
self.categories
.get(category)
.map(|period| (*period, Ground::Category(category.clone())))
});
let origins = origins.into_iter().filter_map(|key| {
self.origins
.get(key)
.map(|period| (*period, Ground::Origin(key.to_owned())))
});
longest(categories.chain(origins).collect())
};
Retention {
period,
ground,
floor: self.bounds.floor_ground.clone(),
ends: period.ends(accessed),
}
}
}