geolite 0.0.12

open source geocode solution
use super::policy::house_number_policy;

#[derive(Clone, Debug)]
pub struct house_number {
  stored_form: String,
  key: Option<String>,
  shape: house_number_shape,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum house_number_shape {
  simple,
  suffixed,
  compound,
  free,
}

impl PartialEq for house_number {
  fn eq(&self, other: &Self) -> bool {
    self.comparison_key() == other.comparison_key()
  }
}

impl house_number {
  pub fn normalize(raw: &str, policy: &house_number_policy) -> Option<Self> {
    let trimmed = raw.trim_matches(' ');
    if trimmed.is_empty() {
      return None;
    }
    if policy
      .drop_values
      .iter()
      .any(|value| value.eq_ignore_ascii_case(trimmed))
    {
      return None;
    }
    Some(Self::from_text(&canonical_suffix(trimmed)))
  }

  pub fn recognize(token: &str, policy: &house_number_policy) -> Option<Self> {
    let token = token.trim_matches(' ');
    let core = token.strip_suffix(',').unwrap_or(token);
    let core = if policy.allow_hash_prefix {
      core.strip_prefix('#').unwrap_or(core).trim_start_matches(' ')
    } else {
      core
    };
    if core.is_empty() {
      return None;
    }
    let shape = classify(core);
    if !policy.shapes.contains(&shape) {
      return None;
    }
    if !digits_within(core, shape, policy.max_digits) {
      return None;
    }
    Some(Self::from_text(core))
  }

  pub fn from_stored(text: &str) -> Self {
    Self::from_text(text)
  }

  fn from_text(text: &str) -> Self {
    let key = comparison_key_of(text);
    Self {
      key: (key != text).then_some(key),
      shape: classify(text),
      stored_form: text.to_string(),
    }
  }

  pub fn stored_form(&self) -> &str {
    &self.stored_form
  }

  pub fn comparison_key(&self) -> &str {
    self.key.as_deref().unwrap_or(&self.stored_form)
  }

  pub fn shape(&self) -> house_number_shape {
    self.shape
  }

  pub fn leading_value(&self) -> Option<u32> {
    let digits: String = self
      .stored_form
      .chars()
      .take_while(char::is_ascii_digit)
      .collect();
    digits.parse().ok()
  }
}

fn canonical_suffix(text: &str) -> String {
  let chars: Vec<char> = text.chars().collect();
  let length = chars.len();
  let last = chars[length - 1];
  if !last.is_ascii_alphabetic() {
    return text.to_string();
  }
  if length >= 3 && (chars[length - 2] == ' ' || chars[length - 2] == '-') {
    let prefix = &chars[..length - 2];
    if prefix.iter().all(char::is_ascii_digit) {
      return suffixed(prefix, last);
    }
  }
  if length >= 2 {
    let prefix = &chars[..length - 1];
    if prefix.iter().all(char::is_ascii_digit) {
      return suffixed(prefix, last);
    }
  }
  text.to_string()
}

fn suffixed(digits: &[char], letter: char) -> String {
  let mut out: String = digits.iter().collect();
  out.push(letter.to_ascii_uppercase());
  out
}

fn comparison_key_of(text: &str) -> String {
  let stripped = text
    .strip_prefix('#')
    .unwrap_or(text)
    .trim_matches(' ')
    .to_ascii_uppercase();
  match split_compound(&stripped) {
    Some((first, second)) => format!("{first}-{second}"),
    None => stripped,
  }
}

fn classify(text: &str) -> house_number_shape {
  let chars: Vec<char> = text.chars().collect();
  if chars.is_empty() {
    return house_number_shape::free;
  }
  if chars.iter().all(char::is_ascii_digit) {
    return house_number_shape::simple;
  }
  let digits = chars.iter().take_while(|c| c.is_ascii_digit()).count();
  if digits > 0 && chars.len() - digits == 1 && chars[digits].is_ascii_alphabetic() {
    return house_number_shape::suffixed;
  }
  if split_compound(&text.to_ascii_uppercase()).is_some() {
    return house_number_shape::compound;
  }
  house_number_shape::free
}

fn split_compound(text: &str) -> Option<(String, String)> {
  let chars: Vec<char> = text.chars().collect();
  let mut at = 0;

  let first_digits = run_of_digits(&chars, &mut at);
  if first_digits.is_empty() || (first_digits.len() > 1 && first_digits.starts_with('0')) {
    return None;
  }
  let first_letter = optional_letter(&chars, &mut at);
  let separated_by_hyphen = chars.get(at) == Some(&'-');
  if separated_by_hyphen {
    at += 1;
  }
  if !separated_by_hyphen && first_letter.is_none() {
    return None;
  }

  let second_digits = run_of_digits(&chars, &mut at);
  if second_digits.is_empty() {
    return None;
  }
  let second_letter = optional_letter(&chars, &mut at);
  if at != chars.len() {
    return None;
  }

  Some((
    part(&first_digits, first_letter),
    part(&second_digits, second_letter),
  ))
}

fn run_of_digits(chars: &[char], at: &mut usize) -> String {
  let start = *at;
  while chars.get(*at).is_some_and(char::is_ascii_digit) {
    *at += 1;
  }
  chars[start..*at].iter().collect()
}

fn optional_letter(chars: &[char], at: &mut usize) -> Option<char> {
  match chars.get(*at) {
    Some(c) if c.is_ascii_alphabetic() => {
      *at += 1;
      Some(*c)
    }
    _ => None,
  }
}

fn part(digits: &str, letter: Option<char>) -> String {
  match letter {
    Some(c) => format!("{digits}{}", c.to_ascii_uppercase()),
    None => digits.to_string(),
  }
}

fn digits_within(text: &str, shape: house_number_shape, max_digits: u8) -> bool {
  let max = max_digits as usize;
  match shape {
    house_number_shape::simple | house_number_shape::suffixed => {
      let digits = text.chars().take_while(char::is_ascii_digit).count();
      (1..=max).contains(&digits)
    }
    house_number_shape::compound => match split_compound(&text.to_ascii_uppercase()) {
      Some((first, second)) => [first, second].iter().all(|p| {
        let digits = p.chars().take_while(char::is_ascii_digit).count();
        (1..=max).contains(&digits)
      }),
      None => false,
    },
    house_number_shape::free => false,
  }
}

#[cfg(test)]
#[path = "value.test.rs"]
mod tests;