1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Domain {
7 pub value: String,
9 pub labels: Vec<String>,
11}
12
13fn is_valid_label(label: &str) -> bool {
14 !label.is_empty()
15 && label.len() <= 63
16 && !label.starts_with('-')
17 && !label.ends_with('-')
18 && label
19 .chars()
20 .all(|character| character.is_ascii_alphanumeric() || character == '-')
21}
22
23fn normalize_candidate(input: &str) -> Option<String> {
24 let trimmed = input.trim().trim_end_matches('.');
25
26 if trimmed.is_empty()
27 || trimmed.len() > 253
28 || trimmed.contains(':')
29 || trimmed.contains('/')
30 || trimmed.chars().any(char::is_whitespace)
31 {
32 return None;
33 }
34
35 let normalized = trimmed.to_ascii_lowercase();
36
37 if normalized.split('.').all(is_valid_label) {
38 Some(normalized)
39 } else {
40 None
41 }
42}
43
44pub fn is_valid_domain(input: &str) -> bool {
46 normalize_candidate(input).is_some_and(|domain| domain.contains('.'))
47}
48
49pub fn is_valid_hostname(input: &str) -> bool {
51 normalize_candidate(input).is_some()
52}
53
54pub fn normalize_domain(input: &str) -> Option<String> {
56 normalize_candidate(input).filter(|domain| domain.contains('.'))
57}
58
59pub fn split_domain_labels(input: &str) -> Vec<String> {
61 normalize_domain(input)
62 .map(|domain| domain.split('.').map(String::from).collect())
63 .unwrap_or_default()
64}
65
66pub fn domain_depth(input: &str) -> usize {
68 split_domain_labels(input).len()
69}
70
71pub fn root_domain_guess(input: &str) -> Option<String> {
73 let labels = split_domain_labels(input);
74
75 if labels.len() >= 2 {
76 Some(labels[labels.len() - 2..].join("."))
77 } else {
78 None
79 }
80}
81
82pub fn has_subdomain(input: &str) -> bool {
84 domain_depth(input) > 2
85}
86
87pub fn is_ascii_domain(input: &str) -> bool {
89 normalize_domain(input).is_some_and(|domain| domain.is_ascii())
90}