mf2 2.0.0

Unicode MessageFormat 2 for Rust, for Leptos web applications and native CLI and terminal applications: the one crate an application names — the call-site types, the Leptos layer, the native application support and its Ratatui text behind features, the formatter, and compile_str for ad-hoc messages.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
//! Negotiation: an **ordered list of typed sources and sinks**
//! (`plans/04-leptos-integration.md` §6, §11 item 5).
//!
//! Not a boolean matrix. The prior-art audit found ~60 hand-parsed
//! parameters covering the full `initial_language_from_<source>_to_<target>`
//! cross-product, and a live copy-paste bug inside it. Here a source is a
//! trait, a sink is a trait, and configuration is the order they are listed
//! in — so adding one is one implementation, not 2ⁿ new parameter names.
//!
//! The first source that yields a locale this build has **wins**; the
//! negotiated tag is then written to every sink, and serialized into the
//! page, so the client never negotiates again (§11 item 3).

use alloc::borrow::{Cow, ToOwned};
use alloc::format;
use alloc::string::String;
use alloc::sync::Arc;
use alloc::vec::Vec;

use ::http::header::{ACCEPT_LANGUAGE, COOKIE, HeaderName, HeaderValue, SET_COOKIE};
use ::http::request::Parts;
use mf2_catalog::Dir;

/// What negotiation decided, for one request.
#[derive(Clone, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub struct Negotiated {
    /// The tag, always one this build has.
    pub tag: &'static str,
    /// Its base direction, as the build recorded it.
    pub dir: Dir,
    /// Which source matched, or `"default"`.
    pub from: &'static str,
    /// Whether a source matched at all. `false` means the default locale was
    /// used, which is a normal outcome, not an error.
    pub matched: bool,
}

impl Negotiated {
    /// `<html dir>`.
    #[must_use]
    pub fn dir_attr(&self) -> &'static str {
        if self.dir == Dir::Rtl { "rtl" } else { "ltr" }
    }
}

/// Somewhere a request can carry a locale.
pub trait LocaleSource: Send + Sync + std::fmt::Debug {
    /// A short name, which is what [`Negotiated::from`] reports.
    fn name(&self) -> &'static str;

    /// The request header this source reads, if any. Every one of these goes
    /// into `Vary`, because the response body depends on it.
    fn vary(&self) -> Option<HeaderName> {
        None
    }

    /// The query parameter this source reads, if it reads one: the name
    /// `<LocaleSwitcher>`'s `<select>` submits under.
    fn query(&self) -> Option<&'static str> {
        None
    }

    /// The tags this request offers, best first. Called once per request.
    fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>);
}

/// Somewhere the negotiated locale is written back to.
pub trait LocaleSink: Send + Sync + std::fmt::Debug {
    /// A short name, for diagnostics.
    fn name(&self) -> &'static str;

    /// The response header this sink adds, if any.
    fn store(&self, negotiated: &Negotiated) -> Option<(HeaderName, HeaderValue)>;
}

/// A cookie, read and written: the only source that remembers a choice the
/// user made, so it comes first by default.
///
/// As a sink it writes only an **explicit** choice — a locale that came from
/// the query (`?lang=`) or the path. A locale guessed from `Accept-Language`
/// or the default is not remembered, and a cookie that was read is not
/// written back, so its expiry does not slide. The client writes the cookie
/// itself on every switch.
#[derive(Clone, Debug)]
pub struct CookieLocale {
    /// The cookie's name.
    pub name: &'static str,
    /// `Max-Age`, in seconds. A year by default.
    pub max_age: u32,
    /// `Path`.
    pub path: &'static str,
    /// `SameSite`. `Lax` by default: a locale is not a credential, and `Lax`
    /// survives a link from another site.
    pub same_site: &'static str,
    /// `Secure`.
    pub secure: bool,
}

impl Default for CookieLocale {
    fn default() -> CookieLocale {
        CookieLocale {
            // The name a `static-locale` client writes on a switch.
            name: crate::links::LOCALE_COOKIE,
            max_age: 31_536_000,
            path: "/",
            same_site: "Lax",
            secure: true,
        }
    }
}

