use anyhow::Result;
use chrono::NaiveDate;
use serde_json::Value;
use std::collections::HashMap;
use crate::parsers::common::{
decode_inner_object, decode_outer_object, get_idx, GetOuterErrorMessages,
};
use crate::requests::config::explore::ExploreResult;
#[derive(Debug)]
pub(crate) struct ExploreRawChunk {
pub payload: Option<String>,
}
impl<'de> serde::Deserialize<'de> for ExploreRawChunk {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let outer = Vec::<serde_json::Value>::deserialize(d)?;
let wrb_entry: Vec<serde_json::Value> = crate::parsers::common::get_idx(&outer, 0)
.ok_or_else(|| serde::de::Error::custom("missing wrb.fr entry at [0]"))?;
let payload: Option<String> = crate::parsers::common::get_idx(&wrb_entry, 2);
Ok(ExploreRawChunk { payload })
}
}
impl GetOuterErrorMessages for ExploreRawChunk {
fn get_error_messages(&self) -> Option<Vec<String>> {
None
}
}
pub fn parse_explore_response(raw: &str) -> Result<Vec<ExploreResult>> {
let chunks: Vec<ExploreRawChunk> = match decode_outer_object(raw) {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "explore: failed to decode outer object");
return Ok(vec![]);
}
};
if chunks.is_empty() {
tracing::debug!("explore: no chunks found");
return Ok(vec![]);
}
let mut destinations: HashMap<String, ExploreResult> = HashMap::new();
for chunk in &chunks {
let payload = match chunk.payload.as_deref() {
Some(p) if !p.is_empty() => p,
_ => continue,
};
let arr: Vec<Value> = match decode_inner_object(payload) {
Ok(a) => a,
Err(e) => {
tracing::debug!(error = %e, "explore: failed to decode inner payload");
continue;
}
};
if let Some(outer) = arr.get(3).and_then(|v| v.as_array()) {
if let Some(dest_list) = outer.first().and_then(|v| v.as_array()) {
for entry in dest_list {
if let Ok(dest) = parse_destination_entry(entry.clone()) {
destinations.entry(dest.place_id.clone()).or_insert(dest);
}
}
}
}
if let Some(detail_list) = arr
.get(4)
.and_then(|v| v.as_array())
.and_then(|outer| outer.first())
.and_then(|v| v.as_array())
{
for entry in detail_list {
let entry_arr = match entry.as_array() {
Some(a) => a,
None => continue,
};
let place_id: String = match get_idx(entry_arr, 0) {
Some(s) => s,
None => continue,
};
let dest = match destinations.get_mut(&place_id) {
Some(d) => d,
None => continue,
};
if let Some(price_info) = entry_arr.get(1).and_then(|v| v.as_array()) {
if let Some(price_pair) = price_info.first().and_then(|v| v.as_array()) {
dest.price = get_idx(price_pair, 1);
}
if let Some(tok) = get_idx::<String>(price_info, 1) {
dest.booking_token = tok;
}
}
if let Some(flight_detail) = get_idx::<Vec<Value>>(entry_arr, 6) {
dest.airline = get_idx(&flight_detail, 0);
if let Some(s) = get_idx::<i64>(&flight_detail, 2) {
dest.stops = Some(s.clamp(0, 255) as u8);
}
if let Some(d) = get_idx::<i64>(&flight_detail, 3) {
dest.flight_duration_minutes = Some(d.max(0) as u32);
}
dest.flight_airport = get_idx(&flight_detail, 5);
}
if let Some(acc_outer) = get_idx::<Vec<Value>>(entry_arr, 15) {
if let Some(acc_pair) = acc_outer.first().and_then(|v| v.as_array()) {
dest.accommodation_price = get_idx(acc_pair, 1);
}
}
}
}
}
Ok(destinations.into_values().collect())
}
fn parse_destination_entry(v: Value) -> Result<ExploreResult> {
let arr = match v {
Value::Array(a) => a,
_ => anyhow::bail!("destination entry is not an array"),
};
let place_id: String = get_idx(&arr, 0).unwrap_or_default();
if place_id.is_empty() {
anyhow::bail!("destination entry has empty place_id");
}
let coords_arr: Vec<f64> = get_idx(&arr, 1).unwrap_or_default();
let coords = (
coords_arr.first().copied().unwrap_or(0.0),
coords_arr.get(1).copied().unwrap_or(0.0),
);
let name: String = get_idx(&arr, 2).unwrap_or_default();
let image_url: Option<String> = get_idx(&arr, 3);
let country: String = get_idx(&arr, 4).unwrap_or_default();
let nearest_airport: String = get_idx(&arr, 15).unwrap_or_default();
let date_from: Option<NaiveDate> = get_idx::<String>(&arr, 11)
.as_deref()
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
let date_to: Option<NaiveDate> = get_idx::<String>(&arr, 12)
.as_deref()
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
Ok(ExploreResult {
place_id,
name,
country,
coords,
image_url,
nearest_airport,
flight_airport: None, date_from,
date_to,
price: None,
airline: None,
stops: None,
flight_duration_minutes: None,
accommodation_price: None,
booking_token: String::new(), })
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parse_empty_response_returns_empty_vec() {
let raw = ")]}'
\n
[[\"noop\"]]
\n
";
let results = parse_explore_response(raw).unwrap();
assert!(results.is_empty());
}
#[test]
fn parse_destination_entry_happy_path() {
let mut v = vec![serde_json::Value::Null; 29];
v[0] = serde_json::json!("/m/0vzm");
v[1] = serde_json::json!([48.2082, 16.3738]);
v[2] = serde_json::json!("Vienna");
v[3] = serde_json::json!("https://example.com/img.jpg");
v[4] = serde_json::json!("Austria");
v[11] = serde_json::json!("2026-08-01");
v[12] = serde_json::json!("2026-08-08");
v[15] = serde_json::json!("VIE");
v[17] = serde_json::json!(120);
v[22] = serde_json::json!(0);
let result = parse_destination_entry(Value::Array(v)).unwrap();
assert_eq!(result.place_id, "/m/0vzm");
assert_eq!(result.name, "Vienna");
assert_eq!(result.country, "Austria");
assert!((result.coords.0 - 48.2082).abs() < 1e-3);
assert_eq!(result.nearest_airport, "VIE");
assert_eq!(result.flight_duration_minutes, None); assert_eq!(result.stops, None); assert!(result.date_from.is_some());
assert!(result.date_to.is_some());
}
#[test]
fn parse_destination_entry_empty_place_id_errors() {
let entry = serde_json::json!(["", "Paris"]);
assert!(parse_destination_entry(entry).is_err());
}
}