Skip to main content

synap_sdk/
geospatial.rs

1//! Geospatial operations (GEOADD/GEODIST/GEORADIUS/GEOPOS/GEOHASH)
2
3use crate::client::SynapClient;
4use crate::error::Result;
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7
8/// Distance unit
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DistanceUnit {
11    Meters,
12    Kilometers,
13    Miles,
14    Feet,
15}
16
17impl DistanceUnit {
18    fn as_str(&self) -> &'static str {
19        match self {
20            DistanceUnit::Meters => "m",
21            DistanceUnit::Kilometers => "km",
22            DistanceUnit::Miles => "mi",
23            DistanceUnit::Feet => "ft",
24        }
25    }
26}
27
28/// Location coordinate
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Location {
31    pub lat: f64,
32    pub lon: f64,
33    pub member: String,
34}
35
36/// Coordinate
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Coordinate {
39    pub lat: f64,
40    pub lon: f64,
41}
42
43/// Georadius result
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GeoradiusResult {
46    pub member: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub distance: Option<f64>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub coord: Option<Coordinate>,
51}
52
53/// Geospatial statistics
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct GeospatialStats {
56    pub total_keys: usize,
57    pub total_locations: usize,
58    pub geoadd_count: usize,
59    pub geodist_count: usize,
60    pub georadius_count: usize,
61    pub geopos_count: usize,
62    pub geohash_count: usize,
63}
64
65#[derive(Clone)]
66pub struct GeospatialManager {
67    client: SynapClient,
68}
69
70impl GeospatialManager {
71    pub(crate) fn new(client: SynapClient) -> Self {
72        Self { client }
73    }
74
75    /// Add geospatial locations (GEOADD)
76    ///
77    /// # Arguments
78    ///
79    /// * `key` - Geospatial key
80    /// * `locations` - Array of locations (lat, lon, member)
81    /// * `nx` - Only add new elements (don't update existing)
82    /// * `xx` - Only update existing elements (don't add new)
83    /// * `ch` - Return count of changed elements
84    ///
85    /// # Returns
86    ///
87    /// Number of elements added
88    pub async fn geoadd(
89        &self,
90        key: &str,
91        locations: Vec<Location>,
92        nx: bool,
93        xx: bool,
94        ch: bool,
95    ) -> Result<usize> {
96        // Validate coordinates
97        for loc in &locations {
98            if !(-90.0..=90.0).contains(&loc.lat) {
99                return Err(crate::error::SynapError::ServerError(format!(
100                    "Latitude must be between -90 and 90, got: {}",
101                    loc.lat
102                )));
103            }
104            if !(-180.0..=180.0).contains(&loc.lon) {
105                return Err(crate::error::SynapError::ServerError(format!(
106                    "Longitude must be between -180 and 180, got: {}",
107                    loc.lon
108                )));
109            }
110        }
111
112        let payload = json!({
113            "key": key,
114            "locations": locations,
115            "nx": nx,
116            "xx": xx,
117            "ch": ch,
118        });
119
120        let response = self
121            .client
122            .send_command("geospatial.geoadd", payload)
123            .await?;
124        Ok(response["added"].as_u64().unwrap_or(0) as usize)
125    }
126
127    /// Calculate distance between two members (GEODIST)
128    ///
129    /// # Arguments
130    ///
131    /// * `key` - Geospatial key
132    /// * `member1` - First member
133    /// * `member2` - Second member
134    /// * `unit` - Distance unit
135    ///
136    /// # Returns
137    ///
138    /// Distance in specified unit, or None if either member doesn't exist
139    pub async fn geodist(
140        &self,
141        key: &str,
142        member1: &str,
143        member2: &str,
144        unit: DistanceUnit,
145    ) -> Result<Option<f64>> {
146        let payload = json!({
147            "key": key,
148            "member1": member1,
149            "member2": member2,
150            "unit": unit.as_str(),
151        });
152
153        let response = self
154            .client
155            .send_command("geospatial.geodist", payload)
156            .await?;
157        let distance = response["distance"].as_f64();
158        Ok(distance)
159    }
160
161    /// Query members within radius (GEORADIUS)
162    ///
163    /// # Arguments
164    ///
165    /// * `key` - Geospatial key
166    /// * `center_lat` - Center latitude
167    /// * `center_lon` - Center longitude
168    /// * `radius` - Radius
169    /// * `unit` - Distance unit
170    /// * `with_dist` - Include distance in results
171    /// * `with_coord` - Include coordinates in results
172    /// * `count` - Maximum number of results
173    /// * `sort` - Sort order ("ASC" or "DESC")
174    ///
175    /// # Returns
176    ///
177    /// Vector of matching members with optional distance and coordinates
178    #[allow(clippy::too_many_arguments)]
179    pub async fn georadius(
180        &self,
181        key: &str,
182        center_lat: f64,
183        center_lon: f64,
184        radius: f64,
185        unit: DistanceUnit,
186        with_dist: bool,
187        with_coord: bool,
188        count: Option<usize>,
189        sort: Option<&str>,
190    ) -> Result<Vec<GeoradiusResult>> {
191        if !(-90.0..=90.0).contains(&center_lat) {
192            return Err(crate::error::SynapError::ServerError(format!(
193                "Latitude must be between -90 and 90, got: {}",
194                center_lat
195            )));
196        }
197        if !(-180.0..=180.0).contains(&center_lon) {
198            return Err(crate::error::SynapError::ServerError(format!(
199                "Longitude must be between -180 and 180, got: {}",
200                center_lon
201            )));
202        }
203
204        let mut payload = json!({
205            "key": key,
206            "center_lat": center_lat,
207            "center_lon": center_lon,
208            "radius": radius,
209            "unit": unit.as_str(),
210            "with_dist": with_dist,
211            "with_coord": with_coord,
212        });
213
214        if let Some(c) = count {
215            payload["count"] = json!(c);
216        }
217        if let Some(s) = sort {
218            payload["sort"] = json!(s);
219        }
220
221        let response = self
222            .client
223            .send_command("geospatial.georadius", payload)
224            .await?;
225        let results: Vec<GeoradiusResult> =
226            serde_json::from_value(response["results"].clone()).unwrap_or_default();
227        Ok(results)
228    }
229
230    /// Query members within radius of given member (GEORADIUSBYMEMBER)
231    ///
232    /// # Arguments
233    ///
234    /// * `key` - Geospatial key
235    /// * `member` - Center member
236    /// * `radius` - Radius
237    /// * `unit` - Distance unit
238    /// * `with_dist` - Include distance in results
239    /// * `with_coord` - Include coordinates in results
240    /// * `count` - Maximum number of results
241    /// * `sort` - Sort order ("ASC" or "DESC")
242    ///
243    /// # Returns
244    ///
245    /// Vector of matching members with optional distance and coordinates
246    #[allow(clippy::too_many_arguments)]
247    pub async fn georadiusbymember(
248        &self,
249        key: &str,
250        member: &str,
251        radius: f64,
252        unit: DistanceUnit,
253        with_dist: bool,
254        with_coord: bool,
255        count: Option<usize>,
256        sort: Option<&str>,
257    ) -> Result<Vec<GeoradiusResult>> {
258        let mut payload = json!({
259            "key": key,
260            "member": member,
261            "radius": radius,
262            "unit": unit.as_str(),
263            "with_dist": with_dist,
264            "with_coord": with_coord,
265        });
266
267        if let Some(c) = count {
268            payload["count"] = json!(c);
269        }
270        if let Some(s) = sort {
271            payload["sort"] = json!(s);
272        }
273
274        let response = self
275            .client
276            .send_command("geospatial.georadiusbymember", payload)
277            .await?;
278        let results: Vec<GeoradiusResult> =
279            serde_json::from_value(response["results"].clone()).unwrap_or_default();
280        Ok(results)
281    }
282
283    /// Get coordinates of members (GEOPOS)
284    ///
285    /// # Arguments
286    ///
287    /// * `key` - Geospatial key
288    /// * `members` - Array of member names
289    ///
290    /// # Returns
291    ///
292    /// Vector of coordinates (None if member doesn't exist)
293    pub async fn geopos(&self, key: &str, members: &[String]) -> Result<Vec<Option<Coordinate>>> {
294        let payload = json!({
295            "key": key,
296            "members": members,
297        });
298
299        let response = self
300            .client
301            .send_command("geospatial.geopos", payload)
302            .await?;
303        let coords: Vec<Option<Coordinate>> =
304            serde_json::from_value(response["coordinates"].clone()).unwrap_or_default();
305        Ok(coords)
306    }
307
308    /// Advanced geospatial search (GEOSEARCH)
309    ///
310    /// # Arguments
311    ///
312    /// * `key` - Geospatial key
313    /// * `from_member` - Center member (mutually exclusive with from_lonlat)
314    /// * `from_lonlat` - Center coordinates as (lon, lat) tuple (mutually exclusive with from_member)
315    /// * `by_radius` - Search by radius as (radius, unit) tuple
316    /// * `by_box` - Search by bounding box as (width, height, unit) tuple
317    /// * `with_dist` - Include distance in results
318    /// * `with_coord` - Include coordinates in results
319    /// * `with_hash` - Include geohash in results (not yet implemented)
320    /// * `count` - Maximum number of results
321    /// * `sort` - Sort order ("ASC" or "DESC")
322    ///
323    /// # Returns
324    ///
325    /// Vector of matching members with optional distance and coordinates
326    #[allow(clippy::too_many_arguments)]
327    pub async fn geosearch(
328        &self,
329        key: &str,
330        from_member: Option<&str>,
331        from_lonlat: Option<(f64, f64)>,
332        by_radius: Option<(f64, DistanceUnit)>,
333        by_box: Option<(f64, f64, DistanceUnit)>,
334        with_dist: bool,
335        with_coord: bool,
336        with_hash: bool,
337        count: Option<usize>,
338        sort: Option<&str>,
339    ) -> Result<Vec<GeoradiusResult>> {
340        if from_member.is_none() && from_lonlat.is_none() {
341            return Err(crate::error::SynapError::ServerError(
342                "Either 'from_member' or 'from_lonlat' must be provided".to_string(),
343            ));
344        }
345        if by_radius.is_none() && by_box.is_none() {
346            return Err(crate::error::SynapError::ServerError(
347                "Either 'by_radius' or 'by_box' must be provided".to_string(),
348            ));
349        }
350
351        let mut payload = json!({
352            "key": key,
353            "with_dist": with_dist,
354            "with_coord": with_coord,
355            "with_hash": with_hash,
356        });
357
358        if let Some(member) = from_member {
359            payload["from_member"] = json!(member);
360        }
361        if let Some((lon, lat)) = from_lonlat {
362            payload["from_lonlat"] = json!([lon, lat]);
363        }
364        if let Some((radius, unit)) = by_radius {
365            payload["by_radius"] = json!([radius, unit.as_str()]);
366        }
367        if let Some((width, height, unit)) = by_box {
368            payload["by_box"] = json!([width, height, unit.as_str()]);
369        }
370        if let Some(c) = count {
371            payload["count"] = json!(c);
372        }
373        if let Some(s) = sort {
374            payload["sort"] = json!(s);
375        }
376
377        let response = self
378            .client
379            .send_command("geospatial.geosearch", payload)
380            .await?;
381        let results: Vec<GeoradiusResult> =
382            serde_json::from_value(response["results"].clone()).unwrap_or_default();
383        Ok(results)
384    }
385
386    /// Get geohash strings for members (GEOHASH)
387    ///
388    /// # Arguments
389    ///
390    /// * `key` - Geospatial key
391    /// * `members` - Array of member names
392    ///
393    /// # Returns
394    ///
395    /// Vector of geohash strings (None if member doesn't exist)
396    pub async fn geohash(&self, key: &str, members: &[String]) -> Result<Vec<Option<String>>> {
397        let payload = json!({
398            "key": key,
399            "members": members,
400        });
401
402        let response = self
403            .client
404            .send_command("geospatial.geohash", payload)
405            .await?;
406        let geohashes: Vec<Option<String>> =
407            serde_json::from_value(response["geohashes"].clone()).unwrap_or_default();
408        Ok(geohashes)
409    }
410
411    /// Retrieve geospatial statistics
412    pub async fn stats(&self) -> Result<GeospatialStats> {
413        let payload = json!({});
414        let response = self
415            .client
416            .send_command("geospatial.stats", payload)
417            .await?;
418        let stats: GeospatialStats = serde_json::from_value(response).unwrap_or_default();
419        Ok(stats)
420    }
421}