impl LocaleSource for CookieLocale {
    fn name(&self) -> &'static str {
        "cookie"
    }

    fn vary(&self) -> Option<HeaderName> {
        Some(COOKIE)
    }

    fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
        if let Some(value) = cookie(parts, self.name) {
            out.push(Cow::Borrowed(value));
        }
    }
}

/// The value of the first cookie named `name` in the request, trimmed.
pub(crate) fn cookie<'r>(parts: &'r Parts, name: &str) -> Option<&'r str> {
    let header = parts.headers.get(COOKIE).and_then(|v| v.to_str().ok())?;
    header.split(';').find_map(|pair| {
        let (n, value) = pair.trim_start().split_once('=')?;
        (n.trim() == name).then(|| value.trim())
    })
}

impl LocaleSink for CookieLocale {
    fn name(&self) -> &'static str {
        "cookie"
    }

    fn store(&self, negotiated: &Negotiated) -> Option<(HeaderName, HeaderValue)> {
        if !matches!(negotiated.from, "query" | "path") {
            return None;
        }
        let mut cookie = format!(
            "{}={}; Max-Age={}; Path={}; SameSite={}",
            self.name, negotiated.tag, self.max_age, self.path, self.same_site
        );
        if self.secure {
            cookie.push_str("; Secure");
        }
        HeaderValue::from_str(&cookie)
            .ok()
            .map(|value| (SET_COOKIE, value))
    }
}

/// `Accept-Language`, in quality order.
#[derive(Clone, Copy, Debug, Default)]
pub struct AcceptLanguage;

impl LocaleSource for AcceptLanguage {
    fn name(&self) -> &'static str {
        "accept-language"
    }

    fn vary(&self) -> Option<HeaderName> {
        Some(ACCEPT_LANGUAGE)
    }

    fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
        let Some(header) = parts
            .headers
            .get(ACCEPT_LANGUAGE)
            .and_then(|v| v.to_str().ok())
        else {
            return;
        };
        // (quality ×1000, tag), stably sorted so that equal qualities keep
        // the order the client wrote.
        let mut ranked: Vec<(u32, &str)> = Vec::new();
        for item in header.split(',') {
            let mut fields = item.split(';');
            let Some(tag) = fields.next().map(str::trim) else {
                continue;
            };
            if tag.is_empty() {
                continue;
            }
            let quality = fields
                .find_map(|f| f.trim().strip_prefix("q=").map(quality_milli))
                .unwrap_or(1000);
            if quality > 0 {
                ranked.push((quality, tag));
            }
        }
        ranked.sort_by_key(|(quality, _)| core::cmp::Reverse(*quality));
        out.extend(ranked.into_iter().map(|(_, tag)| Cow::Borrowed(tag)));
    }
}

/// `q=0.8` as thousandths, saturating; anything unparseable is `q=1`, which
/// is what a lenient parser should do with a header it did not write.
fn quality_milli(text: &str) -> u32 {
    let text = text.trim();
    let (whole, fraction) = text.split_once('.').unwrap_or((text, ""));
    let whole: u32 = whole.parse().unwrap_or(1);
    let mut milli = whole.saturating_mul(1000);
    for (i, digit) in fraction.chars().take(3).enumerate() {
        let Some(d) = digit.to_digit(10) else { break };
        let scale = match i {
            0 => 100,
            1 => 10,
            _ => 1,
        };
        milli += d * scale;
    }
    milli.min(1000)
}

/// A path prefix — `/es/…` — for sites that want crawlable per-locale URLs.
#[derive(Clone, Copy, Debug, Default)]
pub struct PathPrefix;

impl LocaleSource for PathPrefix {
    fn name(&self) -> &'static str {
        "path"
    }

    fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
        let path = parts.uri.path();
        let first = path.trim_start_matches('/').split('/').next();
        if let Some(segment) = first
            && !segment.is_empty()
        {
            out.push(Cow::Borrowed(segment));
        }
    }
}

