Skip to main content

mailrs_intelligence/
structured.rs

1use serde::{Deserialize, Serialize};
2
3/// Structured data extracted from email HTML (Schema.org JSON-LD).
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct StructuredData {
6    /// Travel / hotel / restaurant reservations.
7    #[serde(skip_serializing_if = "Vec::is_empty")]
8    pub reservations: Vec<Reservation>,
9    /// E-commerce orders / receipts.
10    #[serde(skip_serializing_if = "Vec::is_empty")]
11    pub orders: Vec<Order>,
12    /// Events the message is RSVP'ing to or announcing.
13    #[serde(skip_serializing_if = "Vec::is_empty")]
14    pub events: Vec<EventInfo>,
15    /// Schema.org Action handles (`confirm` / `view` / `track` / `rsvp` URLs).
16    #[serde(skip_serializing_if = "Vec::is_empty")]
17    pub actions: Vec<ActionInfo>,
18}
19
20/// One reservation (flight, hotel, restaurant, rental car, etc).
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Reservation {
23    /// Reservation kind: `flight` / `hotel` / `restaurant` / `rental_car`.
24    #[serde(rename = "type")]
25    pub kind: String,
26    /// Display name (hotel name, restaurant name, etc).
27    pub name: Option<String>,
28    /// Reservation confirmation code.
29    pub reservation_id: Option<String>,
30    /// Status string (`confirmed` / `cancelled` / `pending`).
31    pub status: Option<String>,
32    /// ISO-8601 start date / time.
33    pub start_date: Option<String>,
34    /// ISO-8601 end date / time.
35    pub end_date: Option<String>,
36    /// Location (address / city / airport).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub location: Option<String>,
39    /// Provider / brand (airline, hotel chain, etc).
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub provider: Option<String>,
42    /// IATA code or name of the departure airport (flight only).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub departure_airport: Option<String>,
45    /// IATA code or name of the arrival airport (flight only).
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub arrival_airport: Option<String>,
48    /// Flight number (flight only).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub flight_number: Option<String>,
51}
52
53/// One e-commerce order / receipt.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Order {
56    /// Merchant-assigned order number.
57    pub order_number: Option<String>,
58    /// Merchant / seller name.
59    pub merchant: Option<String>,
60    /// ISO-8601 date the order was placed.
61    pub order_date: Option<String>,
62    /// Status string (`placed` / `shipped` / `delivered` / ...).
63    pub status: Option<String>,
64    /// Line items.
65    #[serde(skip_serializing_if = "Vec::is_empty")]
66    pub items: Vec<OrderItem>,
67    /// Total amount as a string (no parsing — keep original currency formatting).
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub total: Option<String>,
70    /// Currency code (ISO 4217).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub currency: Option<String>,
73}
74
75/// One line item inside an [`Order`].
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct OrderItem {
78    /// Item name.
79    pub name: String,
80    /// Quantity ordered, if specified.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub quantity: Option<u32>,
83    /// Per-item price as a string.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub price: Option<String>,
86}
87
88/// One event referenced in the message.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct EventInfo {
91    /// Event name.
92    pub name: String,
93    /// ISO-8601 start date / time.
94    pub start_date: Option<String>,
95    /// ISO-8601 end date / time.
96    pub end_date: Option<String>,
97    /// Location string.
98    pub location: Option<String>,
99    /// Detail URL (registration page, etc).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub url: Option<String>,
102}
103
104/// Schema.org Action handle ("View Booking", "Track Package", ...).
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ActionInfo {
107    /// Action kind (`confirm` / `view` / `track` / `rsvp`).
108    #[serde(rename = "type")]
109    pub kind: String,
110    /// Action display name.
111    pub name: String,
112    /// Target URL.
113    pub url: Option<String>,
114}
115
116impl StructuredData {
117    /// `true` when no structured data was extracted from the message.
118    pub fn is_empty(&self) -> bool {
119        self.reservations.is_empty()
120            && self.orders.is_empty()
121            && self.events.is_empty()
122            && self.actions.is_empty()
123    }
124}
125
126/// extract Schema.org JSON-LD from HTML email body
127pub fn extract_structured_data(html: &str) -> StructuredData {
128    let mut data = StructuredData::default();
129
130    // find all <script type="application/ld+json"> blocks
131    for block in extract_jsonld_blocks(html) {
132        let Ok(value) = serde_json::from_str::<serde_json::Value>(&block) else {
133            continue;
134        };
135
136        // handle single object or @graph array
137        let items = if let Some(graph) = value.get("@graph").and_then(|g| g.as_array()) {
138            graph.clone()
139        } else if value.is_array() {
140            value.as_array().cloned().unwrap_or_default()
141        } else {
142            vec![value]
143        };
144
145        for item in items {
146            process_jsonld_item(&item, &mut data);
147        }
148    }
149
150    data
151}
152
153fn extract_jsonld_blocks(html: &str) -> Vec<String> {
154    let mut blocks = Vec::new();
155    let lower = html.to_lowercase();
156    let mut search_from = 0;
157
158    while let Some(tag_start) = lower[search_from..].find("application/ld+json") {
159        let abs_tag = search_from + tag_start;
160
161        // find the end of the opening <script> tag
162        let Some(open_end) = lower[abs_tag..].find('>') else {
163            break;
164        };
165        let content_start = abs_tag + open_end + 1;
166
167        // find closing </script>
168        let Some(close_pos) = lower[content_start..].find("</script>") else {
169            break;
170        };
171        let content_end = content_start + close_pos;
172
173        let content = html[content_start..content_end].trim();
174        if !content.is_empty() {
175            blocks.push(content.to_string());
176        }
177
178        search_from = content_end + 9;
179    }
180
181    blocks
182}
183
184fn process_jsonld_item(item: &serde_json::Value, data: &mut StructuredData) {
185    let schema_type = item.get("@type").and_then(|t| t.as_str()).unwrap_or("");
186
187    match schema_type {
188        "FlightReservation" => {
189            let flight = item.get("reservationFor");
190            data.reservations.push(Reservation {
191                kind: "flight".into(),
192                name: get_str(flight, "name"),
193                reservation_id: get_str(Some(item), "reservationId"),
194                status: get_str(Some(item), "reservationStatus")
195                    .map(|s| s.replace("http://schema.org/Reservation", "")),
196                start_date: get_str(flight, "departureTime"),
197                end_date: get_str(flight, "arrivalTime"),
198                location: None,
199                provider: get_nested_str(item, &["provider", "name"])
200                    .or_else(|| get_nested_str(item, &["reservationFor", "airline", "name"])),
201                departure_airport: get_nested_str(
202                    item,
203                    &["reservationFor", "departureAirport", "iataCode"],
204                )
205                .or_else(|| get_nested_str(item, &["reservationFor", "departureAirport", "name"])),
206                arrival_airport: get_nested_str(
207                    item,
208                    &["reservationFor", "arrivalAirport", "iataCode"],
209                )
210                .or_else(|| get_nested_str(item, &["reservationFor", "arrivalAirport", "name"])),
211                flight_number: get_str(flight, "flightNumber"),
212            });
213        }
214        "LodgingReservation" => {
215            data.reservations.push(Reservation {
216                kind: "hotel".into(),
217                name: get_nested_str(item, &["reservationFor", "name"]),
218                reservation_id: get_str(Some(item), "reservationId"),
219                status: get_str(Some(item), "reservationStatus"),
220                start_date: get_str(Some(item), "checkinTime"),
221                end_date: get_str(Some(item), "checkoutTime"),
222                location: get_nested_str(item, &["reservationFor", "address", "streetAddress"]),
223                provider: get_nested_str(item, &["reservationFor", "name"]),
224                departure_airport: None,
225                arrival_airport: None,
226                flight_number: None,
227            });
228        }
229        "FoodEstablishmentReservation" => {
230            data.reservations.push(Reservation {
231                kind: "restaurant".into(),
232                name: get_nested_str(item, &["reservationFor", "name"]),
233                reservation_id: get_str(Some(item), "reservationId"),
234                status: get_str(Some(item), "reservationStatus"),
235                start_date: get_str(Some(item), "startTime"),
236                end_date: get_str(Some(item), "endTime"),
237                location: get_nested_str(item, &["reservationFor", "address", "streetAddress"]),
238                provider: None,
239                departure_airport: None,
240                arrival_airport: None,
241                flight_number: None,
242            });
243        }
244        "RentalCarReservation" => {
245            data.reservations.push(Reservation {
246                kind: "rental_car".into(),
247                name: get_nested_str(item, &["reservationFor", "name"]),
248                reservation_id: get_str(Some(item), "reservationId"),
249                status: get_str(Some(item), "reservationStatus"),
250                start_date: get_str(Some(item), "pickupTime"),
251                end_date: get_str(Some(item), "dropoffTime"),
252                location: get_nested_str(item, &["pickupLocation", "name"]),
253                provider: get_nested_str(item, &["provider", "name"]),
254                departure_airport: None,
255                arrival_airport: None,
256                flight_number: None,
257            });
258        }
259        "Order" => {
260            let items = item
261                .get("orderedItem")
262                .or_else(|| item.get("acceptedOffer"))
263                .and_then(|v| {
264                    if v.is_array() {
265                        v.as_array().cloned()
266                    } else {
267                        Some(vec![v.clone()])
268                    }
269                })
270                .unwrap_or_default()
271                .iter()
272                .filter_map(|oi| {
273                    let name = get_str(Some(oi), "name")
274                        .or_else(|| get_nested_str(oi, &["orderedItem", "name"]))
275                        .unwrap_or_default();
276                    if name.is_empty() {
277                        return None;
278                    }
279                    Some(OrderItem {
280                        name,
281                        quantity: oi
282                            .get("orderQuantity")
283                            .and_then(|q| q.as_u64())
284                            .map(|q| q as u32),
285                        price: get_str(Some(oi), "price"),
286                    })
287                })
288                .collect();
289
290            data.orders.push(Order {
291                order_number: get_str(Some(item), "orderNumber"),
292                merchant: get_nested_str(item, &["seller", "name"])
293                    .or_else(|| get_nested_str(item, &["merchant", "name"])),
294                order_date: get_str(Some(item), "orderDate"),
295                status: get_str(Some(item), "orderStatus")
296                    .map(|s| s.replace("http://schema.org/Order", "")),
297                items,
298                total: get_nested_str(item, &["totalPrice", "value"]).or_else(|| {
299                    get_str(Some(item), "totalPrice").and_then(|v| {
300                        if v.parse::<f64>().is_ok() {
301                            Some(v)
302                        } else {
303                            None
304                        }
305                    })
306                }),
307                currency: get_nested_str(item, &["totalPrice", "currency"])
308                    .or_else(|| get_str(Some(item), "priceCurrency")),
309            });
310        }
311        "Event" | "MusicEvent" | "SportsEvent" | "BusinessEvent" | "SocialEvent" => {
312            data.events.push(EventInfo {
313                name: get_str(Some(item), "name").unwrap_or_default(),
314                start_date: get_str(Some(item), "startDate"),
315                end_date: get_str(Some(item), "endDate"),
316                location: get_nested_str(item, &["location", "name"])
317                    .or_else(|| get_str(Some(item), "location").filter(|s| !s.starts_with('{'))),
318                url: sanitize_url(get_str(Some(item), "url")),
319            });
320        }
321        "ConfirmAction" | "ViewAction" | "TrackAction" | "RsvpAction" | "SaveAction" => {
322            let kind = schema_type.replace("Action", "").to_lowercase();
323            data.actions.push(ActionInfo {
324                kind,
325                name: get_str(Some(item), "name").unwrap_or_else(|| schema_type.into()),
326                url: sanitize_url(
327                    get_nested_str(item, &["target", "urlTemplate"])
328                        .or_else(|| get_str(Some(item), "url")),
329                ),
330            });
331        }
332        // recurse into potential actions on other types
333        _ => {}
334    }
335
336    // extract potentialAction from any item
337    if let Some(actions) = item.get("potentialAction") {
338        let action_list = if actions.is_array() {
339            actions.as_array().cloned().unwrap_or_default()
340        } else {
341            vec![actions.clone()]
342        };
343        for action in action_list {
344            let action_type = action.get("@type").and_then(|t| t.as_str()).unwrap_or("");
345            if matches!(
346                action_type,
347                "ConfirmAction" | "ViewAction" | "TrackAction" | "RsvpAction" | "SaveAction"
348            ) {
349                process_jsonld_item(&action, data);
350            }
351        }
352    }
353}
354
355fn get_str(obj: Option<&serde_json::Value>, key: &str) -> Option<String> {
356    obj?.get(key)?.as_str().map(|s| s.to_string())
357}
358
359fn get_nested_str(obj: &serde_json::Value, path: &[&str]) -> Option<String> {
360    let mut current = obj;
361    for &key in path {
362        current = current.get(key)?;
363    }
364    current.as_str().map(|s| s.to_string())
365}
366
367/// filter URL to only allow http/https protocols
368fn sanitize_url(url: Option<String>) -> Option<String> {
369    url.filter(|u| u.starts_with("http://") || u.starts_with("https://"))
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn extract_flight_reservation() {
378        let html = r#"<html><head>
379        <script type="application/ld+json">
380        {
381            "@context": "http://schema.org",
382            "@type": "FlightReservation",
383            "reservationId": "RXJ34P",
384            "reservationStatus": "http://schema.org/ReservationConfirmed",
385            "reservationFor": {
386                "@type": "Flight",
387                "flightNumber": "110",
388                "airline": {"@type": "Airline", "name": "United Airlines", "iataCode": "UA"},
389                "departureAirport": {"@type": "Airport", "name": "San Francisco", "iataCode": "SFO"},
390                "arrivalAirport": {"@type": "Airport", "name": "Los Angeles", "iataCode": "LAX"},
391                "departureTime": "2026-04-01T08:00:00-07:00",
392                "arrivalTime": "2026-04-01T09:30:00-07:00"
393            }
394        }
395        </script></head><body>Your flight is confirmed.</body></html>"#;
396
397        let data = extract_structured_data(html);
398        assert_eq!(data.reservations.len(), 1);
399        let r = &data.reservations[0];
400        assert_eq!(r.kind, "flight");
401        assert_eq!(r.reservation_id.as_deref(), Some("RXJ34P"));
402        assert_eq!(r.departure_airport.as_deref(), Some("SFO"));
403        assert_eq!(r.arrival_airport.as_deref(), Some("LAX"));
404        assert_eq!(r.flight_number.as_deref(), Some("110"));
405    }
406
407    #[test]
408    fn extract_hotel_reservation() {
409        let html = r#"<html><body>
410        <script type="application/ld+json">
411        {
412            "@type": "LodgingReservation",
413            "reservationId": "HTL-789",
414            "checkinTime": "2026-05-01T15:00:00",
415            "checkoutTime": "2026-05-03T11:00:00",
416            "reservationFor": {
417                "@type": "Hotel",
418                "name": "Grand Hyatt Tokyo",
419                "address": {"streetAddress": "6-10-3 Roppongi"}
420            }
421        }
422        </script></body></html>"#;
423
424        let data = extract_structured_data(html);
425        assert_eq!(data.reservations.len(), 1);
426        let r = &data.reservations[0];
427        assert_eq!(r.kind, "hotel");
428        assert_eq!(r.name.as_deref(), Some("Grand Hyatt Tokyo"));
429        assert_eq!(r.start_date.as_deref(), Some("2026-05-01T15:00:00"));
430    }
431
432    #[test]
433    fn extract_order() {
434        let html = r#"<script type="application/ld+json">
435        {
436            "@type": "Order",
437            "orderNumber": "W001234",
438            "orderDate": "2026-03-01",
439            "seller": {"@type": "Organization", "name": "Amazon"},
440            "orderedItem": [
441                {"@type": "Product", "name": "Rust Programming Book", "orderQuantity": 1, "price": "39.99"},
442                {"@type": "Product", "name": "USB-C Cable", "orderQuantity": 2, "price": "9.99"}
443            ],
444            "totalPrice": "59.97",
445            "priceCurrency": "USD"
446        }
447        </script>"#;
448
449        let data = extract_structured_data(html);
450        assert_eq!(data.orders.len(), 1);
451        let o = &data.orders[0];
452        assert_eq!(o.order_number.as_deref(), Some("W001234"));
453        assert_eq!(o.merchant.as_deref(), Some("Amazon"));
454        assert_eq!(o.items.len(), 2);
455        assert_eq!(o.items[0].name, "Rust Programming Book");
456        assert_eq!(o.items[1].quantity, Some(2));
457        assert_eq!(o.total.as_deref(), Some("59.97"));
458        assert_eq!(o.currency.as_deref(), Some("USD"));
459    }
460
461    #[test]
462    fn extract_event() {
463        let html = r#"<script type="application/ld+json">
464        {
465            "@type": "Event",
466            "name": "RustConf 2026",
467            "startDate": "2026-09-10",
468            "endDate": "2026-09-12",
469            "location": {"@type": "Place", "name": "Portland Convention Center"},
470            "url": "https://rustconf.com"
471        }
472        </script>"#;
473
474        let data = extract_structured_data(html);
475        assert_eq!(data.events.len(), 1);
476        let e = &data.events[0];
477        assert_eq!(e.name, "RustConf 2026");
478        assert_eq!(e.location.as_deref(), Some("Portland Convention Center"));
479    }
480
481    #[test]
482    fn extract_potential_action() {
483        let html = r#"<script type="application/ld+json">
484        {
485            "@type": "Order",
486            "orderNumber": "123",
487            "potentialAction": {
488                "@type": "TrackAction",
489                "name": "Track Package",
490                "target": {"urlTemplate": "https://track.example.com/123"}
491            }
492        }
493        </script>"#;
494
495        let data = extract_structured_data(html);
496        assert_eq!(data.orders.len(), 1);
497        assert_eq!(data.actions.len(), 1);
498        assert_eq!(data.actions[0].kind, "track");
499        assert_eq!(
500            data.actions[0].url.as_deref(),
501            Some("https://track.example.com/123")
502        );
503    }
504
505    #[test]
506    fn multiple_jsonld_blocks() {
507        let html = r#"
508        <script type="application/ld+json">{"@type":"Event","name":"Event A","startDate":"2026-01-01"}</script>
509        <p>some text</p>
510        <script type="application/ld+json">{"@type":"Event","name":"Event B","startDate":"2026-02-01"}</script>
511        "#;
512
513        let data = extract_structured_data(html);
514        assert_eq!(data.events.len(), 2);
515        assert_eq!(data.events[0].name, "Event A");
516        assert_eq!(data.events[1].name, "Event B");
517    }
518
519    #[test]
520    fn graph_array() {
521        let html = r#"<script type="application/ld+json">
522        {
523            "@context": "http://schema.org",
524            "@graph": [
525                {"@type": "Event", "name": "Talk 1", "startDate": "2026-01-01"},
526                {"@type": "Event", "name": "Talk 2", "startDate": "2026-01-02"}
527            ]
528        }
529        </script>"#;
530
531        let data = extract_structured_data(html);
532        assert_eq!(data.events.len(), 2);
533    }
534
535    #[test]
536    fn no_jsonld() {
537        let html = "<html><body><p>Just plain HTML</p></body></html>";
538        let data = extract_structured_data(html);
539        assert!(data.is_empty());
540    }
541
542    #[test]
543    fn invalid_json() {
544        let html = r#"<script type="application/ld+json">{not valid json}</script>"#;
545        let data = extract_structured_data(html);
546        assert!(data.is_empty());
547    }
548
549    #[test]
550    fn restaurant_reservation() {
551        let html = r#"<script type="application/ld+json">
552        {
553            "@type": "FoodEstablishmentReservation",
554            "reservationId": "RES-456",
555            "startTime": "2026-04-15T19:00:00",
556            "reservationFor": {
557                "@type": "FoodEstablishment",
558                "name": "Sushi Dai",
559                "address": {"streetAddress": "Tsukiji, Tokyo"}
560            }
561        }
562        </script>"#;
563
564        let data = extract_structured_data(html);
565        assert_eq!(data.reservations.len(), 1);
566        assert_eq!(data.reservations[0].kind, "restaurant");
567        assert_eq!(data.reservations[0].name.as_deref(), Some("Sushi Dai"));
568    }
569
570    #[test]
571    fn rental_car_reservation() {
572        let html = r#"<script type="application/ld+json">
573        {
574            "@type": "RentalCarReservation",
575            "reservationId": "CAR-789",
576            "pickupTime": "2026-06-01T10:00:00",
577            "dropoffTime": "2026-06-05T10:00:00",
578            "pickupLocation": {"@type": "Place", "name": "Narita Airport"},
579            "provider": {"@type": "Organization", "name": "Toyota Rent a Car"},
580            "reservationFor": {"@type": "RentalCar", "name": "Toyota Corolla"}
581        }
582        </script>"#;
583
584        let data = extract_structured_data(html);
585        assert_eq!(data.reservations.len(), 1);
586        let r = &data.reservations[0];
587        assert_eq!(r.kind, "rental_car");
588        assert_eq!(r.location.as_deref(), Some("Narita Airport"));
589        assert_eq!(r.provider.as_deref(), Some("Toyota Rent a Car"));
590    }
591}
592
593#[cfg(test)]
594mod boundary_tests {
595    use super::*;
596
597    #[test]
598    fn empty_html_returns_empty() {
599        assert!(extract_structured_data("").is_empty());
600    }
601
602    #[test]
603    fn jsonld_with_only_whitespace_inside_ignored() {
604        let html = r#"<script type="application/ld+json">    </script>"#;
605        assert!(extract_structured_data(html).is_empty());
606    }
607
608    #[test]
609    fn ignores_non_jsonld_script_tags() {
610        let html = r#"<script type="text/javascript">var x = 1;</script>"#;
611        assert!(extract_structured_data(html).is_empty());
612    }
613
614    #[test]
615    fn mixed_jsonld_and_html_content() {
616        let html = r#"<html><body><p>Hi</p>
617        <script type="application/ld+json">{"@type":"Order","orderNumber":"X","seller":{"name":"S"}}</script>
618        <p>Bye</p></body></html>"#;
619        let data = extract_structured_data(html);
620        assert_eq!(data.orders.len(), 1);
621    }
622
623    #[test]
624    fn jsonld_uppercase_type_attribute() {
625        // Some emails use TYPE= instead of type=. Our matcher lowercases
626        // the search string, so this should still work.
627        let html = r#"<script TYPE="APPLICATION/LD+JSON">{"@type":"Event","name":"E","startDate":"2026-01-01"}</script>"#;
628        let data = extract_structured_data(html);
629        assert_eq!(data.events.len(), 1);
630    }
631
632    #[test]
633    fn rejects_malformed_jsonld() {
634        let html = r#"<script type="application/ld+json">{not "valid": json,,,}</script>"#;
635        assert!(extract_structured_data(html).is_empty());
636    }
637
638    #[test]
639    fn event_without_name_still_extracted_with_empty_name() {
640        let html = r#"<script type="application/ld+json">{"@type":"Event","startDate":"2026-09-10"}</script>"#;
641        let data = extract_structured_data(html);
642        assert_eq!(data.events.len(), 1);
643        assert_eq!(data.events[0].name, "");
644    }
645
646    #[test]
647    fn order_with_empty_items_list() {
648        let html = r#"<script type="application/ld+json">{"@type":"Order","orderNumber":"X","orderedItem":[]}</script>"#;
649        let data = extract_structured_data(html);
650        assert_eq!(data.orders.len(), 1);
651        assert!(data.orders[0].items.is_empty());
652    }
653
654    #[test]
655    fn potential_action_unknown_type_skipped() {
656        let html = r#"<script type="application/ld+json">{"@type":"Order","orderNumber":"X","potentialAction":{"@type":"WhatIsThisAction","name":"???"}}</script>"#;
657        let data = extract_structured_data(html);
658        assert_eq!(data.actions.len(), 0);
659    }
660
661    #[test]
662    fn url_with_javascript_scheme_filtered_out() {
663        let html = r#"<script type="application/ld+json">{"@type":"Event","name":"E","startDate":"2026-01-01","url":"javascript:alert(1)"}</script>"#;
664        let data = extract_structured_data(html);
665        assert!(data.events[0].url.is_none());
666    }
667
668    #[test]
669    fn url_data_scheme_filtered_out() {
670        let html = r#"<script type="application/ld+json">{"@type":"Event","name":"E","startDate":"2026-01-01","url":"data:text/html,evil"}</script>"#;
671        let data = extract_structured_data(html);
672        assert!(data.events[0].url.is_none());
673    }
674
675    #[test]
676    fn url_http_preserved() {
677        let html = r#"<script type="application/ld+json">{"@type":"Event","name":"E","startDate":"2026-01-01","url":"http://insecure.example.com"}</script>"#;
678        let data = extract_structured_data(html);
679        assert_eq!(
680            data.events[0].url.as_deref(),
681            Some("http://insecure.example.com")
682        );
683    }
684
685    #[test]
686    fn missing_optional_fields_are_none() {
687        let html = r#"<script type="application/ld+json">{"@type":"FlightReservation"}</script>"#;
688        let data = extract_structured_data(html);
689        assert_eq!(data.reservations.len(), 1);
690        let r = &data.reservations[0];
691        assert!(r.reservation_id.is_none());
692        assert!(r.flight_number.is_none());
693        assert!(r.departure_airport.is_none());
694    }
695
696    #[test]
697    fn order_status_strips_schema_url() {
698        let html = r#"<script type="application/ld+json">{"@type":"Order","orderNumber":"X","orderStatus":"http://schema.org/OrderProcessing"}</script>"#;
699        let data = extract_structured_data(html);
700        assert_eq!(data.orders[0].status.as_deref(), Some("Processing"));
701    }
702
703    #[test]
704    fn is_empty_returns_true_for_default() {
705        let data = StructuredData::default();
706        assert!(data.is_empty());
707    }
708
709    #[test]
710    fn is_empty_false_when_anything_present() {
711        let mut data = StructuredData::default();
712        data.events.push(EventInfo {
713            name: "x".into(),
714            start_date: None,
715            end_date: None,
716            location: None,
717            url: None,
718        });
719        assert!(!data.is_empty());
720    }
721}