1use std::fmt;
2
3use url::Url;
4
5use crate::error::KagiError;
6
7#[derive(Clone, PartialEq, Eq, Hash)]
8pub struct NonEmptyString(String);
9
10impl NonEmptyString {
11 pub fn new(field: &'static str, value: impl Into<String>) -> Result<Self, KagiError> {
12 let candidate = value.into();
13 let trimmed = candidate.trim();
14
15 if trimmed.is_empty() {
16 return Err(KagiError::InvalidInput {
17 field,
18 reason: "value cannot be empty".to_string(),
19 });
20 }
21
22 Ok(Self(trimmed.to_string()))
23 }
24
25 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28}
29
30impl fmt::Debug for NonEmptyString {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 formatter
33 .debug_tuple("NonEmptyString")
34 .field(&self.0)
35 .finish()
36 }
37}
38
39#[derive(Clone, PartialEq, Eq, Hash)]
40pub struct NonBlankString(String);
41
42impl NonBlankString {
43 pub fn new(field: &'static str, value: impl Into<String>) -> Result<Self, KagiError> {
44 let candidate = value.into();
45 if candidate.trim().is_empty() {
46 return Err(KagiError::InvalidInput {
47 field,
48 reason: "value cannot be blank".to_string(),
49 });
50 }
51
52 Ok(Self(candidate))
53 }
54
55 pub fn as_str(&self) -> &str {
56 &self.0
57 }
58}
59
60impl fmt::Debug for NonBlankString {
61 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62 formatter
63 .debug_tuple("NonBlankString")
64 .field(&self.0)
65 .finish()
66 }
67}
68
69#[derive(Clone, PartialEq, Eq, Hash)]
70pub struct HttpUrl(String);
71
72impl HttpUrl {
73 pub fn new(field: &'static str, value: impl AsRef<str>) -> Result<Self, KagiError> {
74 let parsed = Url::parse(value.as_ref()).map_err(|source| KagiError::InvalidInput {
75 field,
76 reason: format!("invalid URL: {source}"),
77 })?;
78
79 if !matches!(parsed.scheme(), "http" | "https") {
80 return Err(KagiError::InvalidInput {
81 field,
82 reason: "URL must use http or https".to_string(),
83 });
84 }
85
86 Ok(Self(parsed.to_string()))
87 }
88
89 pub fn as_str(&self) -> &str {
90 &self.0
91 }
92}
93
94impl fmt::Debug for HttpUrl {
95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96 formatter.debug_tuple("HttpUrl").field(&self.0).finish()
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::{HttpUrl, NonBlankString, NonEmptyString};
103
104 #[test]
105 fn non_empty_string_rejects_blank_values() {
106 let result = NonEmptyString::new("query", " ");
107 assert!(result.is_err());
108 }
109
110 #[test]
111 fn http_url_rejects_non_http_scheme() {
112 let result = HttpUrl::new("url", "ftp://example.com");
113 assert!(result.is_err());
114 }
115
116 #[test]
117 fn http_url_normalizes_idn_host_to_punycode() {
118 let from_unicode =
119 HttpUrl::new("url", "https://bücher.example/search?q=kagi").expect("should parse");
120 let from_punycode = HttpUrl::new("url", "https://xn--bcher-kva.example/search?q=kagi")
121 .expect("should parse");
122
123 assert_eq!(from_unicode.as_str(), from_punycode.as_str());
124 assert_eq!(
125 from_unicode.as_str(),
126 "https://xn--bcher-kva.example/search?q=kagi"
127 );
128 }
129
130 #[test]
131 fn non_blank_string_preserves_original_whitespace() {
132 let parsed = NonBlankString::new("text", " keep me ").expect("should parse");
133 assert_eq!(parsed.as_str(), " keep me ");
134 }
135}