use std::collections::BTreeMap;
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, 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(endpoint) => format!("origin:{endpoint}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Retention {
years: Years,
ground: Ground,
ends: Date,
}
impl Retention {
#[must_use]
pub fn years(&self) -> Years {
self.years
}
#[must_use]
pub fn ground(&self) -> &Ground {
&self.ground
}
#[must_use]
pub fn ends(&self) -> 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("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 {
default: Years,
categories: BTreeMap<Category, Years>,
origins: BTreeMap<String, Years>,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
default: Years::FLOOR,
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 floor = |key: String, years: u16| {
Years::new(years).ok_or(RetentionError::UnderFloor { key, years })
};
let default = floor("years".to_owned(), years)?;
let categories = categories
.iter()
.map(|(code, years)| {
let category = map
.category(code)
.ok_or_else(|| RetentionError::UnknownCategory { code: code.clone() })?;
Ok((category, floor(format!("categories.{code}"), *years)?))
})
.collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
let origins = origins
.iter()
.map(|(endpoint, years)| {
if endpoint.is_empty() || endpoint.chars().any(char::is_control) {
return Err(RetentionError::OriginKey);
}
Ok((
endpoint.clone(),
floor(format!("origins.{endpoint}"), *years)?,
))
})
.collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
Ok(Self {
default,
categories,
origins,
})
}
pub fn origins(&self) -> impl Iterator<Item = &str> {
self.origins.keys().map(String::as_str)
}
#[must_use]
pub fn longest(&self) -> Years {
self.categories
.values()
.chain(self.origins.values())
.copied()
.fold(self.default, Years::max)
}
#[must_use]
pub fn retain<'a>(
&self,
recorded: Timestamp,
classified: &Classification,
origins: impl IntoIterator<Item = &'a str>,
) -> Retention {
let (years, ground) = if classified.unclassified().is_some() {
(self.longest(), Ground::Unclassified)
} else {
let categories = classified.categories().keys().filter_map(|category| {
self.categories
.get(category)
.map(|years| (*years, Ground::Category(category.clone())))
});
let origins = origins.into_iter().filter_map(|endpoint| {
self.origins
.get(endpoint)
.map(|years| (*years, Ground::Origin(endpoint.to_owned())))
});
categories
.chain(origins)
.fold((self.default, Ground::Default), |kept, candidate| {
if candidate.0 > kept.0 {
candidate
} else {
kept
}
})
};
Retention {
years,
ground,
ends: ends(recorded, years),
}
}
}
fn ends(recorded: Timestamp, years: Years) -> Date {
let accessed = Offset::UTC.to_datetime(recorded).date();
Span::new()
.try_years(i64::from(years.get()))
.and_then(|span| span.try_days(1))
.map_or(Date::MAX, |span| accessed.saturating_add(span))
}