1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::classifier::Validity;
5
6pub const EVENT_VERSION: u8 = 1;
7
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "kebab-case")]
10pub enum Via {
11 LinkHeader,
12 HtmlLink,
13 WellKnown,
14 PageApi,
15 Response,
16 Catalog,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct DiscoveryEvent {
21 pub v: u8,
22 pub standard: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub aliases: Option<Vec<String>>,
25 pub origin: String,
26 pub url: String,
27 pub via: Via,
28 pub page: String,
29 #[serde(rename = "targetId")]
30 pub target_id: String,
31 pub status: Validity,
32 pub summary: Value,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub reason: Option<String>,
35}
36
37impl DiscoveryEvent {
38 #[must_use]
39 pub fn to_json(&self) -> String {
40 serde_json::to_string(self).unwrap_or_default()
41 }
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47
48 #[test]
49 fn serialize_roundtrip() {
50 let event = DiscoveryEvent {
51 v: EVENT_VERSION,
52 standard: "ard".into(),
53 aliases: None,
54 origin: "https://example.com".into(),
55 url: "https://example.com/.well-known/ard.json".into(),
56 via: Via::WellKnown,
57 page: "https://example.com/shop".into(),
58 target_id: "target1".into(),
59 status: Validity::Valid,
60 summary: serde_json::json!({"name": "test"}),
61 reason: None,
62 };
63 let json = event.to_json();
64 let parsed: DiscoveryEvent = serde_json::from_str(&json).unwrap();
65 assert_eq!(parsed, event);
66 }
67
68 #[test]
69 fn via_serializes_as_kebab() {
70 assert_eq!(
71 serde_json::to_string(&Via::LinkHeader).unwrap(),
72 r#""link-header""#
73 );
74 assert_eq!(
75 serde_json::to_string(&Via::HtmlLink).unwrap(),
76 r#""html-link""#
77 );
78 assert_eq!(
79 serde_json::to_string(&Via::WellKnown).unwrap(),
80 r#""well-known""#
81 );
82 assert_eq!(
83 serde_json::to_string(&Via::PageApi).unwrap(),
84 r#""page-api""#
85 );
86 assert_eq!(
87 serde_json::to_string(&Via::Response).unwrap(),
88 r#""response""#
89 );
90 assert_eq!(
91 serde_json::to_string(&Via::Catalog).unwrap(),
92 r#""catalog""#
93 );
94 }
95
96 #[test]
97 fn reason_omitted_when_none() {
98 let event = DiscoveryEvent {
99 v: EVENT_VERSION,
100 standard: "test".into(),
101 aliases: None,
102 origin: "https://x.com".into(),
103 url: "https://x.com/f".into(),
104 via: Via::Response,
105 page: "https://x.com/".into(),
106 target_id: "t".into(),
107 status: Validity::Valid,
108 summary: serde_json::json!({}),
109 reason: None,
110 };
111 let json = event.to_json();
112 assert!(!json.contains("reason"));
113 }
114
115 #[test]
116 fn reason_included_when_present() {
117 let event = DiscoveryEvent {
118 v: EVENT_VERSION,
119 standard: "test".into(),
120 aliases: None,
121 origin: "https://x.com".into(),
122 url: "https://x.com/f".into(),
123 via: Via::Response,
124 page: "https://x.com/".into(),
125 target_id: "t".into(),
126 status: Validity::Invalid,
127 summary: serde_json::json!(null),
128 reason: Some("bad json".into()),
129 };
130 let json = event.to_json();
131 assert!(json.contains("\"reason\":\"bad json\""));
132 }
133
134 #[test]
135 fn event_version_is_one() {
136 assert_eq!(EVENT_VERSION, 1);
137 }
138
139 #[test]
140 fn aliases_omitted_when_none() {
141 let event = DiscoveryEvent {
142 v: EVENT_VERSION,
143 standard: "ard".into(),
144 aliases: None,
145 origin: "https://x.com".into(),
146 url: "https://x.com/f".into(),
147 via: Via::WellKnown,
148 page: "https://x.com/".into(),
149 target_id: "t".into(),
150 status: Validity::Valid,
151 summary: serde_json::json!({}),
152 reason: None,
153 };
154 let json = event.to_json();
155 assert!(!json.contains("aliases"));
156 }
157
158 #[test]
159 fn aliases_included_when_present() {
160 let event = DiscoveryEvent {
161 v: EVENT_VERSION,
162 standard: "ard".into(),
163 aliases: Some(vec!["ai-catalog".into()]),
164 origin: "https://x.com".into(),
165 url: "https://x.com/.well-known/ard.json".into(),
166 via: Via::WellKnown,
167 page: "https://x.com/".into(),
168 target_id: "t".into(),
169 status: Validity::Valid,
170 summary: serde_json::json!({"name": "test"}),
171 reason: None,
172 };
173 let json = event.to_json();
174 assert!(json.contains("\"aliases\":[\"ai-catalog\"]"));
175 let parsed: DiscoveryEvent = serde_json::from_str(&json).unwrap();
176 assert_eq!(parsed.aliases, Some(vec!["ai-catalog".to_string()]));
177 }
178
179 #[test]
180 fn aliases_roundtrip() {
181 let event = DiscoveryEvent {
182 v: EVENT_VERSION,
183 standard: "ard".into(),
184 aliases: Some(vec!["ai-catalog".into()]),
185 origin: "https://example.com".into(),
186 url: "https://example.com/.well-known/ard.json".into(),
187 via: Via::WellKnown,
188 page: "https://example.com/".into(),
189 target_id: "t".into(),
190 status: Validity::Valid,
191 summary: serde_json::json!({}),
192 reason: None,
193 };
194 let json = event.to_json();
195 let parsed: DiscoveryEvent = serde_json::from_str(&json).unwrap();
196 assert_eq!(parsed, event);
197 }
198}