use std::time::SystemTime;
use crate::config::{BlursToml, LocaleToml, SeverityToml};
use crate::moderation::types::ActionType;
use super::policy::LabelEmissionPolicy;
#[derive(Debug, Clone)]
pub struct ActionForEmission {
pub action_type: ActionType,
pub expires_at: Option<SystemTime>,
pub subject_did: String,
pub subject_uri: Option<String>,
pub reason_codes: Vec<String>,
pub cid: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelDraft {
pub val: String,
pub severity: SeverityToml,
pub blurs: Option<BlursToml>,
pub locales: Vec<LocaleToml>,
pub uri: String,
pub cid: Option<String>,
pub neg: bool,
pub cts: SystemTime,
pub exp: Option<SystemTime>,
}
pub fn resolve_action_labels(
action: &ActionForEmission,
policy: &LabelEmissionPolicy,
now: SystemTime,
) -> Vec<LabelDraft> {
if !policy.enabled {
return Vec::new();
}
let Some(spec) = policy.resolve_action_label(action.action_type) else {
return Vec::new();
};
let uri = action
.subject_uri
.clone()
.unwrap_or_else(|| action.subject_did.clone());
let exp = exp_for(action);
vec![LabelDraft {
val: spec.val,
severity: spec.severity,
blurs: spec.blurs,
locales: spec.locales,
uri,
cid: None,
neg: false,
cts: now,
exp,
}]
}
pub fn resolve_reason_labels(
action: &ActionForEmission,
policy: &LabelEmissionPolicy,
now: SystemTime,
) -> Vec<LabelDraft> {
if !policy.enabled || !policy.emit_reason_labels || action.reason_codes.is_empty() {
return Vec::new();
}
if matches!(action.action_type, ActionType::Note) {
return Vec::new();
}
if matches!(action.action_type, ActionType::Warning) && !policy.warning_emits_label {
return Vec::new();
}
let uri = action
.subject_uri
.clone()
.unwrap_or_else(|| action.subject_did.clone());
let exp = exp_for(action);
action
.reason_codes
.iter()
.map(|reason_code| LabelDraft {
val: policy.resolve_reason_label_value(reason_code),
severity: SeverityToml::Inform,
blurs: None,
locales: Vec::new(),
uri: uri.clone(),
cid: None,
neg: false,
cts: now,
exp,
})
.collect()
}
fn exp_for(action: &ActionForEmission) -> Option<SystemTime> {
match action.action_type {
ActionType::TempSuspension => action.expires_at,
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LabelEmissionPolicyToml, LabelSpecToml};
use std::collections::BTreeMap;
use std::time::{Duration, UNIX_EPOCH};
const SUBJECT_DID: &str = "did:plc:subject0000000000000000";
const SUBJECT_URI: &str = "at://did:plc:subject0000000000000000/app.bsky.feed.post/aaa";
fn t0() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(2_000_000_000)
}
fn account_action(action_type: ActionType, reasons: &[&str]) -> ActionForEmission {
ActionForEmission {
action_type,
expires_at: None,
subject_did: SUBJECT_DID.to_string(),
subject_uri: None,
reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
cid: None,
}
}
fn record_action(action_type: ActionType, reasons: &[&str]) -> ActionForEmission {
ActionForEmission {
action_type,
expires_at: None,
subject_did: SUBJECT_DID.to_string(),
subject_uri: Some(SUBJECT_URI.to_string()),
reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
cid: None,
}
}
fn temp_suspension_action(reasons: &[&str], expires_at: SystemTime) -> ActionForEmission {
ActionForEmission {
action_type: ActionType::TempSuspension,
expires_at: Some(expires_at),
subject_did: SUBJECT_DID.to_string(),
subject_uri: None,
reason_codes: reasons.iter().map(|s| s.to_string()).collect(),
cid: None,
}
}
fn policy(patch: serde_json::Value) -> LabelEmissionPolicy {
let mut base = 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 !patch.is_null() {
base["label_emission"] = patch;
}
let cfg: crate::config::Config = serde_json::from_value(base).expect("config deserializes");
LabelEmissionPolicy::from_config(&cfg).expect("policy resolves")
}
fn defaults_policy() -> LabelEmissionPolicy {
LabelEmissionPolicy::defaults()
}
fn _toml_types_exist() -> (LabelSpecToml, LabelEmissionPolicyToml, BTreeMap<String, ()>) {
unreachable!()
}
#[test]
fn action_labels_disabled_policy_returns_empty() {
let p = policy(serde_json::json!({ "enabled": false }));
assert!(
resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0()).is_empty()
);
}
#[test]
fn action_labels_note_returns_empty() {
let p = defaults_policy();
assert!(
resolve_action_labels(&account_action(ActionType::Note, &["spam"]), &p, t0())
.is_empty()
);
}
#[test]
fn action_labels_warning_suppressed_by_default() {
let p = defaults_policy();
assert!(
resolve_action_labels(&account_action(ActionType::Warning, &["spam"]), &p, t0())
.is_empty()
);
}
#[test]
fn action_labels_warning_emits_when_flag_true() {
let p = policy(serde_json::json!({ "warning_emits_label": true }));
let out = resolve_action_labels(&account_action(ActionType::Warning, &["spam"]), &p, t0());
assert_eq!(out.len(), 1);
assert_eq!(out[0].val, "!warn");
assert!(matches!(out[0].severity, SeverityToml::Inform));
assert!(!out[0].neg);
}
#[test]
fn action_labels_takedown_emits_default() {
let p = defaults_policy();
let out = resolve_action_labels(
&account_action(ActionType::Takedown, &["hate-speech"]),
&p,
t0(),
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].val, "!takedown");
assert!(matches!(out[0].severity, SeverityToml::Alert));
assert_eq!(out[0].exp, None);
}
#[test]
fn action_labels_indef_suspension_emits_hide_no_exp() {
let p = defaults_policy();
let out =
resolve_action_labels(&account_action(ActionType::IndefSuspension, &[]), &p, t0());
assert_eq!(out.len(), 1);
assert_eq!(out[0].val, "!hide");
assert_eq!(out[0].exp, None);
}
#[test]
fn action_labels_temp_suspension_with_expiry_propagates_to_exp() {
let p = defaults_policy();
let exp = t0() + Duration::from_secs(7 * 86_400);
let out = resolve_action_labels(&temp_suspension_action(&[], exp), &p, t0());
assert_eq!(out.len(), 1);
assert_eq!(out[0].val, "!hide");
assert_eq!(out[0].exp, Some(exp));
}
#[test]
fn action_labels_temp_suspension_without_expiry_emits_with_none_exp() {
let p = defaults_policy();
let action = ActionForEmission {
action_type: ActionType::TempSuspension,
expires_at: None,
subject_did: SUBJECT_DID.to_string(),
subject_uri: None,
reason_codes: vec![],
cid: None,
};
let out = resolve_action_labels(&action, &p, t0());
assert_eq!(out.len(), 1);
assert_eq!(out[0].exp, None);
}
#[test]
fn action_labels_operator_override_replaces_val() {
let p = policy(serde_json::json!({
"action_label_overrides": {
"takedown": { "val": "!hideaway-takedown", "severity": "alert" }
}
}));
let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
assert_eq!(out[0].val, "!hideaway-takedown");
}
#[test]
fn action_labels_severity_override_applies_to_default_val() {
let p = policy(serde_json::json!({
"warning_emits_label": true,
"severity_overrides": { "warning": "alert" }
}));
let out = resolve_action_labels(&account_action(ActionType::Warning, &[]), &p, t0());
assert_eq!(out[0].val, "!warn"); assert!(matches!(out[0].severity, SeverityToml::Alert)); }
#[test]
fn action_labels_account_subject_uses_did_as_uri() {
let p = defaults_policy();
let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
assert_eq!(out[0].uri, SUBJECT_DID);
assert_eq!(out[0].cid, None);
}
#[test]
fn action_labels_record_subject_uses_uri() {
let p = defaults_policy();
let out = resolve_action_labels(&record_action(ActionType::Takedown, &[]), &p, t0());
assert_eq!(out[0].uri, SUBJECT_URI);
assert_eq!(out[0].cid, None);
}
#[test]
fn action_labels_cts_takes_now_arg() {
let p = defaults_policy();
let now = t0() + Duration::from_secs(42);
let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, now);
assert_eq!(out[0].cts, now);
}
#[test]
fn action_labels_blurs_and_locales_propagate_from_override() {
let p = policy(serde_json::json!({
"action_label_overrides": {
"takedown": {
"val": "!hideaway-takedown",
"severity": "alert",
"blurs": "media",
"locales": [
{ "lang": "en", "name": "Removed", "description": "Account removed by moderation" }
]
}
}
}));
let out = resolve_action_labels(&account_action(ActionType::Takedown, &[]), &p, t0());
assert!(matches!(out[0].blurs, Some(BlursToml::Media)));
assert_eq!(out[0].locales.len(), 1);
assert_eq!(out[0].locales[0].lang, "en");
}
#[test]
fn reason_labels_disabled_policy_returns_empty() {
let p = policy(serde_json::json!({ "enabled": false }));
let action = account_action(ActionType::Takedown, &["spam", "hate-speech"]);
assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
}
#[test]
fn reason_labels_emit_reason_labels_false_returns_empty() {
let p = policy(serde_json::json!({ "emit_reason_labels": false }));
let action = account_action(ActionType::Takedown, &["spam"]);
assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
}
#[test]
fn reason_labels_empty_reason_codes_returns_empty() {
let p = defaults_policy();
let action = account_action(ActionType::Takedown, &[]);
assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
}
#[test]
fn reason_labels_note_returns_empty_even_with_reasons() {
let p = defaults_policy();
let action = account_action(ActionType::Note, &["spam", "hate-speech"]);
assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
}
#[test]
fn reason_labels_warning_suppressed_by_default() {
let p = defaults_policy();
let action = account_action(ActionType::Warning, &["spam"]);
assert!(resolve_reason_labels(&action, &p, t0()).is_empty());
}
#[test]
fn reason_labels_warning_emits_when_warning_emits_label_true() {
let p = policy(serde_json::json!({ "warning_emits_label": true }));
let action = account_action(ActionType::Warning, &["spam"]);
let out = resolve_reason_labels(&action, &p, t0());
assert_eq!(out.len(), 1);
assert_eq!(out[0].val, "reason-spam");
}
#[test]
fn reason_labels_takedown_with_three_reasons_emits_three() {
let p = defaults_policy();
let action = account_action(ActionType::Takedown, &["spam", "hate-speech", "harassment"]);
let out = resolve_reason_labels(&action, &p, t0());
assert_eq!(out.len(), 3);
let vals: Vec<&str> = out.iter().map(|d| d.val.as_str()).collect();
assert_eq!(
vals,
vec!["reason-spam", "reason-hate-speech", "reason-harassment"]
);
for d in &out {
assert!(matches!(d.severity, SeverityToml::Inform));
assert!(!d.neg);
assert_eq!(d.cts, t0());
assert_eq!(d.uri, SUBJECT_DID);
}
}
#[test]
fn reason_labels_temp_suspension_inherits_action_exp() {
let p = defaults_policy();
let exp = t0() + Duration::from_secs(7 * 86_400);
let action = temp_suspension_action(&["spam", "nsfw"], exp);
let out = resolve_reason_labels(&action, &p, t0());
assert_eq!(out.len(), 2);
for d in &out {
assert_eq!(d.exp, Some(exp));
}
}
#[test]
fn reason_labels_use_custom_prefix() {
let p = policy(serde_json::json!({ "reason_label_prefix": "rsn-" }));
let action = account_action(ActionType::Takedown, &["spam"]);
let out = resolve_reason_labels(&action, &p, t0());
assert_eq!(out[0].val, "rsn-spam");
}
#[test]
fn reason_labels_use_empty_prefix_yields_bare_reason_codes() {
let p = policy(serde_json::json!({ "reason_label_prefix": "" }));
let action = account_action(ActionType::Takedown, &["spam"]);
let out = resolve_reason_labels(&action, &p, t0());
assert_eq!(out[0].val, "spam");
}
#[test]
fn reason_labels_record_subject_uses_uri() {
let p = defaults_policy();
let action = record_action(ActionType::Takedown, &["spam"]);
let out = resolve_reason_labels(&action, &p, t0());
assert_eq!(out[0].uri, SUBJECT_URI);
}
#[test]
fn reason_labels_takedown_has_no_exp() {
let p = defaults_policy();
let action = account_action(ActionType::Takedown, &["spam"]);
let out = resolve_reason_labels(&action, &p, t0());
assert_eq!(out[0].exp, None);
}
#[test]
fn outputs_deterministic_for_same_inputs() {
let p = defaults_policy();
let action = account_action(ActionType::Takedown, &["spam", "hate-speech"]);
let now = t0();
let a = resolve_action_labels(&action, &p, now);
let b = resolve_action_labels(&action, &p, now);
assert_eq!(a, b);
let r1 = resolve_reason_labels(&action, &p, now);
let r2 = resolve_reason_labels(&action, &p, now);
assert_eq!(r1, r2);
}
}