1use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10#[derive(Clone, Debug, Serialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum AdminEvent {
33 HostUpdated { id: Uuid },
35 HostCreated { id: Uuid },
37 HostDeleted { id: Uuid },
39 ServiceStatusChanged { id: Uuid, status: String },
41 SoftwareItemUpdated { id: Uuid },
43 SoftwareItemCreated { id: Uuid },
45 VersionCheckCompleted {
47 host_id: Uuid,
48 software_item_id: Uuid,
49 },
50 UpdateTriggered {
56 update_history_id: Uuid,
57 host_id: Uuid,
58 software_item_id: Uuid,
59 status: String,
61 },
62 UpdateProtectionStarted {
67 update_history_id: Uuid,
68 host_id: Uuid,
69 software_item_id: Uuid,
70 },
71 UpdateStarted {
73 update_history_id: Uuid,
74 host_id: Uuid,
75 software_item_id: Uuid,
76 interactive: bool,
82 },
83 UpdateCompleted {
85 update_history_id: Uuid,
86 host_id: Uuid,
87 software_item_id: Uuid,
88 status: String,
89 },
90 DiscoveryCompleted { host_id: Uuid },
92 SystemServiceStatusChanged { id: Uuid, status: String },
94 SchedulerTaskCompleted { task_id: Uuid },
96 HostTagCreated { id: Uuid },
98 HostTagUpdated { id: Uuid },
100 HostTagDeleted { id: Uuid },
102 HostTagsChanged { host_id: Uuid },
104 GlobalGitHubProviderMisconfigured { problem: String },
106 DataReset,
108 SurfacesChanged,
113 Other(String),
119}
120
121impl AdminEvent {
122 pub fn event_name(&self) -> &str {
128 match self {
129 Self::HostUpdated { .. } => "host_updated",
130 Self::HostCreated { .. } => "host_created",
131 Self::HostDeleted { .. } => "host_deleted",
132 Self::ServiceStatusChanged { .. } => "service_status_changed",
133 Self::SoftwareItemUpdated { .. } => "software_item_updated",
134 Self::SoftwareItemCreated { .. } => "software_item_created",
135 Self::VersionCheckCompleted { .. } => "version_check_completed",
136 Self::UpdateTriggered { .. } => "update_triggered",
137 Self::UpdateProtectionStarted { .. } => "update_protection_started",
138 Self::UpdateStarted { .. } => "update_started",
139 Self::UpdateCompleted { .. } => "update_completed",
140 Self::DiscoveryCompleted { .. } => "discovery_completed",
141 Self::SystemServiceStatusChanged { .. } => "system_service_status_changed",
142 Self::SchedulerTaskCompleted { .. } => "scheduler_task_completed",
143 Self::HostTagCreated { .. } => "host_tag_created",
144 Self::HostTagUpdated { .. } => "host_tag_updated",
145 Self::HostTagDeleted { .. } => "host_tag_deleted",
146 Self::HostTagsChanged { .. } => "host_tags_changed",
147 Self::GlobalGitHubProviderMisconfigured { .. } => {
148 "global_github_provider_misconfigured"
149 }
150 Self::DataReset => "data_reset",
151 Self::SurfacesChanged => "surfaces_changed",
152 Self::Other(v) => v.as_str(),
153 }
154 }
155}
156
157impl<'de> Deserialize<'de> for AdminEvent {
168 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
169 let value = serde_json::Value::deserialize(deserializer)?;
170
171 if let serde_json::Value::String(ref s) = value {
173 return match s.as_str() {
174 "data_reset" => Ok(Self::DataReset),
175 "surfaces_changed" => Ok(Self::SurfacesChanged),
176 other => {
177 tracing::debug!(variant = other, "received unknown AdminEvent variant");
178 Ok(Self::Other(other.to_string()))
179 }
180 };
181 }
182
183 let obj = match value {
185 serde_json::Value::Object(map) => map,
186 _ => {
187 return Err(serde::de::Error::custom(
188 "expected string or object for AdminEvent",
189 ));
190 }
191 };
192
193 let (key, inner) = match obj.into_iter().next() {
194 Some(pair) => pair,
195 None => {
196 return Err(serde::de::Error::custom(
197 "expected non-empty object for AdminEvent",
198 ));
199 }
200 };
201
202 match key.as_str() {
203 "host_updated" => {
204 #[derive(Deserialize)]
205 struct Inner {
206 id: Uuid,
207 }
208 let Inner { id } =
209 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
210 Ok(Self::HostUpdated { id })
211 }
212 "host_created" => {
213 #[derive(Deserialize)]
214 struct Inner {
215 id: Uuid,
216 }
217 let Inner { id } =
218 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
219 Ok(Self::HostCreated { id })
220 }
221 "host_deleted" => {
222 #[derive(Deserialize)]
223 struct Inner {
224 id: Uuid,
225 }
226 let Inner { id } =
227 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
228 Ok(Self::HostDeleted { id })
229 }
230 "service_status_changed" => {
231 #[derive(Deserialize)]
232 struct Inner {
233 id: Uuid,
234 status: String,
235 }
236 let Inner { id, status } =
237 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
238 Ok(Self::ServiceStatusChanged { id, status })
239 }
240 "software_item_updated" => {
241 #[derive(Deserialize)]
242 struct Inner {
243 id: Uuid,
244 }
245 let Inner { id } =
246 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
247 Ok(Self::SoftwareItemUpdated { id })
248 }
249 "software_item_created" => {
250 #[derive(Deserialize)]
251 struct Inner {
252 id: Uuid,
253 }
254 let Inner { id } =
255 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
256 Ok(Self::SoftwareItemCreated { id })
257 }
258 "version_check_completed" => {
259 #[derive(Deserialize)]
260 struct Inner {
261 host_id: Uuid,
262 software_item_id: Uuid,
263 }
264 let Inner {
265 host_id,
266 software_item_id,
267 } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
268 Ok(Self::VersionCheckCompleted {
269 host_id,
270 software_item_id,
271 })
272 }
273 "update_triggered" => {
274 fn default_pending_status() -> String {
275 "pending".into()
276 }
277 #[derive(Deserialize)]
278 struct Inner {
279 update_history_id: Uuid,
280 host_id: Uuid,
281 software_item_id: Uuid,
282 #[serde(default = "default_pending_status")]
283 status: String,
284 }
285 let Inner {
286 update_history_id,
287 host_id,
288 software_item_id,
289 status,
290 } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
291 Ok(Self::UpdateTriggered {
292 update_history_id,
293 host_id,
294 software_item_id,
295 status,
296 })
297 }
298 "update_protection_started" => {
299 #[derive(Deserialize)]
300 struct Inner {
301 update_history_id: Uuid,
302 host_id: Uuid,
303 software_item_id: Uuid,
304 }
305 let Inner {
306 update_history_id,
307 host_id,
308 software_item_id,
309 } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
310 Ok(Self::UpdateProtectionStarted {
311 update_history_id,
312 host_id,
313 software_item_id,
314 })
315 }
316 "update_started" => {
317 #[derive(Deserialize)]
318 struct Inner {
319 update_history_id: Uuid,
320 host_id: Uuid,
321 software_item_id: Uuid,
322 interactive: bool,
323 }
324 let Inner {
325 update_history_id,
326 host_id,
327 software_item_id,
328 interactive,
329 } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
330 Ok(Self::UpdateStarted {
331 update_history_id,
332 host_id,
333 software_item_id,
334 interactive,
335 })
336 }
337 "update_completed" => {
338 #[derive(Deserialize)]
339 struct Inner {
340 update_history_id: Uuid,
341 host_id: Uuid,
342 software_item_id: Uuid,
343 status: String,
344 }
345 let Inner {
346 update_history_id,
347 host_id,
348 software_item_id,
349 status,
350 } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
351 Ok(Self::UpdateCompleted {
352 update_history_id,
353 host_id,
354 software_item_id,
355 status,
356 })
357 }
358 "discovery_completed" => {
359 #[derive(Deserialize)]
360 struct Inner {
361 host_id: Uuid,
362 }
363 let Inner { host_id } =
364 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
365 Ok(Self::DiscoveryCompleted { host_id })
366 }
367 "system_service_status_changed" => {
368 #[derive(Deserialize)]
369 struct Inner {
370 id: Uuid,
371 status: String,
372 }
373 let Inner { id, status } =
374 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
375 Ok(Self::SystemServiceStatusChanged { id, status })
376 }
377 "scheduler_task_completed" => {
378 #[derive(Deserialize)]
379 struct Inner {
380 task_id: Uuid,
381 }
382 let Inner { task_id } =
383 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
384 Ok(Self::SchedulerTaskCompleted { task_id })
385 }
386 "host_tag_created" => {
387 #[derive(Deserialize)]
388 struct Inner {
389 id: Uuid,
390 }
391 let Inner { id } =
392 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
393 Ok(Self::HostTagCreated { id })
394 }
395 "host_tag_updated" => {
396 #[derive(Deserialize)]
397 struct Inner {
398 id: Uuid,
399 }
400 let Inner { id } =
401 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
402 Ok(Self::HostTagUpdated { id })
403 }
404 "host_tag_deleted" => {
405 #[derive(Deserialize)]
406 struct Inner {
407 id: Uuid,
408 }
409 let Inner { id } =
410 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
411 Ok(Self::HostTagDeleted { id })
412 }
413 "host_tags_changed" => {
414 #[derive(Deserialize)]
415 struct Inner {
416 host_id: Uuid,
417 }
418 let Inner { host_id } =
419 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
420 Ok(Self::HostTagsChanged { host_id })
421 }
422 "global_git_hub_provider_misconfigured" => {
425 #[derive(Deserialize)]
426 struct Inner {
427 problem: String,
428 }
429 let Inner { problem } =
430 serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
431 Ok(Self::GlobalGitHubProviderMisconfigured { problem })
432 }
433 other => {
434 tracing::debug!(variant = other, "received unknown AdminEvent variant");
435 Ok(Self::Other(other.to_string()))
436 }
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 const KNOWN_VARIANTS: &[&str] = &[
447 "host_updated",
448 "host_created",
449 "host_deleted",
450 "service_status_changed",
451 "software_item_updated",
452 "software_item_created",
453 "version_check_completed",
454 "update_triggered",
455 "update_protection_started",
456 "update_started",
457 "update_completed",
458 "discovery_completed",
459 "system_service_status_changed",
460 "scheduler_task_completed",
461 "host_tag_created",
462 "host_tag_updated",
463 "host_tag_deleted",
464 "host_tags_changed",
465 "global_git_hub_provider_misconfigured",
466 "data_reset",
467 "surfaces_changed",
468 ];
469
470 fn all_variants() -> Vec<AdminEvent> {
472 let id = Uuid::nil();
473 vec![
474 AdminEvent::HostUpdated { id },
475 AdminEvent::HostCreated { id },
476 AdminEvent::HostDeleted { id },
477 AdminEvent::ServiceStatusChanged {
478 id,
479 status: "approved".to_string(),
480 },
481 AdminEvent::SoftwareItemUpdated { id },
482 AdminEvent::SoftwareItemCreated { id },
483 AdminEvent::VersionCheckCompleted {
484 host_id: id,
485 software_item_id: id,
486 },
487 AdminEvent::UpdateTriggered {
488 update_history_id: id,
489 host_id: id,
490 software_item_id: id,
491 status: "pending".to_string(),
492 },
493 AdminEvent::UpdateProtectionStarted {
494 update_history_id: id,
495 host_id: id,
496 software_item_id: id,
497 },
498 AdminEvent::UpdateStarted {
499 update_history_id: id,
500 host_id: id,
501 software_item_id: id,
502 interactive: false,
503 },
504 AdminEvent::UpdateCompleted {
505 update_history_id: id,
506 host_id: id,
507 software_item_id: id,
508 status: "completed".to_string(),
509 },
510 AdminEvent::DiscoveryCompleted { host_id: id },
511 AdminEvent::SystemServiceStatusChanged {
512 id,
513 status: "approved".to_string(),
514 },
515 AdminEvent::SchedulerTaskCompleted { task_id: id },
516 AdminEvent::HostTagCreated { id },
517 AdminEvent::HostTagUpdated { id },
518 AdminEvent::HostTagDeleted { id },
519 AdminEvent::HostTagsChanged { host_id: id },
520 AdminEvent::GlobalGitHubProviderMisconfigured {
521 problem: "api_base_url requires auth_token".to_string(),
522 },
523 AdminEvent::DataReset,
524 AdminEvent::SurfacesChanged,
525 ]
526 }
527
528 #[test]
529 fn event_name_returns_correct_strings() {
530 let id = Uuid::nil();
531 assert_eq!(AdminEvent::HostUpdated { id }.event_name(), "host_updated");
532 assert_eq!(AdminEvent::HostCreated { id }.event_name(), "host_created");
533 assert_eq!(AdminEvent::HostDeleted { id }.event_name(), "host_deleted");
534 assert_eq!(
535 AdminEvent::ServiceStatusChanged {
536 id,
537 status: String::new()
538 }
539 .event_name(),
540 "service_status_changed"
541 );
542 assert_eq!(
543 AdminEvent::SoftwareItemUpdated { id }.event_name(),
544 "software_item_updated"
545 );
546 assert_eq!(
547 AdminEvent::SoftwareItemCreated { id }.event_name(),
548 "software_item_created"
549 );
550 assert_eq!(
551 AdminEvent::VersionCheckCompleted {
552 host_id: id,
553 software_item_id: id,
554 }
555 .event_name(),
556 "version_check_completed"
557 );
558 assert_eq!(
559 AdminEvent::UpdateTriggered {
560 update_history_id: id,
561 host_id: id,
562 software_item_id: id,
563 status: "pending".to_string(),
564 }
565 .event_name(),
566 "update_triggered"
567 );
568 assert_eq!(
569 AdminEvent::UpdateStarted {
570 update_history_id: id,
571 host_id: id,
572 software_item_id: id,
573 interactive: false,
574 }
575 .event_name(),
576 "update_started"
577 );
578 assert_eq!(
579 AdminEvent::UpdateCompleted {
580 update_history_id: id,
581 host_id: id,
582 software_item_id: id,
583 status: String::new(),
584 }
585 .event_name(),
586 "update_completed"
587 );
588 assert_eq!(
589 AdminEvent::DiscoveryCompleted { host_id: id }.event_name(),
590 "discovery_completed"
591 );
592 assert_eq!(
593 AdminEvent::SystemServiceStatusChanged {
594 id,
595 status: String::new()
596 }
597 .event_name(),
598 "system_service_status_changed"
599 );
600 assert_eq!(
601 AdminEvent::SchedulerTaskCompleted { task_id: id }.event_name(),
602 "scheduler_task_completed"
603 );
604 assert_eq!(
605 AdminEvent::GlobalGitHubProviderMisconfigured {
606 problem: String::new(),
607 }
608 .event_name(),
609 "global_github_provider_misconfigured"
610 );
611 }
612
613 #[test]
614 fn event_name_count_matches_variant_count() {
615 assert_eq!(all_variants().len(), KNOWN_VARIANTS.len());
618 }
619
620 #[test]
621 fn update_protection_started_event_name() {
622 let id = Uuid::nil();
623 let event = AdminEvent::UpdateProtectionStarted {
624 update_history_id: id,
625 host_id: id,
626 software_item_id: id,
627 };
628 assert_eq!(event.event_name(), "update_protection_started");
629 }
630
631 #[test]
634 fn unknown_variant_deserializes_to_other() {
635 let json = r#"{"future_variant":{"host_id":"00000000-0000-0000-0000-000000000000"}}"#;
637 let event: AdminEvent = serde_json::from_str(json).expect("should accept unknown variant");
638 assert!(
639 matches!(event, AdminEvent::Other(ref v) if v == "future_variant"),
640 "expected Other(\"future_variant\"), got: {event:?}"
641 );
642 }
643
644 #[test]
646 fn unknown_unit_variant_deserializes_to_other() {
647 let json = r#""brand_new_unit_event""#;
648 let event: AdminEvent = serde_json::from_str(json).expect("should accept unknown variant");
649 assert!(
650 matches!(event, AdminEvent::Other(ref v) if v == "brand_new_unit_event"),
651 "expected Other(\"brand_new_unit_event\"), got: {event:?}"
652 );
653 }
654
655 #[test]
659 fn known_variants_round_trip_through_custom_deserialize() {
660 for event in all_variants() {
661 let json = serde_json::to_string(&event).expect("serialization should succeed");
662 let deserialized: AdminEvent =
663 serde_json::from_str(&json).expect("deserialization should succeed");
664 assert_eq!(
665 event.event_name(),
666 deserialized.event_name(),
667 "event_name mismatch after round-trip for: {json}"
668 );
669 assert!(
671 !matches!(deserialized, AdminEvent::Other(_)),
672 "known variant round-tripped to Other: {json}"
673 );
674 }
675 }
676
677 #[test]
678 fn update_triggered_missing_status_defaults_to_pending() {
679 let json = r#"{"update_triggered":{"update_history_id":"00000000-0000-0000-0000-000000000000","host_id":"00000000-0000-0000-0000-000000000000","software_item_id":"00000000-0000-0000-0000-000000000000"}}"#;
680 let event: AdminEvent =
681 serde_json::from_str(json).expect("backward-compat deserialization");
682 assert!(
683 matches!(event, AdminEvent::UpdateTriggered { status: ref s, .. } if s == "pending"),
684 "expected UpdateTriggered with pending status, got: {event:?}"
685 );
686 }
687
688 #[test]
690 fn other_event_name_returns_raw_string() {
691 let event = AdminEvent::Other("some_future_event".to_string());
692 assert_eq!(event.event_name(), "some_future_event");
693 }
694}