···3939use crate::http::middleware_i18n::Language;
4040use crate::http::timezones::supported_timezones;
4141use crate::http::utils::url_from_aturi;
4242-use crate::services::GeocodingService;
4242+use crate::services::{NominatimClient, AddressGeocodingStrategies, AddressComponents};
4343+use crate::services::nominatim_client::GeoExt;
4344use crate::storage::event::event_insert;
44454546use super::cache_countries::cached_countries;
···240241241242 let mut locations = vec![EventLocation::Address(address.clone())];
242243243243- // Try to geocode the address and add coordinates if successful
244244+ // Apply progressive geocoding strategies to manually entered addresses
244245 let address_string = crate::storage::event::format_address(&address);
245246 if !address_string.trim().is_empty() {
246246- let geocoding_service = GeocodingService::new();
247247- if let Ok(geocoding_result) = geocoding_service.geocode_address(&address_string).await {
248248- let geo_location = Geo::Current {
249249- latitude: geocoding_result.latitude.to_string(),
250250- longitude: geocoding_result.longitude.to_string(),
251251- name: Some(geocoding_result.display_name),
252252- };
253253- locations.push(EventLocation::Geo(geo_location));
247247+ // Use the new progressive geocoding strategies
248248+ match apply_geocoding_strategies(&web_context, &BuildLocationForm::from(build_event_form.clone())).await {
249249+ Ok((lat, lon, venue_metadata)) => {
250250+ let geo_location = Geo::Current {
251251+ latitude: lat,
252252+ longitude: lon,
253253+ name: venue_metadata.as_ref()
254254+ .and_then(|m| Some(m.bilingual_names.display_name.clone()))
255255+ .or_else(|| Some(address_string.clone())),
256256+ };
257257+ locations.push(EventLocation::Geo(geo_location));
258258+ }
259259+ Err(e) => {
260260+ tracing::warn!("Progressive geocoding failed: {}", e);
261261+ // Continue without coordinates - graceful degradation
262262+ }
254263 }
255255- // If geocoding fails, we continue without coordinates
256256- // The map will fall back to geocoding at view time
257264 }
258265259266 locations
···522529 location_form.build_state = Some(BuildEventContentState::Selecting);
523530 } else {
524531 location_form.build_state = Some(BuildEventContentState::Selected);
532532+533533+ // Apply geocoding strategies if address components are provided but no coordinates yet
534534+ if location_form.latitude.is_none() && location_form.longitude.is_none() {
535535+ if location_form.location_country.is_some() ||
536536+ location_form.location_locality.is_some() ||
537537+ location_form.location_street.is_some() {
538538+539539+ // Apply progressive geocoding strategies
540540+ match apply_geocoding_strategies(&web_context, &location_form).await {
541541+ Ok((lat, lon, venue_metadata)) => {
542542+ location_form.latitude = Some(lat.clone());
543543+ location_form.longitude = Some(lon.clone());
544544+545545+ // Apply venue enhancement data if available
546546+ if let Some(metadata) = venue_metadata {
547547+ if let Some(category) = metadata.category {
548548+ location_form.venue_category = Some(category);
549549+ }
550550+ if let Some(importance) = metadata.importance {
551551+ location_form.venue_quality = Some(importance);
552552+ }
553553+ }
554554+555555+ tracing::info!("Successfully geocoded manual address: {}, {}", lat, lon);
556556+ }
557557+ Err(e) => {
558558+ tracing::warn!("Geocoding failed for manual address: {}", e);
559559+ // Continue without coordinates - graceful degradation
560560+ }
561561+ }
562562+ }
563563+ }
525564 }
526565 }
527566···694733695734 set
696735}
736736+737737+/// Apply progressive geocoding strategies to manually entered address components
738738+async fn apply_geocoding_strategies(
739739+ web_context: &WebContext,
740740+ location_form: &BuildLocationForm,
741741+) -> Result<(String, String, Option<crate::services::VenueMetadata>)> {
742742+ use crate::services::{NominatimClient, AddressGeocodingStrategies, AddressComponents};
743743+744744+ // Initialize NominatimClient
745745+ let nominatim_url = std::env::var("NOMINATIM_URL")
746746+ .unwrap_or_else(|_| "http://nominatim-quebec:8080".to_string());
747747+748748+ let nominatim_client = NominatimClient::new(
749749+ web_context.cache_pool.clone(),
750750+ nominatim_url,
751751+ )?;
752752+753753+ // Create address components from form data
754754+ let components = AddressComponents::from((
755755+ &location_form.location_country,
756756+ &location_form.location_name,
757757+ &location_form.location_street,
758758+ &location_form.location_locality,
759759+ &location_form.location_region,
760760+ &location_form.location_postal_code,
761761+ ));
762762+763763+ // Apply geocoding strategies
764764+ let geocoding_service = AddressGeocodingStrategies::new(nominatim_client);
765765+ let strategy_result = geocoding_service.geocode_with_strategies(&components).await?;
766766+767767+ tracing::info!(
768768+ "Geocoding successful with strategy '{}' after {} attempts",
769769+ strategy_result.strategy_used,
770770+ strategy_result.attempts
771771+ );
772772+773773+ // Extract coordinates
774774+ let lat = strategy_result.result.geo.latitude().to_string();
775775+ let lon = strategy_result.result.geo.longitude().to_string();
776776+777777+ // Return coordinates and venue metadata
778778+ Ok((lat, lon, Some(strategy_result.result.venue_metadata)))
779779+}
+101-18
src/http/handle_edit_event.rs
···1515 },
1616 lexicon::community::lexicon::location::{Address, Geo},
1717 },
1818- services::geocoding::GeocodingService,
1818+ services::{NominatimClient, AddressGeocodingStrategies, AddressComponents},
1919+ services::nominatim_client::GeoExt,
1920 contextual_error,
2021 http::context::UserRequestContext,
2122 http::errors::EditEventError,
···524525 name: build_event_form.location_name.clone(),
525526 };
526527527527- // Initialize geocoding service and attempt to geocode the address
528528- let geocoding_service = GeocodingService::new();
529528 let mut event_locations = vec![EventLocation::Address(address.clone())];
530529531531- // Try to geocode the address - format it first
532532- let formatted_address = crate::storage::event::format_address(&address);
533533- match geocoding_service.geocode_address(&formatted_address).await {
534534- Ok(geocode_result) => {
535535- // Add the geocoded coordinates as a Geo location
536536- let geo_location = Geo::Current {
537537- latitude: geocode_result.latitude.to_string(),
538538- longitude: geocode_result.longitude.to_string(),
539539- name: Some(geocode_result.display_name),
540540- };
541541- event_locations.push(EventLocation::Geo(geo_location));
542542- }
543543- Err(_) => {
544544- // Geocoding failed, but we continue with just the address
545545- // This is graceful degradation - the event can still be created
530530+ // Apply progressive geocoding strategies if any location components are provided
531531+ if build_event_form.location_country.is_some() ||
532532+ build_event_form.location_locality.is_some() ||
533533+ build_event_form.location_street.is_some() {
534534+535535+ // Create a temporary BuildLocationForm to use with geocoding strategies
536536+ let location_form = BuildLocationForm {
537537+ location_country: build_event_form.location_country.clone(),
538538+ location_name: build_event_form.location_name.clone(),
539539+ location_street: build_event_form.location_street.clone(),
540540+ location_locality: build_event_form.location_locality.clone(),
541541+ location_region: build_event_form.location_region.clone(),
542542+ location_postal_code: build_event_form.location_postal_code.clone(),
543543+ latitude: build_event_form.latitude.clone(),
544544+ longitude: build_event_form.longitude.clone(),
545545+ venue_category: build_event_form.venue_category.clone(),
546546+ venue_quality: build_event_form.venue_quality.clone(),
547547+ // Set defaults for other required fields
548548+ build_state: None,
549549+ location_country_error: None,
550550+ location_name_error: None,
551551+ location_street_error: None,
552552+ location_locality_error: None,
553553+ location_region_error: None,
554554+ location_postal_code_error: None,
555555+ };
556556+557557+ // Apply progressive geocoding strategies
558558+ match apply_geocoding_strategies_for_edit(&ctx.web_context.cache_pool, &location_form).await {
559559+ Ok((lat, lon, venue_metadata)) => {
560560+ // Add the geocoded coordinates as a Geo location
561561+ let mut geo_name = None;
562562+563563+ // Use venue metadata display name if available, otherwise derive from address
564564+ if let Some(metadata) = venue_metadata {
565565+ if !metadata.bilingual_names.display_name.is_empty() {
566566+ geo_name = Some(metadata.bilingual_names.display_name.clone());
567567+ }
568568+ }
569569+570570+ // Fallback to formatted address if no display name
571571+ if geo_name.is_none() {
572572+ geo_name = Some(crate::storage::event::format_address(&address));
573573+ }
574574+575575+ let geo_location = Geo::Current {
576576+ latitude: lat,
577577+ longitude: lon,
578578+ name: geo_name,
579579+ };
580580+ event_locations.push(EventLocation::Geo(geo_location));
581581+ }
582582+ Err(e) => {
583583+ // Geocoding failed, but we continue with just the address
584584+ // This is graceful degradation - the event can still be updated
585585+ tracing::warn!("Progressive geocoding failed during event edit: {}", e);
586586+ }
546587 }
547588 }
548589···688729 &canonical_url,
689730 ))
690731}
732732+733733+/// Apply progressive geocoding strategies to manually entered address components during event editing
734734+async fn apply_geocoding_strategies_for_edit(
735735+ cache_pool: &crate::storage::CachePool,
736736+ location_form: &BuildLocationForm,
737737+) -> Result<(String, String, Option<crate::services::VenueMetadata>)> {
738738+ // Initialize NominatimClient
739739+ let nominatim_url = std::env::var("NOMINATIM_URL")
740740+ .unwrap_or_else(|_| "http://nominatim-quebec:8080".to_string());
741741+742742+ let nominatim_client = NominatimClient::new(
743743+ cache_pool.clone(),
744744+ nominatim_url,
745745+ )?;
746746+747747+ // Create address components from form data
748748+ let components = AddressComponents::from((
749749+ &location_form.location_country,
750750+ &location_form.location_name,
751751+ &location_form.location_street,
752752+ &location_form.location_locality,
753753+ &location_form.location_region,
754754+ &location_form.location_postal_code,
755755+ ));
756756+757757+ // Apply geocoding strategies
758758+ let geocoding_service = AddressGeocodingStrategies::new(nominatim_client);
759759+ let strategy_result = geocoding_service.geocode_with_strategies(&components).await?;
760760+761761+ tracing::info!(
762762+ "Edit event geocoding successful with strategy '{}' after {} attempts",
763763+ strategy_result.strategy_used,
764764+ strategy_result.attempts
765765+ );
766766+767767+ // Extract coordinates
768768+ let lat = strategy_result.result.geo.latitude().to_string();
769769+ let lon = strategy_result.result.geo.longitude().to_string();
770770+771771+ // Return coordinates and venue metadata
772772+ Ok((lat, lon, Some(strategy_result.result.venue_metadata)))
773773+}
+467
src/services/address_geocoding_strategies.rs
···11+//! # Address Geocoding Strategies
22+//!
33+//! Progressive geocoding strategies for manually entered addresses during event creation/editing.
44+//! This service applies multiple fallback strategies to ensure the highest probability of
55+//! successful geocoding while maintaining lexicon compatibility.
66+//!
77+//! ## Strategy Order
88+//! 1. **Exact Address**: Try the complete address as entered
99+//! 2. **Formatted Address**: Standardize format and try again
1010+//! 3. **Simplified Address**: Remove specific details, keep core components
1111+//! 4. **City + Region**: Try just city and region/province
1212+//! 5. **City Only**: Final fallback to city/locality only
1313+//!
1414+//! ## Usage
1515+//! ```rust
1616+//! let geocoder = AddressGeocodingStrategies::new(nominatim_client);
1717+//! let result = geocoder.geocode_with_strategies(&address_components).await?;
1818+//! ```
1919+2020+use anyhow::Result;
2121+use crate::services::nominatim_client::{NominatimClient, NominatimSearchResult, NominatimError};
2222+use crate::atproto::lexicon::community::lexicon::location::Address;
2323+use tracing;
2424+2525+/// Address geocoding strategies with progressive fallback
2626+pub struct AddressGeocodingStrategies {
2727+ nominatim_client: NominatimClient,
2828+}
2929+3030+/// Address components for geocoding
3131+#[derive(Debug, Clone)]
3232+pub struct AddressComponents {
3333+ pub name: Option<String>,
3434+ pub street: Option<String>,
3535+ pub locality: Option<String>,
3636+ pub region: Option<String>,
3737+ pub postal_code: Option<String>,
3838+ pub country: String, // Required field
3939+}
4040+4141+/// Geocoding strategy result with metadata
4242+#[derive(Debug, Clone)]
4343+pub struct GeocodingStrategyResult {
4444+ pub result: NominatimSearchResult,
4545+ pub strategy_used: String,
4646+ pub attempts: u32,
4747+}
4848+4949+impl AddressGeocodingStrategies {
5050+ /// Create a new geocoding strategies service
5151+ pub fn new(nominatim_client: NominatimClient) -> Self {
5252+ Self {
5353+ nominatim_client,
5454+ }
5555+ }
5656+5757+ /// Apply progressive geocoding strategies to address components
5858+ pub async fn geocode_with_strategies(&self, components: &AddressComponents) -> Result<GeocodingStrategyResult> {
5959+ let strategies = self.generate_geocoding_strategies(components);
6060+6161+ for (attempt, (strategy_name, query)) in strategies.iter().enumerate() {
6262+ tracing::debug!("Geocoding attempt {}: {} with query: '{}'", attempt + 1, strategy_name, query);
6363+6464+ match self.nominatim_client.search_address(query).await {
6565+ Ok(result) => {
6666+ tracing::info!("Geocoding successful with strategy '{}' after {} attempts", strategy_name, attempt + 1);
6767+ return Ok(GeocodingStrategyResult {
6868+ result,
6969+ strategy_used: strategy_name.clone(),
7070+ attempts: attempt as u32 + 1,
7171+ });
7272+ }
7373+ Err(e) => {
7474+ tracing::debug!("Strategy '{}' failed: {}", strategy_name, e);
7575+7676+ // Check if this is a "no results" error vs a service error
7777+ let error_string = e.to_string();
7878+ if error_string.contains("No results found") {
7979+ continue; // Try next strategy
8080+ } else {
8181+ // Service error - return immediately
8282+ return Err(e);
8383+ }
8484+ }
8585+ }
8686+ }
8787+8888+ // All strategies failed
8989+ Err(anyhow::anyhow!(
9090+ "All geocoding strategies failed for address: {}",
9191+ self.format_address_for_logging(components)
9292+ ))
9393+ }
9494+9595+ /// Generate progressive geocoding strategies
9696+ fn generate_geocoding_strategies(&self, components: &AddressComponents) -> Vec<(String, String)> {
9797+ let mut strategies = Vec::new();
9898+9999+ // Strategy 1: Exact address (full components as provided)
100100+ if let Some(exact_query) = self.build_exact_address(components) {
101101+ strategies.push(("Exact Address".to_string(), exact_query));
102102+ }
103103+104104+ // Strategy 2: Formatted address (standardized format)
105105+ if let Some(formatted_query) = self.build_formatted_address(components) {
106106+ strategies.push(("Formatted Address".to_string(), formatted_query));
107107+ }
108108+109109+ // Strategy 3: Simplified address (remove apartment numbers, building details)
110110+ if let Some(simplified_query) = self.build_simplified_address(components) {
111111+ strategies.push(("Simplified Address".to_string(), simplified_query));
112112+ }
113113+114114+ // Strategy 4: City + Region + Country
115115+ if let Some(city_region_query) = self.build_city_region_address(components) {
116116+ strategies.push(("City + Region".to_string(), city_region_query));
117117+ }
118118+119119+ // Strategy 5: City + Country only (final fallback)
120120+ if let Some(city_only_query) = self.build_city_only_address(components) {
121121+ strategies.push(("City Only".to_string(), city_only_query));
122122+ }
123123+124124+ strategies
125125+ }
126126+127127+ /// Build exact address query from all provided components
128128+ fn build_exact_address(&self, components: &AddressComponents) -> Option<String> {
129129+ let mut parts = Vec::new();
130130+131131+ // Add venue name if provided
132132+ if let Some(name) = &components.name {
133133+ if !name.trim().is_empty() {
134134+ parts.push(name.trim().to_string());
135135+ }
136136+ }
137137+138138+ // Add street address
139139+ if let Some(street) = &components.street {
140140+ if !street.trim().is_empty() {
141141+ parts.push(street.trim().to_string());
142142+ }
143143+ }
144144+145145+ // Add locality (city)
146146+ if let Some(locality) = &components.locality {
147147+ if !locality.trim().is_empty() {
148148+ parts.push(locality.trim().to_string());
149149+ }
150150+ }
151151+152152+ // Add region (province/state)
153153+ if let Some(region) = &components.region {
154154+ if !region.trim().is_empty() {
155155+ parts.push(region.trim().to_string());
156156+ }
157157+ }
158158+159159+ // Add postal code
160160+ if let Some(postal_code) = &components.postal_code {
161161+ if !postal_code.trim().is_empty() {
162162+ parts.push(postal_code.trim().to_string());
163163+ }
164164+ }
165165+166166+ // Add country
167167+ if !components.country.trim().is_empty() {
168168+ parts.push(components.country.trim().to_string());
169169+ }
170170+171171+ if parts.is_empty() {
172172+ None
173173+ } else {
174174+ Some(parts.join(", "))
175175+ }
176176+ }
177177+178178+ /// Build formatted address with standardized component order
179179+ fn build_formatted_address(&self, components: &AddressComponents) -> Option<String> {
180180+ let mut parts = Vec::new();
181181+182182+ // Standard format: [Name], [Street], [City], [Region] [PostalCode], [Country]
183183+ if let Some(name) = &components.name {
184184+ if !name.trim().is_empty() && !self.is_street_address_like(name) {
185185+ parts.push(name.trim().to_string());
186186+ }
187187+ }
188188+189189+ if let Some(street) = &components.street {
190190+ if !street.trim().is_empty() {
191191+ parts.push(street.trim().to_string());
192192+ }
193193+ }
194194+195195+ if let Some(locality) = &components.locality {
196196+ if !locality.trim().is_empty() {
197197+ let mut city_part = locality.trim().to_string();
198198+199199+ // Combine region and postal code with city if available
200200+ if let Some(region) = &components.region {
201201+ if !region.trim().is_empty() {
202202+ city_part.push_str(", ");
203203+ city_part.push_str(region.trim());
204204+ }
205205+ }
206206+207207+ if let Some(postal_code) = &components.postal_code {
208208+ if !postal_code.trim().is_empty() {
209209+ city_part.push(' ');
210210+ city_part.push_str(postal_code.trim());
211211+ }
212212+ }
213213+214214+ parts.push(city_part);
215215+ }
216216+ }
217217+218218+ // Always add country
219219+ if !components.country.trim().is_empty() {
220220+ parts.push(components.country.trim().to_string());
221221+ }
222222+223223+ if parts.is_empty() {
224224+ None
225225+ } else {
226226+ Some(parts.join(", "))
227227+ }
228228+ }
229229+230230+ /// Build simplified address removing specific details
231231+ fn build_simplified_address(&self, components: &AddressComponents) -> Option<String> {
232232+ let mut parts = Vec::new();
233233+234234+ // Simplify street address - remove apartment numbers, building numbers
235235+ if let Some(street) = &components.street {
236236+ let simplified_street = self.simplify_street_address(street);
237237+ if !simplified_street.trim().is_empty() {
238238+ parts.push(simplified_street);
239239+ }
240240+ }
241241+242242+ // Keep city as-is
243243+ if let Some(locality) = &components.locality {
244244+ if !locality.trim().is_empty() {
245245+ parts.push(locality.trim().to_string());
246246+ }
247247+ }
248248+249249+ // Keep region
250250+ if let Some(region) = &components.region {
251251+ if !region.trim().is_empty() {
252252+ parts.push(region.trim().to_string());
253253+ }
254254+ }
255255+256256+ // Keep country
257257+ if !components.country.trim().is_empty() {
258258+ parts.push(components.country.trim().to_string());
259259+ }
260260+261261+ if parts.is_empty() {
262262+ None
263263+ } else {
264264+ Some(parts.join(", "))
265265+ }
266266+ }
267267+268268+ /// Build city + region query
269269+ fn build_city_region_address(&self, components: &AddressComponents) -> Option<String> {
270270+ let mut parts = Vec::new();
271271+272272+ if let Some(locality) = &components.locality {
273273+ if !locality.trim().is_empty() {
274274+ parts.push(locality.trim().to_string());
275275+ }
276276+ }
277277+278278+ if let Some(region) = &components.region {
279279+ if !region.trim().is_empty() {
280280+ parts.push(region.trim().to_string());
281281+ }
282282+ }
283283+284284+ if !components.country.trim().is_empty() {
285285+ parts.push(components.country.trim().to_string());
286286+ }
287287+288288+ if parts.is_empty() {
289289+ None
290290+ } else {
291291+ Some(parts.join(", "))
292292+ }
293293+ }
294294+295295+ /// Build city-only query (final fallback)
296296+ fn build_city_only_address(&self, components: &AddressComponents) -> Option<String> {
297297+ if let Some(locality) = &components.locality {
298298+ if !locality.trim().is_empty() {
299299+ let mut query = locality.trim().to_string();
300300+301301+ if !components.country.trim().is_empty() {
302302+ query.push_str(", ");
303303+ query.push_str(components.country.trim());
304304+ }
305305+306306+ return Some(query);
307307+ }
308308+ }
309309+ None
310310+ }
311311+312312+ /// Check if a name looks like a street address rather than a venue name
313313+ fn is_street_address_like(&self, name: &str) -> bool {
314314+ let name_lower = name.to_lowercase();
315315+316316+ // Check for common street address patterns
317317+ name_lower.starts_with("rue ")
318318+ || name_lower.starts_with("street ")
319319+ || name_lower.starts_with("avenue ")
320320+ || name_lower.starts_with("boulevard ")
321321+ || name_lower.starts_with("chemin ")
322322+ || name_lower.starts_with("place ")
323323+ || name_lower.contains(" street")
324324+ || name_lower.contains(" avenue")
325325+ || name_lower.contains(" boulevard")
326326+ || name_lower.contains(" st ")
327327+ || name_lower.contains(" ave ")
328328+ || name_lower.ends_with(" st")
329329+ || name_lower.ends_with(" ave")
330330+ }
331331+332332+ /// Simplify street address by removing apartment numbers and complex details
333333+ fn simplify_street_address(&self, street: &str) -> String {
334334+ let mut simplified = street.to_string();
335335+336336+ // Remove apartment/unit numbers (patterns like "Apt 123", "Unit 4B", "#205")
337337+ simplified = regex::Regex::new(r",?\s*(apt|apartment|unit|suite|#)\s*[0-9a-zA-Z-]+")
338338+ .unwrap()
339339+ .replace_all(&simplified, "")
340340+ .to_string();
341341+342342+ // Remove building number ranges (e.g., "1000-1010" -> "")
343343+ simplified = regex::Regex::new(r"^\d+-\d+\s+")
344344+ .unwrap()
345345+ .replace_all(&simplified, "")
346346+ .to_string();
347347+348348+ // Remove complex building numbers (keep simple ones)
349349+ simplified = regex::Regex::new(r"^\d{4,}\s+")
350350+ .unwrap()
351351+ .replace_all(&simplified, "")
352352+ .to_string();
353353+354354+ simplified.trim().to_string()
355355+ }
356356+357357+ /// Format address components for logging (privacy-safe)
358358+ fn format_address_for_logging(&self, components: &AddressComponents) -> String {
359359+ let mut parts = Vec::new();
360360+361361+ if let Some(locality) = &components.locality {
362362+ if !locality.trim().is_empty() {
363363+ parts.push(locality.trim().to_string());
364364+ }
365365+ }
366366+367367+ if let Some(region) = &components.region {
368368+ if !region.trim().is_empty() {
369369+ parts.push(region.trim().to_string());
370370+ }
371371+ }
372372+373373+ if !components.country.trim().is_empty() {
374374+ parts.push(components.country.trim().to_string());
375375+ }
376376+377377+ if parts.is_empty() {
378378+ "[no valid components]".to_string()
379379+ } else {
380380+ parts.join(", ")
381381+ }
382382+ }
383383+}
384384+385385+/// Convert form components to AddressComponents
386386+impl From<(&Option<String>, &Option<String>, &Option<String>, &Option<String>, &Option<String>, &Option<String>)> for AddressComponents {
387387+ fn from((country, name, street, locality, region, postal_code): (&Option<String>, &Option<String>, &Option<String>, &Option<String>, &Option<String>, &Option<String>)) -> Self {
388388+ Self {
389389+ name: name.clone(),
390390+ street: street.clone(),
391391+ locality: locality.clone(),
392392+ region: region.clone(),
393393+ postal_code: postal_code.clone(),
394394+ country: country.clone().unwrap_or_else(|| "Canada".to_string()),
395395+ }
396396+ }
397397+}
398398+399399+#[cfg(test)]
400400+mod tests {
401401+ use super::*;
402402+ use deadpool_redis::Pool as RedisPool;
403403+404404+ // Helper function to create a test strategies instance
405405+ fn create_test_strategies() -> AddressGeocodingStrategies {
406406+ // Create a minimal mock Redis pool for testing
407407+ let redis_pool = deadpool_redis::Config::default()
408408+ .create_pool(Some(deadpool_redis::Runtime::Tokio1))
409409+ .unwrap();
410410+411411+ let nominatim_client = NominatimClient::new(
412412+ redis_pool,
413413+ "http://test:8080".to_string()
414414+ ).unwrap();
415415+416416+ AddressGeocodingStrategies::new(nominatim_client)
417417+ }
418418+419419+ #[test]
420420+ fn test_build_exact_address() {
421421+ let components = AddressComponents {
422422+ name: Some("Place des Arts".to_string()),
423423+ street: Some("175 Rue Sainte-Catherine Ouest".to_string()),
424424+ locality: Some("Montréal".to_string()),
425425+ region: Some("QC".to_string()),
426426+ postal_code: Some("H2X 1Z8".to_string()),
427427+ country: "Canada".to_string(),
428428+ };
429429+430430+ // Create a proper mock for testing - we'll test the address building logic separately
431431+ let strategies = create_test_strategies();
432432+433433+ let exact = strategies.build_exact_address(&components).unwrap();
434434+ assert_eq!(exact, "Place des Arts, 175 Rue Sainte-Catherine Ouest, Montréal, QC, H2X 1Z8, Canada");
435435+ }
436436+437437+ #[test]
438438+ fn test_simplify_street_address() {
439439+ let strategies = create_test_strategies();
440440+441441+ assert_eq!(
442442+ strategies.simplify_street_address("123 Main St, Apt 456"),
443443+ "123 Main St"
444444+ );
445445+446446+ assert_eq!(
447447+ strategies.simplify_street_address("1000-1010 Sherbrooke St"),
448448+ "Sherbrooke St"
449449+ );
450450+451451+ assert_eq!(
452452+ strategies.simplify_street_address("12345 Complex Ave, Unit 4B"),
453453+ "Complex Ave"
454454+ );
455455+ }
456456+457457+ #[test]
458458+ fn test_is_street_address_like() {
459459+ let strategies = create_test_strategies();
460460+461461+ assert!(strategies.is_street_address_like("123 Main Street"));
462462+ assert!(strategies.is_street_address_like("Rue Sainte-Catherine"));
463463+ assert!(strategies.is_street_address_like("Boulevard Saint-Laurent"));
464464+ assert!(!strategies.is_street_address_like("Place des Arts"));
465465+ assert!(!strategies.is_street_address_like("McGill University"));
466466+ }
467467+}
+8-2
src/services/events/venue_integration.rs
···210210 ));
211211 }
212212213213+ let final_limit = limit.unwrap_or(10);
214214+215215+ // Request more results initially since many will be filtered out
216216+ // Use 3x the requested limit to account for filtering
217217+ let search_limit = (final_limit * 3).min(50); // Cap at 50 for performance
218218+213219 // Perform a full venue search to get both names and data
214220 let search_request = VenueSearchRequest {
215221 query: query.to_string(),
216222 language: language.map(|s| s.to_string()),
217217- limit,
223223+ limit: Some(search_limit),
218224 bounds: None,
219225 };
220226···252258253259 venue_name.map(|name| (name, venue))
254260 })
255255- .take(limit.unwrap_or(10))
261261+ .take(final_limit) // Apply the final limit after filtering
256262 .collect();
257263258264 debug!(
+2
src/services/mod.rs
···11pub mod geocoding;
22pub mod nominatim_client;
33+pub mod address_geocoding_strategies;
34pub mod venues;
45pub mod events;
56···89910pub use geocoding::{GeocodingService, GeocodingResult};
1011pub use nominatim_client::{NominatimClient, NominatimSearchResult, VenueMetadata, BilingualNames};
1212+pub use address_geocoding_strategies::{AddressGeocodingStrategies, AddressComponents, GeocodingStrategyResult};
1113pub use venues::{
1214 VenueSearchService, VenueSearchRequest, VenueSearchResponse, VenueNearbyRequest,
1315 VenueSearchResult, VenueDetails, VenueCategory, SearchRadius, handle_venue_search,
+99
src/services/nominatim_client.rs
···247247 Ok(result)
248248 }
249249250250+ /// Search for multiple addresses and return lexicon-compatible results
251251+ pub async fn search_address_multiple(&self, query: &str, limit: Option<usize>) -> Result<Vec<NominatimSearchResult>> {
252252+ if query.trim().is_empty() {
253253+ return Err(NominatimError::NoResults(query.to_string()).into());
254254+ }
255255+256256+ let limit = limit.unwrap_or(10).min(20); // Default 10, max 20 for performance
257257+258258+ // Check cache first (for single result, we'll use the existing cache for first result)
259259+ let cache_key = format!("{}:{}", CACHE_PREFIX_SEARCH, Self::hash_query(query));
260260+ let cached_first = self.get_cached_search_result(&cache_key).await.ok();
261261+262262+ // Perform multiple search
263263+ let results = self.search_multiple_with_retry(query, limit).await?;
264264+265265+ // Cache the first result if we don't have it cached
266266+ if cached_first.is_none() && !results.is_empty() {
267267+ if let Err(e) = self.cache_search_result(&cache_key, &results[0]).await {
268268+ tracing::warn!("Failed to cache search result: {}", e);
269269+ }
270270+271271+ // Cache enhanced venue metadata for first result
272272+ if let Err(e) = self.cache_venue_metadata(&results[0]).await {
273273+ tracing::warn!("Failed to cache venue metadata: {}", e);
274274+ }
275275+ }
276276+277277+ Ok(results)
278278+ }
279279+250280 /// Reverse geocode coordinates and return lexicon-compatible results
251281 pub async fn reverse_geocode(&self, lat: f64, lon: f64) -> Result<NominatimSearchResult> {
252282 // Validate coordinates
···314344 last_error = Some(e);
315345 if attempt < MAX_RETRIES {
316346 tracing::warn!("Reverse geocode attempt {} failed, retrying...", attempt + 1);
347347+ tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
348348+ }
349349+ }
350350+ }
351351+ }
352352+353353+ Err(last_error.unwrap())
354354+ }
355355+356356+ /// Internal multiple search implementation with retry logic
357357+ async fn search_multiple_with_retry(&self, query: &str, limit: usize) -> Result<Vec<NominatimSearchResult>> {
358358+ let mut last_error = None;
359359+360360+ for attempt in 0..=MAX_RETRIES {
361361+ match self.perform_search_multiple(query, limit).await {
362362+ Ok(results) => return Ok(results),
363363+ Err(e) => {
364364+ last_error = Some(e);
365365+ if attempt < MAX_RETRIES {
366366+ tracing::warn!("Multiple search attempt {} failed, retrying...", attempt + 1);
317367 tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
318368 }
319369 }
···353403 } else {
354404 Err(NominatimError::NoResults(query.to_string()).into())
355405 }
406406+ }
407407+408408+ /// Perform the actual search API call for multiple results
409409+ async fn perform_search_multiple(&self, query: &str, limit: usize) -> Result<Vec<NominatimSearchResult>> {
410410+ let url = format!(
411411+ "{}/search?format=json&q={}&limit={}&addressdetails=1&accept-language=fr-ca,fr,en",
412412+ self.base_url,
413413+ urlencoding::encode(query),
414414+ limit
415415+ );
416416+417417+ tracing::debug!("Nominatim multiple search URL: {}", url);
418418+419419+ let response = self.http_client.get(&url).send().await?;
420420+421421+ // Check for rate limiting
422422+ if response.status() == 429 {
423423+ return Err(NominatimError::RateLimited.into());
424424+ }
425425+426426+ // Check for service availability
427427+ if response.status() == 503 {
428428+ return Err(NominatimError::ServiceUnavailable.into());
429429+ }
430430+431431+ let data: Vec<NominatimApiResponse> = response.json().await
432432+ .map_err(|e| NominatimError::ParseError(e.to_string()))?;
433433+434434+ if data.is_empty() {
435435+ return Err(NominatimError::NoResults(query.to_string()).into());
436436+ }
437437+438438+ // Convert all results to lexicon format
439439+ let mut results = Vec::new();
440440+ for response in data {
441441+ match self.convert_nominatim_response_to_lexicon(&response) {
442442+ Ok(result) => results.push(result),
443443+ Err(e) => {
444444+ tracing::warn!("Failed to convert Nominatim response to lexicon: {}", e);
445445+ // Continue with other results rather than failing completely
446446+ }
447447+ }
448448+ }
449449+450450+ if results.is_empty() {
451451+ return Err(NominatimError::NoResults(query.to_string()).into());
452452+ }
453453+454454+ Ok(results)
356455 }
357456358457 /// Perform the actual reverse geocoding API call
+58-8
src/services/venues/venue_search.rs
···236236 return Err(VenueSearchError::QueryTooShort);
237237 }
238238239239+ let final_limit = limit.unwrap_or(10);
240240+241241+ // Request more results initially since many will be filtered out
242242+ // Use 3x the requested limit to account for filtering
243243+ let search_limit = (final_limit * 3).min(50); // Cap at 50 for performance
244244+239245 // For now, perform a simple search and extract venue names
240246 // TODO: Implement proper autocomplete with cached suggestions
241247 let search_request = VenueSearchRequest {
242248 query: query_prefix.to_string(),
243249 language: language.map(|s| s.to_string()),
244244- limit: limit.or(Some(10)),
250250+ limit: Some(search_limit),
245251 bounds: None,
246252 };
247253···253259 // Extract venue name prioritizing venue details over address
254260 if let Some(details) = &venue.details {
255261 let venue_name = details.bilingual_names.get_name_for_language(language);
256256- // Only use venue name if it's not empty and not just a street address
257257- if !venue_name.trim().is_empty() && !venue_name.to_lowercase().starts_with("rue ") && !venue_name.to_lowercase().starts_with("street ") {
262262+ // Use venue name if it's not empty and not obviously a street address
263263+ if !venue_name.trim().is_empty() && !is_obvious_street_address(&venue_name) {
258264 return Some(venue_name.to_string());
259265 }
260266 }
261267262262- // Fall back to address name if it looks like a venue name (not a street)
268268+ // Fall back to address name if it's not obviously a street address
263269 if let Some(address_name) = venue.address.name() {
264264- if !address_name.to_lowercase().starts_with("rue ") && !address_name.to_lowercase().starts_with("street ") {
270270+ if !is_obvious_street_address(&address_name) {
265271 return Some(address_name);
266272 }
267273 }
268274269269- // Skip if we only have street addresses
275275+ // If we have locality information, include it as a venue suggestion
276276+ // This helps with places like "centre" in "Mont-Carmel"
277277+ if let crate::atproto::lexicon::community::lexicon::location::Address::Current { locality: Some(locality), .. } = &venue.address {
278278+ if !locality.trim().is_empty() && !is_obvious_street_address(locality) {
279279+ return Some(locality.clone());
280280+ }
281281+ }
282282+270283 None
271284 })
285285+ .take(final_limit) // Apply the final limit after filtering
272286 .collect();
273287274288 Ok(suggestions)
···293307294308 /// Perform Nominatim search with proper error handling
295309 async fn perform_nominatim_search(&self, request: &VenueSearchRequest) -> Result<Vec<NominatimSearchResult>, VenueSearchError> {
296296- match self.nominatim_client.search_address(&request.query).await {
297297- Ok(result) => Ok(vec![result]),
310310+ // Use the multiple search method to get more results
311311+ let limit = request.limit.unwrap_or(10);
312312+ match self.nominatim_client.search_address_multiple(&request.query, Some(limit)).await {
313313+ Ok(results) => Ok(results),
298314 Err(e) => {
299315 // Convert the anyhow error by checking its error chain for specific Nominatim errors
300316 let error_str = e.to_string();
···347363 cache_enhanced: enhanced_details.is_some(),
348364 })
349365 }
366366+}
367367+368368+/// Check if a name is obviously a street address rather than a venue name
369369+fn is_obvious_street_address(name: &str) -> bool {
370370+ let name_lower = name.to_lowercase();
371371+372372+ // Common street prefixes in French and English
373373+ let street_prefixes = [
374374+ "rue ", "street ", "avenue ", "av ", "boulevard ", "boul ", "blvd ",
375375+ "chemin ", "route ", "autoroute ", "place ", "square ", "circle ",
376376+ "drive ", "dr ", "road ", "rd ", "lane ", "ln ", "way ", "court ", "ct "
377377+ ];
378378+379379+ // Check if starts with obvious street indicators
380380+ for prefix in &street_prefixes {
381381+ if name_lower.starts_with(prefix) {
382382+ return true;
383383+ }
384384+ }
385385+386386+ // Check for numeric street patterns like "123 Main St" or "45e Avenue"
387387+ if name_lower.chars().next().map_or(false, |c| c.is_ascii_digit()) {
388388+ return true;
389389+ }
390390+391391+ // Check for ordinal street patterns like "1ere Avenue", "2e Rue"
392392+ if name_lower.contains("e rue") || name_lower.contains("e avenue") ||
393393+ name_lower.contains("ere rue") || name_lower.contains("ere avenue") ||
394394+ name_lower.contains("nd street") || name_lower.contains("rd street") ||
395395+ name_lower.contains("th street") || name_lower.contains("st street") {
396396+ return true;
397397+ }
398398+399399+ false
350400}
351401352402/// Extension trait for Address to provide name access