1use std::time::SystemTime;
83
84use crate::moderation::decay::StrikeState;
85use crate::moderation::types::ActionType;
86
87use super::automation::{PolicyAutomationPolicy, PolicyRule};
88
89#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ActionForPolicyEval {
102 pub effective_at: SystemTime,
105 pub action_type: ActionType,
108 pub revoked_at: Option<SystemTime>,
112 pub triggered_by_policy_rule: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct PendingActionForPolicyEval {
127 pub triggered_by_policy_rule: String,
130 pub resolution: Option<PendingResolution>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum PendingResolution {
145 Confirmed,
148 Dismissed,
151}
152
153pub fn resolve_firing_rule<'a>(
159 state_before: &StrikeState,
160 state_after: &StrikeState,
161 subject_history: &[ActionForPolicyEval],
162 pending_actions: &[PendingActionForPolicyEval],
163 policy: &'a PolicyAutomationPolicy,
164) -> Option<&'a PolicyRule> {
165 if !policy.enabled {
166 return None;
167 }
168 if subject_is_takendown(subject_history) {
169 return None;
170 }
171
172 for rule in policy.rules_in_severity_order() {
173 if !rule_matches_crossing(rule, state_before, state_after) {
174 continue;
175 }
176 if rule_already_fired_for_window(rule, subject_history, pending_actions) {
177 continue;
178 }
179 return Some(rule);
180 }
181 None
182}
183
184pub fn rule_matches_crossing(
189 rule: &PolicyRule,
190 state_before: &StrikeState,
191 state_after: &StrikeState,
192) -> bool {
193 let before = i64::from(state_before.current_count);
194 let after = i64::from(state_after.current_count);
195 before < rule.threshold_strikes && after >= rule.threshold_strikes
196}
197
198pub fn rule_already_fired_for_window(
202 rule: &PolicyRule,
203 subject_history: &[ActionForPolicyEval],
204 pending_actions: &[PendingActionForPolicyEval],
205) -> bool {
206 let any_subject_fire = subject_history.iter().any(|a| {
207 a.triggered_by_policy_rule.as_deref() == Some(rule.name.as_str()) && a.revoked_at.is_none()
208 });
209 let any_pending_fire = pending_actions
210 .iter()
211 .any(|p| p.triggered_by_policy_rule == rule.name && p.resolution.is_none());
212 any_subject_fire || any_pending_fire
213}
214
215fn subject_is_takendown(history: &[ActionForPolicyEval]) -> bool {
222 history
223 .iter()
224 .any(|a| matches!(a.action_type, ActionType::Takedown) && a.revoked_at.is_none())
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::policy::automation::{PolicyAutomationPolicy, PolicyMode, PolicyRule};
231 use std::collections::BTreeMap;
232 use std::time::{Duration, UNIX_EPOCH};
233
234 fn t0() -> SystemTime {
237 UNIX_EPOCH + Duration::from_secs(2_000_000_000)
238 }
239
240 fn state(count: u32) -> StrikeState {
245 StrikeState {
246 current_count: count,
247 raw_total: count,
248 revoked_count: 0,
249 decayed_count: 0,
250 active_suspension: None,
251 good_standing: count == 0,
252 }
253 }
254
255 fn rule(name: &str, threshold: i64, action_type: ActionType, mode: PolicyMode) -> PolicyRule {
256 PolicyRule {
257 name: name.to_string(),
258 threshold_strikes: threshold,
259 action_type,
260 mode,
261 duration: if matches!(action_type, ActionType::TempSuspension) {
262 Some(Duration::from_secs(86_400))
263 } else {
264 None
265 },
266 reason_codes: vec!["policy-threshold".to_string()],
267 }
268 }
269
270 fn policy_with(rules: Vec<PolicyRule>) -> PolicyAutomationPolicy {
271 let mut map = BTreeMap::new();
272 for r in rules {
273 map.insert(r.name.clone(), r);
274 }
275 PolicyAutomationPolicy {
276 enabled: true,
277 rules: map,
278 }
279 }
280
281 fn unrevoked_action(action_type: ActionType, rule_name: Option<&str>) -> ActionForPolicyEval {
282 ActionForPolicyEval {
283 effective_at: t0(),
284 action_type,
285 revoked_at: None,
286 triggered_by_policy_rule: rule_name.map(str::to_string),
287 }
288 }
289
290 fn revoked_action(action_type: ActionType, rule_name: Option<&str>) -> ActionForPolicyEval {
291 ActionForPolicyEval {
292 effective_at: t0(),
293 action_type,
294 revoked_at: Some(t0() + Duration::from_secs(60)),
295 triggered_by_policy_rule: rule_name.map(str::to_string),
296 }
297 }
298
299 fn pending(
300 rule_name: &str,
301 resolution: Option<PendingResolution>,
302 ) -> PendingActionForPolicyEval {
303 PendingActionForPolicyEval {
304 triggered_by_policy_rule: rule_name.to_string(),
305 resolution,
306 }
307 }
308
309 #[test]
314 fn crossing_below_to_above_matches() {
315 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
316 assert!(rule_matches_crossing(&r, &state(4), &state(5)));
317 }
318
319 #[test]
320 fn crossing_below_to_well_above_matches() {
321 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
322 assert!(rule_matches_crossing(&r, &state(2), &state(7)));
323 }
324
325 #[test]
326 fn crossing_above_to_above_does_not_match() {
327 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
330 assert!(!rule_matches_crossing(&r, &state(7), &state(9)));
331 }
332
333 #[test]
334 fn crossing_below_to_below_does_not_match() {
335 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
336 assert!(!rule_matches_crossing(&r, &state(2), &state(4)));
337 }
338
339 #[test]
340 fn crossing_above_to_below_does_not_match() {
341 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
344 assert!(!rule_matches_crossing(&r, &state(7), &state(3)));
345 }
346
347 #[test]
348 fn crossing_pre_at_threshold_does_not_match() {
349 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
355 assert!(!rule_matches_crossing(&r, &state(5), &state(6)));
356 }
357
358 #[test]
359 fn crossing_pre_just_below_post_exactly_at_threshold_matches() {
360 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
363 assert!(rule_matches_crossing(&r, &state(4), &state(5)));
364 }
365
366 #[test]
371 fn never_fired_window_open() {
372 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
373 assert!(!rule_already_fired_for_window(&r, &[], &[]));
374 }
375
376 #[test]
377 fn unrevoked_subject_firing_window_closed() {
378 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
379 let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
380 assert!(rule_already_fired_for_window(&r, &history, &[]));
381 }
382
383 #[test]
384 fn revoked_subject_firing_window_open() {
385 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
386 let history = vec![revoked_action(ActionType::Warning, Some("warn_5"))];
387 assert!(!rule_already_fired_for_window(&r, &history, &[]));
388 }
389
390 #[test]
391 fn unresolved_pending_window_closed() {
392 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
393 let pendings = vec![pending("warn_5", None)];
394 assert!(rule_already_fired_for_window(&r, &[], &pendings));
395 }
396
397 #[test]
398 fn dismissed_pending_window_open() {
399 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
400 let pendings = vec![pending("warn_5", Some(PendingResolution::Dismissed))];
401 assert!(!rule_already_fired_for_window(&r, &[], &pendings));
402 }
403
404 #[test]
405 fn confirmed_pending_alone_window_open_subject_actions_carries_state() {
406 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
417 let pendings = vec![pending("warn_5", Some(PendingResolution::Confirmed))];
418 assert!(!rule_already_fired_for_window(&r, &[], &pendings));
419 }
420
421 #[test]
422 fn confirmed_pending_with_subject_action_window_closed() {
423 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
428 let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
429 let pendings = vec![pending("warn_5", Some(PendingResolution::Confirmed))];
430 assert!(rule_already_fired_for_window(&r, &history, &pendings));
431 }
432
433 #[test]
434 fn unrelated_rule_firings_dont_close_window() {
435 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
436 let history = vec![unrevoked_action(ActionType::Warning, Some("other_rule"))];
437 let pendings = vec![pending("yet_another_rule", None)];
438 assert!(!rule_already_fired_for_window(&r, &history, &pendings));
439 }
440
441 #[test]
442 fn moderator_action_with_no_rule_attribution_doesnt_close_window() {
443 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
446 let history = vec![unrevoked_action(ActionType::Warning, None)];
447 assert!(!rule_already_fired_for_window(&r, &history, &[]));
448 }
449
450 #[test]
451 fn either_subject_or_pending_firing_closes_window() {
452 let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
453 let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
455 assert!(rule_already_fired_for_window(&r, &history, &[]));
456 let pendings = vec![pending("warn_5", None)];
458 assert!(rule_already_fired_for_window(&r, &[], &pendings));
459 }
460
461 #[test]
466 fn disabled_policy_returns_none() {
467 let mut policy = policy_with(vec![rule(
468 "warn_5",
469 5,
470 ActionType::Warning,
471 PolicyMode::Auto,
472 )]);
473 policy.enabled = false;
474 assert!(resolve_firing_rule(&state(0), &state(10), &[], &[], &policy).is_none());
475 }
476
477 #[test]
478 fn empty_policy_returns_none() {
479 let policy = policy_with(vec![]);
480 assert!(resolve_firing_rule(&state(0), &state(10), &[], &[], &policy).is_none());
481 }
482
483 #[test]
484 fn no_rules_match_crossing_returns_none() {
485 let policy = policy_with(vec![rule(
486 "warn_10",
487 10,
488 ActionType::Warning,
489 PolicyMode::Auto,
490 )]);
491 assert!(resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).is_none());
493 }
494
495 #[test]
496 fn single_matching_rule_returns_it() {
497 let policy = policy_with(vec![rule(
498 "warn_5",
499 5,
500 ActionType::Warning,
501 PolicyMode::Auto,
502 )]);
503 let fired =
504 resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
505 assert_eq!(fired.name, "warn_5");
506 }
507
508 #[test]
509 fn matching_rule_already_fired_returns_none() {
510 let policy = policy_with(vec![rule(
511 "warn_5",
512 5,
513 ActionType::Warning,
514 PolicyMode::Auto,
515 )]);
516 let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
517 assert!(
518 resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).is_none(),
519 "already-fired rule must not re-fire while window closed"
520 );
521 }
522
523 #[test]
524 fn takedown_in_history_blocks_all_firings() {
525 let policy = policy_with(vec![rule(
526 "warn_5",
527 5,
528 ActionType::Warning,
529 PolicyMode::Auto,
530 )]);
531 let history = vec![unrevoked_action(ActionType::Takedown, None)];
532 assert!(
533 resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).is_none(),
534 "takedown is terminal — no policy fires"
535 );
536 }
537
538 #[test]
539 fn revoked_takedown_does_not_block() {
540 let policy = policy_with(vec![rule(
545 "warn_5",
546 5,
547 ActionType::Warning,
548 PolicyMode::Auto,
549 )]);
550 let history = vec![revoked_action(ActionType::Takedown, None)];
551 let fired = resolve_firing_rule(&state(0), &state(5), &history, &[], &policy);
552 assert!(
553 fired.is_some(),
554 "revoked takedown should not block policy evaluation"
555 );
556 }
557
558 #[test]
559 fn severity_order_takedown_wins_over_indef() {
560 let policy = policy_with(vec![
561 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
562 rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
563 rule("takedown_5", 5, ActionType::Takedown, PolicyMode::Auto),
564 ]);
565 let fired =
566 resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
567 assert_eq!(fired.name, "takedown_5");
568 }
569
570 #[test]
571 fn severity_order_indef_beats_temp() {
572 let policy = policy_with(vec![
573 rule("temp_5", 5, ActionType::TempSuspension, PolicyMode::Auto),
574 rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
575 ]);
576 let fired =
577 resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
578 assert_eq!(fired.name, "indef_5");
579 }
580
581 #[test]
582 fn severity_order_temp_beats_warning() {
583 let policy = policy_with(vec![
584 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
585 rule("temp_5", 5, ActionType::TempSuspension, PolicyMode::Auto),
586 ]);
587 let fired =
588 resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
589 assert_eq!(fired.name, "temp_5");
590 }
591
592 #[test]
593 fn tie_within_severity_higher_threshold_wins() {
594 let policy = policy_with(vec![
598 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
599 rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
600 ]);
601 let fired =
602 resolve_firing_rule(&state(0), &state(15), &[], &[], &policy).expect("rule fires");
603 assert_eq!(fired.name, "warn_10");
604 }
605
606 #[test]
607 fn highest_severity_already_fired_falls_through_to_next() {
608 let policy = policy_with(vec![
616 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
617 rule("takedown_5", 5, ActionType::Takedown, PolicyMode::Auto),
618 ]);
619 let history = vec![
632 revoked_action(ActionType::Warning, Some("warn_5")),
636 ];
637 let fired =
638 resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).expect("rule fires");
639 assert_eq!(fired.name, "takedown_5");
640 }
641
642 #[test]
643 fn all_matching_rules_already_fired_returns_none() {
644 let policy = policy_with(vec![
645 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
646 rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
647 ]);
648 let history = vec![
649 unrevoked_action(ActionType::Warning, Some("warn_5")),
650 unrevoked_action(ActionType::Warning, Some("warn_10")),
651 ];
652 assert!(
653 resolve_firing_rule(&state(0), &state(15), &history, &[], &policy).is_none(),
654 "all matching rules already fired → no firing"
655 );
656 }
657
658 #[test]
659 fn higher_severity_already_fired_falls_through_to_lower() {
660 let policy = policy_with(vec![
665 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
666 rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
667 ]);
668 let history = vec![unrevoked_action(
669 ActionType::IndefSuspension,
670 Some("indef_5"),
671 )];
672 let fired = resolve_firing_rule(&state(0), &state(5), &history, &[], &policy)
673 .expect("warning rule fires");
674 assert_eq!(fired.name, "warn_5");
675 }
676
677 #[test]
678 fn pending_blocks_higher_severity_falls_through() {
679 let policy = policy_with(vec![
682 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
683 rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Flag),
684 ]);
685 let pendings = vec![pending("indef_5", None)];
686 let fired = resolve_firing_rule(&state(0), &state(5), &[], &pendings, &policy)
687 .expect("warning rule fires");
688 assert_eq!(fired.name, "warn_5");
689 }
690
691 #[test]
692 fn flag_mode_rule_returned_just_like_auto() {
693 let policy = policy_with(vec![rule(
697 "indef_5",
698 5,
699 ActionType::IndefSuspension,
700 PolicyMode::Flag,
701 )]);
702 let fired =
703 resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
704 assert_eq!(fired.mode, PolicyMode::Flag);
705 }
706
707 #[test]
712 fn outputs_deterministic_for_same_inputs() {
713 let policy = policy_with(vec![
714 rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
715 rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
716 rule(
717 "indef_15",
718 15,
719 ActionType::IndefSuspension,
720 PolicyMode::Auto,
721 ),
722 ]);
723 let s_before = state(0);
724 let s_after = state(20);
725 let a = resolve_firing_rule(&s_before, &s_after, &[], &[], &policy);
726 let b = resolve_firing_rule(&s_before, &s_after, &[], &[], &policy);
727 assert_eq!(a.map(|r| r.name.as_str()), b.map(|r| r.name.as_str()));
728 }
729}