1use std::collections::BTreeMap;
29use std::fmt;
30
31use jiff::civil::Date;
32use jiff::tz::Offset;
33use jiff::{Span, Timestamp};
34
35use crate::category::Category;
36use crate::classify::Classification;
37use crate::map::CategoryMap;
38
39pub const FLOOR_YEARS: u16 = 3;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
45pub struct Years(u16);
46
47impl Years {
48 pub const FLOOR: Self = Self(FLOOR_YEARS);
50
51 #[must_use]
53 pub const fn new(years: u16) -> Option<Self> {
54 if years < FLOOR_YEARS {
55 None
56 } else {
57 Some(Self(years))
58 }
59 }
60
61 #[must_use]
63 pub const fn get(self) -> u16 {
64 self.0
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70#[non_exhaustive]
71pub enum Period {
72 Years(u16),
74 Days(u32),
76 Indefinite,
79}
80
81impl Period {
82 #[must_use]
85 pub fn years(self) -> Option<Years> {
86 match self {
87 Self::Years(years) => Years::new(years),
88 Self::Days(_) | Self::Indefinite => None,
89 }
90 }
91
92 #[must_use]
95 pub fn code(self) -> String {
96 match self {
97 Self::Years(years) => format!("P{years}Y"),
98 Self::Days(days) => format!("P{days}D"),
99 Self::Indefinite => "indefinite".to_owned(),
100 }
101 }
102
103 fn fewest_days(self) -> Option<u64> {
106 match self {
107 Self::Years(years) => Some(u64::from(years).saturating_mul(365)),
108 Self::Days(days) => Some(u64::from(days)),
109 Self::Indefinite => None,
110 }
111 }
112
113 fn most_days(self) -> Option<u64> {
117 match self {
118 Self::Years(years) => {
119 let years = u64::from(years);
120 Some(years.saturating_mul(365).saturating_add(years.div_ceil(4)))
121 }
122 Self::Days(days) => Some(u64::from(days)),
123 Self::Indefinite => None,
124 }
125 }
126
127 fn reaches(self, floor: Self) -> bool {
129 if let (Self::Years(kept), Self::Years(needed)) = (self, floor) {
130 return kept >= needed;
131 }
132 match (self.fewest_days(), floor.most_days()) {
133 (None, _) => true,
134 (Some(_), None) => false,
135 (Some(kept), Some(needed)) => kept >= needed,
136 }
137 }
138
139 fn within(self, ceiling: Self) -> bool {
141 if let (Self::Years(kept), Self::Years(limit)) = (self, ceiling) {
142 return kept <= limit;
143 }
144 match (self.most_days(), ceiling.fewest_days()) {
145 (_, None) => true,
146 (None, Some(_)) => false,
147 (Some(kept), Some(limit)) => kept <= limit,
148 }
149 }
150
151 fn ends(self, accessed: Date) -> Option<Date> {
155 let span = match self {
156 Self::Years(years) => Span::new()
157 .try_years(i64::from(years))
158 .and_then(|span| span.try_days(1)),
159 Self::Days(days) => Span::new().try_days(i64::from(days).saturating_add(1)),
160 Self::Indefinite => return None,
161 };
162 Some(span.map_or(Date::MAX, |span| accessed.saturating_add(span)))
165 }
166
167 fn outlasted_by(self, other: Self, accessed: Date) -> bool {
170 match (self.ends(accessed), other.ends(accessed)) {
171 (None, _) => false,
172 (Some(_), None) => true,
173 (Some(ours), Some(theirs)) => theirs > ours,
174 }
175 }
176}
177
178impl fmt::Display for Period {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(&self.code())
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum FloorGround {
188 Union,
191 National(String),
194 OutsideUnion(String),
197}
198
199impl FloorGround {
200 #[must_use]
203 pub fn code(&self) -> String {
204 match self {
205 Self::Union => "eu-2025-327-art-9-2".to_owned(),
206 Self::National(citation) => format!("national:{citation}"),
207 Self::OutsideUnion(citation) => format!("outside-union:{citation}"),
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct Bounds {
216 floor: Period,
217 floor_ground: FloorGround,
218 ceiling: Option<(Period, String)>,
219}
220
221impl Default for Bounds {
222 fn default() -> Self {
223 Self::union()
224 }
225}
226
227impl Bounds {
228 #[must_use]
231 pub fn union() -> Self {
232 Self {
233 floor: Period::Years(FLOOR_YEARS),
234 floor_ground: FloorGround::Union,
235 ceiling: None,
236 }
237 }
238
239 pub fn national(floor: Period, ground: String) -> Result<Self, RetentionError> {
248 if floor == Period::Indefinite {
249 return Err(RetentionError::FloorIndefinite);
250 }
251 if !floor.reaches(Period::Years(FLOOR_YEARS)) {
252 return Err(RetentionError::NationalUnderUnion { floor });
253 }
254 Ok(Self {
255 floor,
256 floor_ground: FloorGround::National(ground),
257 ceiling: None,
258 })
259 }
260
261 pub fn outside_union(floor: Period, ground: String) -> Result<Self, RetentionError> {
269 if floor == Period::Indefinite {
270 return Err(RetentionError::FloorIndefinite);
271 }
272 Ok(Self {
273 floor,
274 floor_ground: FloorGround::OutsideUnion(ground),
275 ceiling: None,
276 })
277 }
278
279 pub fn with_ceiling(mut self, ceiling: Period, ground: String) -> Result<Self, RetentionError> {
287 if !ceiling.reaches(self.floor) {
288 return Err(RetentionError::CeilingUnderFloor {
289 ceiling,
290 floor: self.floor,
291 });
292 }
293 self.ceiling = Some((ceiling, ground));
294 Ok(self)
295 }
296
297 #[must_use]
299 pub fn floor(&self) -> Period {
300 self.floor
301 }
302
303 #[must_use]
305 pub fn floor_ground(&self) -> &FloorGround {
306 &self.floor_ground
307 }
308
309 #[must_use]
311 pub fn ceiling(&self) -> Option<(Period, &str)> {
312 self.ceiling
313 .as_ref()
314 .map(|(period, ground)| (*period, ground.as_str()))
315 }
316
317 fn admit(&self, key: String, period: Period) -> Result<Period, RetentionError> {
319 if !period.reaches(self.floor) {
320 if let (Period::Years(years), FloorGround::Union) = (period, &self.floor_ground) {
321 return Err(RetentionError::UnderFloor { key, years });
322 }
323 return Err(RetentionError::UnderBound {
324 key,
325 period,
326 floor: self.floor,
327 ground: self.floor_ground.code(),
328 });
329 }
330 if let Some((ceiling, ground)) = &self.ceiling
331 && !period.within(*ceiling)
332 {
333 return Err(RetentionError::OverCeiling {
334 key,
335 period,
336 ceiling: *ceiling,
337 ground: ground.clone(),
338 });
339 }
340 Ok(period)
341 }
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
346#[non_exhaustive]
347pub enum Ground {
348 Default,
350 Unclassified,
353 Category(Category),
355 Origin(String),
358}
359
360impl Ground {
361 #[must_use]
364 pub fn code(&self) -> String {
365 match self {
366 Self::Default => "default".to_owned(),
367 Self::Unclassified => "unclassified".to_owned(),
368 Self::Category(category) => format!("category:{}", category.token()),
369 Self::Origin(key) => format!("origin:{key}"),
370 }
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct Retention {
377 period: Period,
378 ground: Ground,
379 floor: FloorGround,
380 ends: Option<Date>,
381}
382
383impl Retention {
384 #[must_use]
386 pub fn period(&self) -> Period {
387 self.period
388 }
389
390 #[must_use]
392 pub fn ground(&self) -> &Ground {
393 &self.ground
394 }
395
396 #[must_use]
398 pub fn floor(&self) -> &FloorGround {
399 &self.floor
400 }
401
402 #[must_use]
406 pub fn ends(&self) -> Option<Date> {
407 self.ends
408 }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
413#[non_exhaustive]
414pub enum RetentionError {
415 #[error(
417 "{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"
418 )]
419 UnderFloor {
420 key: String,
423 years: u16,
425 },
426 #[error(
429 "{key} keeps a record {period}, which does not reach the floor of {floor} ({ground}) from every date of access"
430 )]
431 UnderBound {
432 key: String,
434 period: Period,
436 floor: Period,
438 ground: String,
440 },
441 #[error("{key} keeps a record {period}, past the ceiling of {ceiling} ({ground})")]
443 OverCeiling {
444 key: String,
446 period: Period,
448 ceiling: Period,
450 ground: String,
452 },
453 #[error(
455 "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"
456 )]
457 NationalUnderUnion {
458 floor: Period,
460 },
461 #[error("a retention floor is a finite period")]
463 FloorIndefinite,
464 #[error("the ceiling of {ceiling} does not reach the floor of {floor}")]
466 CeilingUnderFloor {
467 ceiling: Period,
469 floor: Period,
471 },
472 #[error("categories key {code:?} is neither a priority category nor a declared national one")]
475 UnknownCategory {
476 code: String,
478 },
479 #[error("an origins key is empty or holds a control character")]
481 OriginKey,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct RetentionPolicy {
487 bounds: Bounds,
488 default: Period,
489 categories: BTreeMap<Category, Period>,
490 origins: BTreeMap<String, Period>,
491}
492
493impl Default for RetentionPolicy {
494 fn default() -> Self {
495 Self {
496 bounds: Bounds::union(),
497 default: Period::Years(FLOOR_YEARS),
498 categories: BTreeMap::new(),
499 origins: BTreeMap::new(),
500 }
501 }
502}
503
504impl RetentionPolicy {
505 pub fn declare(
515 years: u16,
516 categories: &BTreeMap<String, u16>,
517 origins: &BTreeMap<String, u16>,
518 map: &CategoryMap,
519 ) -> Result<Self, RetentionError> {
520 let in_years = |table: &BTreeMap<String, u16>| {
521 table
522 .iter()
523 .map(|(key, years)| (key.clone(), Period::Years(*years)))
524 .collect::<BTreeMap<_, _>>()
525 };
526 Self::declare_within(
527 &Bounds::union(),
528 Period::Years(years),
529 &in_years(categories),
530 &in_years(origins),
531 map,
532 )
533 }
534
535 pub fn declare_within(
545 bounds: &Bounds,
546 default: Period,
547 categories: &BTreeMap<String, Period>,
548 origins: &BTreeMap<String, Period>,
549 map: &CategoryMap,
550 ) -> Result<Self, RetentionError> {
551 let default = bounds.admit("years".to_owned(), default)?;
552 let categories = categories
553 .iter()
554 .map(|(code, period)| {
555 let category = map
556 .category(code)
557 .ok_or_else(|| RetentionError::UnknownCategory { code: code.clone() })?;
558 Ok((
559 category,
560 bounds.admit(format!("categories.{code}"), *period)?,
561 ))
562 })
563 .collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
564 let origins = origins
565 .iter()
566 .map(|(key, period)| {
567 if key.is_empty() || key.chars().any(char::is_control) {
568 return Err(RetentionError::OriginKey);
569 }
570 Ok((
571 key.clone(),
572 bounds.admit(format!("origins.{key}"), *period)?,
573 ))
574 })
575 .collect::<Result<BTreeMap<_, _>, RetentionError>>()?;
576 Ok(Self {
577 bounds: bounds.clone(),
578 default,
579 categories,
580 origins,
581 })
582 }
583
584 #[must_use]
586 pub fn bounds(&self) -> &Bounds {
587 &self.bounds
588 }
589
590 pub fn origins(&self) -> impl Iterator<Item = &str> {
592 self.origins.keys().map(String::as_str)
593 }
594
595 #[must_use]
599 pub fn longest(&self) -> Period {
600 self.categories
601 .values()
602 .chain(self.origins.values())
603 .copied()
604 .fold(self.default, |kept, candidate| {
605 let longer = match (kept.fewest_days(), candidate.fewest_days()) {
606 (None, _) => false,
607 (Some(_), None) => true,
608 (Some(ours), Some(theirs)) => theirs > ours,
609 };
610 if longer { candidate } else { kept }
611 })
612 }
613
614 #[must_use]
623 pub fn retain<'a>(
624 &self,
625 recorded: Timestamp,
626 classified: &Classification,
627 origins: impl IntoIterator<Item = &'a str>,
628 ) -> Retention {
629 let accessed = Offset::UTC.to_datetime(recorded).date();
630 let longest = |candidates: Vec<(Period, Ground)>| {
631 candidates
632 .into_iter()
633 .fold((self.default, Ground::Default), |kept, candidate| {
634 if kept.0.outlasted_by(candidate.0, accessed) {
635 candidate
636 } else {
637 kept
638 }
639 })
640 };
641 let (period, ground) = if classified.unclassified().is_some() {
642 let (period, _) = longest(
643 self.categories
644 .values()
645 .chain(self.origins.values())
646 .map(|period| (*period, Ground::Unclassified))
647 .collect(),
648 );
649 (period, Ground::Unclassified)
650 } else {
651 let categories = classified.categories().keys().filter_map(|category| {
652 self.categories
653 .get(category)
654 .map(|period| (*period, Ground::Category(category.clone())))
655 });
656 let origins = origins.into_iter().filter_map(|key| {
657 self.origins
658 .get(key)
659 .map(|period| (*period, Ground::Origin(key.to_owned())))
660 });
661 longest(categories.chain(origins).collect())
662 };
663 Retention {
664 period,
665 ground,
666 floor: self.bounds.floor_ground.clone(),
667 ends: period.ends(accessed),
668 }
669 }
670}