1use std::path::{Path, PathBuf};
34
35pub mod permissions;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Entry {
41 pub symbol: String,
42 pub value: String,
43 pub source: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct StrEntry {
51 pub key: String,
52 pub params: Vec<StrParam>,
53 pub doc: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct StrParam {
61 pub name: String,
62 pub numeric: bool,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct LocaleEntry {
70 pub locale: String,
71 pub sources: Vec<PathBuf>,
72}
73
74#[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 pub strings: Vec<StrEntry>,
82 pub locales: Vec<LocaleEntry>,
85}
86
87pub 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 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
109pub 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
120fn 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
140fn plan_images(dir: &Path) -> Result<Vec<Entry>, String> {
147 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
187fn 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
206fn 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; }
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
233fn 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
248fn 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
269pub fn message_keys(ftl_src: &str) -> Vec<String> {
273 ftl_messages(ftl_src).into_iter().map(|m| m.key).collect()
274}
275
276fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
283 let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
285 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 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 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
356fn 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 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
382fn 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
396fn 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
404struct FtlMessage {
406 key: String,
407 params: Params,
408 value_text: String,
409}
410
411fn 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>;
441type 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 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 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#[derive(Debug, Clone, PartialEq)]
511pub struct FtlCall {
512 pub key: String,
514 pub name: String,
516 pub named: Vec<(String, String)>,
519}
520
521pub 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
596fn 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
608fn 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
639fn 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
651pub 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 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
669fn 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 let sources: Vec<String> = l
693 .sources
694 .iter()
695 .map(|p| format!("include_str!({:?})", p.display().to_string()))
696 .collect();
697 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
729fn 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
752fn 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 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 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
821fn 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
836fn 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
848pub 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
869fn display(path: &Path) -> String {
871 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 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"); 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}"); 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"); 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 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 }!"); assert!(!entry(&plan, "greeting").params[0].numeric);
1030 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 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 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 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}"); 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)], 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!`")); assert!(
1131 code.contains(
1132 "pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
1133 )
1134 );
1135 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 #[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"]); 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 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"); 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 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 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 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 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 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}