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