use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErasureStrategy {
HardDelete,
Anonymize,
Retain,
}
#[derive(Debug, Clone)]
pub struct ModelRegistration {
pub table: String,
pub erasure_strategy: ErasureStrategy,
pub retain_reason: Option<String>,
}
impl ModelRegistration {
#[must_use]
pub fn hard_delete(table: impl Into<String>) -> Self {
Self {
table: table.into(),
erasure_strategy: ErasureStrategy::HardDelete,
retain_reason: None,
}
}
#[must_use]
pub fn anonymize(table: impl Into<String>) -> Self {
Self {
table: table.into(),
erasure_strategy: ErasureStrategy::Anonymize,
retain_reason: None,
}
}
#[must_use]
pub fn retain(table: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
table: table.into(),
erasure_strategy: ErasureStrategy::Retain,
retain_reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GdprRegistry {
registrations: Vec<ModelRegistration>,
}
impl GdprRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn register(mut self, registration: ModelRegistration) -> Self {
self.registrations.push(registration);
self
}
#[must_use]
pub fn registered_tables(&self) -> Vec<&str> {
self.registrations
.iter()
.map(|r| r.table.as_str())
.collect()
}
#[must_use]
pub fn get(&self, table: &str) -> Option<&ModelRegistration> {
self.registrations.iter().find(|r| r.table == table)
}
#[must_use]
pub const fn is_populated(&self) -> bool {
!self.registrations.is_empty()
}
#[must_use]
pub fn retained_tables(&self) -> Vec<&ModelRegistration> {
self.registrations
.iter()
.filter(|r| r.erasure_strategy == ErasureStrategy::Retain)
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportManifest {
pub user_id: String,
pub generated_at: String,
pub framework_version: String,
pub tables_included: Vec<String>,
pub tables_retained: Vec<RetainedTable>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetainedTable {
pub table: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportArchive {
pub manifest: ExportManifest,
pub tables: HashMap<String, Vec<Value>>,
}
impl ExportArchive {
#[must_use]
pub fn new(user_id: impl Into<String>) -> Self {
Self {
manifest: ExportManifest {
user_id: user_id.into(),
generated_at: chrono::Utc::now().to_rfc3339(),
framework_version: env!("CARGO_PKG_VERSION").to_owned(),
..Default::default()
},
tables: HashMap::new(),
}
}
pub fn add_table(&mut self, table: impl Into<String>, records: Vec<Value>) {
let table = table.into();
self.manifest.tables_included.push(table.clone());
self.tables.insert(table, records);
}
pub fn add_retained(&mut self, table: impl Into<String>, reason: impl Into<String>) {
self.manifest.tables_retained.push(RetainedTable {
table: table.into(),
reason: reason.into(),
});
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn erasure_strategy_hard_delete_serializes_as_snake_case() {
let s = serde_json::to_string(&ErasureStrategy::HardDelete).unwrap();
assert_eq!(s, r#""hard_delete""#);
}
#[test]
fn erasure_strategy_anonymize_serializes() {
let s = serde_json::to_string(&ErasureStrategy::Anonymize).unwrap();
assert_eq!(s, r#""anonymize""#);
}
#[test]
fn erasure_strategy_retain_serializes() {
let s = serde_json::to_string(&ErasureStrategy::Retain).unwrap();
assert_eq!(s, r#""retain""#);
}
#[test]
fn erasure_strategy_round_trips_through_json() {
for strategy in [
ErasureStrategy::HardDelete,
ErasureStrategy::Anonymize,
ErasureStrategy::Retain,
] {
let json = serde_json::to_string(&strategy).unwrap();
let decoded: ErasureStrategy = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, strategy);
}
}
#[test]
fn model_registration_hard_delete_sets_strategy() {
let r = ModelRegistration::hard_delete("posts");
assert_eq!(r.table, "posts");
assert_eq!(r.erasure_strategy, ErasureStrategy::HardDelete);
assert!(r.retain_reason.is_none());
}
#[test]
fn model_registration_anonymize_sets_strategy() {
let r = ModelRegistration::anonymize("comments");
assert_eq!(r.table, "comments");
assert_eq!(r.erasure_strategy, ErasureStrategy::Anonymize);
assert!(r.retain_reason.is_none());
}
#[test]
fn model_registration_retain_sets_strategy_and_reason() {
let r = ModelRegistration::retain("invoices", "7-year financial retention");
assert_eq!(r.table, "invoices");
assert_eq!(r.erasure_strategy, ErasureStrategy::Retain);
assert_eq!(
r.retain_reason.as_deref(),
Some("7-year financial retention")
);
}
#[test]
fn registry_empty_by_default() {
let registry = GdprRegistry::new();
assert!(!registry.is_populated());
assert!(registry.registered_tables().is_empty());
}
#[test]
fn registry_register_adds_model() {
let registry = GdprRegistry::new().register(ModelRegistration::hard_delete("posts"));
assert!(registry.is_populated());
assert_eq!(registry.registered_tables(), vec!["posts"]);
}
#[test]
fn registry_get_returns_registration_for_known_table() {
let registry = GdprRegistry::new()
.register(ModelRegistration::hard_delete("posts"))
.register(ModelRegistration::anonymize("comments"));
let reg = registry.get("posts").expect("posts must be registered");
assert_eq!(reg.erasure_strategy, ErasureStrategy::HardDelete);
}
#[test]
fn registry_get_returns_none_for_unknown_table() {
let registry = GdprRegistry::new().register(ModelRegistration::hard_delete("posts"));
assert!(registry.get("orders").is_none());
}
#[test]
fn registry_retained_tables_filters_correctly() {
let registry = GdprRegistry::new()
.register(ModelRegistration::hard_delete("posts"))
.register(ModelRegistration::retain("invoices", "financial hold"))
.register(ModelRegistration::anonymize("comments"))
.register(ModelRegistration::retain("contracts", "legal hold"));
let retained = registry.retained_tables();
assert_eq!(retained.len(), 2);
assert!(retained.iter().any(|r| r.table == "invoices"));
assert!(retained.iter().any(|r| r.table == "contracts"));
}
#[test]
fn registry_registered_tables_returns_all_table_names() {
let registry = GdprRegistry::new()
.register(ModelRegistration::hard_delete("posts"))
.register(ModelRegistration::anonymize("comments"))
.register(ModelRegistration::retain("invoices", "hold"));
let tables = registry.registered_tables();
assert_eq!(tables.len(), 3);
assert!(tables.contains(&"posts"));
assert!(tables.contains(&"comments"));
assert!(tables.contains(&"invoices"));
}
#[test]
fn export_archive_new_sets_user_id() {
let archive = ExportArchive::new("user-42");
assert_eq!(archive.manifest.user_id, "user-42");
}
#[test]
fn export_archive_add_table_populates_tables_and_manifest() {
let mut archive = ExportArchive::new("u1");
archive.add_table(
"posts",
vec![serde_json::json!({"id": 1, "title": "Hello"})],
);
assert_eq!(archive.tables.len(), 1);
assert!(archive.tables.contains_key("posts"));
assert!(
archive
.manifest
.tables_included
.contains(&"posts".to_owned())
);
}
#[test]
fn export_archive_add_retained_populates_manifest() {
let mut archive = ExportArchive::new("u1");
archive.add_retained("invoices", "7-year financial hold");
assert_eq!(archive.manifest.tables_retained.len(), 1);
assert_eq!(archive.manifest.tables_retained[0].table, "invoices");
assert_eq!(
archive.manifest.tables_retained[0].reason,
"7-year financial hold"
);
}
#[test]
fn export_archive_to_json_is_valid_json() {
let mut archive = ExportArchive::new("u1");
archive.add_table("posts", vec![serde_json::json!({"id": 1})]);
let json = archive.to_json().expect("serialization must succeed");
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("must parse as valid JSON");
assert!(
parsed.get("manifest").is_some(),
"archive JSON must have a manifest key"
);
assert!(
parsed.get("tables").is_some(),
"archive JSON must have a tables key"
);
}
#[test]
fn export_archive_json_contains_user_id_in_manifest() {
let archive = ExportArchive::new("user-99");
let json = archive.to_json().unwrap();
assert!(
json.contains("user-99"),
"JSON must contain the user_id: {json}"
);
}
#[test]
fn export_archive_json_contains_framework_version() {
let archive = ExportArchive::new("u1");
let json = archive.to_json().unwrap();
assert!(
json.contains("framework_version"),
"JSON must include framework_version: {json}"
);
}
}