use crate::error::{ParclError, Result};
use crate::models::{LocationType, Market, PaginatedResponse, SortBy, SortOrder, USRegion};
use crate::ParclClient;
pub struct SearchClient<'a> {
client: &'a ParclClient,
}
#[derive(Debug, Default, Clone)]
pub struct SearchParams {
pub query: Option<String>,
pub location_type: Option<LocationType>,
pub region: Option<USRegion>,
pub state_abbreviation: Option<String>,
pub state_fips_code: Option<String>,
pub parcl_id: Option<i64>,
pub geoid: Option<String>,
pub sort_by: Option<SortBy>,
pub sort_order: Option<SortOrder>,
pub limit: Option<u32>,
pub auto_paginate: bool,
}
impl SearchParams {
pub fn new() -> Self {
Self::default()
}
pub fn query(mut self, query: impl Into<String>) -> Self {
self.query = Some(query.into());
self
}
pub fn location_type(mut self, location_type: LocationType) -> Self {
self.location_type = Some(location_type);
self
}
pub fn region(mut self, region: USRegion) -> Self {
self.region = Some(region);
self
}
pub fn state(mut self, state: impl Into<String>) -> Self {
self.state_abbreviation = Some(state.into().to_uppercase());
self
}
pub fn state_fips_code(mut self, code: impl Into<String>) -> Self {
self.state_fips_code = Some(code.into());
self
}
pub fn parcl_id(mut self, parcl_id: i64) -> Self {
self.parcl_id = Some(parcl_id);
self
}
pub fn geoid(mut self, geoid: impl Into<String>) -> Self {
self.geoid = Some(geoid.into());
self
}
pub fn sort_by(mut self, sort_by: SortBy) -> Self {
self.sort_by = Some(sort_by);
self
}
pub fn sort_order(mut self, sort_order: SortOrder) -> Self {
self.sort_order = Some(sort_order);
self
}
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn auto_paginate(mut self, auto_paginate: bool) -> Self {
self.auto_paginate = auto_paginate;
self
}
pub(crate) fn to_query_string(&self) -> String {
let mut params = Vec::new();
if let Some(ref q) = self.query {
params.push(format!("query={}", urlencoding::encode(q)));
}
if let Some(lt) = self.location_type {
params.push(format!("location_type={}", lt.as_str()));
}
if let Some(r) = self.region {
params.push(format!("region={}", r.as_str()));
}
if let Some(ref s) = self.state_abbreviation {
params.push(format!("state_abbreviation={}", s));
}
if let Some(ref s) = self.state_fips_code {
params.push(format!("state_fips_code={}", s));
}
if let Some(id) = self.parcl_id {
params.push(format!("parcl_id={}", id));
}
if let Some(ref g) = self.geoid {
params.push(format!("geoid={}", g));
}
if let Some(sb) = self.sort_by {
params.push(format!("sort_by={}", sb.as_str()));
}
if let Some(so) = self.sort_order {
params.push(format!("sort_order={}", so.as_str()));
}
if let Some(l) = self.limit {
params.push(format!("limit={}", l));
}
if params.is_empty() {
String::new()
} else {
format!("?{}", params.join("&"))
}
}
}
impl<'a> SearchClient<'a> {
pub(crate) fn new(client: &'a ParclClient) -> Self {
Self { client }
}
pub async fn markets(&self, params: SearchParams) -> Result<PaginatedResponse<Market>> {
let query = params.to_query_string();
let url = format!("{}/v1/search/markets{}", self.client.base_url, query);
let mut response = self.fetch_page(&url).await?;
if params.auto_paginate {
while let Some(ref next_url) = response.links.next {
let next_page = self.fetch_page(next_url).await?;
self.client.update_credits(&next_page.account);
response.items.extend(next_page.items);
response.links = next_page.links;
}
}
self.client.update_credits(&response.account);
Ok(response)
}
async fn fetch_page(&self, url: &str) -> Result<PaginatedResponse<Market>> {
for attempt in 0..=self.client.retry_config.max_retries {
let response = self
.client
.http
.get(url)
.header("Authorization", &self.client.api_key)
.send()
.await?;
let status = response.status();
if status.as_u16() == 429 && attempt < self.client.retry_config.max_retries {
let backoff = self.client.retry_config.initial_backoff_ms * 2u64.pow(attempt);
tokio::time::sleep(std::time::Duration::from_millis(backoff)).await;
continue;
}
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
if status.as_u16() == 429 {
return Err(ParclError::RateLimited {
attempts: attempt + 1,
message,
});
}
return Err(ParclError::ApiError {
status: status.as_u16(),
message,
});
}
let data: PaginatedResponse<Market> = response.json().await?;
return Ok(data);
}
unreachable!()
}
}
mod urlencoding {
pub fn encode(input: &str) -> String {
let mut encoded = String::new();
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char);
}
b' ' => encoded.push_str("%20"),
_ => encoded.push_str(&format!("%{:02X}", byte)),
}
}
encoded
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{LocationType, SortBy, SortOrder, USRegion};
#[test]
fn search_params_default() {
let params = SearchParams::new();
assert!(params.query.is_none());
assert!(params.location_type.is_none());
assert!(!params.auto_paginate);
}
#[test]
fn search_params_builder() {
let params = SearchParams::new()
.query("Los Angeles")
.state("CA")
.location_type(LocationType::City)
.limit(10)
.auto_paginate(true);
assert_eq!(params.query, Some("Los Angeles".into()));
assert_eq!(params.state_abbreviation, Some("CA".into()));
assert_eq!(params.location_type, Some(LocationType::City));
assert_eq!(params.limit, Some(10));
assert!(params.auto_paginate);
}
#[test]
fn search_params_state_uppercase() {
let params = SearchParams::new().state("ca");
assert_eq!(params.state_abbreviation, Some("CA".into()));
}
#[test]
fn search_params_empty_query_string() {
let params = SearchParams::new();
assert_eq!(params.to_query_string(), "");
}
#[test]
fn search_params_query_string_single() {
let params = SearchParams::new().query("test");
assert_eq!(params.to_query_string(), "?query=test");
}
#[test]
fn search_params_query_string_multiple() {
let params = SearchParams::new()
.query("San Francisco")
.state("CA")
.limit(5);
let qs = params.to_query_string();
assert!(qs.starts_with('?'));
assert!(qs.contains("query=San%20Francisco"));
assert!(qs.contains("state_abbreviation=CA"));
assert!(qs.contains("limit=5"));
}
#[test]
fn search_params_query_string_all_fields() {
let params = SearchParams::new()
.query("test")
.location_type(LocationType::City)
.region(USRegion::Pacific)
.state("CA")
.state_fips_code("06")
.parcl_id(123)
.geoid("geo123")
.sort_by(SortBy::TotalPopulation)
.sort_order(SortOrder::Desc)
.limit(10);
let qs = params.to_query_string();
assert!(qs.contains("query=test"));
assert!(qs.contains("location_type=CITY"));
assert!(qs.contains("region=PACIFIC"));
assert!(qs.contains("state_abbreviation=CA"));
assert!(qs.contains("state_fips_code=06"));
assert!(qs.contains("parcl_id=123"));
assert!(qs.contains("geoid=geo123"));
assert!(qs.contains("sort_by=TOTAL_POPULATION"));
assert!(qs.contains("sort_order=DESC"));
assert!(qs.contains("limit=10"));
}
#[test]
fn urlencoding_basic() {
assert_eq!(urlencoding::encode("hello"), "hello");
assert_eq!(urlencoding::encode("hello world"), "hello%20world");
assert_eq!(urlencoding::encode("a+b"), "a%2Bb");
assert_eq!(urlencoding::encode("test@example"), "test%40example");
}
#[test]
fn urlencoding_preserves_safe_chars() {
assert_eq!(
urlencoding::encode("abc-123_456.789~xyz"),
"abc-123_456.789~xyz"
);
}
}