1use crate::{SyncPolicy, SyncTarget};
2use origin_domain::{Health, SyncState};
3use serde::Serialize;
4use time::OffsetDateTime;
5
6#[derive(Debug, Clone, Serialize)]
12#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
13pub struct SyncStatus {
14 pub target: SyncTarget,
15 pub state: SyncState,
16 pub health: Health,
17 pub due_at: Option<String>,
19}
20
21const CRITICAL_STREAK: u32 = 3;
23
24const STALE_INTERVALS: i32 = 3;
27
28pub fn health_of(state: &SyncState, policy: &SyncPolicy, now: OffsetDateTime) -> Health {
33 if state.failure_streak >= CRITICAL_STREAK {
34 return Health::Critical;
35 }
36
37 match state.last_success {
38 None if state.failure_streak == 0 => Health::Unknown,
40 None => Health::Warning,
41
42 Some(last_success) => {
43 let stale = now - last_success > policy.interval * STALE_INTERVALS;
44
45 if state.failure_streak > 0 || stale {
46 Health::Warning
47 } else {
48 Health::Healthy
49 }
50 }
51 }
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use origin_domain::{ErrorKind, SyncOutcome};
58 use time::Duration;
59 use time::macros::datetime;
60
61 const NOW: OffsetDateTime = datetime!(2026-08-23 10:00 UTC);
62
63 fn policy() -> SyncPolicy {
64 SyncPolicy::every(Duration::minutes(5))
65 }
66
67 fn failed(state: &mut SyncState, times: u32) {
68 for _ in 0..times {
69 state.record(
70 NOW,
71 SyncOutcome::Failed {
72 kind: ErrorKind::Network,
73 message: "timeout".into(),
74 },
75 );
76 }
77 }
78
79 #[test]
80 fn a_target_that_never_ran_is_unknown_not_broken() {
81 assert_eq!(
82 health_of(&SyncState::default(), &policy(), NOW),
83 Health::Unknown
84 );
85 }
86
87 #[test]
88 fn a_fresh_success_is_healthy() {
89 let mut state = SyncState::default();
90 state.record(NOW, SyncOutcome::Updated);
91
92 assert_eq!(health_of(&state, &policy(), NOW), Health::Healthy);
93 }
94
95 #[test]
96 fn a_single_failure_is_a_warning_not_a_crisis() {
97 let mut state = SyncState::default();
98 state.record(NOW, SyncOutcome::Updated);
99 failed(&mut state, 1);
100
101 assert_eq!(health_of(&state, &policy(), NOW), Health::Warning);
102 }
103
104 #[test]
105 fn repeated_failures_become_critical() {
106 let mut state = SyncState::default();
107 failed(&mut state, CRITICAL_STREAK);
108
109 assert_eq!(health_of(&state, &policy(), NOW), Health::Critical);
110 }
111
112 #[test]
113 fn silence_is_reported_even_when_nothing_visibly_failed() {
114 let mut state = SyncState::default();
115 state.record(NOW, SyncOutcome::Updated);
116
117 let much_later = NOW + Duration::minutes(5) * STALE_INTERVALS + Duration::seconds(1);
118
119 assert_eq!(
120 health_of(&state, &policy(), much_later),
121 Health::Warning,
122 "a target that quietly stopped running is not healthy"
123 );
124 }
125
126 #[test]
127 fn a_not_modified_response_keeps_a_target_healthy() {
128 let mut state = SyncState::default();
129 state.record(NOW, SyncOutcome::NotModified);
130
131 assert_eq!(health_of(&state, &policy(), NOW), Health::Healthy);
132 }
133}