/// A query parameter — `?lang=es` — which is how a link can force a locale
/// without a cookie.
#[derive(Clone, Copy, Debug)]
pub struct QueryParam(pub &'static str);

impl Default for QueryParam {
    fn default() -> QueryParam {
        // The name a `static-locale` client removes on a switch.
        QueryParam(crate::links::LOCALE_QUERY)
    }
}

impl LocaleSource for QueryParam {
    fn name(&self) -> &'static str {
        "query"
    }

    fn query(&self) -> Option<&'static str> {
        Some(self.0)
    }

    fn candidates<'r>(&self, parts: &'r Parts, out: &mut Vec<Cow<'r, str>>) {
        let Some(query) = parts.uri.query() else {
            return;
        };
        for pair in query.split('&') {
            if let Some((name, value)) = pair.split_once('=')
                && name == self.0
                && !value.is_empty()
            {
                out.push(Cow::Borrowed(value));
                return;
            }
        }
    }
}

/// The ordered list itself, and a tower layer: `.layer(Negotiator::default())`
/// negotiates each request, puts the answer in its extensions (where a
/// Leptos render and the generated `Locale` extractor find it), and writes
/// `Content-Language`, `Vary` and every sink's header on a response that
/// read it.
#[derive(Clone, Debug)]
pub struct Negotiator {
    sources: Vec<Arc<dyn LocaleSource>>,
    sinks: Vec<Arc<dyn LocaleSink>>,
    locales: &'static [(&'static str, Dir)],
    default: &'static str,
    vary: Option<HeaderValue>,
}

impl Negotiator {
    /// An empty negotiator over the build's own locales: every request gets
    /// the source locale until a source is added.
    ///
    /// The locales come from the generated `install()`, so call this after
    /// it.
    #[must_use]
    pub fn empty() -> Negotiator {
        Negotiator::over(super::locales(), super::source_locale())
    }

