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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum ListingValueError {
24 Empty { field: &'static str },
26 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub struct ListingName(String);
46
47impl ListingName {
48 pub fn new(value: impl AsRef<str>) -> Result<Self, ListingValueError> {
54 non_empty(value, "listing name").map(Self)
55 }
56
57 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
86pub struct ListingUrl(String);
87
88impl ListingUrl {
89 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
124pub struct ListingProvider(String);
125
126impl ListingProvider {
127 pub fn new(value: impl AsRef<str>) -> Result<Self, ListingValueError> {
133 non_empty(value, "listing provider").map(Self)
134 }
135
136 #[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#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct NapRecord {
158 name: String,
159 address: String,
160 phone: String,
161}
162
163impl NapRecord {
164 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 #[must_use]
183 pub fn name(&self) -> &str {
184 &self.name
185 }
186
187 #[must_use]
189 pub fn address(&self) -> &str {
190 &self.address
191 }
192
193 #[must_use]
195 pub fn phone(&self) -> &str {
196 &self.phone
197 }
198}
199
200#[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 #[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 #[must_use]
221 pub const fn is_consistent(self) -> bool {
222 self.matches_name && self.matches_address && self.matches_phone
223 }
224
225 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
237pub enum ListingStatus {
238 Claimed,
240 Unclaimed,
242 Pending,
244 Suppressed,
246 Duplicate,
248 Unknown,
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct Citation {
255 name: ListingName,
256 provider: ListingProvider,
257 url: Option<ListingUrl>,
258}
259
260impl Citation {
261 #[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 #[must_use]
273 pub fn with_url(mut self, url: ListingUrl) -> Self {
274 self.url = Some(url);
275 self
276 }
277
278 #[must_use]
280 pub const fn provider(&self) -> &ListingProvider {
281 &self.provider
282 }
283}
284
285#[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 #[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 #[must_use]
312 pub const fn with_status(mut self, status: ListingStatus) -> Self {
313 self.status = status;
314 self
315 }
316
317 #[must_use]
319 pub fn with_url(mut self, url: ListingUrl) -> Self {
320 self.url = Some(url);
321 self
322 }
323
324 #[must_use]
326 pub fn with_citation(mut self, citation: Citation) -> Self {
327 self.citation = Some(citation);
328 self
329 }
330
331 #[must_use]
333 pub fn with_nap_record(mut self, record: NapRecord) -> Self {
334 self.nap_record = Some(record);
335 self
336 }
337
338 #[must_use]
340 pub const fn status(&self) -> ListingStatus {
341 self.status
342 }
343
344 #[must_use]
346 pub const fn name(&self) -> &ListingName {
347 &self.name
348 }
349
350 #[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}