Skip to main content

use_listing/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn non_empty(value: impl AsRef<str>, field: &'static str) -> Result<String, ListingValueError> {
8    let trimmed = value.as_ref().trim();
9    if trimmed.is_empty() {
10        Err(ListingValueError::Empty { field })
11    } else {
12        Ok(trimmed.to_string())
13    }
14}
15
16fn is_http_url(value: &str) -> bool {
17    let lower = value.to_ascii_lowercase();
18    (lower.starts_with("https://") || lower.starts_with("http://")) && value.contains('.')
19}
20
21/// Error returned by listing primitive constructors.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum ListingValueError {
24    /// The supplied value was empty after trimming whitespace.
25    Empty { field: &'static str },
26    /// A URL did not look like an HTTP or HTTPS URL.
27    InvalidUrl,
28}
29
30impl fmt::Display for ListingValueError {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
34            Self::InvalidUrl => {
35                formatter.write_str("listing URL must start with http:// or https://")
36            },
37        }
38    }
39}
40
41impl Error for ListingValueError {}
42
43/// A business or directory listing name.
44#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub struct ListingName(String);
46
47impl ListingName {
48    /// Creates a listing name.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`ListingValueError::Empty`] when the name is empty.
53    pub fn new(value: impl AsRef<str>) -> Result<Self, ListingValueError> {
54        non_empty(value, "listing name").map(Self)
55    }
56
57    /// Returns the listing name.
58    #[must_use]
59    pub fn as_str(&self) -> &str {
60        &self.0
61    }
62}
63
64impl AsRef<str> for ListingName {
65    fn as_ref(&self) -> &str {
66        self.as_str()
67    }
68}
69
70impl fmt::Display for ListingName {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        formatter.write_str(self.as_str())
73    }
74}
75
76impl FromStr for ListingName {
77    type Err = ListingValueError;
78
79    fn from_str(value: &str) -> Result<Self, Self::Err> {
80        Self::new(value)
81    }
82}
83
84/// A listing profile URL.
85#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
86pub struct ListingUrl(String);
87
88impl ListingUrl {
89    /// Creates a listing URL from an HTTP or HTTPS URL-like string.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`ListingValueError::InvalidUrl`] when the URL shape is unsupported.
94    pub fn new(value: impl AsRef<str>) -> Result<Self, ListingValueError> {
95        let trimmed = non_empty(value, "listing URL")?;
96        if is_http_url(&trimmed) {
97            Ok(Self(trimmed))
98        } else {
99            Err(ListingValueError::InvalidUrl)
100        }
101    }
102
103    /// Returns the URL string.
104    #[must_use]
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108}
109
110impl AsRef<str> for ListingUrl {
111    fn as_ref(&self) -> &str {
112        self.as_str()
113    }
114}
115
116impl fmt::Display for ListingUrl {
117    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118        formatter.write_str(self.as_str())
119    }
120}
121
122/// A directory, listing, or citation provider label.
123#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
124pub struct ListingProvider(String);
125
126impl ListingProvider {
127    /// Creates a provider label.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`ListingValueError::Empty`] when the provider is empty.
132    pub fn new(value: impl AsRef<str>) -> Result<Self, ListingValueError> {
133        non_empty(value, "listing provider").map(Self)
134    }
135
136    /// Returns the provider label.
137    #[must_use]
138    pub fn as_str(&self) -> &str {
139        &self.0
140    }
141}
142
143impl AsRef<str> for ListingProvider {
144    fn as_ref(&self) -> &str {
145        self.as_str()
146    }
147}
148
149impl fmt::Display for ListingProvider {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        formatter.write_str(self.as_str())
152    }
153}
154
155/// A name/address/phone record used for consistency checks.
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct NapRecord {
158    name: String,
159    address: String,
160    phone: String,
161}
162
163impl NapRecord {
164    /// Creates a name/address/phone record.
165    ///
166    /// # Errors
167    ///
168    /// Returns [`ListingValueError::Empty`] when any component is empty.
169    pub fn new(
170        name: impl AsRef<str>,
171        address: impl AsRef<str>,
172        phone: impl AsRef<str>,
173    ) -> Result<Self, ListingValueError> {
174        Ok(Self {
175            name: non_empty(name, "name")?,
176            address: non_empty(address, "address")?,
177            phone: non_empty(phone, "phone")?,
178        })
179    }
180
181    /// Returns the name component.
182    #[must_use]
183    pub fn name(&self) -> &str {
184        &self.name
185    }
186
187    /// Returns the address component.
188    #[must_use]
189    pub fn address(&self) -> &str {
190        &self.address
191    }
192
193    /// Returns the phone component.
194    #[must_use]
195    pub fn phone(&self) -> &str {
196        &self.phone
197    }
198}
199
200/// Consistency result for name/address/phone fields.
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
202pub struct NapConsistency {
203    pub matches_name: bool,
204    pub matches_address: bool,
205    pub matches_phone: bool,
206}
207
208impl NapConsistency {
209    /// Compares two name/address/phone records with case-insensitive text matching.
210    #[must_use]
211    pub fn compare(expected: &NapRecord, observed: &NapRecord) -> Self {
212        Self {
213            matches_name: expected.name.eq_ignore_ascii_case(&observed.name),
214            matches_address: expected.address.eq_ignore_ascii_case(&observed.address),
215            matches_phone: expected.phone == observed.phone,
216        }
217    }
218
219    /// Returns `true` when all fields match.
220    #[must_use]
221    pub const fn is_consistent(self) -> bool {
222        self.matches_name && self.matches_address && self.matches_phone
223    }
224
225    /// Returns a simple `0.0..=1.0` consistency score.
226    #[must_use]
227    pub fn score(self) -> f32 {
228        let matches = u8::from(self.matches_name)
229            + u8::from(self.matches_address)
230            + u8::from(self.matches_phone);
231        f32::from(matches) / 3.0
232    }
233}
234
235/// Status label for a listing profile.
236#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
237pub enum ListingStatus {
238    /// The listing is claimed or verified.
239    Claimed,
240    /// The listing is not claimed.
241    Unclaimed,
242    /// The listing is pending verification or publication.
243    Pending,
244    /// The listing is suppressed or hidden.
245    Suppressed,
246    /// The listing is a duplicate.
247    Duplicate,
248    /// The status is unknown.
249    Unknown,
250}
251
252/// A citation mention for a listing.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct Citation {
255    name: ListingName,
256    provider: ListingProvider,
257    url: Option<ListingUrl>,
258}
259
260impl Citation {
261    /// Creates a citation from a name and provider.
262    #[must_use]
263    pub const fn new(name: ListingName, provider: ListingProvider) -> Self {
264        Self {
265            name,
266            provider,
267            url: None,
268        }
269    }
270
271    /// Sets the citation URL.
272    #[must_use]
273    pub fn with_url(mut self, url: ListingUrl) -> Self {
274        self.url = Some(url);
275        self
276    }
277
278    /// Returns the provider.
279    #[must_use]
280    pub const fn provider(&self) -> &ListingProvider {
281        &self.provider
282    }
283}
284
285/// A business listing profile descriptor.
286#[derive(Clone, Debug, Eq, PartialEq)]
287pub struct ListingProfile {
288    name: ListingName,
289    provider: ListingProvider,
290    status: ListingStatus,
291    url: Option<ListingUrl>,
292    citation: Option<Citation>,
293    nap_record: Option<NapRecord>,
294}
295
296impl ListingProfile {
297    /// Creates a listing profile.
298    #[must_use]
299    pub const fn new(name: ListingName, provider: ListingProvider) -> Self {
300        Self {
301            name,
302            provider,
303            status: ListingStatus::Unknown,
304            url: None,
305            citation: None,
306            nap_record: None,
307        }
308    }
309
310    /// Sets the listing status.
311    #[must_use]
312    pub const fn with_status(mut self, status: ListingStatus) -> Self {
313        self.status = status;
314        self
315    }
316
317    /// Sets the profile URL.
318    #[must_use]
319    pub fn with_url(mut self, url: ListingUrl) -> Self {
320        self.url = Some(url);
321        self
322    }
323
324    /// Sets the citation.
325    #[must_use]
326    pub fn with_citation(mut self, citation: Citation) -> Self {
327        self.citation = Some(citation);
328        self
329    }
330
331    /// Sets the name/address/phone record.
332    #[must_use]
333    pub fn with_nap_record(mut self, record: NapRecord) -> Self {
334        self.nap_record = Some(record);
335        self
336    }
337
338    /// Returns the status.
339    #[must_use]
340    pub const fn status(&self) -> ListingStatus {
341        self.status
342    }
343
344    /// Returns the listing name.
345    #[must_use]
346    pub const fn name(&self) -> &ListingName {
347        &self.name
348    }
349
350    /// Returns the provider.
351    #[must_use]
352    pub const fn provider(&self) -> &ListingProvider {
353        &self.provider
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::{
360        Citation, ListingName, ListingProfile, ListingProvider, ListingStatus, ListingUrl,
361        NapConsistency, NapRecord,
362    };
363
364    #[test]
365    fn validates_listing_url_shape() {
366        assert!(ListingUrl::new("https://example.com/listing").is_ok());
367        assert!(ListingUrl::new("example.com/listing").is_err());
368    }
369
370    #[test]
371    fn scores_nap_consistency() {
372        let expected = NapRecord::new("Example Cafe", "1 Main St", "+1-555").unwrap();
373        let observed = NapRecord::new("example cafe", "1 Main St", "+1-000").unwrap();
374        let consistency = NapConsistency::compare(&expected, &observed);
375
376        assert!(!consistency.is_consistent());
377        assert!((consistency.score() - (2.0 / 3.0)).abs() < f32::EPSILON);
378    }
379
380    #[test]
381    fn builds_listing_profile() {
382        let name = ListingName::new("Example Cafe").unwrap();
383        let provider = ListingProvider::new("Directory").unwrap();
384        let citation = Citation::new(name.clone(), provider.clone());
385        let profile = ListingProfile::new(name, provider)
386            .with_status(ListingStatus::Claimed)
387            .with_citation(citation);
388
389        assert_eq!(profile.status(), ListingStatus::Claimed);
390        assert_eq!(profile.provider().as_str(), "Directory");
391    }
392}