#[cfg(feature = "async")]
use shaum_types::ShaumError;
use shaum_types::GeoCoordinate;
#[cfg(feature = "async")]
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct LocationInfo {
pub coords: GeoCoordinate,
pub city: Option<String>,
pub region: Option<String>,
pub country: Option<String>,
}
impl LocationInfo {
pub fn display_name(&self) -> String {
let parts: Vec<&str> = [
self.city.as_deref(),
self.region.as_deref(),
self.country.as_deref(),
]
.into_iter()
.flatten()
.collect();
if parts.is_empty() {
format!("{:.4}°, {:.4}°", self.coords.lat, self.coords.lng)
} else {
parts.join(", ")
}
}
}
#[cfg(feature = "local-geo")]
pub struct LocalGeoProvider;
#[cfg(feature = "local-geo")]
impl LocalGeoProvider {
pub fn lookup(
ip: std::net::IpAddr,
db_path: &std::path::Path,
) -> Result<LocationInfo, ShaumError> {
use maxminddb::{Reader, geoip2};
let reader = Reader::open_readfile(db_path).map_err(|e| {
ShaumError::DatabaseError(format!(
"Failed to open MaxMind DB at {:?}: {}",
db_path, e
))
})?;
let city: geoip2::City = reader.lookup(ip).map_err(|e| {
ShaumError::DatabaseError(format!("IP lookup failed for {}: {}", ip, e))
})?;
let location = city.location.ok_or_else(|| {
ShaumError::DatabaseError(format!("No location data for IP {}", ip))
})?;
let lat = location.latitude.unwrap_or(0.0);
let lng = location.longitude.unwrap_or(0.0);
Ok(LocationInfo {
coords: GeoCoordinate::new_unchecked(lat, lng),
city: city
.city
.and_then(|c| c.names)
.and_then(|n| n.get("en").map(|s| s.to_string())),
region: city
.subdivisions
.and_then(|s| s.into_iter().next())
.and_then(|s| s.names)
.and_then(|n| n.get("en").map(|s| s.to_string())),
country: city
.country
.and_then(|c| c.names)
.and_then(|n| n.get("en").map(|s| s.to_string())),
})
}
}
#[cfg(feature = "async")]
#[derive(Debug, Clone)]
pub struct DetailedLocationInfo {
pub coords: GeoCoordinate,
pub kelurahan: Option<String>,
pub kecamatan: Option<String>,
pub kabupaten: Option<String>,
pub provinsi: Option<String>,
pub country: Option<String>,
pub display_name: String,
}
#[cfg(feature = "async")]
impl DetailedLocationInfo {
pub fn alamat_lengkap(&self) -> String {
let parts: Vec<&str> = [
self.kelurahan.as_deref(),
self.kecamatan.as_deref().map(|k| format!("Kec. {}", k).leak() as &str),
self.kabupaten.as_deref(),
self.provinsi.as_deref(),
]
.into_iter()
.flatten()
.collect();
if parts.is_empty() {
self.display_name.clone()
} else {
parts.join(", ")
}
}
}
#[cfg(feature = "async")]
#[derive(Debug, Deserialize)]
struct NominatimResponse {
display_name: String,
address: NominatimAddress,
}
#[cfg(feature = "async")]
#[derive(Debug, Deserialize)]
struct NominatimAddress {
village: Option<String>, suburb: Option<String>, neighbourhood: Option<String>,
county: Option<String>, municipality: Option<String>, city_district: Option<String>,
city: Option<String>,
town: Option<String>,
#[serde(rename = "state")]
province: Option<String>,
country: Option<String>,
#[serde(rename = "ISO3166-2-lvl4")]
iso_province: Option<String>,
}
#[cfg(feature = "async")]
pub async fn reverse_geocode(coords: GeoCoordinate) -> Result<DetailedLocationInfo, ShaumError> {
let client = reqwest::Client::builder()
.user_agent("shaum-lib/0.6.0 (Islamic prayer times library)")
.build()
.map_err(|e| ShaumError::NetworkError(format!("Failed to create HTTP client: {}", e)))?;
let url = format!(
"https://nominatim.openstreetmap.org/reverse?lat={}&lon={}&format=json&addressdetails=1&accept-language=id",
coords.lat, coords.lng
);
let response = client
.get(&url)
.send()
.await
.map_err(|e| ShaumError::NetworkError(format!("Nominatim request failed: {}", e)))?;
let data: NominatimResponse = response
.json()
.await
.map_err(|e| ShaumError::NetworkError(format!("Failed to parse Nominatim response: {}", e)))?;
let addr = &data.address;
let kelurahan = addr.village.clone()
.or_else(|| addr.suburb.clone())
.or_else(|| addr.neighbourhood.clone());
let kecamatan = addr.county.clone()
.or_else(|| addr.municipality.clone())
.or_else(|| addr.city_district.clone());
let kabupaten = addr.city.clone()
.or_else(|| addr.town.clone());
Ok(DetailedLocationInfo {
coords,
kelurahan,
kecamatan,
kabupaten,
provinsi: addr.province.clone(),
country: addr.country.clone(),
display_name: data.display_name,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_location_info_display_name() {
let info = LocationInfo {
coords: GeoCoordinate::new_unchecked(-6.2088, 106.8456),
city: Some("Jakarta".to_string()),
region: Some("DKI Jakarta".to_string()),
country: Some("Indonesia".to_string()),
};
assert_eq!(info.display_name(), "Jakarta, DKI Jakarta, Indonesia");
}
#[test]
fn test_location_info_display_name_coords_only() {
let info = LocationInfo {
coords: GeoCoordinate::new_unchecked(-6.2088, 106.8456),
city: None,
region: None,
country: None,
};
assert!(info.display_name().contains("-6.2088"));
}
#[cfg(feature = "async")]
#[tokio::test]
#[ignore]
async fn test_get_location_info_http() {
#[allow(deprecated)]
let result = get_location_info_from_ip().await;
assert!(result.is_ok());
}
}