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//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use crate::error::IdValidationError;
14use crate::{DbValue, ToDbValue};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use std::fmt;
18
19const MAX_LEN: usize = 35;
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
22#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
23#[cfg_attr(feature = "sqlx", sqlx(transparent))]
24#[serde(transparent)]
25pub struct LocaleCode(String);
26
27impl LocaleCode {
28    pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
29        let value = value.into();
30        if value.is_empty() {
31            return Err(IdValidationError::empty("LocaleCode"));
32        }
33        if value.len() > MAX_LEN {
34            return Err(IdValidationError::invalid(
35                "LocaleCode",
36                "exceeds 35 characters",
37            ));
38        }
39        let mut subtags = value.split('-');
40        let primary = subtags.next().unwrap_or("");
41        let plen = primary.len();
42        if !(2..=3).contains(&plen) || !primary.chars().all(|c| c.is_ascii_lowercase()) {
43            return Err(IdValidationError::invalid(
44                "LocaleCode",
45                "primary subtag must be 2-3 lowercase ASCII letters",
46            ));
47        }
48        for sub in subtags {
49            let len = sub.len();
50            if !(2..=8).contains(&len) || !sub.chars().all(|c| c.is_ascii_alphanumeric()) {
51                return Err(IdValidationError::invalid(
52                    "LocaleCode",
53                    "subtag must be 2-8 alphanumeric ASCII characters",
54                ));
55            }
56        }
57        Ok(Self(value))
58    }
59
60    #[must_use]
61    #[expect(
62        clippy::expect_used,
63        reason = "infallible constructor reserved for already-validated inputs; untrusted input \
64                  must go through try_new"
65    )]
66    pub fn new(value: impl Into<String>) -> Self {
67        Self::try_new(value).expect("LocaleCode validation failed")
68    }
69
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76impl fmt::Display for LocaleCode {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "{}", self.0)
79    }
80}
81
82impl TryFrom<String> for LocaleCode {
83    type Error = IdValidationError;
84
85    fn try_from(s: String) -> Result<Self, Self::Error> {
86        Self::try_new(s)
87    }
88}
89
90impl TryFrom<&str> for LocaleCode {
91    type Error = IdValidationError;
92
93    fn try_from(s: &str) -> Result<Self, Self::Error> {
94        Self::try_new(s)
95    }
96}
97
98impl std::str::FromStr for LocaleCode {
99    type Err = IdValidationError;
100
101    fn from_str(s: &str) -> Result<Self, Self::Err> {
102        Self::try_new(s)
103    }
104}
105
106impl AsRef<str> for LocaleCode {
107    fn as_ref(&self) -> &str {
108        &self.0
109    }
110}
111
112impl<'de> Deserialize<'de> for LocaleCode {
113    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114    where
115        D: serde::Deserializer<'de>,
116    {
117        let s = String::deserialize(deserializer)?;
118        Self::try_new(s).map_err(serde::de::Error::custom)
119    }
120}
121
122impl ToDbValue for LocaleCode {
123    fn to_db_value(&self) -> DbValue {
124        DbValue::String(self.0.clone())
125    }
126}
127
128impl ToDbValue for &LocaleCode {
129    fn to_db_value(&self) -> DbValue {
130        DbValue::String(self.0.clone())
131    }
132}