Skip to main content

day_build/
lib.rs

1//! day-build — resource-constant codegen for a Day app's `build.rs` (DESIGN.md §18.5).
2//!
3//! An app's `build.rs` calls [`generate_resources`], which scans the project's
4//! `resource/{images,assets,fonts}` directories and writes typed symbolic constants to
5//! `$OUT_DIR/day_resources.rs`:
6//!
7//! ```text
8//! pub mod images { use day::ImageName;
9//!     pub const nav_system: ImageName = ImageName::from_static("nav_system"); }
10//! pub mod assets { use day::AssetName;
11//!     pub const numbers_bin: AssetName = AssetName::from_static("numbers.bin"); }
12//! pub mod fonts  { use day::FontFamily;
13//!     pub const pacifico: FontFamily = FontFamily::from_static("Pacifico"); }
14//! pub mod locales { pub const DEFAULT: &str = "en";
15//!     pub const CATALOG: &[(&str, &str)] = &[("en", include_str!("…/en/app.ftl")), …];
16//!     pub fn install() { day::install_locales(DEFAULT, CATALOG) } }
17//! ```
18//!
19//! The app surfaces it once (`pub mod res { include!(concat!(env!("OUT_DIR"), "/day_resources.rs")); }`)
20//! and then writes `image(res::images::nav_system)` — a typo is a compile error and the resource is
21//! guaranteed bundled. `cargo:rerun-if-changed` on each resource dir regenerates when a file is
22//! added or removed.
23//!
24//! This crate is also the canonical source of the resource-name → identifier rules: the CLI stagers
25//! (`day-cli/src/resources`) reuse [`sanitize_ident`] and the derivation helpers here so the string
26//! baked into a constant is exactly the name staged into each backend's native store.
27//!
28//! For the same reason it owns [`permissions`]: the CLI generates each platform's permission
29//! declarations from that table while `day-part-permissions` queries the same permissions at
30//! runtime, and the two must never disagree (docs/permissions.md).
31
32use std::path::{Path, PathBuf};
33
34pub mod permissions;
35
36/// A single generated constant: its Rust `symbol`, the `value` string it wraps (the wire name the
37/// backend resolves by), and the `source` file (for the doc comment).
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Entry {
40    pub symbol: String,
41    pub value: String,
42    pub source: String,
43}
44
45/// A generated localization function: the Fluent message `key` (the Rust fn name), its sorted
46/// `params` (each `$variable` the message references, agreed across all locales), and `doc` (the
47/// reference-locale value text, for the generated doc comment).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct StrEntry {
50    pub key: String,
51    pub params: Vec<StrParam>,
52    pub doc: String,
53}
54
55/// One generated function parameter: the Fluent `$variable` name and whether it is used as a
56/// **number** (a plural/`select` selector or `NUMBER()` argument) — which types it as
57/// `IntoNumberFArg` instead of `IntoFArg`, so a string can't be passed where a plural count is needed.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct StrParam {
60    pub name: String,
61    pub numeric: bool,
62}
63
64/// One locale's catalog: the directory name under `resource/locales/` (the tag apps pass to
65/// `set_locale`) and every `.ftl` beneath it, sorted. Multiple files concatenate into the single
66/// source string the Fluent bundle is built from.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct LocaleEntry {
69    pub locale: String,
70    pub sources: Vec<PathBuf>,
71}
72
73/// The full set of constants to emit, grouped by bucket.
74#[derive(Debug, Default, Clone, PartialEq, Eq)]
75pub struct ResourcePlan {
76    pub images: Vec<Entry>,
77    pub assets: Vec<Entry>,
78    pub fonts: Vec<Entry>,
79    /// Localization keys → `res::str::<key>(params…)` functions (§18.5).
80    pub strings: Vec<StrEntry>,
81    /// The locales themselves → the `res::locales` catalog (§18.5), so an app registers them
82    /// with one call instead of a hand-maintained `include_str!` list.
83    pub locales: Vec<LocaleEntry>,
84}
85
86/// The build-script entry point: scan `resource/{images,assets,fonts}` under `CARGO_MANIFEST_DIR`,
87/// emit `$OUT_DIR/day_resources.rs`, and register the resource dirs for `cargo:rerun-if-changed`.
88/// Returns `Err` (with a fix hint) on a name that is not portable or a symbol collision — the app
89/// `build.rs` should `.expect(...)` this so the problem fails the build loudly.
90pub fn generate_resources() -> Result<(), String> {
91    let root = PathBuf::from(env("CARGO_MANIFEST_DIR")?);
92    let out = PathBuf::from(env("OUT_DIR")?);
93    let plan = plan_resources(&root)?;
94    let code = render(&plan);
95    std::fs::write(out.join("day_resources.rs"), code)
96        .map_err(|e| format!("day-build: writing day_resources.rs: {e}"))?;
97    // Regenerate when a resource is added/removed/renamed (a proc-macro could not do this reliably).
98    for bucket in ["images", "assets", "fonts", "locales"] {
99        println!("cargo:rerun-if-changed=resource/{bucket}");
100    }
101    Ok(())
102}
103
104fn env(key: &str) -> Result<String, String> {
105    std::env::var(key).map_err(|_| format!("day-build: ${key} is not set (call from a build.rs)"))
106}
107
108/// Scan and validate a project's resources into a [`ResourcePlan`] (the pure, testable core).
109pub fn plan_resources(root: &Path) -> Result<ResourcePlan, String> {
110    Ok(ResourcePlan {
111        images: plan_images(&root.join("resource/images"))?,
112        assets: plan_assets(&root.join("resource/assets"))?,
113        fonts: plan_fonts(&root.join("resource/fonts"))?,
114        strings: plan_strings(&root.join("resource/locales"))?,
115        locales: plan_locales(&root.join("resource/locales")),
116    })
117}
118
119/// Top-level, non-hidden files in `dir`, sorted by name for deterministic output.
120fn list_files(dir: &Path) -> Vec<PathBuf> {
121    let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
122        .into_iter()
123        .flatten()
124        .flatten()
125        .map(|e| e.path())
126        .filter(|p| {
127            p.is_file()
128                && !p
129                    .file_name()
130                    .and_then(|n| n.to_str())
131                    .unwrap_or("")
132                    .starts_with('.')
133        })
134        .collect();
135    files.sort();
136    files
137}
138
139/// Images: the constant is keyed on the file **stem** (with any `@Nx` HiDPI suffix stripped), which
140/// is the name `image("…")` resolves by. The stem must be *portable* — identical after
141/// [`sanitize_ident`] — because Apple/GTK/Qt resolve it verbatim while Android/ArkUI re-sanitize it;
142/// a non-portable stem would silently resolve to two different names across toolkits, so it is a hard
143/// error with a rename hint. `foo.png` + `foo@2x.png` collapse to one constant; two *distinct* files
144/// claiming the same stem at the same scale collide.
145fn plan_images(dir: &Path) -> Result<Vec<Entry>, String> {
146    // stem -> (scales seen, first source path)
147    let mut seen: std::collections::BTreeMap<String, (Vec<u32>, String)> = Default::default();
148    for path in list_files(dir) {
149        let stem = path
150            .file_stem()
151            .and_then(|s| s.to_str())
152            .unwrap_or_default()
153            .to_string();
154        let (base, scale) = parse_scale(&stem);
155        let src = display(&path);
156        let sane = sanitize_ident(&base);
157        if sane != base {
158            return Err(format!(
159                "day-build: image {base:?} ({src}) is not a portable resource name — it resolves \
160                 to {sane:?} on Android/HarmonyOS but {base:?} on Apple/GTK/Qt. Rename the file so \
161                 its stem is lowercase [a-z0-9_] (e.g. `{sane}`)."
162            ));
163        }
164        let ent = seen
165            .entry(base.clone())
166            .or_insert_with(|| (Vec::new(), src.clone()));
167        if ent.0.contains(&scale) {
168            return Err(format!(
169                "day-build: two files map to image {base:?} at the same scale ({}, {src}) — keep \
170                 one file per image (HiDPI variants use an `@2x`/`@3x` suffix).",
171                ent.1
172            ));
173        }
174        ent.0.push(scale);
175    }
176    Ok(seen
177        .into_iter()
178        .map(|(base, (_, src))| Entry {
179            symbol: base.clone(),
180            value: base,
181            source: src,
182        })
183        .collect())
184}
185
186/// Data assets: the constant wraps the **full file name** (extension included) — the exact string
187/// `resource("…")` resolves by — with the symbol sanitized for Rust (`numbers.bin` → `numbers_bin`).
188fn plan_assets(dir: &Path) -> Result<Vec<Entry>, String> {
189    let mut entries = Vec::new();
190    for path in list_files(dir) {
191        let fname = path
192            .file_name()
193            .and_then(|n| n.to_str())
194            .unwrap_or_default()
195            .to_string();
196        entries.push(Entry {
197            symbol: sanitize_ident(&fname),
198            value: fname,
199            source: display(&path),
200        });
201    }
202    dedup_symbols(entries, "asset")
203}
204
205/// Fonts: the constant wraps the **family name** parsed from the sfnt `name` table (what
206/// `Font::custom` resolves by, *not* the file name), with the symbol derived by the same
207/// `font_ident` rule the runtimes use (`"Special Elite"` → `special_elite`).
208fn plan_fonts(dir: &Path) -> Result<Vec<Entry>, String> {
209    let mut entries = Vec::new();
210    for path in list_files(dir) {
211        let ext = path
212            .extension()
213            .and_then(|e| e.to_str())
214            .map(|e| e.to_ascii_lowercase())
215            .unwrap_or_default();
216        if !matches!(ext.as_str(), "ttf" | "otf") {
217            continue; // non-font files are ignored (matches scan_fonts, which errors at stage time)
218        }
219        let src = display(&path);
220        let bytes = std::fs::read(&path).map_err(|e| format!("day-build: reading {src}: {e}"))?;
221        let names = day_fonts::parse_font_names(&bytes)
222            .ok_or_else(|| format!("day-build: {src}: not a recognizable font (no name table)"))?;
223        entries.push(Entry {
224            symbol: day_fonts::font_ident(&names.family),
225            value: names.family,
226            source: src,
227        });
228    }
229    dedup_symbols(entries, "font")
230}
231
232/// Reject two entries whose symbols collide after sanitization (they would define the same constant).
233fn dedup_symbols(entries: Vec<Entry>, kind: &str) -> Result<Vec<Entry>, String> {
234    let mut seen: std::collections::BTreeMap<String, String> = Default::default();
235    for e in &entries {
236        if let Some(prev) = seen.insert(e.symbol.clone(), e.source.clone()) {
237            return Err(format!(
238                "day-build: {kind}s {} and {} both map to the symbol `{}` — rename one so they \
239                 differ after sanitization to [a-z0-9_].",
240                prev, e.source, e.symbol
241            ));
242        }
243    }
244    Ok(entries)
245}
246
247/// Recursively collect every `*.ftl` under `dir` (sorted, for deterministic diagnostics/output).
248fn ftl_files(dir: &Path) -> Vec<PathBuf> {
249    let mut out = Vec::new();
250    let mut stack = vec![dir.to_path_buf()];
251    while let Some(d) = stack.pop() {
252        let Ok(entries) = std::fs::read_dir(&d) else {
253            continue;
254        };
255        for e in entries.flatten() {
256            let p = e.path();
257            if p.is_dir() {
258                stack.push(p);
259            } else if p.extension().is_some_and(|x| x == "ftl") {
260                out.push(p);
261            }
262        }
263    }
264    out.sort();
265    out
266}
267
268/// The message keys defined in a Fluent source (terms/attributes/comments ignored). Public so the
269/// CLI lint (`day lint` fluent coverage) shares this one `fluent-syntax` parser with the codegen and
270/// the runtime resolver, instead of a hand-rolled line scanner.
271pub fn message_keys(ftl_src: &str) -> Vec<String> {
272    ftl_messages(ftl_src).into_iter().map(|m| m.key).collect()
273}
274
275/// Localization keys → parameter-typed `res::str` functions. Parses each `.ftl` with `fluent-syntax`
276/// (the same syntax `fluent-bundle` resolves at runtime), collects every message's `$variable` set
277/// (and which vars are numeric — plural/`select` selectors), unions keys across locales, and enforces
278/// two build-time rules: each key must be a valid Rust identifier (the kebab→snake forcing rule) and
279/// all locales must agree on a key's parameter names. A param is typed numeric if *any* locale uses it
280/// numerically; the generated doc shows the value from the reference locale (`en` if present).
281fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
282    // key -> (params: name -> numeric, the locale file that first defined it)
283    let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
284    // key -> (reference value text, whether it came from `en`)
285    let mut docs: std::collections::BTreeMap<String, (String, bool)> = Default::default();
286    for path in ftl_files(dir) {
287        let src = std::fs::read_to_string(&path)
288            .map_err(|e| format!("day-build: reading {}: {e}", display(&path)))?;
289        let loc = display(&path);
290        let is_en = locale_of(&path) == "en";
291        for msg in ftl_messages(&src) {
292            if !is_rust_ident(&msg.key) {
293                return Err(format!(
294                    "day-build: localization key {:?} ({loc}) is not a valid Rust identifier — \
295                     rename it to snake_case (e.g. `{}`) in every resource/locales/*/*.ftl (Fluent \
296                     allows `-`, Rust identifiers do not).",
297                    msg.key,
298                    msg.key.replace('-', "_")
299                ));
300            }
301            // Doc: prefer the `en` value, else keep the first one seen.
302            let have_en = matches!(docs.get(&msg.key), Some((_, true)));
303            if !have_en && (is_en || !docs.contains_key(&msg.key)) {
304                docs.insert(msg.key.clone(), (msg.value_text, is_en));
305            }
306            // Params: names must agree across locales; numeric is the OR across locales.
307            use std::collections::btree_map::Entry;
308            match agreed.entry(msg.key.clone()) {
309                Entry::Vacant(v) => {
310                    v.insert((msg.params, loc.clone()));
311                }
312                Entry::Occupied(mut o) => {
313                    let (prev, prev_loc) = o.get_mut();
314                    let prev_names: Vars = prev.keys().cloned().collect();
315                    let this_names: Vars = msg.params.keys().cloned().collect();
316                    if prev_names != this_names {
317                        return Err(format!(
318                            "day-build: localization key {:?} references different parameters across \
319                             locales — {prev_loc} has {{{}}}, {loc} has {{{}}}. Every locale's \
320                             message must use the same `$variables`.",
321                            msg.key,
322                            comma(&prev_names),
323                            comma(&this_names)
324                        ));
325                    }
326                    for (name, numeric) in msg.params {
327                        if numeric && let Some(v) = prev.get_mut(&name) {
328                            *v = true;
329                        }
330                    }
331                }
332            }
333        }
334    }
335    Ok(agreed
336        .into_iter()
337        .map(|(key, (params, _))| {
338            let doc = docs.remove(&key).map(|(t, _)| t).unwrap_or_default();
339            StrEntry {
340                key,
341                params: params
342                    .into_iter()
343                    .map(|(name, numeric)| StrParam { name, numeric })
344                    .collect(),
345                doc,
346            }
347        })
348        .collect())
349}
350
351fn comma(names: &Vars) -> String {
352    names.iter().cloned().collect::<Vec<_>>().join(", ")
353}
354
355/// Group `resource/locales/<locale>/**/*.ftl` by locale directory — the catalog `res::locales`
356/// renders. Discovery is the whole point: adding or deleting a locale directory is the entire
357/// act of adding or dropping a language, with no source list to keep in step (the
358/// `cargo:rerun-if-changed` on `resource/locales` in [`generate_resources`] is what makes the
359/// directory itself a build input).
360///
361/// Not fallible: unlike [`plan_strings`] — which validates keys and parameters — a locale
362/// directory carries no name rules of its own. A tag that Fluent can't parse degrades to `en`
363/// at runtime (`day_l10n::build_bundles`), which is the engine's call, not the build's.
364fn plan_locales(dir: &Path) -> Vec<LocaleEntry> {
365    let mut by_locale: std::collections::BTreeMap<String, Vec<PathBuf>> = Default::default();
366    for path in ftl_files(dir) {
367        let locale = locale_of(&path);
368        // A stray `.ftl` directly in `resource/locales/` has the bucket itself as its parent and
369        // names no locale — skip it rather than inventing a `locales` language.
370        if locale.is_empty() || path.parent() == Some(dir) {
371            continue;
372        }
373        by_locale.entry(locale).or_default().push(path);
374    }
375    by_locale
376        .into_iter()
377        .map(|(locale, sources)| LocaleEntry { locale, sources })
378        .collect()
379}
380
381/// The fallback locale for the generated `install()`: `en` when the app ships it, else the first
382/// locale alphabetically (a single-locale app gets its own language; a multi-locale app without
383/// English gets a deterministic pick). Apps needing another default call
384/// `install_locales(other, res::locales::CATALOG)` — the catalog stays generated either way.
385fn default_locale(locales: &[LocaleEntry]) -> String {
386    if locales.iter().any(|l| l.locale == "en") {
387        return "en".to_string();
388    }
389    locales
390        .first()
391        .map(|l| l.locale.clone())
392        .unwrap_or_else(|| "en".to_string())
393}
394
395/// The locale directory name of a `resource/locales/<locale>/*.ftl` path (its parent dir name).
396fn locale_of(path: &Path) -> String {
397    path.parent()
398        .and_then(|p| p.file_name())
399        .map(|n| n.to_string_lossy().into_owned())
400        .unwrap_or_default()
401}
402
403/// One parsed Fluent message: its key, `$variables` (name → used-as-a-number), and value text.
404struct FtlMessage {
405    key: String,
406    params: Params,
407    value_text: String,
408}
409
410/// Parse a Fluent resource → one [`FtlMessage`] per message (terms/attributes/comments/junk ignored;
411/// a parse error on an unrelated entry is tolerated — the partial resource is still walked).
412fn ftl_messages(src: &str) -> Vec<FtlMessage> {
413    use fluent_syntax::ast::Entry;
414    let res = match fluent_syntax::parser::parse(src) {
415        Ok(r) => r,
416        Err((r, _errs)) => r,
417    };
418    let mut out = Vec::new();
419    for entry in &res.body {
420        if let Entry::Message(m) = entry {
421            let mut params = Params::new();
422            let value_text = match &m.value {
423                Some(value) => {
424                    collect_pattern_vars(value, &mut params, false);
425                    pattern_text(value)
426                }
427                None => String::new(),
428            };
429            out.push(FtlMessage {
430                key: m.id.name.to_string(),
431                params,
432                value_text,
433            });
434        }
435    }
436    out
437}
438
439type Vars = std::collections::BTreeSet<String>;
440/// `$variable` name → whether it is used numerically (plural/`select` selector or `NUMBER()` arg).
441type Params = std::collections::BTreeMap<String, bool>;
442
443fn collect_pattern_vars(p: &fluent_syntax::ast::Pattern<&str>, out: &mut Params, numeric: bool) {
444    use fluent_syntax::ast::PatternElement;
445    for el in &p.elements {
446        if let PatternElement::Placeable { expression } = el {
447            collect_expr_vars(expression, out, numeric);
448        }
449    }
450}
451
452fn collect_expr_vars(e: &fluent_syntax::ast::Expression<&str>, out: &mut Params, numeric: bool) {
453    use fluent_syntax::ast::Expression;
454    match e {
455        Expression::Inline(ie) => collect_inline_vars(ie, out, numeric),
456        Expression::Select { selector, variants } => {
457            // A plural/number select makes its selector numeric; a string select (`$gender ->
458            // [male]…`) does not. Variant bodies are ordinary (non-numeric) context.
459            collect_inline_vars(selector, out, is_number_select(variants));
460            for v in variants {
461                collect_pattern_vars(&v.value, out, false);
462            }
463        }
464    }
465}
466
467fn collect_inline_vars(
468    ie: &fluent_syntax::ast::InlineExpression<&str>,
469    out: &mut Params,
470    numeric: bool,
471) {
472    use fluent_syntax::ast::InlineExpression as X;
473    match ie {
474        X::VariableReference { id } => {
475            *out.entry(id.name.to_string()).or_insert(false) |= numeric;
476        }
477        X::Placeable { expression } => collect_expr_vars(expression, out, numeric),
478        X::FunctionReference { id, arguments } => {
479            // The built-in `NUMBER(...)` forces its positional arg numeric; named options don't.
480            // `DATETIME(...)` deliberately does NOT: its argument is an ISO-8601 string (or an
481            // epoch number the app formats itself), so the generated `res::str` fn keeps the
482            // general `IntoFArg` bound (docs/localization.md "Formatted values").
483            let num = id.name.eq_ignore_ascii_case("NUMBER");
484            for a in &arguments.positional {
485                collect_inline_vars(a, out, num);
486            }
487            for n in &arguments.named {
488                collect_inline_vars(&n.value, out, false);
489            }
490        }
491        X::TermReference {
492            arguments: Some(arguments),
493            ..
494        } => {
495            for a in &arguments.positional {
496                collect_inline_vars(a, out, false);
497            }
498            for n in &arguments.named {
499                collect_inline_vars(&n.value, out, false);
500            }
501        }
502        _ => {}
503    }
504}
505
506/// One `FUNC(...)` call in a message value — `day lint` validates function names and option
507/// values across every locale file with this (the shared fluent-syntax parse, like
508/// [`message_keys`]).
509#[derive(Debug, Clone, PartialEq)]
510pub struct FtlCall {
511    /// The message key the call appears under.
512    pub key: String,
513    /// The function name as written (`NUMBER`, `DATETIME`, …).
514    pub name: String,
515    /// Named options with their literal values (`style: "percent"` → `("style", "percent")`;
516    /// non-literal option values are omitted).
517    pub named: Vec<(String, String)>,
518}
519
520/// Every function call in every message of a Fluent resource (parse errors tolerated — the
521/// partial resource is walked, matching [`message_keys`]).
522pub fn function_calls(src: &str) -> Vec<FtlCall> {
523    use fluent_syntax::ast::Entry;
524    let res = match fluent_syntax::parser::parse(src) {
525        Ok(r) => r,
526        Err((r, _errs)) => r,
527    };
528    let mut out = Vec::new();
529    for entry in &res.body {
530        if let Entry::Message(m) = entry
531            && let Some(value) = &m.value
532        {
533            collect_pattern_calls(value, m.id.name, &mut out);
534        }
535    }
536    out
537}
538
539fn collect_pattern_calls(p: &fluent_syntax::ast::Pattern<&str>, key: &str, out: &mut Vec<FtlCall>) {
540    use fluent_syntax::ast::PatternElement;
541    for el in &p.elements {
542        if let PatternElement::Placeable { expression } = el {
543            collect_expr_calls(expression, key, out);
544        }
545    }
546}
547
548fn collect_expr_calls(e: &fluent_syntax::ast::Expression<&str>, key: &str, out: &mut Vec<FtlCall>) {
549    use fluent_syntax::ast::Expression;
550    match e {
551        Expression::Inline(ie) => collect_inline_calls(ie, key, out),
552        Expression::Select { selector, variants } => {
553            collect_inline_calls(selector, key, out);
554            for v in variants {
555                collect_pattern_calls(&v.value, key, out);
556            }
557        }
558    }
559}
560
561fn collect_inline_calls(
562    ie: &fluent_syntax::ast::InlineExpression<&str>,
563    key: &str,
564    out: &mut Vec<FtlCall>,
565) {
566    use fluent_syntax::ast::InlineExpression as X;
567    match ie {
568        X::FunctionReference { id, arguments } => {
569            let named = arguments
570                .named
571                .iter()
572                .filter_map(|n| {
573                    let value = match &n.value {
574                        X::StringLiteral { value } => value.to_string(),
575                        X::NumberLiteral { value } => value.to_string(),
576                        _ => return None,
577                    };
578                    Some((n.name.name.to_string(), value))
579                })
580                .collect();
581            out.push(FtlCall {
582                key: key.to_string(),
583                name: id.name.to_string(),
584                named,
585            });
586            for a in &arguments.positional {
587                collect_inline_calls(a, key, out);
588            }
589        }
590        X::Placeable { expression } => collect_expr_calls(expression, key, out),
591        _ => {}
592    }
593}
594
595/// Whether a `select` is a **plural / number** select (selector is a number) rather than a string
596/// select (e.g. `$gender -> [male] [female]`): true if any variant key is a number literal or a CLDR
597/// plural category other than the ambiguous `other` (which both plural and string selects use).
598fn is_number_select(variants: &[fluent_syntax::ast::Variant<&str>]) -> bool {
599    use fluent_syntax::ast::VariantKey;
600    const PLURAL: &[&str] = &["zero", "one", "two", "few", "many"];
601    variants.iter().any(|v| match &v.key {
602        VariantKey::NumberLiteral { .. } => true,
603        VariantKey::Identifier { name } => PLURAL.contains(&name.to_ascii_lowercase().as_str()),
604    })
605}
606
607/// A one-line, human-readable rendering of a message value for the generated doc comment
608/// (`Hello, { $name }!`, `{ $count -> … }`), whitespace collapsed. Backticks are stripped so the
609/// value can be wrapped in a doc-comment code span.
610fn pattern_text(p: &fluent_syntax::ast::Pattern<&str>) -> String {
611    use fluent_syntax::ast::PatternElement;
612    let mut s = String::new();
613    for el in &p.elements {
614        match el {
615            PatternElement::TextElement { value } => s.push_str(value),
616            PatternElement::Placeable { expression } => s.push_str(&placeable_text(expression)),
617        }
618    }
619    s.split_whitespace()
620        .collect::<Vec<_>>()
621        .join(" ")
622        .replace('`', "'")
623}
624
625fn placeable_text(e: &fluent_syntax::ast::Expression<&str>) -> String {
626    use fluent_syntax::ast::{Expression, InlineExpression as X};
627    match e {
628        Expression::Inline(X::VariableReference { id }) => format!("{{ ${} }}", id.name),
629        Expression::Inline(X::StringLiteral { value }) => format!("{{ \"{value}\" }}"),
630        Expression::Select {
631            selector: X::VariableReference { id },
632            ..
633        } => format!("{{ ${} -> … }}", id.name),
634        _ => "{ … }".to_string(),
635    }
636}
637
638/// A valid Rust identifier: leading `[A-Za-z_]`, remaining `[A-Za-z0-9_]`, and not the bare `_`.
639/// Keyword idents still count as valid — `ident_token` raw-escapes them at render time.
640fn is_rust_ident(s: &str) -> bool {
641    let mut chars = s.chars();
642    let Some(first) = chars.next() else {
643        return false;
644    };
645    (first.is_ascii_alphabetic() || first == '_')
646        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
647        && s != "_"
648}
649
650/// Render a plan to the `day_resources.rs` source text. This file is `include!`d inside the app's
651/// `pub mod res { … }`, so the lint waivers are **outer** attributes on each bucket module (an inner
652/// `#![…]` is not valid at an `include!` site) and cover a bucket with no constants (unused `use`).
653pub fn render(plan: &ResourcePlan) -> String {
654    let mut s = String::new();
655    s.push_str("// @generated by day-build — do not edit.\n");
656    s.push_str("// Regenerated on every build from resource/{images,assets,fonts,locales}.\n\n");
657    // `locales::install()` names `day::install_locales`, so the generated file needs the umbrella
658    // crate in scope wherever it is included — the same assumption the other buckets make with
659    // `day::ImageName`.
660    render_bucket(&mut s, "images", "ImageName", &plan.images);
661    render_bucket(&mut s, "assets", "AssetName", &plan.assets);
662    render_bucket(&mut s, "fonts", "FontFamily", &plan.fonts);
663    render_strings(&mut s, &plan.strings);
664    render_locales(&mut s, &plan.locales);
665    s
666}
667
668/// Render the `locales` bucket: the app's whole Fluent catalog, embedded. `install()` is the
669/// one-liner an app's `root()` calls — the source list can't drift from the directory because
670/// it IS the directory.
671///
672/// Paths are absolute because this file is `include!`d from `$OUT_DIR`, so a relative
673/// `include_str!` would resolve against `$OUT_DIR` rather than the crate. Several `.ftl` files
674/// in one locale directory `concat!` into a single source (Fluent bundles are per-locale, and
675/// `day_l10n::install` keys them by tag — two entries for one tag would shadow, not merge).
676fn render_locales(s: &mut String, locales: &[LocaleEntry]) {
677    s.push_str("#[allow(dead_code)]\npub mod locales {\n");
678    s.push_str(&format!(
679        "    /// The fallback locale — the one whose strings show when the running locale has no\n\
680         \x20   /// translation for a key.\n    pub const DEFAULT: &str = {:?};\n\n",
681        default_locale(locales)
682    ));
683    s.push_str(
684        "    /// Every locale under `resource/locales/`, embedded at build time: one\n\
685     \x20   /// `(tag, fluent-source)` pair per directory.\n\
686     \x20   pub const CATALOG: &[(&str, &str)] = &[\n",
687    );
688    for l in locales {
689        // The FULL path, not `display`'s three-component diagnostic form — and `{:?}` escapes it
690        // into a valid Rust literal (Windows separators included).
691        let sources: Vec<String> = l
692            .sources
693            .iter()
694            .map(|p| format!("include_str!({:?})", p.display().to_string()))
695            .collect();
696        // `concat!` of one argument is the identity, so the single-file case stays readable.
697        let src = if sources.len() == 1 {
698            sources.into_iter().next().unwrap_or_default()
699        } else {
700            format!("concat!({}, \"\\n\")", sources.join(", \"\\n\", "))
701        };
702        s.push_str(&format!("        ({:?}, {src}),\n", l.locale));
703    }
704    s.push_str("    ];\n\n");
705    s.push_str(
706        "    /// Register [`CATALOG`] under [`DEFAULT`] — call once, before the first localized\n\
707     \x20   /// string is read (the top of the app's `root()`). For a different fallback:\n\
708     \x20   /// `day::install_locales(\"fr\", res::locales::CATALOG)`.\n\
709     \x20   pub fn install() {\n        day::install_locales(DEFAULT, CATALOG);\n    }\n",
710    );
711    s.push_str("}\n\n");
712}
713
714/// Render the `str` bucket: one `pub fn` per localization key whose signature carries the message's
715/// parameters, so `res::str::greeting(name)` == `tr("greeting").arg("name", name)` — checked at
716/// compile time (a missing key or wrong arity is an error).
717fn render_strings(s: &mut String, entries: &[StrEntry]) {
718    s.push_str("#[allow(dead_code, unused_imports, non_snake_case, clippy::too_many_arguments)]\n");
719    s.push_str("pub mod str {\n");
720    for e in entries {
721        // Each param is `impl day::IntoFArg<Mn>` — or `IntoNumberFArg` when the message uses it as a
722        // plural/`select` selector (a distinct marker generic per arg). The Rust parameter ident is
723        // sanitized while the `.arg("…")` string stays the exact Fluent variable.
724        let generics: Vec<String> = (0..e.params.len()).map(|i| format!("M{i}")).collect();
725        let sig_params: Vec<String> = e
726            .params
727            .iter()
728            .enumerate()
729            .map(|(i, p)| {
730                let ty = if p.numeric {
731                    "IntoNumberFArg"
732                } else {
733                    "IntoFArg"
734                };
735                format!(
736                    "{}: impl day::{ty}<M{i}>",
737                    ident_token(&sanitize_ident(&p.name))
738                )
739            })
740            .collect();
741        let generic_list = if generics.is_empty() {
742            String::new()
743        } else {
744            format!("<{}>", generics.join(", "))
745        };
746        let mut body = format!("day::tr({:?})", e.key);
747        for p in &e.params {
748            body.push_str(&format!(
749                ".arg({:?}, {})",
750                p.name,
751                ident_token(&sanitize_ident(&p.name))
752            ));
753        }
754        // Doc shows the key + the reference-locale value, so IDE hover reveals the actual text.
755        let doc = if e.doc.is_empty() {
756            format!("`{}`", e.key)
757        } else {
758            format!("`{}` — `{}`", e.key, e.doc)
759        };
760        s.push_str(&format!(
761            "    /// {doc}\n    pub fn {}{generic_list}({}) -> day::LocalizedText {{ {body} }}\n",
762            ident_token(&e.key),
763            sig_params.join(", "),
764        ));
765    }
766    s.push_str("}\n\n");
767}
768
769fn render_bucket(s: &mut String, module: &str, ty: &str, entries: &[Entry]) {
770    s.push_str("#[allow(non_upper_case_globals, dead_code, unused_imports)]\n");
771    s.push_str(&format!("pub mod {module} {{\n    use day::{ty};\n"));
772    for e in entries {
773        s.push_str(&format!(
774            "    /// `{}`\n    pub const {}: {ty} = {ty}::from_static({:?});\n",
775            e.source,
776            ident_token(&e.symbol),
777            e.value,
778        ));
779    }
780    s.push_str("}\n\n");
781}
782
783/// Wrap a Rust keyword symbol as a raw identifier so a resource named e.g. `type` still compiles.
784fn ident_token(sym: &str) -> String {
785    const KEYWORDS: &[&str] = &[
786        "as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
787        "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
788        "static", "struct", "trait", "true", "type", "union", "unsafe", "use", "where", "while",
789        "async", "await", "try",
790    ];
791    if KEYWORDS.contains(&sym) {
792        format!("r#{sym}")
793    } else {
794        sym.to_string()
795    }
796}
797
798/// Split a `foo@2x` stem into (`"foo"`, 2); a bare `foo` yields (`"foo"`, 1).
799fn parse_scale(stem: &str) -> (String, u32) {
800    if let Some((base, tail)) = stem.rsplit_once('@')
801        && let Some(digits) = tail.strip_suffix('x')
802        && let Ok(scale) = digits.parse::<u32>()
803        && scale >= 1
804    {
805        return (base.to_string(), scale);
806    }
807    (stem.to_string(), 1)
808}
809
810/// Sanitize a name to the strictest platform identifier rules (Android `R` / ArkUI): lowercase, only
811/// `[a-z0-9_]`, forced leading letter. The canonical copy — the CLI stagers re-export this so the
812/// staged native name and the generated constant string agree by construction.
813pub fn sanitize_ident(name: &str) -> String {
814    let mut s: String = name
815        .chars()
816        .map(|c| {
817            let c = c.to_ascii_lowercase();
818            if c.is_ascii_alphanumeric() || c == '_' {
819                c
820            } else {
821                '_'
822            }
823        })
824        .collect();
825    if !s.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
826        s.insert(0, 'r');
827    }
828    s
829}
830
831/// A project-relative-ish display path for error messages / doc comments (`resource/images/x.png`).
832fn display(path: &Path) -> String {
833    // Keep the last three components (`resource/<bucket>/<file>`) when present — stable across
834    // machines and enough to locate the file.
835    let comps: Vec<_> = path.components().collect();
836    let n = comps.len();
837    let start = n.saturating_sub(3);
838    comps[start..]
839        .iter()
840        .map(|c| c.as_os_str().to_string_lossy())
841        .collect::<Vec<_>>()
842        .join("/")
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    fn tmp(label: &str) -> PathBuf {
850        // Unique per test so the parallel test threads never clobber each other's dirs.
851        let d = std::env::temp_dir().join(format!("day-build-{}-{label}", std::process::id()));
852        let _ = std::fs::remove_dir_all(&d);
853        d
854    }
855
856    fn touch(dir: &Path, name: &str, bytes: &[u8]) {
857        std::fs::create_dir_all(dir).unwrap();
858        std::fs::write(dir.join(name), bytes).unwrap();
859    }
860
861    #[test]
862    fn sanitize_matches_strictest_rules() {
863        assert_eq!(sanitize_ident("nav_system"), "nav_system");
864        assert_eq!(sanitize_ident("Nav-System"), "nav_system");
865        assert_eq!(sanitize_ident("123"), "r123");
866        assert_eq!(sanitize_ident("numbers.bin"), "numbers_bin");
867    }
868
869    #[test]
870    fn images_dedup_scale_variants_and_key_on_stem() {
871        let root = tmp("images-dedup");
872        let img = root.join("resource/images");
873        touch(&img, "nav_system.png", b"x");
874        touch(&img, "day_logo.png", b"x");
875        touch(&img, "day_logo@2x.png", b"x"); // HiDPI variant of the same logical image
876        let plan = plan_resources(&root).unwrap();
877        let syms: Vec<_> = plan.images.iter().map(|e| e.symbol.as_str()).collect();
878        assert_eq!(syms, vec!["day_logo", "nav_system"]);
879        assert_eq!(plan.images[0].value, "day_logo");
880        std::fs::remove_dir_all(&root).ok();
881    }
882
883    #[test]
884    fn non_portable_image_stem_is_rejected() {
885        let root = tmp("non-portable");
886        touch(&root.join("resource/images"), "Nav-System.png", b"x");
887        let err = plan_resources(&root).unwrap_err();
888        assert!(err.contains("portable"), "{err}");
889        assert!(err.contains("nav_system"), "{err}"); // suggests the fix
890        std::fs::remove_dir_all(&root).ok();
891    }
892
893    #[test]
894    fn same_stem_same_scale_collides() {
895        let root = tmp("collide");
896        let img = root.join("resource/images");
897        touch(&img, "logo.png", b"x");
898        touch(&img, "logo.jpg", b"x"); // two distinct files, both stem `logo`, scale 1
899        let err = plan_resources(&root).unwrap_err();
900        assert!(err.contains("same scale"), "{err}");
901        std::fs::remove_dir_all(&root).ok();
902    }
903
904    #[test]
905    fn asset_symbol_sanitized_value_verbatim() {
906        let root = tmp("assets");
907        touch(&root.join("resource/assets"), "numbers.bin", b"x");
908        let plan = plan_resources(&root).unwrap();
909        assert_eq!(plan.assets[0].symbol, "numbers_bin");
910        assert_eq!(plan.assets[0].value, "numbers.bin");
911        std::fs::remove_dir_all(&root).ok();
912    }
913
914    #[test]
915    fn render_shape_is_typed_and_lowercase() {
916        let plan = ResourcePlan {
917            images: vec![Entry {
918                symbol: "nav_system".into(),
919                value: "nav_system".into(),
920                source: "resource/images/nav_system.png".into(),
921            }],
922            ..Default::default()
923        };
924        let code = render(&plan);
925        assert!(code.contains("#[allow(non_upper_case_globals, dead_code, unused_imports)]"));
926        assert!(code.contains("pub mod images {"));
927        assert!(code.contains("use day::ImageName;"));
928        assert!(
929            code.contains(
930                "pub const nav_system: ImageName = ImageName::from_static(\"nav_system\");"
931            )
932        );
933    }
934
935    #[test]
936    fn keyword_symbol_becomes_raw_ident() {
937        let plan = ResourcePlan {
938            images: vec![Entry {
939                symbol: "type".into(),
940                value: "type".into(),
941                source: "resource/images/type.png".into(),
942            }],
943            ..Default::default()
944        };
945        assert!(render(&plan).contains("pub const r#type: ImageName"));
946    }
947
948    #[test]
949    fn missing_dirs_yield_empty_plan() {
950        let root = tmp("missing-dirs");
951        std::fs::create_dir_all(&root).unwrap();
952        let plan = plan_resources(&root).unwrap();
953        assert!(plan.images.is_empty() && plan.assets.is_empty() && plan.fonts.is_empty());
954        assert!(plan.strings.is_empty());
955        std::fs::remove_dir_all(&root).ok();
956    }
957
958    fn ftl(root: &Path, locale: &str, body: &str) {
959        let dir = root.join("resource/locales").join(locale);
960        std::fs::create_dir_all(&dir).unwrap();
961        std::fs::write(dir.join("app.ftl"), body).unwrap();
962    }
963
964    fn entry<'a>(plan: &'a ResourcePlan, key: &str) -> &'a StrEntry {
965        plan.strings
966            .iter()
967            .find(|e| e.key == key)
968            .expect("key present")
969    }
970    fn names(e: &StrEntry) -> Vec<&str> {
971        e.params.iter().map(|p| p.name.as_str()).collect()
972    }
973
974    #[test]
975    fn extracts_keys_params_numeric_and_doc() {
976        let root = tmp("str-extract");
977        // `counter_value` uses $count in a plural select (multiline) — same variable SET as a flat
978        // value, and numeric (a plural selector); `greeting` has one non-numeric param; `nav_home`
979        // has none. The doc captures the reference-locale value text (#5).
980        ftl(
981            &root,
982            "en",
983            "nav_home = Home\n\
984             greeting = Hello, { $name }!\n\
985             counter_value = { $count ->\n    [one] { $count } click\n   *[other] { $count } clicks\n}\n",
986        );
987        let plan = plan_resources(&root).unwrap();
988        assert!(names(entry(&plan, "nav_home")).is_empty());
989        assert_eq!(names(entry(&plan, "greeting")), vec!["name"]);
990        assert_eq!(entry(&plan, "greeting").doc, "Hello, { $name }!"); // #5
991        assert!(!entry(&plan, "greeting").params[0].numeric);
992        // #2: a plural-select selector is typed numeric.
993        assert_eq!(names(entry(&plan, "counter_value")), vec!["count"]);
994        assert!(entry(&plan, "counter_value").params[0].numeric);
995        std::fs::remove_dir_all(&root).ok();
996    }
997
998    #[test]
999    fn string_select_selector_is_not_numeric() {
1000        let root = tmp("str-gender");
1001        // A `select` on a string (gender) must NOT force its selector numeric.
1002        ftl(
1003            &root,
1004            "en",
1005            "hi = { $gender ->\n    [male] Mr\n    [female] Ms\n   *[other] Mx\n} { $name }\n",
1006        );
1007        let plan = plan_resources(&root).unwrap();
1008        let g = entry(&plan, "hi");
1009        assert!(
1010            !g.params
1011                .iter()
1012                .find(|p| p.name == "gender")
1013                .unwrap()
1014                .numeric
1015        );
1016        assert!(!g.params.iter().find(|p| p.name == "name").unwrap().numeric);
1017        std::fs::remove_dir_all(&root).ok();
1018    }
1019
1020    #[test]
1021    fn numeric_is_ored_across_locales() {
1022        let root = tmp("str-numeric-or");
1023        // `en` uses $count as a plural selector (numeric); `zh` uses it as a flat interpolation.
1024        // The param must be numeric because SOME locale needs a number.
1025        ftl(
1026            &root,
1027            "en",
1028            "n = { $count ->\n    [one] one\n   *[other] many\n}\n",
1029        );
1030        ftl(&root, "zh", "n = { $count } times\n");
1031        let plan = plan_resources(&root).unwrap();
1032        assert!(entry(&plan, "n").params[0].numeric);
1033        std::fs::remove_dir_all(&root).ok();
1034    }
1035
1036    #[test]
1037    fn message_keys_lists_message_ids_only() {
1038        // Public parser shared with `day lint`: messages only (terms/comments excluded).
1039        let keys = message_keys("a = x\n# comment\n-term = y\nb = { $v }\n");
1040        assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
1041    }
1042
1043    #[test]
1044    fn kebab_key_is_rejected() {
1045        let root = tmp("str-kebab");
1046        ftl(&root, "en", "nav-home = Home\n");
1047        let err = plan_resources(&root).unwrap_err();
1048        assert!(err.contains("not a valid Rust identifier"), "{err}");
1049        assert!(err.contains("nav_home"), "{err}"); // suggests the fix
1050        std::fs::remove_dir_all(&root).ok();
1051    }
1052
1053    #[test]
1054    fn cross_locale_param_disagreement_is_rejected() {
1055        let root = tmp("str-params");
1056        ftl(&root, "en", "greeting = Hello, { $name }!\n");
1057        ftl(&root, "fr", "greeting = Bonjour, { $nom }!\n");
1058        let err = plan_resources(&root).unwrap_err();
1059        assert!(err.contains("different parameters"), "{err}");
1060        std::fs::remove_dir_all(&root).ok();
1061    }
1062
1063    #[test]
1064    fn renders_param_typed_functions() {
1065        let p = |name: &str, numeric: bool| StrParam {
1066            name: name.into(),
1067            numeric,
1068        };
1069        let plan = ResourcePlan {
1070            strings: vec![
1071                StrEntry {
1072                    key: "hello_world".into(),
1073                    params: vec![],
1074                    doc: "Hello!".into(),
1075                },
1076                StrEntry {
1077                    key: "counter_value".into(),
1078                    params: vec![p("count", true)], // numeric plural → IntoNumberFArg
1079                    doc: "{ $count -> … }".into(),
1080                },
1081                StrEntry {
1082                    key: "deviceinfo_system".into(),
1083                    params: vec![p("name", false), p("version", false)],
1084                    doc: String::new(),
1085                },
1086            ],
1087            ..Default::default()
1088        };
1089        let code = render(&plan);
1090        assert!(code.contains("pub mod str {"));
1091        assert!(code.contains("/// `hello_world` — `Hello!`")); // #5: doc shows the value
1092        assert!(
1093            code.contains(
1094                "pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
1095            )
1096        );
1097        // #2: a numeric param is `IntoNumberFArg`; non-numeric stays `IntoFArg`.
1098        assert!(code.contains(
1099            "pub fn counter_value<M0>(count: impl day::IntoNumberFArg<M0>) -> day::LocalizedText { day::tr(\"counter_value\").arg(\"count\", count) }"
1100        ));
1101        assert!(code.contains(
1102            "pub fn deviceinfo_system<M0, M1>(name: impl day::IntoFArg<M0>, version: impl day::IntoFArg<M1>) -> day::LocalizedText { day::tr(\"deviceinfo_system\").arg(\"name\", name).arg(\"version\", version) }"
1103        ));
1104    }
1105
1106    // ---- The `locales` catalog: the app's whole language list, discovered not declared ----
1107
1108    #[test]
1109    fn locales_are_discovered_and_sorted() {
1110        let root = tmp("locales-discover");
1111        ftl(&root, "en", "hello = Hello");
1112        ftl(&root, "fr", "hello = Bonjour");
1113        ftl(&root, "zh-CN", "hello = 你好");
1114        let plan = plan_resources(&root).unwrap();
1115        let tags: Vec<_> = plan.locales.iter().map(|l| l.locale.as_str()).collect();
1116        assert_eq!(tags, vec!["en", "fr", "zh-CN"]); // sorted → deterministic output
1117        assert!(plan.locales.iter().all(|l| l.sources.len() == 1));
1118        std::fs::remove_dir_all(&root).ok();
1119    }
1120
1121    #[test]
1122    fn locale_catalog_renders_embedded_sources() {
1123        let root = tmp("locales-render");
1124        ftl(&root, "en", "hello = Hello");
1125        ftl(&root, "fr", "hello = Bonjour");
1126        let plan = plan_resources(&root).unwrap();
1127        let code = render(&plan);
1128        assert!(code.contains("pub mod locales {"));
1129        assert!(code.contains("pub const DEFAULT: &str = \"en\";"));
1130        assert!(code.contains("pub const CATALOG: &[(&str, &str)] = &["));
1131        // Absolute paths: the generated file is `include!`d from $OUT_DIR, so a relative
1132        // `include_str!` would resolve against the wrong directory. Compare against the plan's
1133        // own source path, not a re-`join`ed one: on Windows, discovery separates components
1134        // with `\` where a joined `"a/b"` literal keeps its `/`, so the strings differ even
1135        // when the paths agree. `ends_with` compares components, so it holds on both.
1136        let en = &plan
1137            .locales
1138            .iter()
1139            .find(|l| l.locale == "en")
1140            .unwrap()
1141            .sources[0];
1142        assert!(
1143            en.is_absolute() && en.ends_with("en/app.ftl"),
1144            "{}",
1145            en.display()
1146        );
1147        assert!(
1148            code.contains(&format!(
1149                "(\"en\", include_str!({:?}))",
1150                en.display().to_string()
1151            )),
1152            "{code}"
1153        );
1154        assert!(code.contains("day::install_locales(DEFAULT, CATALOG);"));
1155        std::fs::remove_dir_all(&root).ok();
1156    }
1157
1158    #[test]
1159    fn several_ftl_files_in_one_locale_concatenate() {
1160        let root = tmp("locales-multifile");
1161        ftl(&root, "en", "hello = Hello"); // app.ftl
1162        let dir = root.join("resource/locales/en");
1163        std::fs::write(dir.join("errors.ftl"), "oops = Oops").unwrap();
1164        let plan = plan_resources(&root).unwrap();
1165        assert_eq!(plan.locales.len(), 1, "one bundle per locale, not per file");
1166        assert_eq!(plan.locales[0].sources.len(), 2);
1167        // Both keys are still generated, and the sources join into ONE catalog entry (a second
1168        // entry for the same tag would shadow the first in day_l10n's per-locale bundle map).
1169        let keys: Vec<_> = plan.strings.iter().map(|e| e.key.as_str()).collect();
1170        assert_eq!(keys, vec!["hello", "oops"]);
1171        let code = render(&plan);
1172        assert_eq!(code.matches("(\"en\", ").count(), 1);
1173        assert!(code.contains("concat!(include_str!("), "{code}");
1174        std::fs::remove_dir_all(&root).ok();
1175    }
1176
1177    #[test]
1178    fn default_locale_prefers_en_then_first() {
1179        let root = tmp("locales-default-en");
1180        ftl(&root, "fr", "hello = Bonjour");
1181        ftl(&root, "en", "hello = Hello");
1182        assert_eq!(
1183            default_locale(&plan_resources(&root).unwrap().locales),
1184            "en"
1185        );
1186        std::fs::remove_dir_all(&root).ok();
1187
1188        // No English: the first tag alphabetically, so the pick is deterministic.
1189        let root = tmp("locales-default-noen");
1190        ftl(&root, "fr", "hello = Bonjour");
1191        ftl(&root, "ar", "hello = مرحبا");
1192        assert_eq!(
1193            default_locale(&plan_resources(&root).unwrap().locales),
1194            "ar"
1195        );
1196        std::fs::remove_dir_all(&root).ok();
1197    }
1198
1199    #[test]
1200    fn no_locales_yields_an_empty_catalog() {
1201        // An app with no `resource/locales/` still compiles: `install()` registers nothing and
1202        // day-l10n's built-in core catalog keeps answering framework keys.
1203        let root = tmp("locales-none");
1204        touch(&root.join("resource/images"), "logo.png", b"x");
1205        let plan = plan_resources(&root).unwrap();
1206        assert!(plan.locales.is_empty());
1207        let code = render(&plan);
1208        assert!(code.contains("pub const DEFAULT: &str = \"en\";"));
1209        assert!(code.contains("pub const CATALOG: &[(&str, &str)] = &[\n    ];"));
1210        std::fs::remove_dir_all(&root).ok();
1211    }
1212
1213    #[test]
1214    fn stray_ftl_outside_a_locale_dir_is_ignored() {
1215        // `resource/locales/loose.ftl` names no language — it must not become a `locales` locale.
1216        let root = tmp("locales-stray");
1217        ftl(&root, "en", "hello = Hello");
1218        std::fs::write(root.join("resource/locales/loose.ftl"), "stray = Stray").unwrap();
1219        let plan = plan_resources(&root).unwrap();
1220        let tags: Vec<_> = plan.locales.iter().map(|l| l.locale.as_str()).collect();
1221        assert_eq!(tags, vec!["en"]);
1222        std::fs::remove_dir_all(&root).ok();
1223    }
1224}