1use serde::Serialize;
22use serde_json::Value;
23
24use crate::config::{
25 BlursToml, DefaultSettingToml, LabelValueDefinitionToml, LabelerConfigToml, LocaleToml,
26 SeverityToml,
27};
28
29pub const RECORD_TYPE: &str = "app.bsky.labeler.service";
31
32pub const RECORD_COLLECTION: &str = "app.bsky.labeler.service";
35
36pub const RECORD_RKEY: &str = "self";
39
40#[derive(Debug, Clone, Serialize)]
44pub struct ServiceRecord {
45 #[serde(rename = "$type")]
47 pub record_type: &'static str,
48 #[serde(rename = "createdAt")]
52 pub created_at: String,
53 pub policies: Policies,
56 #[serde(rename = "reasonTypes", skip_serializing_if = "Vec::is_empty")]
60 pub reason_types: Vec<String>,
61 #[serde(rename = "subjectTypes", skip_serializing_if = "Vec::is_empty")]
64 pub subject_types: Vec<String>,
65 #[serde(rename = "subjectCollections", skip_serializing_if = "Vec::is_empty")]
69 pub subject_collections: Vec<String>,
70}
71
72#[derive(Debug, Clone, Serialize)]
76pub struct Policies {
77 #[serde(rename = "labelValues")]
81 pub label_values: Vec<String>,
82 #[serde(
85 rename = "labelValueDefinitions",
86 skip_serializing_if = "Vec::is_empty"
87 )]
88 pub label_value_definitions: Vec<LabelValueDefinition>,
89}
90
91#[derive(Debug, Clone, Serialize)]
96pub struct LabelValueDefinition {
97 pub identifier: String,
100 pub severity: &'static str,
103 pub blurs: &'static str,
106 #[serde(rename = "defaultSetting", skip_serializing_if = "Option::is_none")]
110 pub default_setting: Option<&'static str>,
111 #[serde(rename = "adultOnly", skip_serializing_if = "Option::is_none")]
113 pub adult_only: Option<bool>,
114 pub locales: Vec<Locale>,
116}
117
118#[derive(Debug, Clone, Serialize)]
120pub struct Locale {
121 pub lang: String,
123 pub name: String,
125 pub description: String,
127}
128
129#[derive(Debug, thiserror::Error)]
133pub enum RenderError {
134 #[error("labeler.label_values is empty; the record must declare at least one label")]
138 NoLabelValues,
139 #[error("labeler.label_value_definitions[{idx}] has no locales; §6.4 requires at least one")]
142 NoLocales {
143 idx: usize,
146 },
147}
148
149pub fn render(cfg: &LabelerConfigToml, created_at: &str) -> Result<ServiceRecord, RenderError> {
154 if cfg.label_values.is_empty() {
155 return Err(RenderError::NoLabelValues);
156 }
157 for (idx, def) in cfg.label_value_definitions.iter().enumerate() {
158 if def.locales.is_empty() {
159 return Err(RenderError::NoLocales { idx });
160 }
161 }
162 let definitions = cfg
163 .label_value_definitions
164 .iter()
165 .map(project_definition)
166 .collect();
167 Ok(ServiceRecord {
168 record_type: RECORD_TYPE,
169 created_at: created_at.to_string(),
170 policies: Policies {
171 label_values: cfg.label_values.clone(),
172 label_value_definitions: definitions,
173 },
174 reason_types: cfg.reason_types.clone(),
175 subject_types: cfg.subject_types.clone(),
176 subject_collections: cfg.subject_collections.clone(),
177 })
178}
179
180fn project_definition(src: &LabelValueDefinitionToml) -> LabelValueDefinition {
181 LabelValueDefinition {
182 identifier: src.identifier.clone(),
183 severity: severity_str(src.severity),
184 blurs: blurs_str(src.blurs),
185 default_setting: src.default_setting.map(default_setting_str),
186 adult_only: src.adult_only,
187 locales: src
188 .locales
189 .iter()
190 .map(|l: &LocaleToml| Locale {
191 lang: l.lang.clone(),
192 name: l.name.clone(),
193 description: l.description.clone(),
194 })
195 .collect(),
196 }
197}
198
199fn severity_str(s: SeverityToml) -> &'static str {
200 match s {
201 SeverityToml::Inform => "inform",
202 SeverityToml::Alert => "alert",
203 SeverityToml::None => "none",
204 }
205}
206
207fn blurs_str(b: BlursToml) -> &'static str {
208 match b {
209 BlursToml::Content => "content",
210 BlursToml::Media => "media",
211 BlursToml::None => "none",
212 }
213}
214
215fn default_setting_str(d: DefaultSettingToml) -> &'static str {
216 match d {
217 DefaultSettingToml::Ignore => "ignore",
218 DefaultSettingToml::Warn => "warn",
219 DefaultSettingToml::Hide => "hide",
220 }
221}
222
223pub fn content_hash(record: &ServiceRecord) -> [u8; 32] {
231 content_hash_value(serde_json::to_value(record).expect("ServiceRecord serializes"))
232}
233
234pub(crate) fn content_hash_value(mut v: Value) -> [u8; 32] {
247 if let Value::Object(map) = &mut v {
248 map.remove("createdAt");
249 }
250 let canonical = canonicalize(v);
253 let bytes = serde_json::to_vec(&canonical).expect("Value serializes");
254 proto_blue_crypto::sha256(&bytes)
255}
256
257fn canonicalize(v: Value) -> Value {
262 match v {
263 Value::Object(map) => {
264 let mut sorted: std::collections::BTreeMap<String, Value> =
265 std::collections::BTreeMap::new();
266 for (k, v) in map {
267 sorted.insert(k, canonicalize(v));
268 }
269 serde_json::to_value(sorted).expect("BTreeMap<String, Value> serializes")
270 }
271 Value::Array(xs) => Value::Array(xs.into_iter().map(canonicalize).collect()),
272 other => other,
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::config::{LabelValueDefinitionToml, LocaleToml};
280
281 fn sample_cfg() -> LabelerConfigToml {
282 LabelerConfigToml {
283 label_values: vec!["spam".into()],
284 label_value_definitions: vec![LabelValueDefinitionToml {
285 identifier: "spam".into(),
286 severity: SeverityToml::Alert,
287 blurs: BlursToml::None,
288 default_setting: Some(DefaultSettingToml::Warn),
289 adult_only: Some(false),
290 locales: vec![LocaleToml {
291 lang: "en".into(),
292 name: "Spam".into(),
293 description: "Unsolicited promotional content.".into(),
294 }],
295 }],
296 reason_types: vec!["com.atproto.moderation.defs#reasonSpam".into()],
297 subject_types: vec!["account".into(), "record".into()],
298 subject_collections: vec!["app.bsky.feed.post".into()],
299 }
300 }
301
302 #[test]
303 fn rendered_record_has_every_required_field() {
304 let rec = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
305 let v = serde_json::to_value(&rec).unwrap();
306 assert_eq!(v["$type"], "app.bsky.labeler.service");
308 assert_eq!(v["createdAt"], "2026-04-23T00:00:00.000Z");
309 assert!(v["policies"].is_object());
310 assert!(v["policies"]["labelValues"].is_array());
312 assert_eq!(v["policies"]["labelValues"][0], "spam");
313 let def = &v["policies"]["labelValueDefinitions"][0];
315 for field in ["identifier", "severity", "blurs", "locales"] {
316 assert!(def.get(field).is_some(), "missing {field} in def: {def}");
317 }
318 assert!(!def["locales"].as_array().unwrap().is_empty());
320 let locale = &def["locales"][0];
321 for field in ["lang", "name", "description"] {
322 assert!(
323 locale.get(field).is_some(),
324 "missing {field} in locale: {locale}"
325 );
326 }
327 }
328
329 #[test]
330 fn empty_label_values_rejected() {
331 let mut cfg = sample_cfg();
332 cfg.label_values.clear();
333 assert!(matches!(
334 render(&cfg, "2026-04-23T00:00:00.000Z"),
335 Err(RenderError::NoLabelValues)
336 ));
337 }
338
339 #[test]
340 fn empty_locales_rejected() {
341 let mut cfg = sample_cfg();
342 cfg.label_value_definitions[0].locales.clear();
343 assert!(matches!(
344 render(&cfg, "2026-04-23T00:00:00.000Z"),
345 Err(RenderError::NoLocales { idx: 0 })
346 ));
347 }
348
349 #[test]
350 fn content_hash_excludes_created_at() {
351 let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
352 let b = render(&sample_cfg(), "2030-12-31T23:59:59.999Z").unwrap();
353 assert_eq!(
354 content_hash(&a),
355 content_hash(&b),
356 "hashes must match when only createdAt differs"
357 );
358 }
359
360 #[test]
361 fn content_hash_changes_on_label_value_change() {
362 let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
363 let mut cfg = sample_cfg();
364 cfg.label_values.push("abuse".into());
365 let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
366 assert_ne!(content_hash(&a), content_hash(&b));
367 }
368
369 #[test]
370 fn content_hash_changes_on_severity_change() {
371 let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
372 let mut cfg = sample_cfg();
373 cfg.label_value_definitions[0].severity = SeverityToml::Inform;
374 let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
375 assert_ne!(content_hash(&a), content_hash(&b));
376 }
377
378 #[test]
379 fn content_hash_changes_on_locale_change() {
380 let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
381 let mut cfg = sample_cfg();
382 cfg.label_value_definitions[0].locales.push(LocaleToml {
383 lang: "fr".into(),
384 name: "Spam".into(),
385 description: "Contenu promotionnel non sollicité.".into(),
386 });
387 let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
388 assert_ne!(content_hash(&a), content_hash(&b));
389 }
390
391 #[test]
392 fn content_hash_is_stable_across_runs() {
393 let cfg = sample_cfg();
396 let a = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
397 let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
398 assert_eq!(content_hash(&a), content_hash(&b));
399 }
400
401 #[test]
402 fn optional_fields_omitted_when_empty() {
403 let mut cfg = sample_cfg();
404 cfg.reason_types.clear();
405 cfg.subject_types.clear();
406 cfg.subject_collections.clear();
407 let rec = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
408 let v = serde_json::to_value(&rec).unwrap();
409 assert!(v.get("reasonTypes").is_none());
411 assert!(v.get("subjectTypes").is_none());
412 assert!(v.get("subjectCollections").is_none());
413 }
414}