Skip to main content

use_domain/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// Stores a normalized domain name and its labels.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Domain {
7    /// Normalized domain value.
8    pub value: String,
9    /// Individual domain labels.
10    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
44/// Returns `true` when the input is a valid ASCII domain name with at least one dot.
45pub fn is_valid_domain(input: &str) -> bool {
46    normalize_candidate(input).is_some_and(|domain| domain.contains('.'))
47}
48
49/// Returns `true` when the input is a valid ASCII hostname or domain name.
50pub fn is_valid_hostname(input: &str) -> bool {
51    normalize_candidate(input).is_some()
52}
53
54/// Normalizes a valid domain name.
55pub fn normalize_domain(input: &str) -> Option<String> {
56    normalize_candidate(input).filter(|domain| domain.contains('.'))
57}
58
59/// Splits a valid domain name into labels.
60pub 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
66/// Returns the number of labels in a valid domain name.
67pub fn domain_depth(input: &str) -> usize {
68    split_domain_labels(input).len()
69}
70
71/// Returns a naive root-domain guess based on the last two labels.
72pub 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
82/// Returns `true` when the domain has more than two labels.
83pub fn has_subdomain(input: &str) -> bool {
84    domain_depth(input) > 2
85}
86
87/// Returns `true` when the normalized domain is ASCII.
88pub fn is_ascii_domain(input: &str) -> bool {
89    normalize_domain(input).is_some_and(|domain| domain.is_ascii())
90}