1use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Entry {
31 pub symbol: String,
32 pub value: String,
33 pub source: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct StrEntry {
41 pub key: String,
42 pub params: Vec<StrParam>,
43 pub doc: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct StrParam {
51 pub name: String,
52 pub numeric: bool,
53}
54
55#[derive(Debug, Default, Clone, PartialEq, Eq)]
57pub struct ResourcePlan {
58 pub images: Vec<Entry>,
59 pub assets: Vec<Entry>,
60 pub fonts: Vec<Entry>,
61 pub strings: Vec<StrEntry>,
63}
64
65pub fn generate_resources() -> Result<(), String> {
70 let root = PathBuf::from(env("CARGO_MANIFEST_DIR")?);
71 let out = PathBuf::from(env("OUT_DIR")?);
72 let plan = plan_resources(&root)?;
73 let code = render(&plan);
74 std::fs::write(out.join("day_resources.rs"), code)
75 .map_err(|e| format!("day-build: writing day_resources.rs: {e}"))?;
76 for bucket in ["images", "assets", "fonts", "locales"] {
78 println!("cargo:rerun-if-changed=resource/{bucket}");
79 }
80 Ok(())
81}
82
83fn env(key: &str) -> Result<String, String> {
84 std::env::var(key).map_err(|_| format!("day-build: ${key} is not set (call from a build.rs)"))
85}
86
87pub fn plan_resources(root: &Path) -> Result<ResourcePlan, String> {
89 Ok(ResourcePlan {
90 images: plan_images(&root.join("resource/images"))?,
91 assets: plan_assets(&root.join("resource/assets"))?,
92 fonts: plan_fonts(&root.join("resource/fonts"))?,
93 strings: plan_strings(&root.join("resource/locales"))?,
94 })
95}
96
97fn list_files(dir: &Path) -> Vec<PathBuf> {
99 let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
100 .into_iter()
101 .flatten()
102 .flatten()
103 .map(|e| e.path())
104 .filter(|p| {
105 p.is_file()
106 && !p
107 .file_name()
108 .and_then(|n| n.to_str())
109 .unwrap_or("")
110 .starts_with('.')
111 })
112 .collect();
113 files.sort();
114 files
115}
116
117fn plan_images(dir: &Path) -> Result<Vec<Entry>, String> {
124 let mut seen: std::collections::BTreeMap<String, (Vec<u32>, String)> = Default::default();
126 for path in list_files(dir) {
127 let stem = path
128 .file_stem()
129 .and_then(|s| s.to_str())
130 .unwrap_or_default()
131 .to_string();
132 let (base, scale) = parse_scale(&stem);
133 let src = display(&path);
134 let sane = sanitize_ident(&base);
135 if sane != base {
136 return Err(format!(
137 "day-build: image {base:?} ({src}) is not a portable resource name — it resolves \
138 to {sane:?} on Android/HarmonyOS but {base:?} on Apple/GTK/Qt. Rename the file so \
139 its stem is lowercase [a-z0-9_] (e.g. `{sane}`)."
140 ));
141 }
142 let ent = seen
143 .entry(base.clone())
144 .or_insert_with(|| (Vec::new(), src.clone()));
145 if ent.0.contains(&scale) {
146 return Err(format!(
147 "day-build: two files map to image {base:?} at the same scale ({}, {src}) — keep \
148 one file per image (HiDPI variants use an `@2x`/`@3x` suffix).",
149 ent.1
150 ));
151 }
152 ent.0.push(scale);
153 }
154 Ok(seen
155 .into_iter()
156 .map(|(base, (_, src))| Entry {
157 symbol: base.clone(),
158 value: base,
159 source: src,
160 })
161 .collect())
162}
163
164fn plan_assets(dir: &Path) -> Result<Vec<Entry>, String> {
167 let mut entries = Vec::new();
168 for path in list_files(dir) {
169 let fname = path
170 .file_name()
171 .and_then(|n| n.to_str())
172 .unwrap_or_default()
173 .to_string();
174 entries.push(Entry {
175 symbol: sanitize_ident(&fname),
176 value: fname,
177 source: display(&path),
178 });
179 }
180 dedup_symbols(entries, "asset")
181}
182
183fn plan_fonts(dir: &Path) -> Result<Vec<Entry>, String> {
187 let mut entries = Vec::new();
188 for path in list_files(dir) {
189 let ext = path
190 .extension()
191 .and_then(|e| e.to_str())
192 .map(|e| e.to_ascii_lowercase())
193 .unwrap_or_default();
194 if !matches!(ext.as_str(), "ttf" | "otf") {
195 continue; }
197 let src = display(&path);
198 let bytes = std::fs::read(&path).map_err(|e| format!("day-build: reading {src}: {e}"))?;
199 let names = day_fonts::parse_font_names(&bytes)
200 .ok_or_else(|| format!("day-build: {src}: not a recognizable font (no name table)"))?;
201 entries.push(Entry {
202 symbol: day_fonts::font_ident(&names.family),
203 value: names.family,
204 source: src,
205 });
206 }
207 dedup_symbols(entries, "font")
208}
209
210fn dedup_symbols(entries: Vec<Entry>, kind: &str) -> Result<Vec<Entry>, String> {
212 let mut seen: std::collections::BTreeMap<String, String> = Default::default();
213 for e in &entries {
214 if let Some(prev) = seen.insert(e.symbol.clone(), e.source.clone()) {
215 return Err(format!(
216 "day-build: {kind}s {} and {} both map to the symbol `{}` — rename one so they \
217 differ after sanitization to [a-z0-9_].",
218 prev, e.source, e.symbol
219 ));
220 }
221 }
222 Ok(entries)
223}
224
225fn ftl_files(dir: &Path) -> Vec<PathBuf> {
227 let mut out = Vec::new();
228 let mut stack = vec![dir.to_path_buf()];
229 while let Some(d) = stack.pop() {
230 let Ok(entries) = std::fs::read_dir(&d) else {
231 continue;
232 };
233 for e in entries.flatten() {
234 let p = e.path();
235 if p.is_dir() {
236 stack.push(p);
237 } else if p.extension().is_some_and(|x| x == "ftl") {
238 out.push(p);
239 }
240 }
241 }
242 out.sort();
243 out
244}
245
246pub fn message_keys(ftl_src: &str) -> Vec<String> {
250 ftl_messages(ftl_src).into_iter().map(|m| m.key).collect()
251}
252
253fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
260 let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
262 let mut docs: std::collections::BTreeMap<String, (String, bool)> = Default::default();
264 for path in ftl_files(dir) {
265 let src = std::fs::read_to_string(&path)
266 .map_err(|e| format!("day-build: reading {}: {e}", display(&path)))?;
267 let loc = display(&path);
268 let is_en = locale_of(&path) == "en";
269 for msg in ftl_messages(&src) {
270 if !is_rust_ident(&msg.key) {
271 return Err(format!(
272 "day-build: localization key {:?} ({loc}) is not a valid Rust identifier — \
273 rename it to snake_case (e.g. `{}`) in every resource/locales/*/*.ftl (Fluent \
274 allows `-`, Rust identifiers do not).",
275 msg.key,
276 msg.key.replace('-', "_")
277 ));
278 }
279 let have_en = matches!(docs.get(&msg.key), Some((_, true)));
281 if !have_en && (is_en || !docs.contains_key(&msg.key)) {
282 docs.insert(msg.key.clone(), (msg.value_text, is_en));
283 }
284 use std::collections::btree_map::Entry;
286 match agreed.entry(msg.key.clone()) {
287 Entry::Vacant(v) => {
288 v.insert((msg.params, loc.clone()));
289 }
290 Entry::Occupied(mut o) => {
291 let (prev, prev_loc) = o.get_mut();
292 let prev_names: Vars = prev.keys().cloned().collect();
293 let this_names: Vars = msg.params.keys().cloned().collect();
294 if prev_names != this_names {
295 return Err(format!(
296 "day-build: localization key {:?} references different parameters across \
297 locales — {prev_loc} has {{{}}}, {loc} has {{{}}}. Every locale's \
298 message must use the same `$variables`.",
299 msg.key,
300 comma(&prev_names),
301 comma(&this_names)
302 ));
303 }
304 for (name, numeric) in msg.params {
305 if numeric && let Some(v) = prev.get_mut(&name) {
306 *v = true;
307 }
308 }
309 }
310 }
311 }
312 }
313 Ok(agreed
314 .into_iter()
315 .map(|(key, (params, _))| {
316 let doc = docs.remove(&key).map(|(t, _)| t).unwrap_or_default();
317 StrEntry {
318 key,
319 params: params
320 .into_iter()
321 .map(|(name, numeric)| StrParam { name, numeric })
322 .collect(),
323 doc,
324 }
325 })
326 .collect())
327}
328
329fn comma(names: &Vars) -> String {
330 names.iter().cloned().collect::<Vec<_>>().join(", ")
331}
332
333fn locale_of(path: &Path) -> String {
335 path.parent()
336 .and_then(|p| p.file_name())
337 .map(|n| n.to_string_lossy().into_owned())
338 .unwrap_or_default()
339}
340
341struct FtlMessage {
343 key: String,
344 params: Params,
345 value_text: String,
346}
347
348fn ftl_messages(src: &str) -> Vec<FtlMessage> {
351 use fluent_syntax::ast::Entry;
352 let res = match fluent_syntax::parser::parse(src) {
353 Ok(r) => r,
354 Err((r, _errs)) => r,
355 };
356 let mut out = Vec::new();
357 for entry in &res.body {
358 if let Entry::Message(m) = entry {
359 let mut params = Params::new();
360 let value_text = match &m.value {
361 Some(value) => {
362 collect_pattern_vars(value, &mut params, false);
363 pattern_text(value)
364 }
365 None => String::new(),
366 };
367 out.push(FtlMessage {
368 key: m.id.name.to_string(),
369 params,
370 value_text,
371 });
372 }
373 }
374 out
375}
376
377type Vars = std::collections::BTreeSet<String>;
378type Params = std::collections::BTreeMap<String, bool>;
380
381fn collect_pattern_vars(p: &fluent_syntax::ast::Pattern<&str>, out: &mut Params, numeric: bool) {
382 use fluent_syntax::ast::PatternElement;
383 for el in &p.elements {
384 if let PatternElement::Placeable { expression } = el {
385 collect_expr_vars(expression, out, numeric);
386 }
387 }
388}
389
390fn collect_expr_vars(e: &fluent_syntax::ast::Expression<&str>, out: &mut Params, numeric: bool) {
391 use fluent_syntax::ast::Expression;
392 match e {
393 Expression::Inline(ie) => collect_inline_vars(ie, out, numeric),
394 Expression::Select { selector, variants } => {
395 collect_inline_vars(selector, out, is_number_select(variants));
398 for v in variants {
399 collect_pattern_vars(&v.value, out, false);
400 }
401 }
402 }
403}
404
405fn collect_inline_vars(
406 ie: &fluent_syntax::ast::InlineExpression<&str>,
407 out: &mut Params,
408 numeric: bool,
409) {
410 use fluent_syntax::ast::InlineExpression as X;
411 match ie {
412 X::VariableReference { id } => {
413 *out.entry(id.name.to_string()).or_insert(false) |= numeric;
414 }
415 X::Placeable { expression } => collect_expr_vars(expression, out, numeric),
416 X::FunctionReference { id, arguments } => {
417 let num = id.name.eq_ignore_ascii_case("NUMBER");
422 for a in &arguments.positional {
423 collect_inline_vars(a, out, num);
424 }
425 for n in &arguments.named {
426 collect_inline_vars(&n.value, out, false);
427 }
428 }
429 X::TermReference {
430 arguments: Some(arguments),
431 ..
432 } => {
433 for a in &arguments.positional {
434 collect_inline_vars(a, out, false);
435 }
436 for n in &arguments.named {
437 collect_inline_vars(&n.value, out, false);
438 }
439 }
440 _ => {}
441 }
442}
443
444#[derive(Debug, Clone, PartialEq)]
448pub struct FtlCall {
449 pub key: String,
451 pub name: String,
453 pub named: Vec<(String, String)>,
456}
457
458pub fn function_calls(src: &str) -> Vec<FtlCall> {
461 use fluent_syntax::ast::Entry;
462 let res = match fluent_syntax::parser::parse(src) {
463 Ok(r) => r,
464 Err((r, _errs)) => r,
465 };
466 let mut out = Vec::new();
467 for entry in &res.body {
468 if let Entry::Message(m) = entry
469 && let Some(value) = &m.value
470 {
471 collect_pattern_calls(value, m.id.name, &mut out);
472 }
473 }
474 out
475}
476
477fn collect_pattern_calls(p: &fluent_syntax::ast::Pattern<&str>, key: &str, out: &mut Vec<FtlCall>) {
478 use fluent_syntax::ast::PatternElement;
479 for el in &p.elements {
480 if let PatternElement::Placeable { expression } = el {
481 collect_expr_calls(expression, key, out);
482 }
483 }
484}
485
486fn collect_expr_calls(e: &fluent_syntax::ast::Expression<&str>, key: &str, out: &mut Vec<FtlCall>) {
487 use fluent_syntax::ast::Expression;
488 match e {
489 Expression::Inline(ie) => collect_inline_calls(ie, key, out),
490 Expression::Select { selector, variants } => {
491 collect_inline_calls(selector, key, out);
492 for v in variants {
493 collect_pattern_calls(&v.value, key, out);
494 }
495 }
496 }
497}
498
499fn collect_inline_calls(
500 ie: &fluent_syntax::ast::InlineExpression<&str>,
501 key: &str,
502 out: &mut Vec<FtlCall>,
503) {
504 use fluent_syntax::ast::InlineExpression as X;
505 match ie {
506 X::FunctionReference { id, arguments } => {
507 let named = arguments
508 .named
509 .iter()
510 .filter_map(|n| {
511 let value = match &n.value {
512 X::StringLiteral { value } => value.to_string(),
513 X::NumberLiteral { value } => value.to_string(),
514 _ => return None,
515 };
516 Some((n.name.name.to_string(), value))
517 })
518 .collect();
519 out.push(FtlCall {
520 key: key.to_string(),
521 name: id.name.to_string(),
522 named,
523 });
524 for a in &arguments.positional {
525 collect_inline_calls(a, key, out);
526 }
527 }
528 X::Placeable { expression } => collect_expr_calls(expression, key, out),
529 _ => {}
530 }
531}
532
533fn is_number_select(variants: &[fluent_syntax::ast::Variant<&str>]) -> bool {
537 use fluent_syntax::ast::VariantKey;
538 const PLURAL: &[&str] = &["zero", "one", "two", "few", "many"];
539 variants.iter().any(|v| match &v.key {
540 VariantKey::NumberLiteral { .. } => true,
541 VariantKey::Identifier { name } => PLURAL.contains(&name.to_ascii_lowercase().as_str()),
542 })
543}
544
545fn pattern_text(p: &fluent_syntax::ast::Pattern<&str>) -> String {
549 use fluent_syntax::ast::PatternElement;
550 let mut s = String::new();
551 for el in &p.elements {
552 match el {
553 PatternElement::TextElement { value } => s.push_str(value),
554 PatternElement::Placeable { expression } => s.push_str(&placeable_text(expression)),
555 }
556 }
557 s.split_whitespace()
558 .collect::<Vec<_>>()
559 .join(" ")
560 .replace('`', "'")
561}
562
563fn placeable_text(e: &fluent_syntax::ast::Expression<&str>) -> String {
564 use fluent_syntax::ast::{Expression, InlineExpression as X};
565 match e {
566 Expression::Inline(X::VariableReference { id }) => format!("{{ ${} }}", id.name),
567 Expression::Inline(X::StringLiteral { value }) => format!("{{ \"{value}\" }}"),
568 Expression::Select {
569 selector: X::VariableReference { id },
570 ..
571 } => format!("{{ ${} -> … }}", id.name),
572 _ => "{ … }".to_string(),
573 }
574}
575
576fn is_rust_ident(s: &str) -> bool {
579 let mut chars = s.chars();
580 let Some(first) = chars.next() else {
581 return false;
582 };
583 (first.is_ascii_alphabetic() || first == '_')
584 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
585 && s != "_"
586}
587
588pub fn render(plan: &ResourcePlan) -> String {
592 let mut s = String::new();
593 s.push_str("// @generated by day-build — do not edit.\n");
594 s.push_str("// Regenerated on every build from resource/{images,assets,fonts,locales}.\n\n");
595 render_bucket(&mut s, "images", "ImageName", &plan.images);
596 render_bucket(&mut s, "assets", "AssetName", &plan.assets);
597 render_bucket(&mut s, "fonts", "FontFamily", &plan.fonts);
598 render_strings(&mut s, &plan.strings);
599 s
600}
601
602fn render_strings(s: &mut String, entries: &[StrEntry]) {
606 s.push_str("#[allow(dead_code, unused_imports, non_snake_case, clippy::too_many_arguments)]\n");
607 s.push_str("pub mod str {\n");
608 for e in entries {
609 let generics: Vec<String> = (0..e.params.len()).map(|i| format!("M{i}")).collect();
613 let sig_params: Vec<String> = e
614 .params
615 .iter()
616 .enumerate()
617 .map(|(i, p)| {
618 let ty = if p.numeric {
619 "IntoNumberFArg"
620 } else {
621 "IntoFArg"
622 };
623 format!(
624 "{}: impl day::{ty}<M{i}>",
625 ident_token(&sanitize_ident(&p.name))
626 )
627 })
628 .collect();
629 let generic_list = if generics.is_empty() {
630 String::new()
631 } else {
632 format!("<{}>", generics.join(", "))
633 };
634 let mut body = format!("day::tr({:?})", e.key);
635 for p in &e.params {
636 body.push_str(&format!(
637 ".arg({:?}, {})",
638 p.name,
639 ident_token(&sanitize_ident(&p.name))
640 ));
641 }
642 let doc = if e.doc.is_empty() {
644 format!("`{}`", e.key)
645 } else {
646 format!("`{}` — `{}`", e.key, e.doc)
647 };
648 s.push_str(&format!(
649 " /// {doc}\n pub fn {}{generic_list}({}) -> day::LocalizedText {{ {body} }}\n",
650 ident_token(&e.key),
651 sig_params.join(", "),
652 ));
653 }
654 s.push_str("}\n\n");
655}
656
657fn render_bucket(s: &mut String, module: &str, ty: &str, entries: &[Entry]) {
658 s.push_str("#[allow(non_upper_case_globals, dead_code, unused_imports)]\n");
659 s.push_str(&format!("pub mod {module} {{\n use day::{ty};\n"));
660 for e in entries {
661 s.push_str(&format!(
662 " /// `{}`\n pub const {}: {ty} = {ty}::from_static({:?});\n",
663 e.source,
664 ident_token(&e.symbol),
665 e.value,
666 ));
667 }
668 s.push_str("}\n\n");
669}
670
671fn ident_token(sym: &str) -> String {
673 const KEYWORDS: &[&str] = &[
674 "as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
675 "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
676 "static", "struct", "trait", "true", "type", "union", "unsafe", "use", "where", "while",
677 "async", "await", "try",
678 ];
679 if KEYWORDS.contains(&sym) {
680 format!("r#{sym}")
681 } else {
682 sym.to_string()
683 }
684}
685
686fn parse_scale(stem: &str) -> (String, u32) {
688 if let Some((base, tail)) = stem.rsplit_once('@')
689 && let Some(digits) = tail.strip_suffix('x')
690 && let Ok(scale) = digits.parse::<u32>()
691 && scale >= 1
692 {
693 return (base.to_string(), scale);
694 }
695 (stem.to_string(), 1)
696}
697
698pub fn sanitize_ident(name: &str) -> String {
702 let mut s: String = name
703 .chars()
704 .map(|c| {
705 let c = c.to_ascii_lowercase();
706 if c.is_ascii_alphanumeric() || c == '_' {
707 c
708 } else {
709 '_'
710 }
711 })
712 .collect();
713 if !s.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
714 s.insert(0, 'r');
715 }
716 s
717}
718
719fn display(path: &Path) -> String {
721 let comps: Vec<_> = path.components().collect();
724 let n = comps.len();
725 let start = n.saturating_sub(3);
726 comps[start..]
727 .iter()
728 .map(|c| c.as_os_str().to_string_lossy())
729 .collect::<Vec<_>>()
730 .join("/")
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 fn tmp(label: &str) -> PathBuf {
738 let d = std::env::temp_dir().join(format!("day-build-{}-{label}", std::process::id()));
740 let _ = std::fs::remove_dir_all(&d);
741 d
742 }
743
744 fn touch(dir: &Path, name: &str, bytes: &[u8]) {
745 std::fs::create_dir_all(dir).unwrap();
746 std::fs::write(dir.join(name), bytes).unwrap();
747 }
748
749 #[test]
750 fn sanitize_matches_strictest_rules() {
751 assert_eq!(sanitize_ident("nav_system"), "nav_system");
752 assert_eq!(sanitize_ident("Nav-System"), "nav_system");
753 assert_eq!(sanitize_ident("123"), "r123");
754 assert_eq!(sanitize_ident("numbers.bin"), "numbers_bin");
755 }
756
757 #[test]
758 fn images_dedup_scale_variants_and_key_on_stem() {
759 let root = tmp("images-dedup");
760 let img = root.join("resource/images");
761 touch(&img, "nav_system.png", b"x");
762 touch(&img, "day_logo.png", b"x");
763 touch(&img, "day_logo@2x.png", b"x"); let plan = plan_resources(&root).unwrap();
765 let syms: Vec<_> = plan.images.iter().map(|e| e.symbol.as_str()).collect();
766 assert_eq!(syms, vec!["day_logo", "nav_system"]);
767 assert_eq!(plan.images[0].value, "day_logo");
768 std::fs::remove_dir_all(&root).ok();
769 }
770
771 #[test]
772 fn non_portable_image_stem_is_rejected() {
773 let root = tmp("non-portable");
774 touch(&root.join("resource/images"), "Nav-System.png", b"x");
775 let err = plan_resources(&root).unwrap_err();
776 assert!(err.contains("portable"), "{err}");
777 assert!(err.contains("nav_system"), "{err}"); std::fs::remove_dir_all(&root).ok();
779 }
780
781 #[test]
782 fn same_stem_same_scale_collides() {
783 let root = tmp("collide");
784 let img = root.join("resource/images");
785 touch(&img, "logo.png", b"x");
786 touch(&img, "logo.jpg", b"x"); let err = plan_resources(&root).unwrap_err();
788 assert!(err.contains("same scale"), "{err}");
789 std::fs::remove_dir_all(&root).ok();
790 }
791
792 #[test]
793 fn asset_symbol_sanitized_value_verbatim() {
794 let root = tmp("assets");
795 touch(&root.join("resource/assets"), "numbers.bin", b"x");
796 let plan = plan_resources(&root).unwrap();
797 assert_eq!(plan.assets[0].symbol, "numbers_bin");
798 assert_eq!(plan.assets[0].value, "numbers.bin");
799 std::fs::remove_dir_all(&root).ok();
800 }
801
802 #[test]
803 fn render_shape_is_typed_and_lowercase() {
804 let plan = ResourcePlan {
805 images: vec![Entry {
806 symbol: "nav_system".into(),
807 value: "nav_system".into(),
808 source: "resource/images/nav_system.png".into(),
809 }],
810 ..Default::default()
811 };
812 let code = render(&plan);
813 assert!(code.contains("#[allow(non_upper_case_globals, dead_code, unused_imports)]"));
814 assert!(code.contains("pub mod images {"));
815 assert!(code.contains("use day::ImageName;"));
816 assert!(
817 code.contains(
818 "pub const nav_system: ImageName = ImageName::from_static(\"nav_system\");"
819 )
820 );
821 }
822
823 #[test]
824 fn keyword_symbol_becomes_raw_ident() {
825 let plan = ResourcePlan {
826 images: vec![Entry {
827 symbol: "type".into(),
828 value: "type".into(),
829 source: "resource/images/type.png".into(),
830 }],
831 ..Default::default()
832 };
833 assert!(render(&plan).contains("pub const r#type: ImageName"));
834 }
835
836 #[test]
837 fn missing_dirs_yield_empty_plan() {
838 let root = tmp("missing-dirs");
839 std::fs::create_dir_all(&root).unwrap();
840 let plan = plan_resources(&root).unwrap();
841 assert!(plan.images.is_empty() && plan.assets.is_empty() && plan.fonts.is_empty());
842 assert!(plan.strings.is_empty());
843 std::fs::remove_dir_all(&root).ok();
844 }
845
846 fn ftl(root: &Path, locale: &str, body: &str) {
847 let dir = root.join("resource/locales").join(locale);
848 std::fs::create_dir_all(&dir).unwrap();
849 std::fs::write(dir.join("app.ftl"), body).unwrap();
850 }
851
852 fn entry<'a>(plan: &'a ResourcePlan, key: &str) -> &'a StrEntry {
853 plan.strings
854 .iter()
855 .find(|e| e.key == key)
856 .expect("key present")
857 }
858 fn names(e: &StrEntry) -> Vec<&str> {
859 e.params.iter().map(|p| p.name.as_str()).collect()
860 }
861
862 #[test]
863 fn extracts_keys_params_numeric_and_doc() {
864 let root = tmp("str-extract");
865 ftl(
869 &root,
870 "en",
871 "nav_home = Home\n\
872 greeting = Hello, { $name }!\n\
873 counter_value = { $count ->\n [one] { $count } click\n *[other] { $count } clicks\n}\n",
874 );
875 let plan = plan_resources(&root).unwrap();
876 assert!(names(entry(&plan, "nav_home")).is_empty());
877 assert_eq!(names(entry(&plan, "greeting")), vec!["name"]);
878 assert_eq!(entry(&plan, "greeting").doc, "Hello, { $name }!"); assert!(!entry(&plan, "greeting").params[0].numeric);
880 assert_eq!(names(entry(&plan, "counter_value")), vec!["count"]);
882 assert!(entry(&plan, "counter_value").params[0].numeric);
883 std::fs::remove_dir_all(&root).ok();
884 }
885
886 #[test]
887 fn string_select_selector_is_not_numeric() {
888 let root = tmp("str-gender");
889 ftl(
891 &root,
892 "en",
893 "hi = { $gender ->\n [male] Mr\n [female] Ms\n *[other] Mx\n} { $name }\n",
894 );
895 let plan = plan_resources(&root).unwrap();
896 let g = entry(&plan, "hi");
897 assert!(
898 !g.params
899 .iter()
900 .find(|p| p.name == "gender")
901 .unwrap()
902 .numeric
903 );
904 assert!(!g.params.iter().find(|p| p.name == "name").unwrap().numeric);
905 std::fs::remove_dir_all(&root).ok();
906 }
907
908 #[test]
909 fn numeric_is_ored_across_locales() {
910 let root = tmp("str-numeric-or");
911 ftl(
914 &root,
915 "en",
916 "n = { $count ->\n [one] one\n *[other] many\n}\n",
917 );
918 ftl(&root, "zh", "n = { $count } times\n");
919 let plan = plan_resources(&root).unwrap();
920 assert!(entry(&plan, "n").params[0].numeric);
921 std::fs::remove_dir_all(&root).ok();
922 }
923
924 #[test]
925 fn message_keys_lists_message_ids_only() {
926 let keys = message_keys("a = x\n# comment\n-term = y\nb = { $v }\n");
928 assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
929 }
930
931 #[test]
932 fn kebab_key_is_rejected() {
933 let root = tmp("str-kebab");
934 ftl(&root, "en", "nav-home = Home\n");
935 let err = plan_resources(&root).unwrap_err();
936 assert!(err.contains("not a valid Rust identifier"), "{err}");
937 assert!(err.contains("nav_home"), "{err}"); std::fs::remove_dir_all(&root).ok();
939 }
940
941 #[test]
942 fn cross_locale_param_disagreement_is_rejected() {
943 let root = tmp("str-params");
944 ftl(&root, "en", "greeting = Hello, { $name }!\n");
945 ftl(&root, "fr", "greeting = Bonjour, { $nom }!\n");
946 let err = plan_resources(&root).unwrap_err();
947 assert!(err.contains("different parameters"), "{err}");
948 std::fs::remove_dir_all(&root).ok();
949 }
950
951 #[test]
952 fn renders_param_typed_functions() {
953 let p = |name: &str, numeric: bool| StrParam {
954 name: name.into(),
955 numeric,
956 };
957 let plan = ResourcePlan {
958 strings: vec![
959 StrEntry {
960 key: "hello_world".into(),
961 params: vec![],
962 doc: "Hello!".into(),
963 },
964 StrEntry {
965 key: "counter_value".into(),
966 params: vec![p("count", true)], doc: "{ $count -> … }".into(),
968 },
969 StrEntry {
970 key: "deviceinfo_system".into(),
971 params: vec![p("name", false), p("version", false)],
972 doc: String::new(),
973 },
974 ],
975 ..Default::default()
976 };
977 let code = render(&plan);
978 assert!(code.contains("pub mod str {"));
979 assert!(code.contains("/// `hello_world` — `Hello!`")); assert!(
981 code.contains(
982 "pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
983 )
984 );
985 assert!(code.contains(
987 "pub fn counter_value<M0>(count: impl day::IntoNumberFArg<M0>) -> day::LocalizedText { day::tr(\"counter_value\").arg(\"count\", count) }"
988 ));
989 assert!(code.contains(
990 "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) }"
991 ));
992 }
993}