Skip to main content

systemprompt_identifiers/
locale.rs

1//! BCP-47-lite locale identifier.
2//!
3//! Accepts the common subset of language tags: a 2- or 3-letter primary
4//! subtag, optionally followed by additional `-`-separated subtags of 2-8
5//! alphanumerics each. Total length capped at 35 characters per RFC 5646.
6//! Comparison is case-sensitive on the lowercase primary subtag; callers
7//! that need full BCP-47 canonicalisation should add the `language-tags`
8//! crate when the requirement arrives.
9
10use crate::error::IdValidationError;
11use crate::{DbValue, ToDbValue};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::fmt;
15
16const MAX_LEN: usize = 35;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
19#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
20#[cfg_attr(feature = "sqlx", sqlx(transparent))]
21#[serde(transparent)]
22pub struct LocaleCode(String);
23
24impl LocaleCode {
25    pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
26        let value = value.into();
27        if value.is_empty() {
28            return Err(IdValidationError::empty("LocaleCode"));
29        }
30        if value.len() > MAX_LEN {
31            return Err(IdValidationError::invalid(
32                "LocaleCode",
33                "exceeds 35 characters",
34            ));
35        }
36        let mut subtags = value.split('-');
37        let primary = subtags.next().unwrap_or("");
38        let plen = primary.len();
39        if !(2..=3).contains(&plen) || !primary.chars().all(|c| c.is_ascii_lowercase()) {
40            return Err(IdValidationError::invalid(
41                "LocaleCode",
42                "primary subtag must be 2-3 lowercase ASCII letters",
43            ));
44        }
45        for sub in subtags {
46            let len = sub.len();
47            if !(2..=8).contains(&len) || !sub.chars().all(|c| c.is_ascii_alphanumeric()) {
48                return Err(IdValidationError::invalid(
49                    "LocaleCode",
50                    "subtag must be 2-8 alphanumeric ASCII characters",
51                ));
52            }
53        }
54        Ok(Self(value))
55    }
56
57    #[must_use]
58    #[expect(
59        clippy::expect_used,
60        reason = "infallible constructor reserved for already-validated inputs; untrusted input \
61                  must go through try_new"
62    )]
63    pub fn new(value: impl Into<String>) -> Self {
64        // SAFETY: `new` is the infallible constructor reserved for inputs the caller
65        // has already validated (compile-time literals, values that
66        // round-tripped through `try_new` at a boundary). Untrusted input must
67        // go through `try_new`.
68        Self::try_new(value).expect("LocaleCode validation failed")
69    }
70
71    #[must_use]
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77impl fmt::Display for LocaleCode {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "{}", self.0)
80    }
81}
82
83impl TryFrom<String> for LocaleCode {
84    type Error = IdValidationError;
85
86    fn try_from(s: String) -> Result<Self, Self::Error> {
87        Self::try_new(s)
88    }
89}
90
91impl TryFrom<&str> for LocaleCode {
92    type Error = IdValidationError;
93
94    fn try_from(s: &str) -> Result<Self, Self::Error> {
95        Self::try_new(s)
96    }
97}
98
99impl std::str::FromStr for LocaleCode {
100    type Err = IdValidationError;
101
102    fn from_str(s: &str) -> Result<Self, Self::Err> {
103        Self::try_new(s)
104    }
105}
106
107impl AsRef<str> for LocaleCode {
108    fn as_ref(&self) -> &str {
109        &self.0
110    }
111}
112
113impl<'de> Deserialize<'de> for LocaleCode {
114    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
115    where
116        D: serde::Deserializer<'de>,
117    {
118        let s = String::deserialize(deserializer)?;
119        Self::try_new(s).map_err(serde::de::Error::custom)
120    }
121}
122
123impl ToDbValue for LocaleCode {
124    fn to_db_value(&self) -> DbValue {
125        DbValue::String(self.0.clone())
126    }
127}
128
129impl ToDbValue for &LocaleCode {
130    fn to_db_value(&self) -> DbValue {
131        DbValue::String(self.0.clone())
132    }
133}