use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::redaction_marker;
use crate::ids::{GovernanceLogId, GovernanceLogPrefix};
pub const REDACTION_MARKER_PATTERN: &str =
r"^\[redacted by AMD-[0-9]{4}-[0-9]{4}\]$";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Redactable<T> {
Value(T),
Redacted(GovernanceLogId),
}
impl<T> Redactable<T> {
pub fn value(&self) -> Option<&T> {
match self {
Self::Value(v) => Some(v),
Self::Redacted(_) => None,
}
}
pub fn redacted_by(&self) -> Option<&GovernanceLogId> {
match self {
Self::Value(_) => None,
Self::Redacted(id) => Some(id),
}
}
pub fn is_redacted(&self) -> bool {
matches!(self, Self::Redacted(_))
}
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Redactable<U> {
match self {
Self::Value(v) => Redactable::Value(f(v)),
Self::Redacted(id) => Redactable::Redacted(id),
}
}
}
impl<T> From<T> for Redactable<T> {
fn from(value: T) -> Self {
Self::Value(value)
}
}
fn parse_marker(s: &str) -> Option<GovernanceLogId> {
let inner = s.strip_prefix("[redacted by ")?.strip_suffix(']')?;
if !GovernanceLogId::is_citation_shaped(inner) {
return None;
}
let id: GovernanceLogId = inner.parse().ok()?;
(id.prefix() == GovernanceLogPrefix::Amd).then_some(id)
}
impl<T: Serialize> Serialize for Redactable<T> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Self::Value(v) => v.serialize(s),
Self::Redacted(id) => s.serialize_str(&redaction_marker(id)),
}
}
}
impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for Redactable<T> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(d)?;
if let Some(id) = value.as_str().and_then(parse_marker) {
return Ok(Self::Redacted(id));
}
T::deserialize(value)
.map(Self::Value)
.map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "schemars")]
impl<T: schemars::JsonSchema> schemars::JsonSchema for Redactable<T> {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
format!("Redactable_{}", T::schema_name()).into()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
format!("{}::Redactable<{}>", module_path!(), T::schema_id()).into()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
let value = g.subschema_for::<T>();
schemars::json_schema!({
"anyOf": [
value,
{
"type": "string",
"pattern": REDACTION_MARKER_PATTERN,
"description": "Removed by the redaction amendment it names",
},
],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn amd() -> GovernanceLogId {
"AMD-2026-0009".parse().unwrap()
}
#[test]
fn the_marker_parses_as_redacted_and_writes_back_the_same() {
let marker = serde_json::Value::String(redaction_marker(&amd()));
let text: Redactable<String> =
serde_json::from_value(marker.clone()).unwrap();
assert_eq!(text, Redactable::Redacted(amd()));
assert_eq!(serde_json::to_value(&text).unwrap(), marker);
let list: Redactable<Vec<u32>> =
serde_json::from_value(marker.clone()).unwrap();
assert_eq!(list.redacted_by(), Some(&amd()));
assert_eq!(serde_json::to_value(&list).unwrap(), marker);
}
#[test]
fn anything_else_is_a_value() {
for s in [
"[redacted by GOV-2026-0001]",
"[redacted by AMD-2026-001]",
"redacted by AMD-2026-0001",
" [redacted by AMD-2026-0001]",
] {
let v: Redactable<String> =
serde_json::from_value(s.into()).unwrap();
assert_eq!(v, Redactable::Value(s.to_string()));
}
assert!(serde_json::from_value::<Redactable<u32>>("x".into()).is_err());
}
#[test]
fn the_pattern_matches_the_marker() {
let p = REDACTION_MARKER_PATTERN;
let body = &p[1..p.len() - 1];
let marker = redaction_marker(&amd());
assert_eq!(
body.replace(r"\[", "[")
.replace(r"\]", "]")
.replace("[0-9]{4}-[0-9]{4}", "2026-0009"),
marker
);
}
#[cfg(feature = "schemars")]
#[test]
fn schema_is_ref_free() {
let text = crate::responses::inline_schema_for::<
Redactable<Vec<Redactable<String>>>,
>()
.to_string();
assert!(!text.contains("$ref"), "{text}");
assert!(!text.contains("$defs"), "{text}");
assert!(text.contains("anyOf"), "{text}");
}
}