Skip to main content

kagi_sdk/
boundary.rs

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}