Skip to main content

agora_agentkit/govlog/
redactable.rs

1//! A value in an entry's `data` that a redaction may have replaced
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4
5use super::redaction_marker;
6use crate::ids::{GovernanceLogId, GovernanceLogPrefix};
7
8/// The `pattern` of a [`redaction_marker`]
9pub const REDACTION_MARKER_PATTERN: &str =
10    r"^\[redacted by AMD-[0-9]{4}-[0-9]{4}\]$";
11
12/// A value in an entry's `data`, or the [`redaction_marker`] a redaction
13/// left in its place.
14///
15/// On the wire it is `T` or the marker string; it serializes back to
16/// exactly what it parsed from. A `T` that is itself a string reading
17/// exactly like a marker parses as `Redacted`.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub enum Redactable<T> {
20    Value(T),
21    /// Replaced by the redaction amendment (`AMD-`) this names
22    Redacted(GovernanceLogId),
23}
24
25impl<T> Redactable<T> {
26    /// The value, unless it was redacted
27    pub fn value(&self) -> Option<&T> {
28        match self {
29            Self::Value(v) => Some(v),
30            Self::Redacted(_) => None,
31        }
32    }
33
34    /// The amendment that redacted it, if one did
35    pub fn redacted_by(&self) -> Option<&GovernanceLogId> {
36        match self {
37            Self::Value(_) => None,
38            Self::Redacted(id) => Some(id),
39        }
40    }
41
42    pub fn is_redacted(&self) -> bool {
43        matches!(self, Self::Redacted(_))
44    }
45
46    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Redactable<U> {
47        match self {
48            Self::Value(v) => Redactable::Value(f(v)),
49            Self::Redacted(id) => Redactable::Redacted(id),
50        }
51    }
52}
53
54impl<T> From<T> for Redactable<T> {
55    fn from(value: T) -> Self {
56        Self::Value(value)
57    }
58}
59
60/// The amendment a [`redaction_marker`] names, if `s` is one
61fn parse_marker(s: &str) -> Option<GovernanceLogId> {
62    let id: GovernanceLogId = s
63        .strip_prefix("[redacted by ")?
64        .strip_suffix(']')?
65        .parse()
66        .ok()?;
67    (id.prefix() == GovernanceLogPrefix::Amd).then_some(id)
68}
69
70impl<T: Serialize> Serialize for Redactable<T> {
71    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
72        match self {
73            Self::Value(v) => v.serialize(s),
74            Self::Redacted(id) => s.serialize_str(&redaction_marker(id)),
75        }
76    }
77}
78
79impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for Redactable<T> {
80    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
81        let value = serde_json::Value::deserialize(d)?;
82        if let Some(id) = value.as_str().and_then(parse_marker) {
83            return Ok(Self::Redacted(id));
84        }
85        T::deserialize(value)
86            .map(Self::Value)
87            .map_err(serde::de::Error::custom)
88    }
89}
90
91// Hand-written so it inlines: `anyOf [T, marker]`, never a `$ref`.
92#[cfg(feature = "schemars")]
93impl<T: schemars::JsonSchema> schemars::JsonSchema for Redactable<T> {
94    fn inline_schema() -> bool {
95        true
96    }
97
98    fn schema_name() -> std::borrow::Cow<'static, str> {
99        format!("Redactable_{}", T::schema_name()).into()
100    }
101
102    fn schema_id() -> std::borrow::Cow<'static, str> {
103        format!("{}::Redactable<{}>", module_path!(), T::schema_id()).into()
104    }
105
106    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
107        let value = g.subschema_for::<T>();
108        schemars::json_schema!({
109            "anyOf": [
110                value,
111                {
112                    "type": "string",
113                    "pattern": REDACTION_MARKER_PATTERN,
114                    "description": "Removed by the redaction amendment it names",
115                },
116            ],
117        })
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn amd() -> GovernanceLogId {
126        "AMD-2026-0009".parse().unwrap()
127    }
128
129    #[test]
130    fn the_marker_parses_as_redacted_and_writes_back_the_same() {
131        let marker = serde_json::Value::String(redaction_marker(&amd()));
132        let text: Redactable<String> =
133            serde_json::from_value(marker.clone()).unwrap();
134        assert_eq!(text, Redactable::Redacted(amd()));
135        assert_eq!(serde_json::to_value(&text).unwrap(), marker);
136
137        let list: Redactable<Vec<u32>> =
138            serde_json::from_value(marker.clone()).unwrap();
139        assert_eq!(list.redacted_by(), Some(&amd()));
140        assert_eq!(serde_json::to_value(&list).unwrap(), marker);
141    }
142
143    #[test]
144    fn anything_else_is_a_value() {
145        for s in [
146            "[redacted by GOV-2026-0001]",
147            "[redacted by AMD-2026-001]",
148            "redacted by AMD-2026-0001",
149            " [redacted by AMD-2026-0001]",
150        ] {
151            let v: Redactable<String> =
152                serde_json::from_value(s.into()).unwrap();
153            assert_eq!(v, Redactable::Value(s.to_string()));
154        }
155        assert!(serde_json::from_value::<Redactable<u32>>("x".into()).is_err());
156    }
157
158    #[test]
159    fn the_pattern_matches_the_marker() {
160        let p = REDACTION_MARKER_PATTERN;
161        let body = &p[1..p.len() - 1];
162        let marker = redaction_marker(&amd());
163        // No regex dependency: the pattern's fixed text plus the id pattern
164        assert_eq!(
165            body.replace(r"\[", "[")
166                .replace(r"\]", "]")
167                .replace("[0-9]{4}-[0-9]{4}", "2026-0009"),
168            marker
169        );
170    }
171
172    #[cfg(feature = "schemars")]
173    #[test]
174    fn schema_is_ref_free() {
175        let text = crate::responses::inline_schema_for::<
176            Redactable<Vec<Redactable<String>>>,
177        >()
178        .to_string();
179        assert!(!text.contains("$ref"), "{text}");
180        assert!(!text.contains("$defs"), "{text}");
181        assert!(text.contains("anyOf"), "{text}");
182    }
183}