    /// An empty negotiator over an explicit locale table — what a test uses,
    /// and what an application uses when it offers fewer locales than it
    /// built.
    #[must_use]
    pub fn over(locales: &'static [(&'static str, Dir)], default: &'static str) -> Negotiator {
        Negotiator {
            sources: Vec::new(),
            sinks: Vec::new(),
            locales,
            default,
            vary: None,
        }
    }

    /// Appends a source. Order is precedence.
    #[must_use]
    pub fn source(mut self, source: impl LocaleSource + 'static) -> Negotiator {
        self.sources.push(Arc::new(source));
        self.vary = None;
        self
    }

    /// Appends a sink.
    #[must_use]
    pub fn sink(mut self, sink: impl LocaleSink + 'static) -> Negotiator {
        self.sinks.push(Arc::new(sink));
        self
    }

    /// Overrides the locale a request with no match gets. It must be one the
    /// build has, or the source locale is kept.
    #[must_use]
    pub fn default_locale(mut self, tag: &str) -> Negotiator {
        if let Some((found, _)) = self.locales.iter().find(|(t, _)| *t == tag) {
            self.default = found;
        }
        self
    }

    /// The query parameter of the first source that reads one — the name
    /// `<LocaleSwitcher>`'s form submits under — or `None` without one.
    #[must_use]
    pub fn query_name(&self) -> Option<&'static str> {
        self.sources.iter().find_map(|source| source.query())
    }

    /// Every locale this build has, with its direction.
    #[must_use]
    pub fn locales(&self) -> &'static [(&'static str, Dir)] {
        self.locales
    }

    /// What this request should be answered in.
    #[must_use]
    pub fn negotiate(&self, parts: &Parts) -> Negotiated {
        let mut candidates: Vec<Cow<'_, str>> = Vec::new();
        for source in &self.sources {
            candidates.clear();
            source.candidates(parts, &mut candidates);
            // A source's candidates are one reader's list, best first: the
            // one matcher weighs them together (a later entry demoted), as
            // UTS #35 Part 1 matches a list.
            if let Some((tag, dir)) =
                super::best_locale(candidates.iter().map(AsRef::as_ref), self.locales)
            {
                return Negotiated {
                    tag,
                    dir,
                    from: source.name(),
                    matched: true,
                };
            }
        }
        self.unmatched(parts);
        Negotiated {
            tag: self.default,
            dir: self.dir_of(self.default),
            from: "default",
            matched: false,
        }
    }

    /// Says on the server, once per first language, that the reader named
    /// languages and no catalog matches them (E4): a normal outcome, but the
    /// one that shows an application which catalog it lacks — a Traditional
    /// Chinese reader is not served Simplified (question 15).
    fn unmatched(&self, parts: &Parts) {
        let mut named: Vec<String> = Vec::new();
        let mut candidates: Vec<Cow<'_, str>> = Vec::new();
        for source in &self.sources {
            candidates.clear();
            source.candidates(parts, &mut candidates);
            for candidate in &candidates {
                if let Some(tag) = crate::warn::tag(candidate)
                    && named.len() < 8
                    && !named.iter().any(|n| n.eq_ignore_ascii_case(tag))
                {
                    named.push(tag.to_owned());
                }
            }
        }
        let Some(first) = named.first() else {
            return;
        };
        crate::warn::once_for(
            crate::warn::Kind::Unmatched,
            &first.to_ascii_lowercase(),
            || {
                format!(
                    "mf2: no catalog matches the reader's languages ({}), so they are served \
                     the default language, `{}`; a catalog for one of them would serve them",
                    named.join(", "),
                    self.default
                )
            },
        );
    }

    fn dir_of(&self, tag: &str) -> Dir {
        self.locales
            .iter()
            .find(|(t, _)| *t == tag)
            .map_or(Dir::Ltr, |(_, d)| *d)
    }

    /// The `Vary` value: every request header a source reads. A response
    /// that depends on `Cookie` and is cached without saying so is the
    /// classic way to serve one user's language to another.
    #[must_use]
    pub fn vary(&self) -> Option<HeaderValue> {
        if let Some(vary) = &self.vary {
            return Some(vary.clone());
        }
        let mut names: Vec<String> = Vec::new();
        for source in &self.sources {
            if let Some(name) = source.vary()
                && !names.iter().any(|n| n.eq_ignore_ascii_case(name.as_str()))
            {
                // `HeaderName` is always lowercase ASCII, so this is already
                // the canonical spelling.
                names.push(name.as_str().to_owned());
            }
        }
        if names.is_empty() {
            return None;
        }
        HeaderValue::from_str(&names.join(", ")).ok()
    }

    /// The headers every sink wants on this response.
    pub fn store(
        &self,
        negotiated: &Negotiated,
    ) -> impl Iterator<Item = (HeaderName, HeaderValue)> {
        self.sinks
            .iter()
            .filter_map(move |sink| sink.store(negotiated))
    }
}

impl Default for Negotiator {
    /// `?lang=`, then the cookie, then `Accept-Language`, with the cookie as
    /// the sink: what a site wants unless it has said otherwise. The sink
    /// remembers a `?lang=` choice (the switcher's form without the wasm);
    /// the client writes the cookie itself on a switch. The cookie is
    /// `Secure` except in a debug build, which is served over plain HTTP. A
    /// path prefix is deliberately not here — it changes URLs, so a site
    /// opts into it.
    fn default() -> Negotiator {
        Negotiator::empty().defaults()
    }
}

impl Negotiator {
    /// [`Negotiator::default`]'s sources and sink, over this negotiator's
    /// locales: what the generated `Locale` extractor negotiates with when
    /// no layer negotiated first.
    pub(crate) fn defaults(self) -> Negotiator {
        self.source(QueryParam::default())
            .source(CookieLocale::default())
            .source(AcceptLanguage)
            .sink(CookieLocale {
                secure: !cfg!(debug_assertions),
                ..CookieLocale::default()
            })
    }
}

