1use std::path::{Path, PathBuf};
33
34pub mod permissions;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Entry {
40 pub symbol: String,
41 pub value: String,
42 pub source: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct StrEntry {
50 pub key: String,
51 pub params: Vec<StrParam>,
52 pub doc: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct StrParam {
60 pub name: String,
61 pub numeric: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct LocaleEntry {
69 pub locale: String,
70 pub sources: Vec<PathBuf>,
71}
72
73#[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 pub strings: Vec<StrEntry>,
81 pub locales: Vec<LocaleEntry>,
84}
85
86pub 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 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
108pub 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
119fn 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
139fn plan_images(dir: &Path) -> Result<Vec<Entry>, String> {
146 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
186fn 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
205fn 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; }
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
232fn 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
247fn 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
268pub fn message_keys(ftl_src: &str) -> Vec<String> {
272 ftl_messages(ftl_src).into_iter().map(|m| m.key).collect()
273}
274
275fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
282 let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
284 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 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 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
355fn 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 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
381fn 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
395fn 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
403struct FtlMessage {
405 key: String,
406 params: Params,
407 value_text: String,
408}
409
410fn 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>;
440type 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 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 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#[derive(Debug, Clone, PartialEq)]
510pub struct FtlCall {
511 pub key: String,
513 pub name: String,
515 pub named: Vec<(String, String)>,
518}
519
520pub 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
595fn 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
607fn 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
638fn 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
650pub 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 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
668fn 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 let sources: Vec<String> = l
692 .sources
693 .iter()
694 .map(|p| format!("include_str!({:?})", p.display().to_string()))
695 .collect();
696 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
714fn 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 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 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
783fn 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
798fn 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
810pub 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
831fn display(path: &Path) -> String {
833 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 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"); 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}"); 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"); 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 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 }!"); assert!(!entry(&plan, "greeting").params[0].numeric);
992 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 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 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 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}"); 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)], 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!`")); assert!(
1093 code.contains(
1094 "pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
1095 )
1096 );
1097 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 #[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"]); 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 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"); 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 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 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 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 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}