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