use serde::Serialize;
use serde_json::Value;
use crate::config::{
BlursToml, DefaultSettingToml, LabelValueDefinitionToml, LabelerConfigToml, LocaleToml,
SeverityToml,
};
pub const RECORD_TYPE: &str = "app.bsky.labeler.service";
pub const RECORD_COLLECTION: &str = "app.bsky.labeler.service";
pub const RECORD_RKEY: &str = "self";
#[derive(Debug, Clone, Serialize)]
pub struct ServiceRecord {
#[serde(rename = "$type")]
pub record_type: &'static str,
#[serde(rename = "createdAt")]
pub created_at: String,
pub policies: Policies,
#[serde(rename = "reasonTypes", skip_serializing_if = "Vec::is_empty")]
pub reason_types: Vec<String>,
#[serde(rename = "subjectTypes", skip_serializing_if = "Vec::is_empty")]
pub subject_types: Vec<String>,
#[serde(rename = "subjectCollections", skip_serializing_if = "Vec::is_empty")]
pub subject_collections: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Policies {
#[serde(rename = "labelValues")]
pub label_values: Vec<String>,
#[serde(
rename = "labelValueDefinitions",
skip_serializing_if = "Vec::is_empty"
)]
pub label_value_definitions: Vec<LabelValueDefinition>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelValueDefinition {
pub identifier: String,
pub severity: &'static str,
pub blurs: &'static str,
#[serde(rename = "defaultSetting", skip_serializing_if = "Option::is_none")]
pub default_setting: Option<&'static str>,
#[serde(rename = "adultOnly", skip_serializing_if = "Option::is_none")]
pub adult_only: Option<bool>,
pub locales: Vec<Locale>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Locale {
pub lang: String,
pub name: String,
pub description: String,
}
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
#[error("labeler.label_values is empty; the record must declare at least one label")]
NoLabelValues,
#[error("labeler.label_value_definitions[{idx}] has no locales; §6.4 requires at least one")]
NoLocales {
idx: usize,
},
}
pub fn render(cfg: &LabelerConfigToml, created_at: &str) -> Result<ServiceRecord, RenderError> {
if cfg.label_values.is_empty() {
return Err(RenderError::NoLabelValues);
}
for (idx, def) in cfg.label_value_definitions.iter().enumerate() {
if def.locales.is_empty() {
return Err(RenderError::NoLocales { idx });
}
}
let definitions = cfg
.label_value_definitions
.iter()
.map(project_definition)
.collect();
Ok(ServiceRecord {
record_type: RECORD_TYPE,
created_at: created_at.to_string(),
policies: Policies {
label_values: cfg.label_values.clone(),
label_value_definitions: definitions,
},
reason_types: cfg.reason_types.clone(),
subject_types: cfg.subject_types.clone(),
subject_collections: cfg.subject_collections.clone(),
})
}
fn project_definition(src: &LabelValueDefinitionToml) -> LabelValueDefinition {
LabelValueDefinition {
identifier: src.identifier.clone(),
severity: severity_str(src.severity),
blurs: blurs_str(src.blurs),
default_setting: src.default_setting.map(default_setting_str),
adult_only: src.adult_only,
locales: src
.locales
.iter()
.map(|l: &LocaleToml| Locale {
lang: l.lang.clone(),
name: l.name.clone(),
description: l.description.clone(),
})
.collect(),
}
}
fn severity_str(s: SeverityToml) -> &'static str {
match s {
SeverityToml::Inform => "inform",
SeverityToml::Alert => "alert",
SeverityToml::None => "none",
}
}
fn blurs_str(b: BlursToml) -> &'static str {
match b {
BlursToml::Content => "content",
BlursToml::Media => "media",
BlursToml::None => "none",
}
}
fn default_setting_str(d: DefaultSettingToml) -> &'static str {
match d {
DefaultSettingToml::Ignore => "ignore",
DefaultSettingToml::Warn => "warn",
DefaultSettingToml::Hide => "hide",
}
}
pub fn content_hash(record: &ServiceRecord) -> [u8; 32] {
content_hash_value(serde_json::to_value(record).expect("ServiceRecord serializes"))
}
pub(crate) fn content_hash_value(mut v: Value) -> [u8; 32] {
if let Value::Object(map) = &mut v {
map.remove("createdAt");
}
let canonical = canonicalize(v);
let bytes = serde_json::to_vec(&canonical).expect("Value serializes");
proto_blue_crypto::sha256(&bytes)
}
fn canonicalize(v: Value) -> Value {
match v {
Value::Object(map) => {
let mut sorted: std::collections::BTreeMap<String, Value> =
std::collections::BTreeMap::new();
for (k, v) in map {
sorted.insert(k, canonicalize(v));
}
serde_json::to_value(sorted).expect("BTreeMap<String, Value> serializes")
}
Value::Array(xs) => Value::Array(xs.into_iter().map(canonicalize).collect()),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LabelValueDefinitionToml, LocaleToml};
fn sample_cfg() -> LabelerConfigToml {
LabelerConfigToml {
label_values: vec!["spam".into()],
label_value_definitions: vec![LabelValueDefinitionToml {
identifier: "spam".into(),
severity: SeverityToml::Alert,
blurs: BlursToml::None,
default_setting: Some(DefaultSettingToml::Warn),
adult_only: Some(false),
locales: vec![LocaleToml {
lang: "en".into(),
name: "Spam".into(),
description: "Unsolicited promotional content.".into(),
}],
}],
reason_types: vec!["com.atproto.moderation.defs#reasonSpam".into()],
subject_types: vec!["account".into(), "record".into()],
subject_collections: vec!["app.bsky.feed.post".into()],
}
}
#[test]
fn rendered_record_has_every_required_field() {
let rec = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
let v = serde_json::to_value(&rec).unwrap();
assert_eq!(v["$type"], "app.bsky.labeler.service");
assert_eq!(v["createdAt"], "2026-04-23T00:00:00.000Z");
assert!(v["policies"].is_object());
assert!(v["policies"]["labelValues"].is_array());
assert_eq!(v["policies"]["labelValues"][0], "spam");
let def = &v["policies"]["labelValueDefinitions"][0];
for field in ["identifier", "severity", "blurs", "locales"] {
assert!(def.get(field).is_some(), "missing {field} in def: {def}");
}
assert!(!def["locales"].as_array().unwrap().is_empty());
let locale = &def["locales"][0];
for field in ["lang", "name", "description"] {
assert!(
locale.get(field).is_some(),
"missing {field} in locale: {locale}"
);
}
}
#[test]
fn empty_label_values_rejected() {
let mut cfg = sample_cfg();
cfg.label_values.clear();
assert!(matches!(
render(&cfg, "2026-04-23T00:00:00.000Z"),
Err(RenderError::NoLabelValues)
));
}
#[test]
fn empty_locales_rejected() {
let mut cfg = sample_cfg();
cfg.label_value_definitions[0].locales.clear();
assert!(matches!(
render(&cfg, "2026-04-23T00:00:00.000Z"),
Err(RenderError::NoLocales { idx: 0 })
));
}
#[test]
fn content_hash_excludes_created_at() {
let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
let b = render(&sample_cfg(), "2030-12-31T23:59:59.999Z").unwrap();
assert_eq!(
content_hash(&a),
content_hash(&b),
"hashes must match when only createdAt differs"
);
}
#[test]
fn content_hash_changes_on_label_value_change() {
let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
let mut cfg = sample_cfg();
cfg.label_values.push("abuse".into());
let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
assert_ne!(content_hash(&a), content_hash(&b));
}
#[test]
fn content_hash_changes_on_severity_change() {
let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
let mut cfg = sample_cfg();
cfg.label_value_definitions[0].severity = SeverityToml::Inform;
let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
assert_ne!(content_hash(&a), content_hash(&b));
}
#[test]
fn content_hash_changes_on_locale_change() {
let a = render(&sample_cfg(), "2026-04-23T00:00:00.000Z").unwrap();
let mut cfg = sample_cfg();
cfg.label_value_definitions[0].locales.push(LocaleToml {
lang: "fr".into(),
name: "Spam".into(),
description: "Contenu promotionnel non sollicité.".into(),
});
let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
assert_ne!(content_hash(&a), content_hash(&b));
}
#[test]
fn content_hash_is_stable_across_runs() {
let cfg = sample_cfg();
let a = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
let b = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
assert_eq!(content_hash(&a), content_hash(&b));
}
#[test]
fn optional_fields_omitted_when_empty() {
let mut cfg = sample_cfg();
cfg.reason_types.clear();
cfg.subject_types.clear();
cfg.subject_collections.clear();
let rec = render(&cfg, "2026-04-23T00:00:00.000Z").unwrap();
let v = serde_json::to_value(&rec).unwrap();
assert!(v.get("reasonTypes").is_none());
assert!(v.get("subjectTypes").is_none());
assert!(v.get("subjectCollections").is_none());
}
}