Skip to main content

sl_map_apis/
region.rs

1//! Contains functionality related to converting region names to grid coordinates and vice versa
2use redb::ReadableDatabase as _;
3use sl_types::map::{GridCoordinates, GridRectangle, RegionName, RegionNameError, USBNotecard};
4
5/// Represents the possible errors that can occur when converting a region name to grid coordinates
6#[expect(
7    clippy::module_name_repetitions,
8    reason = "the type is going to be used outside the module"
9)]
10#[derive(Debug, thiserror::Error)]
11pub enum RegionNameToGridCoordinatesError {
12    /// HTTP error
13    #[error("HTTP error: {0}")]
14    Http(#[from] reqwest::Error),
15    /// failed to clone request for creation of cache policy
16    #[error("failed to clone request for creation of cache policy")]
17    FailedToCloneRequest,
18    /// Unexpected prefix in response body
19    #[error("Unexpected prefix in response body: {0}")]
20    UnexpectedPrefix(String),
21    /// Unexpected suffix in response body
22    #[error("Unexpected suffix in response body: {0}")]
23    UnexpectedSuffix(String),
24    /// Unexpected infix in response body
25    #[error("Unexpected infix in response body: {0}")]
26    UnexpectedInfix(String),
27    /// error parsing the X coordinate
28    #[error("error parsing the X coordinate {0}: {1}")]
29    X(String, std::num::ParseIntError),
30    /// error parsing the Y coordinate
31    #[error("error parsing the Y coordinate {0}: {1}")]
32    Y(String, std::num::ParseIntError),
33}
34
35/// converts a `RegionName` to `GridCoordinates` using the Linden Lab API
36///
37/// # Errors
38///
39/// returns an error if the HTTP request fails or if the result couldn't
40/// be parsed properly
41#[expect(
42    clippy::module_name_repetitions,
43    reason = "the function is going to be used outside the module"
44)]
45pub async fn region_name_to_grid_coordinates(
46    client: &reqwest::Client,
47    region_name: &RegionName,
48    cached_value_with_cache_policy: Option<(
49        Option<GridCoordinates>,
50        http_cache_semantics::CachePolicy,
51    )>,
52) -> Result<
53    (Option<GridCoordinates>, http_cache_semantics::CachePolicy),
54    RegionNameToGridCoordinatesError,
55> {
56    tracing::debug!(
57        "Looking up grid coordinates for region name {}",
58        region_name
59    );
60    let url = format!(
61        "https://cap.secondlife.com/cap/0/d661249b-2b5a-4436-966a-3d3b8d7a574f?var=coords&sim_name={}",
62        region_name.to_string().replace(' ', "%20")
63    );
64    let request = client.get(&url).build()?;
65    if let Some((cached_value, cache_policy)) = cached_value_with_cache_policy {
66        let now = std::time::SystemTime::now();
67        if let http_cache_semantics::BeforeRequest::Fresh(_) =
68            cache_policy.before_request(&request, now)
69        {
70            tracing::debug!("Using cached grid coordinates/absence");
71            return Ok((cached_value, cache_policy));
72        }
73    }
74    let response = client
75        .execute(
76            request
77                .try_clone()
78                .ok_or(RegionNameToGridCoordinatesError::FailedToCloneRequest)?,
79        )
80        .await?;
81    let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
82    let response = response.text().await?;
83    if response == "var coords = {'error' : true };" {
84        tracing::debug!("Received negative response");
85        return Ok((None, cache_policy));
86    }
87    let Some(response) = response.strip_prefix("var coords = {'x' : ") else {
88        return Err(RegionNameToGridCoordinatesError::UnexpectedPrefix(
89            response.to_owned(),
90        ));
91    };
92    let Some(response) = response.strip_suffix(" };") else {
93        return Err(RegionNameToGridCoordinatesError::UnexpectedSuffix(
94            response.to_owned(),
95        ));
96    };
97    let parts = response.split(", 'y' : ").collect::<Vec<_>>();
98    let [x, y] = parts.as_slice() else {
99        return Err(RegionNameToGridCoordinatesError::UnexpectedInfix(
100            response.to_owned(),
101        ));
102    };
103    let x = x
104        .parse::<u32>()
105        .map_err(|err| RegionNameToGridCoordinatesError::X(x.to_string(), err))?;
106    let y = y
107        .parse::<u32>()
108        .map_err(|err| RegionNameToGridCoordinatesError::Y(y.to_string(), err))?;
109    let grid_coordinates = GridCoordinates::new(x, y);
110    tracing::debug!("Received response: {:?}", grid_coordinates);
111    Ok((Some(grid_coordinates), cache_policy))
112}
113
114/// Represents the possible errors that can occur when converting grid coordinates to a region name
115#[derive(Debug, thiserror::Error)]
116pub enum GridCoordinatesToRegionNameError {
117    /// HTTP error
118    #[error("HTTP error: {0}")]
119    Http(#[from] reqwest::Error),
120    /// failed to clone request for creation of cache policy
121    #[error("failed to clone request for creation of cache policy")]
122    FailedToCloneRequest,
123    /// Unexpected prefix in response body
124    #[error("Unexpected prefix in response body: {0}")]
125    UnexpectedPrefix(String),
126    /// Unexpected suffix in response body
127    #[error("Unexpected suffix in response body: {0}")]
128    UnexpectedSuffix(String),
129    /// error parsing the region name
130    #[error("error parsing the region name {0}: {1}")]
131    RegionName(String, RegionNameError),
132}
133
134/// converts `GridCoordinates` to a `RegionName` using the Linden Lab API
135///
136/// # Errors
137///
138/// returns an error if the HTTP request fails or if the result couldn't
139/// be parsed properly
140pub async fn grid_coordinates_to_region_name(
141    client: &reqwest::Client,
142    grid_coordinates: &GridCoordinates,
143    cached_value_with_cache_policy: Option<(Option<RegionName>, http_cache_semantics::CachePolicy)>,
144) -> Result<(Option<RegionName>, http_cache_semantics::CachePolicy), GridCoordinatesToRegionNameError>
145{
146    tracing::debug!(
147        "Looking up region name for grid coordinates {:?}",
148        grid_coordinates
149    );
150    let url = format!(
151        "https://cap.secondlife.com/cap/0/b713fe80-283b-4585-af4d-a3b7d9a32492?var=region&grid_x={}&grid_y={}",
152        grid_coordinates.x(),
153        grid_coordinates.y()
154    );
155    let request = client.get(&url).build()?;
156    if let Some((cached_value, cache_policy)) = cached_value_with_cache_policy {
157        let now = std::time::SystemTime::now();
158        if let http_cache_semantics::BeforeRequest::Fresh(_) =
159            cache_policy.before_request(&request, now)
160        {
161            tracing::debug!("Returning cached region name/absence");
162            return Ok((cached_value, cache_policy));
163        }
164    }
165    let response = client
166        .execute(
167            request
168                .try_clone()
169                .ok_or(GridCoordinatesToRegionNameError::FailedToCloneRequest)?,
170        )
171        .await?;
172    let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
173    let response = response.text().await?;
174    if response == "var region = {'error' : true };" {
175        tracing::debug!("Received negative response");
176        return Ok((None, cache_policy));
177    }
178    let Some(response) = response.strip_prefix("var region='") else {
179        return Err(GridCoordinatesToRegionNameError::UnexpectedPrefix(
180            response.to_string(),
181        ));
182    };
183    let Some(response) = response.strip_suffix("';") else {
184        return Err(GridCoordinatesToRegionNameError::UnexpectedSuffix(
185            response.to_string(),
186        ));
187    };
188    let region_name = RegionName::try_new(response)
189        .map_err(|err| GridCoordinatesToRegionNameError::RegionName(response.to_owned(), err))?;
190    tracing::debug!("Received region name: {region_name}");
191    Ok((Some(region_name), cache_policy))
192}
193
194/// a cache for region names to grid coordinates
195/// that allows lookups in both directions
196#[expect(
197    clippy::module_name_repetitions,
198    reason = "the type is going to be used outside the module"
199)]
200#[derive(Debug)]
201pub struct RegionNameToGridCoordinatesCache {
202    /// the reqwest Client used to lookup data not cached locally
203    client: reqwest::Client,
204    /// the cache database
205    db: redb::Database,
206    /// the in memory cache of region names to grid coordinates
207    grid_coordinate_cache:
208        lru::LruCache<RegionName, (Option<GridCoordinates>, http_cache_semantics::CachePolicy)>,
209    /// the in memory cache of grid coordinates to region names
210    region_name_cache:
211        lru::LruCache<GridCoordinates, (Option<RegionName>, http_cache_semantics::CachePolicy)>,
212}
213
214/// describes an error that can occur as part of the cache operation for the `RegionNameToGridCoordinatesCache`
215#[derive(Debug, thiserror::Error)]
216pub enum CacheError {
217    /// error decoding the JSON serialized CachePolicy
218    #[error("error decoding the JSON serialized CachePolicy: {0}")]
219    CachePolicyJsonDecodeError(#[from] serde_json::Error),
220    /// redb database error
221    #[error("redb database error: {0}")]
222    DatabaseError(#[from] redb::DatabaseError),
223    /// redb transaction error
224    #[error("redb transaction error: {0}")]
225    TransactionError(#[from] redb::TransactionError),
226    /// redb table error
227    #[error("redb table error: {0}")]
228    TableError(#[from] redb::TableError),
229    /// redb storage error
230    #[error("redb storage error: {0}")]
231    StorageError(#[from] redb::StorageError),
232    /// redb commit error
233    #[error("redb storage error: {0}")]
234    CommitError(#[from] redb::CommitError),
235    /// error looking up grid coordinates via HTTP
236    #[error("error looking up grid coordinates via HTTP: {0}")]
237    GridCoordinatesHttpError(#[from] RegionNameToGridCoordinatesError),
238    /// error looking up region name via HTTP
239    #[error("error looking up region name via HTTP: {0}")]
240    RegionNameHttpError(#[from] GridCoordinatesToRegionNameError),
241    /// error creating region name from cached string
242    #[error("error creating region name from cached string: {0}")]
243    RegionNameError(#[from] RegionNameError),
244    /// error handling system time for cache age calculations
245    #[error("error handling system time for cache age calculations: {0}")]
246    SystemTimeError(#[from] std::time::SystemTimeError),
247}
248
249/// describes the redb table to store region names and grid coordinates
250///
251/// the `_v2` table name reflects the widening of the grid-coordinate components
252/// from `u16` to `u32`; an existing `u16`-typed cache file is left untouched and
253/// a fresh table is created (the cache is regenerated from the grid on demand)
254const GRID_COORDINATE_CACHE_TABLE: redb::TableDefinition<String, (u32, u32)> =
255    redb::TableDefinition::new("grid_coordinates_v2");
256
257/// describes the redb table to store grid coordinates and region names
258const REGION_NAME_CACHE_TABLE: redb::TableDefinition<(u32, u32), String> =
259    redb::TableDefinition::new("region_name_v2");
260
261/// describes the redb table to store the `http_cache_semantics::CachePolicy`
262/// serialized as JSON for a region name to grid coordinate lookup
263const GRID_COORDINATE_CACHE_POLICY_TABLE: redb::TableDefinition<String, String> =
264    redb::TableDefinition::new("grid_coordinate_cache_policy");
265
266/// describes the redb table to store the `http_cache_semantics::CachePolicy`
267/// serialized as JSON for a grid coordinate to region name lookup
268const REGION_NAME_CACHE_POLICY_TABLE: redb::TableDefinition<(u32, u32), String> =
269    redb::TableDefinition::new("region_name_cache_policy_v2");
270
271/// the pre-`u32` (`u16`-coordinate) cache tables, retained only so they can be
272/// dropped on open — they are pure caches (regenerated from the grid), so there
273/// is no migration, only reclaiming the space they occupied. Deletion is by
274/// table name, so the phantom value/key types here are irrelevant.
275const LEGACY_GRID_COORDINATE_CACHE_TABLE: redb::TableDefinition<String, (u16, u16)> =
276    redb::TableDefinition::new("grid_coordinates");
277
278/// the pre-`u32` grid-coordinate-to-region-name cache table (see
279/// [`LEGACY_GRID_COORDINATE_CACHE_TABLE`])
280const LEGACY_REGION_NAME_CACHE_TABLE: redb::TableDefinition<(u16, u16), String> =
281    redb::TableDefinition::new("region_name");
282
283/// the pre-`u32` grid-coordinate-to-region-name cache-policy table (see
284/// [`LEGACY_GRID_COORDINATE_CACHE_TABLE`])
285const LEGACY_REGION_NAME_CACHE_POLICY_TABLE: redb::TableDefinition<(u16, u16), String> =
286    redb::TableDefinition::new("region_name_cache_policy");
287
288impl RegionNameToGridCoordinatesCache {
289    /// create a new cache
290    ///
291    /// # Errors
292    ///
293    /// returns an error if the database could not be created or opened
294    pub fn new(cache_directory: std::path::PathBuf) -> Result<Self, CacheError> {
295        let client = reqwest::Client::new();
296        let db = redb::Database::create(cache_directory.join("region_name.redb"))?;
297        // drop the pre-`u32` (`u16`-coordinate) cache tables if an older cache
298        // file still carries them — they are superseded by the `_v2` tables and
299        // would otherwise just waste space (deleting a missing table is a no-op)
300        {
301            let write_txn = db.begin_write()?;
302            write_txn.delete_table(LEGACY_GRID_COORDINATE_CACHE_TABLE)?;
303            write_txn.delete_table(LEGACY_REGION_NAME_CACHE_TABLE)?;
304            write_txn.delete_table(LEGACY_REGION_NAME_CACHE_POLICY_TABLE)?;
305            write_txn.commit()?;
306        }
307        let grid_coordinate_cache = lru::LruCache::unbounded();
308        let region_name_cache = lru::LruCache::unbounded();
309        Ok(Self {
310            client,
311            db,
312            grid_coordinate_cache,
313            region_name_cache,
314        })
315    }
316
317    /// get the grid coordinates for a region name
318    ///
319    /// # Errors
320    ///
321    /// returns an error if either the local database operations or the HTTP requests fail
322    pub async fn get_grid_coordinates(
323        &mut self,
324        region_name: &RegionName,
325    ) -> Result<Option<GridCoordinates>, CacheError> {
326        tracing::debug!("Retrieving grid coordinates for region {region_name:?}");
327        let cached_value_with_cache_policy = {
328            if let Some(memory_cached_value) = self.grid_coordinate_cache.get(region_name) {
329                Some(memory_cached_value.to_owned())
330            } else {
331                let read_txn = self.db.begin_read()?;
332                let cache_policy = {
333                    if let Ok(table) = read_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE) {
334                        if let Some(access_guard) =
335                            table.get(region_name.to_owned().into_inner())?
336                        {
337                            let cache_policy: http_cache_semantics::CachePolicy =
338                                serde_json::from_str(&access_guard.value())?;
339                            Some(cache_policy)
340                        } else {
341                            None
342                        }
343                    } else {
344                        None
345                    }
346                };
347                if let Some(cache_policy) = cache_policy {
348                    let cached_value = {
349                        if let Ok(table) = read_txn.open_table(GRID_COORDINATE_CACHE_TABLE) {
350                            if let Some(access_guard) =
351                                table.get(region_name.to_owned().into_inner())?
352                            {
353                                let (x, y) = access_guard.value();
354                                Some(GridCoordinates::new(x, y))
355                            } else {
356                                None
357                            }
358                        } else {
359                            None
360                        }
361                    };
362                    Some((cached_value, cache_policy))
363                } else {
364                    None
365                }
366            }
367        };
368        match region_name_to_grid_coordinates(
369            &self.client,
370            region_name,
371            cached_value_with_cache_policy,
372        )
373        .await
374        {
375            Ok((Some(grid_coordinates), cache_policy)) => {
376                if cache_policy.is_storable() {
377                    tracing::debug!("Storing grid coordinates in cache");
378                    let write_txn = self.db.begin_write()?;
379                    {
380                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
381                        table.insert(
382                            region_name.to_owned().into_inner(),
383                            serde_json::to_string(&cache_policy)?,
384                        )?;
385                    }
386                    {
387                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
388                        table.insert(
389                            region_name.to_owned().into_inner(),
390                            (grid_coordinates.x(), grid_coordinates.y()),
391                        )?;
392                    }
393                    write_txn.commit()?;
394                    self.grid_coordinate_cache.put(
395                        region_name.to_owned(),
396                        (Some(grid_coordinates), cache_policy),
397                    );
398                } else {
399                    tracing::debug!("Grid coordinates are not storable");
400                    let write_txn = self.db.begin_write()?;
401                    {
402                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
403                        table.remove(region_name.to_owned().into_inner())?;
404                    }
405                    {
406                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
407                        table.remove(region_name.to_owned().into_inner())?;
408                    }
409                    write_txn.commit()?;
410                    self.grid_coordinate_cache.pop(region_name);
411                }
412                tracing::debug!("Coordinates are {grid_coordinates:?}");
413                Ok(Some(grid_coordinates))
414            }
415            Ok((None, cache_policy)) => {
416                if cache_policy.is_storable() {
417                    tracing::debug!("Storing negative response in cache");
418                    let write_txn = self.db.begin_write()?;
419                    {
420                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
421                        table.insert(
422                            region_name.to_owned().into_inner(),
423                            serde_json::to_string(&cache_policy)?,
424                        )?;
425                    }
426                    {
427                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
428                        table.remove(region_name.to_owned().into_inner())?;
429                    }
430                    write_txn.commit()?;
431                    self.grid_coordinate_cache
432                        .put(region_name.to_owned(), (None, cache_policy));
433                } else {
434                    tracing::debug!("Negative response is not storable");
435                    let write_txn = self.db.begin_write()?;
436                    {
437                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
438                        table.remove(region_name.to_owned().into_inner())?;
439                    }
440                    {
441                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
442                        table.remove(region_name.to_owned().into_inner())?;
443                    }
444                    write_txn.commit()?;
445                    self.grid_coordinate_cache.pop(region_name);
446                }
447                tracing::debug!("No coordinates exist for that name");
448                Ok(None)
449            }
450            Err(err) => Err(CacheError::GridCoordinatesHttpError(err)),
451        }
452    }
453
454    /// get the region name for a set of grid coordinates
455    ///
456    /// # Errors
457    ///
458    /// returns an error if either the local database operations or the HTTP requests fail
459    pub async fn get_region_name(
460        &mut self,
461        grid_coordinates: &GridCoordinates,
462    ) -> Result<Option<RegionName>, CacheError> {
463        tracing::debug!("Retrieving region name for grid coordinates {grid_coordinates:?}");
464        let cached_value_with_cache_policy = {
465            if let Some(memory_cached_value) = self.region_name_cache.get(grid_coordinates) {
466                Some(memory_cached_value.to_owned())
467            } else {
468                let read_txn = self.db.begin_read()?;
469                let cache_policy = {
470                    if let Ok(table) = read_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE) {
471                        if let Some(access_guard) =
472                            table.get((grid_coordinates.x(), grid_coordinates.y()))?
473                        {
474                            let cache_policy: http_cache_semantics::CachePolicy =
475                                serde_json::from_str(&access_guard.value())?;
476                            Some(cache_policy)
477                        } else {
478                            None
479                        }
480                    } else {
481                        None
482                    }
483                };
484                if let Some(cache_policy) = cache_policy {
485                    let cached_value = {
486                        if let Ok(table) = read_txn.open_table(REGION_NAME_CACHE_TABLE) {
487                            if let Some(access_guard) =
488                                table.get((grid_coordinates.x(), grid_coordinates.y()))?
489                            {
490                                let region_name = access_guard.value();
491                                Some(RegionName::try_new(region_name)?)
492                            } else {
493                                None
494                            }
495                        } else {
496                            None
497                        }
498                    };
499                    Some((cached_value, cache_policy))
500                } else {
501                    None
502                }
503            }
504        };
505        match grid_coordinates_to_region_name(
506            &self.client,
507            grid_coordinates,
508            cached_value_with_cache_policy,
509        )
510        .await
511        {
512            Ok((Some(region_name), cache_policy)) => {
513                if cache_policy.is_storable() {
514                    tracing::debug!("Storing region name in cache");
515                    let write_txn = self.db.begin_write()?;
516                    {
517                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
518                        table.insert(
519                            (grid_coordinates.x(), grid_coordinates.y()),
520                            serde_json::to_string(&cache_policy)?,
521                        )?;
522                    }
523                    {
524                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
525                        table.insert(
526                            (grid_coordinates.x(), grid_coordinates.y()),
527                            region_name.to_owned().into_inner(),
528                        )?;
529                    }
530                    write_txn.commit()?;
531                    self.region_name_cache.put(
532                        grid_coordinates.to_owned(),
533                        (Some(region_name.to_owned()), cache_policy),
534                    );
535                } else {
536                    tracing::warn!("Region name response is not storable");
537                    let write_txn = self.db.begin_write()?;
538                    {
539                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
540                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
541                    }
542                    {
543                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
544                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
545                    }
546                    write_txn.commit()?;
547                    self.region_name_cache.pop(grid_coordinates);
548                }
549                tracing::debug!("Region name is {region_name:?}");
550                Ok(Some(region_name))
551            }
552            Ok((None, cache_policy)) => {
553                if cache_policy.is_storable() {
554                    tracing::debug!("Storing negative response in cache");
555                    let write_txn = self.db.begin_write()?;
556                    {
557                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
558                        table.insert(
559                            (grid_coordinates.x(), grid_coordinates.y()),
560                            serde_json::to_string(&cache_policy)?,
561                        )?;
562                    }
563                    {
564                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
565                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
566                    }
567                    write_txn.commit()?;
568                    self.region_name_cache
569                        .put(grid_coordinates.to_owned(), (None, cache_policy));
570                } else {
571                    tracing::debug!("Negative response is not storable");
572                    let write_txn = self.db.begin_write()?;
573                    {
574                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
575                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
576                    }
577                    {
578                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
579                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
580                    }
581                    write_txn.commit()?;
582                    self.region_name_cache.pop(grid_coordinates);
583                }
584                tracing::debug!("No region name exists for those grid coordinates");
585                Ok(None)
586            }
587            Err(err) => Err(CacheError::RegionNameHttpError(err)),
588        }
589    }
590}
591
592/// errors that can occur when converting a USB notecard to a grid rectangle
593#[derive(Debug, thiserror::Error)]
594pub enum USBNotecardToGridRectangleError {
595    /// there were no waypoints in the USB notecards so we could not determine
596    /// a grid rectangle for it
597    #[error(
598        "There were no waypoints in the USB notecards which made determining a grid rectangle for it impossible"
599    )]
600    NoUSBNotecardWaypoints,
601    /// there were errors when converting region names to grid coordinates
602    #[error("error converting region name to grid coordinates: {0}")]
603    CacheError(#[from] CacheError),
604    /// no grid coordinates were returned for one of the region names in the
605    /// USB Notecard
606    #[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
607    NoGridCoordinatesForRegion(RegionName),
608}
609
610/// converts a USB notecard to the `GridRectangle` that contains all the waypoints
611///
612/// # Errors
613///
614/// returns an error if there were no waypoints or if conversions to grid coordinates failed
615pub async fn usb_notecard_to_grid_rectangle(
616    region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
617    usb_notecard: &USBNotecard,
618) -> Result<GridRectangle, USBNotecardToGridRectangleError> {
619    let mut lower_left_x = None;
620    let mut lower_left_y = None;
621    let mut upper_right_x = None;
622    let mut upper_right_y = None;
623    for waypoint in usb_notecard.waypoints() {
624        let grid_coordinates = region_name_to_grid_coordinates_cache
625            .get_grid_coordinates(waypoint.location().region_name())
626            .await?;
627        if let Some(grid_coordinates) = grid_coordinates {
628            if let Some(llx) = lower_left_x {
629                lower_left_x = Some(std::cmp::min(llx, grid_coordinates.x()));
630            } else {
631                lower_left_x = Some(grid_coordinates.x());
632            }
633            if let Some(lly) = lower_left_y {
634                lower_left_y = Some(std::cmp::min(lly, grid_coordinates.y()));
635            } else {
636                lower_left_y = Some(grid_coordinates.y());
637            }
638            if let Some(urx) = upper_right_x {
639                upper_right_x = Some(std::cmp::max(urx, grid_coordinates.x()));
640            } else {
641                upper_right_x = Some(grid_coordinates.x());
642            }
643            if let Some(ury) = upper_right_y {
644                upper_right_y = Some(std::cmp::max(ury, grid_coordinates.y()));
645            } else {
646                upper_right_y = Some(grid_coordinates.y());
647            }
648        } else {
649            return Err(USBNotecardToGridRectangleError::NoGridCoordinatesForRegion(
650                waypoint.location().region_name().to_owned(),
651            ));
652        }
653    }
654    let Some(lower_left_x) = lower_left_x else {
655        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
656    };
657    let Some(lower_left_y) = lower_left_y else {
658        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
659    };
660    let Some(upper_right_x) = upper_right_x else {
661        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
662    };
663    let Some(upper_right_y) = upper_right_y else {
664        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
665    };
666    Ok(GridRectangle::new(
667        GridCoordinates::new(lower_left_x, lower_left_y),
668        GridCoordinates::new(upper_right_x, upper_right_y),
669    ))
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use pretty_assertions::assert_eq;
676
677    #[tokio::test]
678    async fn test_region_name_to_grid_coordinates() -> Result<(), Box<dyn std::error::Error>> {
679        let client = reqwest::Client::new();
680        assert_eq!(
681            region_name_to_grid_coordinates(&client, &RegionName::try_new("Thorkell")?, None)
682                .await?
683                .0,
684            Some(GridCoordinates::new(1136, 1075))
685        );
686        Ok(())
687    }
688
689    #[tokio::test]
690    async fn test_grid_coordinates_to_region_name() -> Result<(), Box<dyn std::error::Error>> {
691        let client = reqwest::Client::new();
692        assert_eq!(
693            grid_coordinates_to_region_name(&client, &GridCoordinates::new(1136, 1075), None)
694                .await?
695                .0,
696            Some(RegionName::try_new("Thorkell")?)
697        );
698        Ok(())
699    }
700
701    #[tokio::test]
702    async fn test_cache_region_name_to_grid_coordinates() -> Result<(), Box<dyn std::error::Error>>
703    {
704        let tempdir = tempfile::tempdir()?;
705        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
706        assert_eq!(
707            cache
708                .get_grid_coordinates(&RegionName::try_new("Thorkell")?)
709                .await?,
710            Some(GridCoordinates::new(1136, 1075))
711        );
712        Ok(())
713    }
714
715    #[tokio::test]
716    async fn test_cache_region_name_to_grid_coordinates_twice()
717    -> Result<(), Box<dyn std::error::Error>> {
718        let tempdir = tempfile::tempdir()?;
719        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
720        assert_eq!(
721            cache
722                .get_grid_coordinates(&RegionName::try_new("Thorkell")?)
723                .await?,
724            Some(GridCoordinates::new(1136, 1075))
725        );
726        assert_eq!(
727            cache
728                .get_grid_coordinates(&RegionName::try_new("Thorkell")?)
729                .await?,
730            Some(GridCoordinates::new(1136, 1075))
731        );
732        Ok(())
733    }
734
735    #[tokio::test]
736    async fn test_cache_region_name_to_grid_coordinates_negative_twice()
737    -> Result<(), Box<dyn std::error::Error>> {
738        let tempdir = tempfile::tempdir()?;
739        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
740        assert_eq!(
741            cache
742                .get_grid_coordinates(&RegionName::try_new("Thorkel")?)
743                .await?,
744            None,
745        );
746        assert_eq!(
747            cache
748                .get_grid_coordinates(&RegionName::try_new("Thorkel")?)
749                .await?,
750            None,
751        );
752        Ok(())
753    }
754
755    #[tokio::test]
756    async fn test_cache_grid_coordinates_to_region_name() -> Result<(), Box<dyn std::error::Error>>
757    {
758        let tempdir = tempfile::tempdir()?;
759        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
760        assert_eq!(
761            cache
762                .get_region_name(&GridCoordinates::new(1136, 1075))
763                .await?,
764            Some(RegionName::try_new("Thorkell")?)
765        );
766        Ok(())
767    }
768
769    #[tokio::test]
770    async fn test_cache_grid_coordinates_to_region_name_twice()
771    -> Result<(), Box<dyn std::error::Error>> {
772        let tempdir = tempfile::tempdir()?;
773        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
774        assert_eq!(
775            cache
776                .get_region_name(&GridCoordinates::new(1136, 1075))
777                .await?,
778            Some(RegionName::try_new("Thorkell")?)
779        );
780        assert_eq!(
781            cache
782                .get_region_name(&GridCoordinates::new(1136, 1075))
783                .await?,
784            Some(RegionName::try_new("Thorkell")?)
785        );
786        Ok(())
787    }
788
789    #[tokio::test]
790    async fn test_cache_grid_coordinates_to_region_name_negative_twice()
791    -> Result<(), Box<dyn std::error::Error>> {
792        let tempdir = tempfile::tempdir()?;
793        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
794        assert_eq!(
795            cache
796                .get_region_name(&GridCoordinates::new(11136, 1075))
797                .await?,
798            None,
799        );
800        assert_eq!(
801            cache
802                .get_region_name(&GridCoordinates::new(11136, 1075))
803                .await?,
804            None,
805        );
806        Ok(())
807    }
808}