agora_agentkit/govlog/
redactable.rs1use serde::{Deserialize, Deserializer, Serialize, Serializer};
4
5use super::redaction_marker;
6use crate::ids::{GovernanceLogId, GovernanceLogPrefix};
7
8pub const REDACTION_MARKER_PATTERN: &str =
10 r"^\[redacted by AMD-[0-9]{4}-[0-9]{4}\]$";
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub enum Redactable<T> {
20 Value(T),
21 Redacted(GovernanceLogId),
23}
24
25impl<T> Redactable<T> {
26 pub fn value(&self) -> Option<&T> {
28 match self {
29 Self::Value(v) => Some(v),
30 Self::Redacted(_) => None,
31 }
32 }
33
34 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
60fn 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#[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 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}