Skip to main content

whitaker_common/i18n/
selection.rs

1//! Locale resolver wiring explicit overrides, environment variables, and
2//! configuration before falling back to the bundled localizer.
3
4use std::fmt;
5
6use log::{debug, warn};
7
8use super::{Localizer, supports_locale};
9
10/// Source for a resolved locale.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum LocaleSource {
13    /// Locale supplied explicitly by the caller.
14    ExplicitArgument,
15    /// Locale sourced from the `DYLINT_LOCALE` environment variable.
16    EnvironmentVariable,
17    /// Locale taken from `dylint.toml` configuration.
18    Configuration,
19    /// Fallback locale bundled with the Whitaker suite.
20    Fallback,
21}
22
23impl fmt::Display for LocaleSource {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::ExplicitArgument => formatter.write_str("explicit locale override"),
27            Self::EnvironmentVariable => formatter.write_str("DYLINT_LOCALE"),
28            Self::Configuration => formatter.write_str("configuration locale"),
29            Self::Fallback => formatter.write_str("fallback locale"),
30        }
31    }
32}
33
34/// Outcome of locale resolution including the effective localizer and provenance.
35#[derive(Clone, Debug)]
36pub struct LocaleSelection {
37    localizer: Localizer,
38    source: LocaleSource,
39    requested: Option<String>,
40}
41
42impl LocaleSelection {
43    const fn new(localizer: Localizer, source: LocaleSource, requested: Option<String>) -> Self {
44        Self {
45            localizer,
46            source,
47            requested,
48        }
49    }
50
51    /// Returns the effective locale source.
52    #[must_use]
53    pub const fn source(&self) -> LocaleSource {
54        self.source
55    }
56
57    /// Returns the locale requested by the resolved source, if any.
58    #[must_use]
59    pub fn requested(&self) -> Option<&str> {
60        self.requested.as_deref()
61    }
62
63    /// Returns the resolved locale tag.
64    #[must_use]
65    pub fn locale(&self) -> &str {
66        self.localizer.locale()
67    }
68
69    /// Whether the fallback locale was used.
70    #[must_use]
71    pub fn used_fallback(&self) -> bool {
72        self.localizer.used_fallback()
73    }
74
75    /// Returns the resolved [`Localizer`].
76    #[must_use]
77    pub fn localizer(&self) -> &Localizer {
78        &self.localizer
79    }
80
81    /// Consumes the selection, yielding the [`Localizer`].
82    #[must_use]
83    pub fn into_localizer(self) -> Localizer {
84        self.localizer
85    }
86
87    /// Emit a debug log summarizing the resolved locale.
88    pub fn log_outcome(&self, target: &str) {
89        debug!(
90            target: target,
91            "resolved {} to `{}`",
92            self.source(),
93            self.locale(),
94        );
95    }
96}
97
98/// Attempt to resolve a locale candidate from the given source.
99fn try_resolve_candidate(source: LocaleSource, raw: Option<&str>) -> Option<LocaleSelection> {
100    let candidate = normalise_locale(raw)?;
101
102    if supports_locale(candidate) {
103        return Some(LocaleSelection::new(
104            Localizer::new(Some(candidate)),
105            source,
106            Some(candidate.to_owned()),
107        ));
108    }
109
110    warn!(
111        target: "i18n::selection",
112        "skipping unsupported {source} `{candidate}`; continuing locale resolution",
113    );
114
115    None
116}
117
118/// Resolve a locale using explicit, environment, and configuration overrides.
119///
120/// The resolver considers candidates in the following order:
121///
122/// 1. The explicit locale supplied by the caller.
123/// 2. The `DYLINT_LOCALE` environment variable.
124/// 3. The workspace configuration (`dylint.toml`).
125/// 4. The embedded fallback when no candidate is valid.
126#[must_use]
127pub fn resolve_localizer(
128    explicit: Option<&str>,
129    environment: Option<String>,
130    configuration: Option<&str>,
131) -> LocaleSelection {
132    let candidates = [
133        (LocaleSource::ExplicitArgument, explicit),
134        (LocaleSource::EnvironmentVariable, environment.as_deref()),
135        (LocaleSource::Configuration, configuration),
136    ];
137
138    candidates
139        .into_iter()
140        .find_map(|(source, raw)| try_resolve_candidate(source, raw))
141        .unwrap_or_else(|| LocaleSelection::new(Localizer::new(None), LocaleSource::Fallback, None))
142}
143
144/// Trim whitespace and discard empty locale candidates.
145#[must_use]
146pub fn normalise_locale(input: Option<&str>) -> Option<&str> {
147    input.map(str::trim).filter(|value| !value.is_empty())
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use rstest::rstest;
154
155    #[derive(Clone, Copy, Debug)]
156    struct ResolutionCase {
157        explicit: Option<&'static str>,
158        environment: Option<&'static str>,
159        configuration: Option<&'static str>,
160        expected_source: LocaleSource,
161        expected_locale: &'static str,
162        expected_fallback: bool,
163    }
164
165    impl ResolutionCase {
166        fn environment(&self) -> Option<String> {
167            self.environment.map(String::from)
168        }
169    }
170
171    #[rstest]
172    #[case(ResolutionCase {
173        explicit: None,
174        environment: None,
175        configuration: None,
176        expected_source: LocaleSource::Fallback,
177        expected_locale: "en-GB",
178        expected_fallback: true,
179    })]
180    #[case(ResolutionCase {
181        explicit: Some("cy"),
182        environment: None,
183        configuration: None,
184        expected_source: LocaleSource::ExplicitArgument,
185        expected_locale: "cy",
186        expected_fallback: false,
187    })]
188    #[case(ResolutionCase {
189        explicit: None,
190        environment: Some("gd"),
191        configuration: None,
192        expected_source: LocaleSource::EnvironmentVariable,
193        expected_locale: "gd",
194        expected_fallback: false,
195    })]
196    #[case(ResolutionCase {
197        explicit: None,
198        environment: None,
199        configuration: Some("cy"),
200        expected_source: LocaleSource::Configuration,
201        expected_locale: "cy",
202        expected_fallback: false,
203    })]
204    #[case(ResolutionCase {
205        explicit: Some("zz"),
206        environment: Some("yy"),
207        configuration: Some("cy"),
208        expected_source: LocaleSource::Configuration,
209        expected_locale: "cy",
210        expected_fallback: false,
211    })]
212    fn resolves_sources(#[case] case: ResolutionCase) {
213        let selection = resolve_localizer(case.explicit, case.environment(), case.configuration);
214
215        assert_eq!(selection.source(), case.expected_source);
216        assert_eq!(selection.locale(), case.expected_locale);
217        assert_eq!(selection.used_fallback(), case.expected_fallback);
218    }
219
220    #[rstest]
221    #[case(None, None)]
222    #[case(Some(""), None)]
223    #[case(Some("  "), None)]
224    #[case(Some("cy"), Some("cy"))]
225    #[case(Some(" cy "), Some("cy"))]
226    fn normalises_candidates(#[case] input: Option<&str>, #[case] expected: Option<&str>) {
227        assert_eq!(normalise_locale(input), expected);
228    }
229}