/// The one matcher (plans/19-native-and-terminal.md §9), shared with a
/// client-only application's boot: the locale that best serves one tag.
pub(crate) fn lookup(
    candidate: &str,
    locales: &[(&'static str, Dir)],
) -> Option<(&'static str, Dir)> {
    super::best_locale([candidate], locales)
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "a test")]
mod tests {
    use super::{
        AcceptLanguage, CookieLocale, Dir, LocaleSink, LocaleSource, Negotiated, Negotiator,
        lookup, quality_milli,
    };
    use ::http::Request;
    use alloc::borrow::{Cow, ToOwned};
    use alloc::string::String;
    use alloc::vec::Vec;

    static LOCALES: &[(&str, Dir)] = &[("en", Dir::Ltr), ("fr-CA", Dir::Ltr), ("ar", Dir::Rtl)];

    fn negotiator() -> Negotiator {
        Negotiator::over(LOCALES, "en")
    }

    fn parts(headers: &[(&str, &str)], uri: &str) -> http::request::Parts {
        let mut builder = Request::builder().uri(uri);
        for (name, value) in headers {
            builder = builder.header(*name, *value);
        }
        builder.body(()).expect("a request").into_parts().0
    }

    #[test]
    fn lookup_finds_the_closest_language_by_cldr() {
        assert_eq!(lookup("en", LOCALES).map(|l| l.0), Some("en"));
        // Another region of English: 5 (`en-*-*`).
        assert_eq!(lookup("EN-GB", LOCALES).map(|l| l.0), Some("en"));
        // `fr` is not in the list, but `fr-CA` is: 4 (`*-*-*`).
        assert_eq!(lookup("fr", LOCALES).map(|l| l.0), Some("fr-CA"));
        assert_eq!(lookup("de", LOCALES), None);
        assert_eq!(lookup("*", LOCALES), None);
    }

    #[test]
    fn a_source_list_is_matched_as_one_list() {
        let negotiator = Negotiator::over(
            &[("de", Dir::Ltr), ("fr", Dir::Ltr), ("zh", Dir::Ltr)],
            "de",
        )
        .source(AcceptLanguage);
        let pick = |header: &str| {
            negotiator
                .negotiate(&parts(&[("accept-language", header)], "/"))
                .tag
        };
        // UTS #35 Part 1's demotion example: a regional variant of the first
        // language (4) beats an exact second one (5).
        assert_eq!(pick("de-AT, fr;q=0.9"), "de");
        // Traditional Chinese is not served Simplified (question 15), but
        // the list's plain `zh` is: 5.
        assert_eq!(pick("zh-TW, zh;q=0.9"), "zh");
        assert_eq!(pick("zh-TW, fr;q=0.5"), "fr");
    }

    #[test]
    fn a_reader_no_catalog_matches_is_named_once_per_language() {
        let negotiator =
            Negotiator::over(&[("en", Dir::Ltr), ("zh", Dir::Ltr)], "en").source(AcceptLanguage);
        for _ in 0..3 {
            let answer = negotiator.negotiate(&parts(
                &[("accept-language", "zh-TW, zh-HK;q=0.8, *;q=0.1")],
                "/",
            ));
            assert_eq!((answer.tag, answer.matched), ("en", false));
        }
        // A reader who names nothing is not warned about.
        let _ = negotiator.negotiate(&parts(&[], "/"));
        let lines = crate::warn::given(crate::warn::Kind::Unmatched);
        let named: Vec<&String> = lines
            .iter()
            .filter(|l| l.contains("(zh-TW, zh-HK)"))
            .collect();
        assert_eq!(named.len(), 1, "{lines:?}");
        assert!(named[0].contains("`en`"));
    }

    #[test]
    fn accept_language_is_read_in_quality_order() {
        let parts = parts(&[("accept-language", "de;q=0.9, fr;q=0.95, en;q=0.2")], "/");
        let mut out: Vec<Cow<'_, str>> = Vec::new();
        AcceptLanguage.candidates(&parts, &mut out);
        assert_eq!(out, ["fr", "de", "en"]);
    }

    #[test]
    fn quality_parses_thousandths_and_clamps() {
        assert_eq!(quality_milli("1"), 1000);
        assert_eq!(quality_milli("0.5"), 500);
        assert_eq!(quality_milli("0.333"), 333);
        assert_eq!(quality_milli("0.3339"), 333);
        assert_eq!(quality_milli("7"), 1000);
    }

    #[test]
    fn the_first_source_that_matches_wins() {
        let negotiator = negotiator()
            .source(super::CookieLocale::default())
            .source(AcceptLanguage);
        // The cookie is listed first, so it beats a better `Accept-Language`.
        let parts = parts(
            &[
                ("cookie", "theme=dark; mf2_locale=ar"),
                ("accept-language", "en"),
            ],
            "/",
        );
        let negotiated = negotiator.negotiate(&parts);
        assert_eq!(negotiated.tag, "ar");
        assert_eq!(negotiated.from, "cookie");
        assert_eq!(negotiated.dir_attr(), "rtl");
    }

    #[test]
    fn the_default_is_query_then_cookie_then_accept_language() {
        let negotiator = negotiator().defaults();
        assert_eq!(negotiator.query_name(), Some("lang"));
        let all = [("cookie", "mf2_locale=fr-CA"), ("accept-language", "en")];
        let negotiated = negotiator.negotiate(&parts(&all, "/?x=1&lang=ar"));
        assert_eq!((negotiated.tag, negotiated.from), ("ar", "query"));
        let negotiated = negotiator.negotiate(&parts(&all, "/"));
        assert_eq!((negotiated.tag, negotiated.from), ("fr-CA", "cookie"));
        let negotiated = negotiator.negotiate(&parts(&all[1..], "/"));
        assert_eq!((negotiated.tag, negotiated.from), ("en", "accept-language"));
        // A `?lang=` choice is remembered; `Secure` only outside a debug build.
        let negotiated = negotiator.negotiate(&parts(&[], "/?lang=ar"));
        let cookie: Vec<String> = negotiator
            .store(&negotiated)
            .map(|(_, v)| v.to_str().unwrap_or("").to_owned())
            .collect();
        let secure = if cfg!(debug_assertions) {
            ""
        } else {
            "; Secure"
        };
        assert_eq!(
            cookie,
            [alloc::format!(
                "mf2_locale=ar; Max-Age=31536000; Path=/; SameSite=Lax{secure}"
            )]
        );
    }

    #[test]
    fn the_query_name_is_the_first_query_source() {
        assert_eq!(negotiator().query_name(), None);
        let negotiator = negotiator()
            .source(AcceptLanguage)
            .source(super::QueryParam("hl"))
            .source(super::QueryParam::default());
        assert_eq!(negotiator.query_name(), Some("hl"));
    }

    #[test]
    fn a_request_with_nothing_to_go_on_gets_the_default_and_says_so() {
        let negotiator = negotiator().source(AcceptLanguage);
        let negotiated = negotiator.negotiate(&parts(&[], "/"));
        assert!(!negotiated.matched);
        assert_eq!(negotiated.from, "default");
    }

    #[test]
    fn vary_names_every_header_a_source_reads() {
        let negotiator = negotiator()
            .source(super::CookieLocale::default())
            .source(AcceptLanguage)
            .source(super::PathPrefix);
        let vary = negotiator.vary().expect("two headers are read");
        assert_eq!(vary.to_str().unwrap_or(""), "cookie, accept-language");
    }

    #[test]
    fn a_path_prefix_is_read_from_the_first_segment() {
        let negotiator = negotiator().source(super::PathPrefix);
        assert_eq!(negotiator.negotiate(&parts(&[], "/ar/inbox")).tag, "ar");
        assert!(!negotiator.negotiate(&parts(&[], "/inbox")).matched);
    }

    fn stored_from(from: &'static str) -> Option<String> {
        let negotiated = Negotiated {
            tag: "fr-CA",
            dir: Dir::Ltr,
            from,
            matched: from != "default",
        };
        CookieLocale::default()
            .store(&negotiated)
            .map(|(_, value)| value.to_str().unwrap_or("").to_owned())
    }

    #[test]
    fn the_cookie_is_written_for_an_explicit_choice() {
        for from in ["query", "path"] {
            assert_eq!(
                stored_from(from).as_deref(),
                Some("mf2_locale=fr-CA; Max-Age=31536000; Path=/; SameSite=Lax; Secure"),
                "from {from}"
            );
        }
    }

    #[test]
    fn the_cookie_is_not_written_for_a_guess_or_a_cookie_already_there() {
        for from in ["cookie", "accept-language", "default"] {
            assert_eq!(stored_from(from), None, "from {from}");
        }
    }
}