Skip to main content

everymap_core/domains/
search.rs

1use crate::error::EveryMapResult;
2use crate::types::{Address, BoundingBox, Coordinate};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6/// Options for forward geocoding (address → coordinate).
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct GeocodeOptions {
9    /// Maximum number of results to return
10    pub limit: Option<u32>,
11    /// Preferred response language (BCP 47 language tag, e.g., "en-US")
12    pub language: Option<String>,
13    /// Restrict results to these country codes (ISO 3166-1 alpha-3)
14    pub country_codes: Vec<String>,
15    /// Restrict search to this bounding box
16    pub bounding_box: Option<BoundingBox>,
17    /// Provider-specific options (HERE: political_view, show, qq; Google: region, components)
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub provider_extra: Option<serde_json::Value>,
20}
21
22/// Options for reverse geocoding (coordinate → address).
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct ReverseGeocodeOptions {
25    /// Maximum number of results to return
26    pub limit: Option<u32>,
27    /// Preferred response language (BCP 47 language tag)
28    pub language: Option<String>,
29    /// Search radius in meters
30    pub radius: Option<f64>,
31    /// Provider-specific options
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub provider_extra: Option<serde_json::Value>,
34}
35
36/// Classification of a search result's match quality.
37#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
38pub enum SearchResultType {
39    /// Exact match (address matched precisely)
40    ExactMatch,
41    /// Approximate match (close but not exact)
42    Approximate,
43    /// Interpolated match (estimated from surrounding data)
44    Interpolated,
45    /// Unknown or provider-specific type
46    Unknown,
47}
48
49/// A unified search result returned by the core trait.
50///
51/// Contains structured data that all providers can populate, plus an optional
52/// `raw` field carrying provider-specific JSON for power users who need more detail.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SearchResult {
55    /// Unique identifier for this result (provider-specific)
56    pub id: Option<String>,
57    /// Geographic position of the result
58    pub coordinate: Coordinate,
59    /// Structured address components
60    pub address: Address,
61    /// Display title (e.g., "Brandenburg Gate")
62    pub title: Option<String>,
63    /// Classification of match quality
64    pub result_type: SearchResultType,
65    /// Distance from the search origin in meters (if applicable)
66    pub distance: Option<f64>,
67    /// Confidence score (0.0-1.0, higher = more confident)
68    pub confidence: Option<f64>,
69    /// POI categories (e.g., "restaurant", "hotel")
70    pub categories: Vec<String>,
71    /// Bounding box for the result (if available)
72    pub bounding_box: Option<BoundingBox>,
73    /// Provider-specific raw data for advanced use cases
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub raw: Option<serde_json::Value>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SearchResponse {
80    pub items: Vec<SearchResult>,
81}
82
83/// Core trait for geocoding and search providers.
84///
85/// This trait covers the portable search domain:
86/// - Forward geocoding (address → coordinates)
87/// - Reverse geocoding (coordinates → address)
88///
89/// Provider-specific capabilities (e.g., HERE's `discover` and `autosuggest`)
90/// are available through extension traits in provider crates.
91#[async_trait]
92pub trait Geocoder: Send + Sync {
93    async fn geocode(
94        &self,
95        query: &str,
96        options: &GeocodeOptions,
97    ) -> EveryMapResult<SearchResponse>;
98    async fn reverse_geocode(
99        &self,
100        coordinate: &Coordinate,
101        options: &ReverseGeocodeOptions,
102    ) -> EveryMapResult<SearchResponse>;
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_geocode_options_default() {
111        let options = GeocodeOptions::default();
112        assert!(options.limit.is_none());
113        assert!(options.language.is_none());
114        assert!(options.country_codes.is_empty());
115        assert!(options.bounding_box.is_none());
116        assert!(options.provider_extra.is_none());
117    }
118
119    #[test]
120    fn test_geocode_options_with_fields() {
121        let options = GeocodeOptions {
122            limit: Some(10),
123            language: Some("en-US".to_string()),
124            country_codes: vec!["DEU".to_string(), "FRA".to_string()],
125            provider_extra: Some(serde_json::json!({"political_view": "ARG"})),
126            ..Default::default()
127        };
128        assert_eq!(options.limit, Some(10));
129        assert_eq!(options.language.as_deref(), Some("en-US"));
130        assert_eq!(options.country_codes.len(), 2);
131        assert!(options.provider_extra.is_some());
132    }
133
134    #[test]
135    fn test_geocode_options_provider_extra_json() {
136        let options = GeocodeOptions {
137            provider_extra: Some(serde_json::json!({
138                "show": ["streetInfo", "mapReference"],
139                "qq": "city=Berlin"
140            })),
141            ..Default::default()
142        };
143        let extra = options.provider_extra.unwrap();
144        assert!(extra.get("show").is_some());
145        assert_eq!(extra["qq"], "city=Berlin");
146    }
147
148    #[test]
149    fn test_reverse_geocode_options_default() {
150        let options = ReverseGeocodeOptions::default();
151        assert!(options.limit.is_none());
152        assert!(options.language.is_none());
153        assert!(options.radius.is_none());
154        assert!(options.provider_extra.is_none());
155    }
156
157    #[test]
158    fn test_search_result_type_serialization() {
159        assert_eq!(
160            serde_json::to_string(&SearchResultType::ExactMatch).unwrap(),
161            "\"ExactMatch\""
162        );
163        assert_eq!(
164            serde_json::to_string(&SearchResultType::Unknown).unwrap(),
165            "\"Unknown\""
166        );
167        let rt: SearchResultType = serde_json::from_str("\"Approximate\"").unwrap();
168        assert_eq!(rt, SearchResultType::Approximate);
169    }
170
171    #[test]
172    fn test_search_result_construction() {
173        let result = SearchResult {
174            id: Some("test-id".to_string()),
175            coordinate: Coordinate::new(52.5, 13.4).unwrap(),
176            address: Address::empty(),
177            title: Some("Test Place".to_string()),
178            result_type: SearchResultType::ExactMatch,
179            distance: Some(150.0),
180            confidence: Some(0.95),
181            categories: vec!["restaurant".to_string()],
182            bounding_box: None,
183            raw: Some(serde_json::json!({"provider_specific": true})),
184        };
185        assert_eq!(result.id.as_deref(), Some("test-id"));
186        assert_eq!(result.coordinate.lat, 52.5);
187        assert_eq!(result.result_type, SearchResultType::ExactMatch);
188        assert!(result.raw.is_some());
189    }
190
191    #[test]
192    fn test_search_response_serialization_roundtrip() {
193        let response = SearchResponse {
194            items: vec![SearchResult {
195                id: Some("abc".to_string()),
196                coordinate: Coordinate::new(1.0, 2.0).unwrap(),
197                address: Address::empty(),
198                title: None,
199                result_type: SearchResultType::Unknown,
200                distance: None,
201                confidence: None,
202                categories: vec![],
203                bounding_box: None,
204                raw: None,
205            }],
206        };
207        let json = serde_json::to_string(&response).unwrap();
208        let deserialized: SearchResponse = serde_json::from_str(&json).unwrap();
209        assert_eq!(deserialized.items.len(), 1);
210        assert_eq!(deserialized.items[0].id.as_deref(), Some("abc"));
211    }
212
213    // --- SearchResultType all variants serde roundtrip ---
214
215    #[test]
216    fn test_search_result_type_all_variants_serde() {
217        let variants = [
218            SearchResultType::ExactMatch,
219            SearchResultType::Approximate,
220            SearchResultType::Interpolated,
221            SearchResultType::Unknown,
222        ];
223        for v in &variants {
224            let json = serde_json::to_string(v).unwrap();
225            let back: SearchResultType = serde_json::from_str(&json).unwrap();
226            assert_eq!(*v, back, "Failed roundtrip for {:?}", v);
227        }
228    }
229
230    #[test]
231    fn test_search_result_type_all_variants_distinct() {
232        let variants = [
233            SearchResultType::ExactMatch,
234            SearchResultType::Approximate,
235            SearchResultType::Interpolated,
236            SearchResultType::Unknown,
237        ];
238        for i in 0..variants.len() {
239            for j in 0..variants.len() {
240                if i != j {
241                    assert_ne!(variants[i], variants[j]);
242                }
243            }
244        }
245    }
246
247    // --- SearchResponse serde roundtrip (empty) ---
248
249    #[test]
250    fn test_search_response_empty_serde_roundtrip() {
251        let response = SearchResponse { items: vec![] };
252        let json = serde_json::to_string(&response).unwrap();
253        let back: SearchResponse = serde_json::from_str(&json).unwrap();
254        assert!(back.items.is_empty());
255    }
256
257    // --- SearchResponse serde roundtrip with full fields ---
258
259    #[test]
260    fn test_search_response_full_serde_roundtrip() {
261        let ne = crate::types::BoundingBox::new(
262            Coordinate::new(52.6, 13.5).unwrap(),
263            Coordinate::new(52.4, 13.3).unwrap(),
264        );
265        let response = SearchResponse {
266            items: vec![SearchResult {
267                id: Some("id-123".to_string()),
268                coordinate: Coordinate::new(52.52, 13.40).unwrap(),
269                address: Address::from_label("Brandenburg Gate".to_string()),
270                title: Some("Brandenburg Gate".to_string()),
271                result_type: SearchResultType::ExactMatch,
272                distance: Some(250.0),
273                confidence: Some(0.98),
274                categories: vec!["monument".to_string(), "landmark".to_string()],
275                bounding_box: Some(ne),
276                raw: Some(serde_json::json!({"source": "here"})),
277            }],
278        };
279        let json = serde_json::to_string(&response).unwrap();
280        let back: SearchResponse = serde_json::from_str(&json).unwrap();
281        assert_eq!(back.items.len(), 1);
282        assert_eq!(back.items[0].id.as_deref(), Some("id-123"));
283        assert_eq!(back.items[0].title.as_deref(), Some("Brandenburg Gate"));
284        assert_eq!(back.items[0].result_type, SearchResultType::ExactMatch);
285        assert_eq!(back.items[0].distance, Some(250.0));
286        assert_eq!(back.items[0].confidence, Some(0.98));
287        assert_eq!(back.items[0].categories.len(), 2);
288        assert!(back.items[0].bounding_box.is_some());
289        assert!(back.items[0].raw.is_some());
290    }
291
292    // --- GeocodeOptions with provider_extra serde roundtrip ---
293
294    #[test]
295    fn test_geocode_options_serde_roundtrip() {
296        let options = GeocodeOptions {
297            limit: Some(5),
298            language: Some("de-DE".to_string()),
299            country_codes: vec!["DEU".to_string()],
300            bounding_box: Some(crate::types::BoundingBox::new(
301                Coordinate::new(52.6, 13.5).unwrap(),
302                Coordinate::new(52.4, 13.3).unwrap(),
303            )),
304            provider_extra: Some(serde_json::json!({"political_view": "ARG"})),
305        };
306        let json = serde_json::to_string(&options).unwrap();
307        let back: GeocodeOptions = serde_json::from_str(&json).unwrap();
308        assert_eq!(back.limit, Some(5));
309        assert_eq!(back.language.as_deref(), Some("de-DE"));
310        assert_eq!(back.country_codes.len(), 1);
311        assert!(back.bounding_box.is_some());
312        assert!(back.provider_extra.is_some());
313    }
314
315    // --- ReverseGeocodeOptions serde roundtrip ---
316
317    #[test]
318    fn test_reverse_geocode_options_serde_roundtrip() {
319        let options = ReverseGeocodeOptions {
320            limit: Some(1),
321            language: Some("fr".to_string()),
322            radius: Some(500.0),
323            provider_extra: Some(serde_json::json!({"include_shapes": true})),
324        };
325        let json = serde_json::to_string(&options).unwrap();
326        let back: ReverseGeocodeOptions = serde_json::from_str(&json).unwrap();
327        assert_eq!(back.limit, Some(1));
328        assert_eq!(back.radius, Some(500.0));
329        assert!(back.provider_extra.is_some());
330    }
331
332    // --- Edge cases ---
333
334    #[test]
335    fn test_search_result_zero_distance() {
336        let result = SearchResult {
337            id: None,
338            coordinate: Coordinate::ORIGIN,
339            address: Address::empty(),
340            title: None,
341            result_type: SearchResultType::Unknown,
342            distance: Some(0.0),
343            confidence: Some(0.0),
344            categories: vec![],
345            bounding_box: None,
346            raw: None,
347        };
348        assert_eq!(result.distance, Some(0.0));
349        assert_eq!(result.confidence, Some(0.0));
350    }
351
352    #[test]
353    fn test_search_result_large_coordinates() {
354        let result = SearchResult {
355            id: None,
356            coordinate: Coordinate::new(89.9999, 179.9999).unwrap(),
357            address: Address::empty(),
358            title: None,
359            result_type: SearchResultType::Unknown,
360            distance: None,
361            confidence: None,
362            categories: vec![],
363            bounding_box: None,
364            raw: None,
365        };
366        assert!((result.coordinate.lat - 89.9999).abs() < f64::EPSILON);
367    }
368
369    #[test]
370    fn test_search_result_with_empty_categories() {
371        let result = SearchResult {
372            id: Some("x".to_string()),
373            coordinate: Coordinate::ORIGIN,
374            address: Address::empty(),
375            title: None,
376            result_type: SearchResultType::Approximate,
377            distance: None,
378            confidence: None,
379            categories: vec![],
380            bounding_box: None,
381            raw: None,
382        };
383        assert!(result.categories.is_empty());
384    }
385}