use std::collections::BTreeMap;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReasonDef {
pub identifier: String,
pub base_weight: u32,
pub severe: bool,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReasonVocabulary {
reasons: BTreeMap<String, ReasonDef>,
}
impl ReasonVocabulary {
pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
let Some(declared) = &cfg.moderation_reasons else {
return Ok(Self::defaults());
};
if declared.is_empty() {
return Err(Error::Signing(
"config: [moderation_reasons] section is empty — \
either declare at least one reason or omit the section entirely"
.to_string(),
));
}
let mut reasons = BTreeMap::new();
for (identifier, toml) in declared {
validate_identifier(identifier)?;
if toml.base_weight < 1 {
return Err(Error::Signing(format!(
"config: [moderation_reasons.{identifier}] base_weight must be >= 1 (got {})",
toml.base_weight
)));
}
if toml.description.trim().is_empty() {
return Err(Error::Signing(format!(
"config: [moderation_reasons.{identifier}] description is required and must be non-empty"
)));
}
reasons.insert(
identifier.clone(),
ReasonDef {
identifier: identifier.clone(),
base_weight: toml.base_weight,
severe: toml.severe,
description: toml.description.clone(),
},
);
}
Ok(Self { reasons })
}
pub fn defaults() -> Self {
const ENTRIES: &[(&str, u32, bool, &str)] = &[
(
"hate-speech",
4,
false,
"Content targeting individuals or groups based on protected characteristics.",
),
(
"harassment",
4,
false,
"Repeated unwanted contact, intimidation, or coordinated targeting.",
),
(
"threats-of-violence",
12,
true,
"Statements expressing intent to harm.",
),
("csam", 999, true, "Child sexual abuse material."),
(
"spam",
2,
false,
"Unsolicited promotional content or repeated link-dropping.",
),
(
"misinformation",
3,
false,
"Demonstrably false factual claims.",
),
(
"nsfw",
2,
false,
"Adult-oriented or sexually explicit content.",
),
(
"other",
2,
false,
"Catch-all for situations not covered by other reasons.",
),
];
let mut reasons = BTreeMap::new();
for &(id, base_weight, severe, description) in ENTRIES {
reasons.insert(
id.to_string(),
ReasonDef {
identifier: id.to_string(),
base_weight,
severe,
description: description.to_string(),
},
);
}
Self { reasons }
}
pub fn lookup(&self, identifier: &str) -> Option<&ReasonDef> {
self.reasons.get(identifier)
}
pub fn iter(&self) -> impl Iterator<Item = &ReasonDef> {
self.reasons.values()
}
pub fn len(&self) -> usize {
self.reasons.len()
}
pub fn is_empty(&self) -> bool {
self.reasons.is_empty()
}
}
fn validate_identifier(s: &str) -> Result<()> {
if s.is_empty() || s.len() > 64 {
return Err(Error::Signing(format!(
"config: moderation reason identifier '{s}' must be 1-64 chars (got {} chars)",
s.len()
)));
}
let mut chars = s.chars();
let first = chars.next().expect("non-empty checked above");
if !first.is_ascii_lowercase() {
return Err(Error::Signing(format!(
"config: moderation reason identifier '{s}' must start with a lowercase ASCII letter (got '{first}')"
)));
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
return Err(Error::Signing(format!(
"config: moderation reason identifier '{s}' contains invalid char '{c}' (allowed: a-z, 0-9, hyphen)"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn config_with_reasons(value: serde_json::Value) -> Config {
let mut v = serde_json::json!({
"service_did": "did:web:labeler.example",
"service_endpoint": "https://labeler.example",
"db_path": "/var/lib/cairn/cairn.db",
"signing_key_path": "/etc/cairn/signing-key.hex",
});
if !value.is_null() {
v["moderation_reasons"] = value;
}
serde_json::from_value(v).expect("config deserializes")
}
#[test]
fn defaults_has_eight_entries() {
let v = ReasonVocabulary::defaults();
assert_eq!(v.len(), 8);
}
#[test]
fn defaults_csam_is_severe_with_high_weight() {
let v = ReasonVocabulary::defaults();
let csam = v.lookup("csam").expect("csam in defaults");
assert!(csam.severe);
assert_eq!(csam.base_weight, 999);
}
#[test]
fn defaults_threats_of_violence_is_severe() {
let v = ReasonVocabulary::defaults();
let tv = v
.lookup("threats-of-violence")
.expect("threats-of-violence in defaults");
assert!(tv.severe);
assert_eq!(tv.base_weight, 12);
}
#[test]
fn defaults_hate_speech_is_non_severe() {
let v = ReasonVocabulary::defaults();
let hs = v.lookup("hate-speech").expect("hate-speech in defaults");
assert!(!hs.severe);
assert_eq!(hs.base_weight, 4);
}
#[test]
fn defaults_iter_is_identifier_ascending() {
let v = ReasonVocabulary::defaults();
let ids: Vec<&str> = v.iter().map(|r| r.identifier.as_str()).collect();
let mut sorted = ids.clone();
sorted.sort();
assert_eq!(ids, sorted, "iter must yield ascending order");
}
#[test]
fn defaults_descriptions_are_non_empty() {
for r in ReasonVocabulary::defaults().iter() {
assert!(
!r.description.trim().is_empty(),
"default reason '{}' has empty description",
r.identifier
);
}
}
#[test]
fn absent_moderation_reasons_block_loads_defaults() {
let cfg = config_with_reasons(serde_json::Value::Null);
let v = ReasonVocabulary::from_config(&cfg).expect("from_config");
assert_eq!(v, ReasonVocabulary::defaults());
}
#[test]
fn empty_moderation_reasons_block_is_rejected() {
let cfg = config_with_reasons(serde_json::json!({}));
let err = ReasonVocabulary::from_config(&cfg).expect_err("empty section must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("section is empty"),
"error message must explain the empty-section problem: {msg}"
);
}
#[test]
fn single_declared_reason_yields_one_entry_no_defaults() {
let cfg = config_with_reasons(serde_json::json!({
"custom-reason": {
"base_weight": 5,
"severe": false,
"description": "an operator-defined reason"
}
}));
let v = ReasonVocabulary::from_config(&cfg).expect("from_config");
assert_eq!(v.len(), 1);
assert!(
v.lookup("custom-reason").is_some(),
"operator's reason loaded"
);
assert!(
v.lookup("hate-speech").is_none(),
"shipped defaults must NOT be loaded when operator declares any block"
);
}
#[test]
fn operator_can_replace_default_identifier_at_different_weight() {
let cfg = config_with_reasons(serde_json::json!({
"spam": {
"base_weight": 5,
"severe": false,
"description": "Operator-tightened spam policy."
}
}));
let v = ReasonVocabulary::from_config(&cfg).expect("from_config");
assert_eq!(v.len(), 1);
let spam = v.lookup("spam").expect("spam present");
assert_eq!(
spam.base_weight, 5,
"operator's value overrides shipped default"
);
}
#[test]
fn severe_defaults_to_false_when_omitted() {
let cfg = config_with_reasons(serde_json::json!({
"minor-thing": {
"base_weight": 1,
"description": "trivial"
}
}));
let v = ReasonVocabulary::from_config(&cfg).expect("from_config");
let r = v.lookup("minor-thing").expect("present");
assert!(!r.severe);
}
#[test]
fn uppercase_identifier_rejected() {
let cfg = config_with_reasons(serde_json::json!({
"Bad-Identifier": {
"base_weight": 1,
"description": "x"
}
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Bad-Identifier") || msg.contains("'B'"),
"got: {msg}"
);
}
#[test]
fn identifier_starting_with_digit_rejected() {
let cfg = config_with_reasons(serde_json::json!({
"1-leading-digit": { "base_weight": 1, "description": "x" }
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("must start with"));
}
#[test]
fn identifier_with_underscore_rejected() {
let cfg = config_with_reasons(serde_json::json!({
"snake_case": { "base_weight": 1, "description": "x" }
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("invalid char"));
}
#[test]
fn identifier_too_long_rejected() {
let long = "a".repeat(65);
let cfg = config_with_reasons(serde_json::json!({
long.clone(): { "base_weight": 1, "description": "x" }
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("1-64 chars"));
}
#[test]
fn empty_identifier_rejected() {
let cfg = config_with_reasons(serde_json::json!({
"": { "base_weight": 1, "description": "x" }
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("1-64 chars"));
}
#[test]
fn zero_base_weight_rejected() {
let cfg = config_with_reasons(serde_json::json!({
"x": { "base_weight": 0, "description": "x" }
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("base_weight must be >= 1"));
}
#[test]
fn empty_description_rejected() {
let cfg = config_with_reasons(serde_json::json!({
"x": { "base_weight": 1, "description": "" }
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("description"));
}
#[test]
fn whitespace_only_description_rejected() {
let cfg = config_with_reasons(serde_json::json!({
"x": { "base_weight": 1, "description": " \t\n " }
}));
let err = ReasonVocabulary::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("description"));
}
#[test]
fn lookup_returns_none_for_nonexistent_identifier() {
let v = ReasonVocabulary::defaults();
assert!(v.lookup("nonexistent").is_none());
}
#[test]
fn lookup_finds_default_identifiers() {
let v = ReasonVocabulary::defaults();
for id in &[
"hate-speech",
"harassment",
"threats-of-violence",
"csam",
"spam",
"misinformation",
"nsfw",
"other",
] {
assert!(
v.lookup(id).is_some(),
"default identifier '{id}' must be present"
);
}
}
}