1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::net::IpAddr;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum HostKind {
9 Ip,
11 Domain,
13 Localhost,
15 Unknown,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Host {
22 pub value: String,
24 pub kind: HostKind,
26}
27
28fn is_valid_label(label: &str) -> bool {
29 !label.is_empty()
30 && label.len() <= 63
31 && !label.starts_with('-')
32 && !label.ends_with('-')
33 && label
34 .chars()
35 .all(|character| character.is_ascii_alphanumeric() || character == '-')
36}
37
38fn looks_like_hostname(input: &str) -> bool {
39 let trimmed = input.trim_end_matches('.');
40
41 !trimmed.is_empty()
42 && trimmed.len() <= 253
43 && !trimmed.contains(':')
44 && !trimmed.contains('/')
45 && !trimmed.chars().any(char::is_whitespace)
46 && trimmed.split('.').all(is_valid_label)
47}
48
49pub fn strip_brackets(input: &str) -> &str {
51 if input.len() >= 2 && input.starts_with('[') && input.ends_with(']') {
52 &input[1..input.len() - 1]
53 } else {
54 input
55 }
56}
57
58pub fn bracket_ipv6_host(input: &str) -> String {
60 let trimmed = input.trim();
61 let stripped = strip_brackets(trimmed).trim();
62
63 match stripped.parse::<IpAddr>() {
64 Ok(IpAddr::V6(address)) => format!("[{address}]"),
65 _ => trimmed.to_string(),
66 }
67}
68
69pub fn detect_host_kind(input: &str) -> HostKind {
71 let trimmed = input.trim();
72
73 if trimmed.is_empty() {
74 return HostKind::Unknown;
75 }
76
77 let candidate = strip_brackets(trimmed).trim();
78
79 if candidate.eq_ignore_ascii_case("localhost") {
80 HostKind::Localhost
81 } else if candidate.parse::<IpAddr>().is_ok() {
82 HostKind::Ip
83 } else if looks_like_hostname(&candidate.to_ascii_lowercase()) {
84 HostKind::Domain
85 } else {
86 HostKind::Unknown
87 }
88}
89
90pub fn parse_host(input: &str) -> Option<Host> {
92 let value = normalize_host(input)?;
93 let kind = detect_host_kind(&value);
94
95 Some(Host { value, kind })
96}
97
98pub fn is_localhost(input: &str) -> bool {
100 matches!(detect_host_kind(input), HostKind::Localhost)
101}
102
103pub fn is_ip_host(input: &str) -> bool {
105 matches!(detect_host_kind(input), HostKind::Ip)
106}
107
108pub fn is_domain_host(input: &str) -> bool {
110 matches!(detect_host_kind(input), HostKind::Domain)
111}
112
113pub fn normalize_host(input: &str) -> Option<String> {
115 let trimmed = input.trim();
116
117 if trimmed.is_empty() {
118 return None;
119 }
120
121 let candidate = strip_brackets(trimmed).trim();
122
123 if candidate.eq_ignore_ascii_case("localhost") {
124 return Some(String::from("localhost"));
125 }
126
127 if let Ok(address) = candidate.parse::<IpAddr>() {
128 return Some(address.to_string());
129 }
130
131 let normalized = candidate.trim_end_matches('.').to_ascii_lowercase();
132
133 if looks_like_hostname(&normalized) {
134 Some(normalized)
135 } else {
136 None
137 }
138}