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::<u16>()
105        .map_err(|err| RegionNameToGridCoordinatesError::X(x.to_string(), err))?;
106    let y = y
107        .parse::<u16>()
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
250const GRID_COORDINATE_CACHE_TABLE: redb::TableDefinition<String, (u16, u16)> =
251    redb::TableDefinition::new("grid_coordinates");
252
253/// describes the redb table to store grid coordinates and region names
254const REGION_NAME_CACHE_TABLE: redb::TableDefinition<(u16, u16), String> =
255    redb::TableDefinition::new("region_name");
256
257/// describes the redb table to store the `http_cache_semantics::CachePolicy`
258/// serialized as JSON for a region name to grid coordinate lookup
259const GRID_COORDINATE_CACHE_POLICY_TABLE: redb::TableDefinition<String, String> =
260    redb::TableDefinition::new("grid_coordinate_cache_policy");
261
262/// describes the redb table to store the `http_cache_semantics::CachePolicy`
263/// serialized as JSON for a grid coordinate to region name lookup
264const REGION_NAME_CACHE_POLICY_TABLE: redb::TableDefinition<(u16, u16), String> =
265    redb::TableDefinition::new("region_name_cache_policy");
266
267impl RegionNameToGridCoordinatesCache {
268    /// create a new cache
269    ///
270    /// # Errors
271    ///
272    /// returns an error if the database could not be created or opened
273    pub fn new(cache_directory: std::path::PathBuf) -> Result<Self, CacheError> {
274        let client = reqwest::Client::new();
275        let db = redb::Database::create(cache_directory.join("region_name.redb"))?;
276        let grid_coordinate_cache = lru::LruCache::unbounded();
277        let region_name_cache = lru::LruCache::unbounded();
278        Ok(Self {
279            client,
280            db,
281            grid_coordinate_cache,
282            region_name_cache,
283        })
284    }
285
286    /// get the grid coordinates for a region name
287    ///
288    /// # Errors
289    ///
290    /// returns an error if either the local database operations or the HTTP requests fail
291    pub async fn get_grid_coordinates(
292        &mut self,
293        region_name: &RegionName,
294    ) -> Result<Option<GridCoordinates>, CacheError> {
295        tracing::debug!("Retrieving grid coordinates for region {region_name:?}");
296        let cached_value_with_cache_policy = {
297            if let Some(memory_cached_value) = self.grid_coordinate_cache.get(region_name) {
298                Some(memory_cached_value.to_owned())
299            } else {
300                let read_txn = self.db.begin_read()?;
301                let cache_policy = {
302                    if let Ok(table) = read_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE) {
303                        if let Some(access_guard) =
304                            table.get(region_name.to_owned().into_inner())?
305                        {
306                            let cache_policy: http_cache_semantics::CachePolicy =
307                                serde_json::from_str(&access_guard.value())?;
308                            Some(cache_policy)
309                        } else {
310                            None
311                        }
312                    } else {
313                        None
314                    }
315                };
316                if let Some(cache_policy) = cache_policy {
317                    let cached_value = {
318                        if let Ok(table) = read_txn.open_table(GRID_COORDINATE_CACHE_TABLE) {
319                            if let Some(access_guard) =
320                                table.get(region_name.to_owned().into_inner())?
321                            {
322                                let (x, y) = access_guard.value();
323                                Some(GridCoordinates::new(x, y))
324                            } else {
325                                None
326                            }
327                        } else {
328                            None
329                        }
330                    };
331                    Some((cached_value, cache_policy))
332                } else {
333                    None
334                }
335            }
336        };
337        match region_name_to_grid_coordinates(
338            &self.client,
339            region_name,
340            cached_value_with_cache_policy,
341        )
342        .await
343        {
344            Ok((Some(grid_coordinates), cache_policy)) => {
345                if cache_policy.is_storable() {
346                    tracing::debug!("Storing grid coordinates in cache");
347                    let write_txn = self.db.begin_write()?;
348                    {
349                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
350                        table.insert(
351                            region_name.to_owned().into_inner(),
352                            serde_json::to_string(&cache_policy)?,
353                        )?;
354                    }
355                    {
356                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
357                        table.insert(
358                            region_name.to_owned().into_inner(),
359                            (grid_coordinates.x(), grid_coordinates.y()),
360                        )?;
361                    }
362                    write_txn.commit()?;
363                    self.grid_coordinate_cache.put(
364                        region_name.to_owned(),
365                        (Some(grid_coordinates), cache_policy),
366                    );
367                } else {
368                    tracing::debug!("Grid coordinates are not storable");
369                    let write_txn = self.db.begin_write()?;
370                    {
371                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
372                        table.remove(region_name.to_owned().into_inner())?;
373                    }
374                    {
375                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
376                        table.remove(region_name.to_owned().into_inner())?;
377                    }
378                    write_txn.commit()?;
379                    self.grid_coordinate_cache.pop(region_name);
380                }
381                tracing::debug!("Coordinates are {grid_coordinates:?}");
382                Ok(Some(grid_coordinates))
383            }
384            Ok((None, cache_policy)) => {
385                if cache_policy.is_storable() {
386                    tracing::debug!("Storing negative response in cache");
387                    let write_txn = self.db.begin_write()?;
388                    {
389                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
390                        table.insert(
391                            region_name.to_owned().into_inner(),
392                            serde_json::to_string(&cache_policy)?,
393                        )?;
394                    }
395                    {
396                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
397                        table.remove(region_name.to_owned().into_inner())?;
398                    }
399                    write_txn.commit()?;
400                    self.grid_coordinate_cache
401                        .put(region_name.to_owned(), (None, cache_policy));
402                } else {
403                    tracing::debug!("Negative response is not storable");
404                    let write_txn = self.db.begin_write()?;
405                    {
406                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_POLICY_TABLE)?;
407                        table.remove(region_name.to_owned().into_inner())?;
408                    }
409                    {
410                        let mut table = write_txn.open_table(GRID_COORDINATE_CACHE_TABLE)?;
411                        table.remove(region_name.to_owned().into_inner())?;
412                    }
413                    write_txn.commit()?;
414                    self.grid_coordinate_cache.pop(region_name);
415                }
416                tracing::debug!("No coordinates exist for that name");
417                Ok(None)
418            }
419            Err(err) => Err(CacheError::GridCoordinatesHttpError(err)),
420        }
421    }
422
423    /// get the region name for a set of grid coordinates
424    ///
425    /// # Errors
426    ///
427    /// returns an error if either the local database operations or the HTTP requests fail
428    pub async fn get_region_name(
429        &mut self,
430        grid_coordinates: &GridCoordinates,
431    ) -> Result<Option<RegionName>, CacheError> {
432        tracing::debug!("Retrieving region name for grid coordinates {grid_coordinates:?}");
433        let cached_value_with_cache_policy = {
434            if let Some(memory_cached_value) = self.region_name_cache.get(grid_coordinates) {
435                Some(memory_cached_value.to_owned())
436            } else {
437                let read_txn = self.db.begin_read()?;
438                let cache_policy = {
439                    if let Ok(table) = read_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE) {
440                        if let Some(access_guard) =
441                            table.get((grid_coordinates.x(), grid_coordinates.y()))?
442                        {
443                            let cache_policy: http_cache_semantics::CachePolicy =
444                                serde_json::from_str(&access_guard.value())?;
445                            Some(cache_policy)
446                        } else {
447                            None
448                        }
449                    } else {
450                        None
451                    }
452                };
453                if let Some(cache_policy) = cache_policy {
454                    let cached_value = {
455                        if let Ok(table) = read_txn.open_table(REGION_NAME_CACHE_TABLE) {
456                            if let Some(access_guard) =
457                                table.get((grid_coordinates.x(), grid_coordinates.y()))?
458                            {
459                                let region_name = access_guard.value();
460                                Some(RegionName::try_new(region_name)?)
461                            } else {
462                                None
463                            }
464                        } else {
465                            None
466                        }
467                    };
468                    Some((cached_value, cache_policy))
469                } else {
470                    None
471                }
472            }
473        };
474        match grid_coordinates_to_region_name(
475            &self.client,
476            grid_coordinates,
477            cached_value_with_cache_policy,
478        )
479        .await
480        {
481            Ok((Some(region_name), cache_policy)) => {
482                if cache_policy.is_storable() {
483                    tracing::debug!("Storing region name in cache");
484                    let write_txn = self.db.begin_write()?;
485                    {
486                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
487                        table.insert(
488                            (grid_coordinates.x(), grid_coordinates.y()),
489                            serde_json::to_string(&cache_policy)?,
490                        )?;
491                    }
492                    {
493                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
494                        table.insert(
495                            (grid_coordinates.x(), grid_coordinates.y()),
496                            region_name.to_owned().into_inner(),
497                        )?;
498                    }
499                    write_txn.commit()?;
500                    self.region_name_cache.put(
501                        grid_coordinates.to_owned(),
502                        (Some(region_name.to_owned()), cache_policy),
503                    );
504                } else {
505                    tracing::warn!("Region name response is not storable");
506                    let write_txn = self.db.begin_write()?;
507                    {
508                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
509                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
510                    }
511                    {
512                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
513                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
514                    }
515                    write_txn.commit()?;
516                    self.region_name_cache.pop(grid_coordinates);
517                }
518                tracing::debug!("Region name is {region_name:?}");
519                Ok(Some(region_name))
520            }
521            Ok((None, cache_policy)) => {
522                if cache_policy.is_storable() {
523                    tracing::debug!("Storing negative response in cache");
524                    let write_txn = self.db.begin_write()?;
525                    {
526                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
527                        table.insert(
528                            (grid_coordinates.x(), grid_coordinates.y()),
529                            serde_json::to_string(&cache_policy)?,
530                        )?;
531                    }
532                    {
533                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
534                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
535                    }
536                    write_txn.commit()?;
537                    self.region_name_cache
538                        .put(grid_coordinates.to_owned(), (None, cache_policy));
539                } else {
540                    tracing::debug!("Negative response is not storable");
541                    let write_txn = self.db.begin_write()?;
542                    {
543                        let mut table = write_txn.open_table(REGION_NAME_CACHE_POLICY_TABLE)?;
544                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
545                    }
546                    {
547                        let mut table = write_txn.open_table(REGION_NAME_CACHE_TABLE)?;
548                        table.remove((grid_coordinates.x(), grid_coordinates.y()))?;
549                    }
550                    write_txn.commit()?;
551                    self.region_name_cache.pop(grid_coordinates);
552                }
553                tracing::debug!("No region name exists for those grid coordinates");
554                Ok(None)
555            }
556            Err(err) => Err(CacheError::RegionNameHttpError(err)),
557        }
558    }
559}
560
561/// errors that can occur when converting a USB notecard to a grid rectangle
562#[derive(Debug, thiserror::Error)]
563pub enum USBNotecardToGridRectangleError {
564    /// there were no waypoints in the USB notecards so we could not determine
565    /// a grid rectangle for it
566    #[error(
567        "There were no waypoints in the USB notecards which made determining a grid rectangle for it impossible"
568    )]
569    NoUSBNotecardWaypoints,
570    /// there were errors when converting region names to grid coordinates
571    #[error("error converting region name to grid coordinates: {0}")]
572    CacheError(#[from] CacheError),
573    /// no grid coordinates were returned for one of the region names in the
574    /// USB Notecard
575    #[error("No grid coordinates were returned for one of the regions in the USB notecard: {0}")]
576    NoGridCoordinatesForRegion(RegionName),
577}
578
579/// converts a USB notecard to the `GridRectangle` that contains all the waypoints
580///
581/// # Errors
582///
583/// returns an error if there were no waypoints or if conversions to grid coordinates failed
584pub async fn usb_notecard_to_grid_rectangle(
585    region_name_to_grid_coordinates_cache: &mut RegionNameToGridCoordinatesCache,
586    usb_notecard: &USBNotecard,
587) -> Result<GridRectangle, USBNotecardToGridRectangleError> {
588    let mut lower_left_x = None;
589    let mut lower_left_y = None;
590    let mut upper_right_x = None;
591    let mut upper_right_y = None;
592    for waypoint in usb_notecard.waypoints() {
593        let grid_coordinates = region_name_to_grid_coordinates_cache
594            .get_grid_coordinates(waypoint.location().region_name())
595            .await?;
596        if let Some(grid_coordinates) = grid_coordinates {
597            if let Some(llx) = lower_left_x {
598                lower_left_x = Some(std::cmp::min(llx, grid_coordinates.x()));
599            } else {
600                lower_left_x = Some(grid_coordinates.x());
601            }
602            if let Some(lly) = lower_left_y {
603                lower_left_y = Some(std::cmp::min(lly, grid_coordinates.y()));
604            } else {
605                lower_left_y = Some(grid_coordinates.y());
606            }
607            if let Some(urx) = upper_right_x {
608                upper_right_x = Some(std::cmp::max(urx, grid_coordinates.x()));
609            } else {
610                upper_right_x = Some(grid_coordinates.x());
611            }
612            if let Some(ury) = upper_right_y {
613                upper_right_y = Some(std::cmp::max(ury, grid_coordinates.y()));
614            } else {
615                upper_right_y = Some(grid_coordinates.y());
616            }
617        } else {
618            return Err(USBNotecardToGridRectangleError::NoGridCoordinatesForRegion(
619                waypoint.location().region_name().to_owned(),
620            ));
621        }
622    }
623    let Some(lower_left_x) = lower_left_x else {
624        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
625    };
626    let Some(lower_left_y) = lower_left_y else {
627        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
628    };
629    let Some(upper_right_x) = upper_right_x else {
630        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
631    };
632    let Some(upper_right_y) = upper_right_y else {
633        return Err(USBNotecardToGridRectangleError::NoUSBNotecardWaypoints);
634    };
635    Ok(GridRectangle::new(
636        GridCoordinates::new(lower_left_x, lower_left_y),
637        GridCoordinates::new(upper_right_x, upper_right_y),
638    ))
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use pretty_assertions::assert_eq;
645
646    #[tokio::test]
647    async fn test_region_name_to_grid_coordinates() -> Result<(), Box<dyn std::error::Error>> {
648        let client = reqwest::Client::new();
649        assert_eq!(
650            region_name_to_grid_coordinates(&client, &RegionName::try_new("Thorkell")?, None)
651                .await?
652                .0,
653            Some(GridCoordinates::new(1136, 1075))
654        );
655        Ok(())
656    }
657
658    #[tokio::test]
659    async fn test_grid_coordinates_to_region_name() -> Result<(), Box<dyn std::error::Error>> {
660        let client = reqwest::Client::new();
661        assert_eq!(
662            grid_coordinates_to_region_name(&client, &GridCoordinates::new(1136, 1075), None)
663                .await?
664                .0,
665            Some(RegionName::try_new("Thorkell")?)
666        );
667        Ok(())
668    }
669
670    #[tokio::test]
671    async fn test_cache_region_name_to_grid_coordinates() -> Result<(), Box<dyn std::error::Error>>
672    {
673        let tempdir = tempfile::tempdir()?;
674        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
675        assert_eq!(
676            cache
677                .get_grid_coordinates(&RegionName::try_new("Thorkell")?)
678                .await?,
679            Some(GridCoordinates::new(1136, 1075))
680        );
681        Ok(())
682    }
683
684    #[tokio::test]
685    async fn test_cache_region_name_to_grid_coordinates_twice()
686    -> Result<(), Box<dyn std::error::Error>> {
687        let tempdir = tempfile::tempdir()?;
688        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
689        assert_eq!(
690            cache
691                .get_grid_coordinates(&RegionName::try_new("Thorkell")?)
692                .await?,
693            Some(GridCoordinates::new(1136, 1075))
694        );
695        assert_eq!(
696            cache
697                .get_grid_coordinates(&RegionName::try_new("Thorkell")?)
698                .await?,
699            Some(GridCoordinates::new(1136, 1075))
700        );
701        Ok(())
702    }
703
704    #[tokio::test]
705    async fn test_cache_region_name_to_grid_coordinates_negative_twice()
706    -> Result<(), Box<dyn std::error::Error>> {
707        let tempdir = tempfile::tempdir()?;
708        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
709        assert_eq!(
710            cache
711                .get_grid_coordinates(&RegionName::try_new("Thorkel")?)
712                .await?,
713            None,
714        );
715        assert_eq!(
716            cache
717                .get_grid_coordinates(&RegionName::try_new("Thorkel")?)
718                .await?,
719            None,
720        );
721        Ok(())
722    }
723
724    #[tokio::test]
725    async fn test_cache_grid_coordinates_to_region_name() -> Result<(), Box<dyn std::error::Error>>
726    {
727        let tempdir = tempfile::tempdir()?;
728        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
729        assert_eq!(
730            cache
731                .get_region_name(&GridCoordinates::new(1136, 1075))
732                .await?,
733            Some(RegionName::try_new("Thorkell")?)
734        );
735        Ok(())
736    }
737
738    #[tokio::test]
739    async fn test_cache_grid_coordinates_to_region_name_twice()
740    -> Result<(), Box<dyn std::error::Error>> {
741        let tempdir = tempfile::tempdir()?;
742        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
743        assert_eq!(
744            cache
745                .get_region_name(&GridCoordinates::new(1136, 1075))
746                .await?,
747            Some(RegionName::try_new("Thorkell")?)
748        );
749        assert_eq!(
750            cache
751                .get_region_name(&GridCoordinates::new(1136, 1075))
752                .await?,
753            Some(RegionName::try_new("Thorkell")?)
754        );
755        Ok(())
756    }
757
758    #[tokio::test]
759    async fn test_cache_grid_coordinates_to_region_name_negative_twice()
760    -> Result<(), Box<dyn std::error::Error>> {
761        let tempdir = tempfile::tempdir()?;
762        let mut cache = RegionNameToGridCoordinatesCache::new(tempdir.path().to_path_buf())?;
763        assert_eq!(
764            cache
765                .get_region_name(&GridCoordinates::new(11136, 1075))
766                .await?,
767            None,
768        );
769        assert_eq!(
770            cache
771                .get_region_name(&GridCoordinates::new(11136, 1075))
772                .await?,
773            None,
774        );
775        Ok(())
776    }
777}