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 inner = s.strip_prefix("[redacted by ")?.strip_suffix(']')?;
63 if !GovernanceLogId::is_citation_shaped(inner) {
66 return None;
67 }
68 let id: GovernanceLogId = inner.parse().ok()?;
69 (id.prefix() == GovernanceLogPrefix::Amd).then_some(id)
70}
71
72impl<T: Serialize> Serialize for Redactable<T> {
73 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
74 match self {
75 Self::Value(v) => v.serialize(s),
76 Self::Redacted(id) => s.serialize_str(&redaction_marker(id)),
77 }
78 }
79}
80
81impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for Redactable<T> {
82 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
83 let value = serde_json::Value::deserialize(d)?;
84 if let Some(id) = value.as_str().and_then(parse_marker) {
85 return Ok(Self::Redacted(id));
86 }
87 T::deserialize(value)
88 .map(Self::Value)
89 .map_err(serde::de::Error::custom)
90 }
91}
92
93#[cfg(feature = "schemars")]
95impl<T: schemars::JsonSchema> schemars::JsonSchema for Redactable<T> {
96 fn inline_schema() -> bool {
97 true
98 }
99
100 fn schema_name() -> std::borrow::Cow<'static, str> {
101 format!("Redactable_{}", T::schema_name()).into()
102 }
103
104 fn schema_id() -> std::borrow::Cow<'static, str> {
105 format!("{}::Redactable<{}>", module_path!(), T::schema_id()).into()
106 }
107
108 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
109 let value = g.subschema_for::<T>();
110 schemars::json_schema!({
111 "anyOf": [
112 value,
113 {
114 "type": "string",
115 "pattern": REDACTION_MARKER_PATTERN,
116 "description": "Removed by the redaction amendment it names",
117 },
118 ],
119 })
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn amd() -> GovernanceLogId {
128 "AMD-2026-0009".parse().unwrap()
129 }
130
131 #[test]
132 fn the_marker_parses_as_redacted_and_writes_back_the_same() {
133 let marker = serde_json::Value::String(redaction_marker(&amd()));
134 let text: Redactable<String> =
135 serde_json::from_value(marker.clone()).unwrap();
136 assert_eq!(text, Redactable::Redacted(amd()));
137 assert_eq!(serde_json::to_value(&text).unwrap(), marker);
138
139 let list: Redactable<Vec<u32>> =
140 serde_json::from_value(marker.clone()).unwrap();
141 assert_eq!(list.redacted_by(), Some(&amd()));
142 assert_eq!(serde_json::to_value(&list).unwrap(), marker);
143 }
144
145 #[test]
146 fn anything_else_is_a_value() {
147 for s in [
148 "[redacted by GOV-2026-0001]",
149 "[redacted by AMD-2026-001]",
150 "redacted by AMD-2026-0001",
151 " [redacted by AMD-2026-0001]",
152 ] {
153 let v: Redactable<String> =
154 serde_json::from_value(s.into()).unwrap();
155 assert_eq!(v, Redactable::Value(s.to_string()));
156 }
157 assert!(serde_json::from_value::<Redactable<u32>>("x".into()).is_err());
158 }
159
160 #[test]
161 fn the_pattern_matches_the_marker() {
162 let p = REDACTION_MARKER_PATTERN;
163 let body = &p[1..p.len() - 1];
164 let marker = redaction_marker(&amd());
165 assert_eq!(
167 body.replace(r"\[", "[")
168 .replace(r"\]", "]")
169 .replace("[0-9]{4}-[0-9]{4}", "2026-0009"),
170 marker
171 );
172 }
173
174 #[cfg(feature = "schemars")]
175 #[test]
176 fn schema_is_ref_free() {
177 let text = crate::responses::inline_schema_for::<
178 Redactable<Vec<Redactable<String>>>,
179 >()
180 .to_string();
181 assert!(!text.contains("$ref"), "{text}");
182 assert!(!text.contains("$defs"), "{text}");
183 assert!(text.contains("anyOf"), "{text}");
184 }
185}