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