use std::path::{Path, PathBuf};
pub mod permissions;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub symbol: String,
pub value: String,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrEntry {
pub key: String,
pub params: Vec<StrParam>,
pub doc: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrParam {
pub name: String,
pub numeric: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocaleEntry {
pub locale: String,
pub sources: Vec<PathBuf>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ResourcePlan {
pub images: Vec<Entry>,
pub assets: Vec<Entry>,
pub fonts: Vec<Entry>,
pub strings: Vec<StrEntry>,
pub locales: Vec<LocaleEntry>,
}
pub fn generate_resources() -> Result<(), String> {
let root = PathBuf::from(env("CARGO_MANIFEST_DIR")?);
let out = PathBuf::from(env("OUT_DIR")?);
let plan = plan_resources(&root)?;
let code = render(&plan);
std::fs::write(out.join("day_resources.rs"), code)
.map_err(|e| format!("day-build: writing day_resources.rs: {e}"))?;
for bucket in ["images", "assets", "fonts", "locales"] {
println!("cargo:rerun-if-changed=resource/{bucket}");
}
Ok(())
}
fn env(key: &str) -> Result<String, String> {
std::env::var(key).map_err(|_| format!("day-build: ${key} is not set (call from a build.rs)"))
}
pub fn plan_resources(root: &Path) -> Result<ResourcePlan, String> {
Ok(ResourcePlan {
images: plan_images(&root.join("resource/images"))?,
assets: plan_assets(&root.join("resource/assets"))?,
fonts: plan_fonts(&root.join("resource/fonts"))?,
strings: plan_strings(&root.join("resource/locales"))?,
locales: plan_locales(&root.join("resource/locales")),
})
}
fn list_files(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& !p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.starts_with('.')
})
.collect();
files.sort();
files
}
fn plan_images(dir: &Path) -> Result<Vec<Entry>, String> {
let mut seen: std::collections::BTreeMap<String, (Vec<u32>, String)> = Default::default();
for path in list_files(dir) {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let (base, scale) = parse_scale(&stem);
let src = display(&path);
let sane = sanitize_ident(&base);
if sane != base {
return Err(format!(
"day-build: image {base:?} ({src}) is not a portable resource name — it resolves \
to {sane:?} on Android/HarmonyOS but {base:?} on Apple/GTK/Qt. Rename the file so \
its stem is lowercase [a-z0-9_] (e.g. `{sane}`)."
));
}
let ent = seen
.entry(base.clone())
.or_insert_with(|| (Vec::new(), src.clone()));
if ent.0.contains(&scale) {
return Err(format!(
"day-build: two files map to image {base:?} at the same scale ({}, {src}) — keep \
one file per image (HiDPI variants use an `@2x`/`@3x` suffix).",
ent.1
));
}
ent.0.push(scale);
}
Ok(seen
.into_iter()
.map(|(base, (_, src))| Entry {
symbol: base.clone(),
value: base,
source: src,
})
.collect())
}
fn plan_assets(dir: &Path) -> Result<Vec<Entry>, String> {
let mut entries = Vec::new();
for path in list_files(dir) {
let fname = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
entries.push(Entry {
symbol: sanitize_ident(&fname),
value: fname,
source: display(&path),
});
}
dedup_symbols(entries, "asset")
}
fn plan_fonts(dir: &Path) -> Result<Vec<Entry>, String> {
let mut entries = Vec::new();
for path in list_files(dir) {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
if !matches!(ext.as_str(), "ttf" | "otf") {
continue; }
let src = display(&path);
let bytes = std::fs::read(&path).map_err(|e| format!("day-build: reading {src}: {e}"))?;
let names = day_fonts::parse_font_names(&bytes)
.ok_or_else(|| format!("day-build: {src}: not a recognizable font (no name table)"))?;
entries.push(Entry {
symbol: day_fonts::font_ident(&names.family),
value: names.family,
source: src,
});
}
dedup_symbols(entries, "font")
}
fn dedup_symbols(entries: Vec<Entry>, kind: &str) -> Result<Vec<Entry>, String> {
let mut seen: std::collections::BTreeMap<String, String> = Default::default();
for e in &entries {
if let Some(prev) = seen.insert(e.symbol.clone(), e.source.clone()) {
return Err(format!(
"day-build: {kind}s {} and {} both map to the symbol `{}` — rename one so they \
differ after sanitization to [a-z0-9_].",
prev, e.source, e.symbol
));
}
}
Ok(entries)
}
fn ftl_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "ftl") {
out.push(p);
}
}
}
out.sort();
out
}
pub fn message_keys(ftl_src: &str) -> Vec<String> {
ftl_messages(ftl_src).into_iter().map(|m| m.key).collect()
}
fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
let mut docs: std::collections::BTreeMap<String, (String, bool)> = Default::default();
for path in ftl_files(dir) {
let src = std::fs::read_to_string(&path)
.map_err(|e| format!("day-build: reading {}: {e}", display(&path)))?;
let loc = display(&path);
let is_en = locale_of(&path) == "en";
for msg in ftl_messages(&src) {
if !is_rust_ident(&msg.key) {
return Err(format!(
"day-build: localization key {:?} ({loc}) is not a valid Rust identifier — \
rename it to snake_case (e.g. `{}`) in every resource/locales/*/*.ftl (Fluent \
allows `-`, Rust identifiers do not).",
msg.key,
msg.key.replace('-', "_")
));
}
let have_en = matches!(docs.get(&msg.key), Some((_, true)));
if !have_en && (is_en || !docs.contains_key(&msg.key)) {
docs.insert(msg.key.clone(), (msg.value_text, is_en));
}
use std::collections::btree_map::Entry;
match agreed.entry(msg.key.clone()) {
Entry::Vacant(v) => {
v.insert((msg.params, loc.clone()));
}
Entry::Occupied(mut o) => {
let (prev, prev_loc) = o.get_mut();
let prev_names: Vars = prev.keys().cloned().collect();
let this_names: Vars = msg.params.keys().cloned().collect();
if prev_names != this_names {
return Err(format!(
"day-build: localization key {:?} references different parameters across \
locales — {prev_loc} has {{{}}}, {loc} has {{{}}}. Every locale's \
message must use the same `$variables`.",
msg.key,
comma(&prev_names),
comma(&this_names)
));
}
for (name, numeric) in msg.params {
if numeric && let Some(v) = prev.get_mut(&name) {
*v = true;
}
}
}
}
}
}
Ok(agreed
.into_iter()
.map(|(key, (params, _))| {
let doc = docs.remove(&key).map(|(t, _)| t).unwrap_or_default();
StrEntry {
key,
params: params
.into_iter()
.map(|(name, numeric)| StrParam { name, numeric })
.collect(),
doc,
}
})
.collect())
}
fn comma(names: &Vars) -> String {
names.iter().cloned().collect::<Vec<_>>().join(", ")
}
fn plan_locales(dir: &Path) -> Vec<LocaleEntry> {
let mut by_locale: std::collections::BTreeMap<String, Vec<PathBuf>> = Default::default();
for path in ftl_files(dir) {
let locale = locale_of(&path);
if locale.is_empty() || path.parent() == Some(dir) {
continue;
}
by_locale.entry(locale).or_default().push(path);
}
by_locale
.into_iter()
.map(|(locale, sources)| LocaleEntry { locale, sources })
.collect()
}
fn default_locale(locales: &[LocaleEntry]) -> String {
if locales.iter().any(|l| l.locale == "en") {
return "en".to_string();
}
locales
.first()
.map(|l| l.locale.clone())
.unwrap_or_else(|| "en".to_string())
}
fn locale_of(path: &Path) -> String {
path.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
struct FtlMessage {
key: String,
params: Params,
value_text: String,
}
fn ftl_messages(src: &str) -> Vec<FtlMessage> {
use fluent_syntax::ast::Entry;
let res = match fluent_syntax::parser::parse(src) {
Ok(r) => r,
Err((r, _errs)) => r,
};
let mut out = Vec::new();
for entry in &res.body {
if let Entry::Message(m) = entry {
let mut params = Params::new();
let value_text = match &m.value {
Some(value) => {
collect_pattern_vars(value, &mut params, false);
pattern_text(value)
}
None => String::new(),
};
out.push(FtlMessage {
key: m.id.name.to_string(),
params,
value_text,
});
}
}
out
}
type Vars = std::collections::BTreeSet<String>;
type Params = std::collections::BTreeMap<String, bool>;
fn collect_pattern_vars(p: &fluent_syntax::ast::Pattern<&str>, out: &mut Params, numeric: bool) {
use fluent_syntax::ast::PatternElement;
for el in &p.elements {
if let PatternElement::Placeable { expression } = el {
collect_expr_vars(expression, out, numeric);
}
}
}
fn collect_expr_vars(e: &fluent_syntax::ast::Expression<&str>, out: &mut Params, numeric: bool) {
use fluent_syntax::ast::Expression;
match e {
Expression::Inline(ie) => collect_inline_vars(ie, out, numeric),
Expression::Select { selector, variants } => {
collect_inline_vars(selector, out, is_number_select(variants));
for v in variants {
collect_pattern_vars(&v.value, out, false);
}
}
}
}
fn collect_inline_vars(
ie: &fluent_syntax::ast::InlineExpression<&str>,
out: &mut Params,
numeric: bool,
) {
use fluent_syntax::ast::InlineExpression as X;
match ie {
X::VariableReference { id } => {
*out.entry(id.name.to_string()).or_insert(false) |= numeric;
}
X::Placeable { expression } => collect_expr_vars(expression, out, numeric),
X::FunctionReference { id, arguments } => {
let num = id.name.eq_ignore_ascii_case("NUMBER");
for a in &arguments.positional {
collect_inline_vars(a, out, num);
}
for n in &arguments.named {
collect_inline_vars(&n.value, out, false);
}
}
X::TermReference {
arguments: Some(arguments),
..
} => {
for a in &arguments.positional {
collect_inline_vars(a, out, false);
}
for n in &arguments.named {
collect_inline_vars(&n.value, out, false);
}
}
_ => {}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FtlCall {
pub key: String,
pub name: String,
pub named: Vec<(String, String)>,
}
pub fn function_calls(src: &str) -> Vec<FtlCall> {
use fluent_syntax::ast::Entry;
let res = match fluent_syntax::parser::parse(src) {
Ok(r) => r,
Err((r, _errs)) => r,
};
let mut out = Vec::new();
for entry in &res.body {
if let Entry::Message(m) = entry
&& let Some(value) = &m.value
{
collect_pattern_calls(value, m.id.name, &mut out);
}
}
out
}
fn collect_pattern_calls(p: &fluent_syntax::ast::Pattern<&str>, key: &str, out: &mut Vec<FtlCall>) {
use fluent_syntax::ast::PatternElement;
for el in &p.elements {
if let PatternElement::Placeable { expression } = el {
collect_expr_calls(expression, key, out);
}
}
}
fn collect_expr_calls(e: &fluent_syntax::ast::Expression<&str>, key: &str, out: &mut Vec<FtlCall>) {
use fluent_syntax::ast::Expression;
match e {
Expression::Inline(ie) => collect_inline_calls(ie, key, out),
Expression::Select { selector, variants } => {
collect_inline_calls(selector, key, out);
for v in variants {
collect_pattern_calls(&v.value, key, out);
}
}
}
}
fn collect_inline_calls(
ie: &fluent_syntax::ast::InlineExpression<&str>,
key: &str,
out: &mut Vec<FtlCall>,
) {
use fluent_syntax::ast::InlineExpression as X;
match ie {
X::FunctionReference { id, arguments } => {
let named = arguments
.named
.iter()
.filter_map(|n| {
let value = match &n.value {
X::StringLiteral { value } => value.to_string(),
X::NumberLiteral { value } => value.to_string(),
_ => return None,
};
Some((n.name.name.to_string(), value))
})
.collect();
out.push(FtlCall {
key: key.to_string(),
name: id.name.to_string(),
named,
});
for a in &arguments.positional {
collect_inline_calls(a, key, out);
}
}
X::Placeable { expression } => collect_expr_calls(expression, key, out),
_ => {}
}
}
fn is_number_select(variants: &[fluent_syntax::ast::Variant<&str>]) -> bool {
use fluent_syntax::ast::VariantKey;
const PLURAL: &[&str] = &["zero", "one", "two", "few", "many"];
variants.iter().any(|v| match &v.key {
VariantKey::NumberLiteral { .. } => true,
VariantKey::Identifier { name } => PLURAL.contains(&name.to_ascii_lowercase().as_str()),
})
}
fn pattern_text(p: &fluent_syntax::ast::Pattern<&str>) -> String {
use fluent_syntax::ast::PatternElement;
let mut s = String::new();
for el in &p.elements {
match el {
PatternElement::TextElement { value } => s.push_str(value),
PatternElement::Placeable { expression } => s.push_str(&placeable_text(expression)),
}
}
s.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace('`', "'")
}
fn placeable_text(e: &fluent_syntax::ast::Expression<&str>) -> String {
use fluent_syntax::ast::{Expression, InlineExpression as X};
match e {
Expression::Inline(X::VariableReference { id }) => format!("{{ ${} }}", id.name),
Expression::Inline(X::StringLiteral { value }) => format!("{{ \"{value}\" }}"),
Expression::Select {
selector: X::VariableReference { id },
..
} => format!("{{ ${} -> … }}", id.name),
_ => "{ … }".to_string(),
}
}
fn is_rust_ident(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
&& s != "_"
}
pub fn render(plan: &ResourcePlan) -> String {
let mut s = String::new();
s.push_str("// @generated by day-build — do not edit.\n");
s.push_str("// Regenerated on every build from resource/{images,assets,fonts,locales}.\n\n");
render_bucket(&mut s, "images", "ImageName", &plan.images);
render_bucket(&mut s, "assets", "AssetName", &plan.assets);
render_bucket(&mut s, "fonts", "FontFamily", &plan.fonts);
render_strings(&mut s, &plan.strings);
render_locales(&mut s, &plan.locales);
s
}
fn render_locales(s: &mut String, locales: &[LocaleEntry]) {
s.push_str("#[allow(dead_code)]\npub mod locales {\n");
s.push_str(&format!(
" /// The fallback locale — the one whose strings show when the running locale has no\n\
\x20 /// translation for a key.\n pub const DEFAULT: &str = {:?};\n\n",
default_locale(locales)
));
s.push_str(
" /// Every locale under `resource/locales/`, embedded at build time: one\n\
\x20 /// `(tag, fluent-source)` pair per directory.\n\
\x20 pub const CATALOG: &[(&str, &str)] = &[\n",
);
for l in locales {
let sources: Vec<String> = l
.sources
.iter()
.map(|p| format!("include_str!({:?})", p.display().to_string()))
.collect();
let src = if sources.len() == 1 {
sources.into_iter().next().unwrap_or_default()
} else {
format!("concat!({}, \"\\n\")", sources.join(", \"\\n\", "))
};
s.push_str(&format!(" ({:?}, {src}),\n", l.locale));
}
s.push_str(" ];\n\n");
s.push_str(
" /// Every bundled locale as `(tag, display name)`, for language pickers. The name\n\
\x20 /// is the catalog's own `language_name` message (each language naming itself), and\n\
\x20 /// falls back to the tag when a catalog does not carry one (docs/localization.md).\n\
\x20 pub const ALL: &[(&str, &str)] = &[\n",
);
for l in locales {
s.push_str(&format!(
" ({:?}, {:?}),\n",
l.locale,
language_name(l).unwrap_or_else(|| l.locale.clone())
));
}
s.push_str(" ];\n\n");
s.push_str(
" /// Register [`CATALOG`] under [`DEFAULT`] — call once, before the first localized\n\
\x20 /// string is read (the top of the app's `root()`). For a different fallback:\n\
\x20 /// `day::install_locales(\"fr\", res::locales::CATALOG)`.\n\
\x20 pub fn install() {\n day::install_locales(DEFAULT, CATALOG);\n }\n",
);
s.push_str("}\n\n");
}
fn language_name(l: &LocaleEntry) -> Option<String> {
for path in &l.sources {
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
for line in text.lines() {
if let Some(value) = line.strip_prefix("language_name") {
let value = value.trim_start();
if let Some(value) = value.strip_prefix('=') {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
}
}
None
}
fn render_strings(s: &mut String, entries: &[StrEntry]) {
s.push_str("#[allow(dead_code, unused_imports, non_snake_case, clippy::too_many_arguments)]\n");
s.push_str("pub mod str {\n");
for e in entries {
let generics: Vec<String> = (0..e.params.len()).map(|i| format!("M{i}")).collect();
let sig_params: Vec<String> = e
.params
.iter()
.enumerate()
.map(|(i, p)| {
let ty = if p.numeric {
"IntoNumberFArg"
} else {
"IntoFArg"
};
format!(
"{}: impl day::{ty}<M{i}>",
ident_token(&sanitize_ident(&p.name))
)
})
.collect();
let generic_list = if generics.is_empty() {
String::new()
} else {
format!("<{}>", generics.join(", "))
};
let mut body = format!("day::tr({:?})", e.key);
for p in &e.params {
body.push_str(&format!(
".arg({:?}, {})",
p.name,
ident_token(&sanitize_ident(&p.name))
));
}
let doc = if e.doc.is_empty() {
format!("`{}`", e.key)
} else {
format!("`{}` — `{}`", e.key, e.doc)
};
s.push_str(&format!(
" /// {doc}\n pub fn {}{generic_list}({}) -> day::LocalizedText {{ {body} }}\n",
ident_token(&e.key),
sig_params.join(", "),
));
}
s.push_str("}\n\n");
}
fn render_bucket(s: &mut String, module: &str, ty: &str, entries: &[Entry]) {
s.push_str("#[allow(non_upper_case_globals, dead_code, unused_imports)]\n");
s.push_str(&format!("pub mod {module} {{\n use day::{ty};\n"));
for e in entries {
s.push_str(&format!(
" /// `{}`\n pub const {}: {ty} = {ty}::from_static({:?});\n",
e.source,
ident_token(&e.symbol),
e.value,
));
}
s.push_str("}\n\n");
}
fn ident_token(sym: &str) -> String {
const KEYWORDS: &[&str] = &[
"as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
"if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
"static", "struct", "trait", "true", "type", "union", "unsafe", "use", "where", "while",
"async", "await", "try",
];
if KEYWORDS.contains(&sym) {
format!("r#{sym}")
} else {
sym.to_string()
}
}
fn parse_scale(stem: &str) -> (String, u32) {
if let Some((base, tail)) = stem.rsplit_once('@')
&& let Some(digits) = tail.strip_suffix('x')
&& let Ok(scale) = digits.parse::<u32>()
&& scale >= 1
{
return (base.to_string(), scale);
}
(stem.to_string(), 1)
}
pub fn sanitize_ident(name: &str) -> String {
let mut s: String = name
.chars()
.map(|c| {
let c = c.to_ascii_lowercase();
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if !s.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
s.insert(0, 'r');
}
s
}
fn display(path: &Path) -> String {
let comps: Vec<_> = path.components().collect();
let n = comps.len();
let start = n.saturating_sub(3);
comps[start..]
.iter()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(label: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("day-build-{}-{label}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn touch(dir: &Path, name: &str, bytes: &[u8]) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(name), bytes).unwrap();
}
#[test]
fn sanitize_matches_strictest_rules() {
assert_eq!(sanitize_ident("nav_system"), "nav_system");
assert_eq!(sanitize_ident("Nav-System"), "nav_system");
assert_eq!(sanitize_ident("123"), "r123");
assert_eq!(sanitize_ident("numbers.bin"), "numbers_bin");
}
#[test]
fn images_dedup_scale_variants_and_key_on_stem() {
let root = tmp("images-dedup");
let img = root.join("resource/images");
touch(&img, "nav_system.png", b"x");
touch(&img, "day_logo.png", b"x");
touch(&img, "day_logo@2x.png", b"x"); let plan = plan_resources(&root).unwrap();
let syms: Vec<_> = plan.images.iter().map(|e| e.symbol.as_str()).collect();
assert_eq!(syms, vec!["day_logo", "nav_system"]);
assert_eq!(plan.images[0].value, "day_logo");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn non_portable_image_stem_is_rejected() {
let root = tmp("non-portable");
touch(&root.join("resource/images"), "Nav-System.png", b"x");
let err = plan_resources(&root).unwrap_err();
assert!(err.contains("portable"), "{err}");
assert!(err.contains("nav_system"), "{err}"); std::fs::remove_dir_all(&root).ok();
}
#[test]
fn same_stem_same_scale_collides() {
let root = tmp("collide");
let img = root.join("resource/images");
touch(&img, "logo.png", b"x");
touch(&img, "logo.jpg", b"x"); let err = plan_resources(&root).unwrap_err();
assert!(err.contains("same scale"), "{err}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn asset_symbol_sanitized_value_verbatim() {
let root = tmp("assets");
touch(&root.join("resource/assets"), "numbers.bin", b"x");
let plan = plan_resources(&root).unwrap();
assert_eq!(plan.assets[0].symbol, "numbers_bin");
assert_eq!(plan.assets[0].value, "numbers.bin");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn render_shape_is_typed_and_lowercase() {
let plan = ResourcePlan {
images: vec![Entry {
symbol: "nav_system".into(),
value: "nav_system".into(),
source: "resource/images/nav_system.png".into(),
}],
..Default::default()
};
let code = render(&plan);
assert!(code.contains("#[allow(non_upper_case_globals, dead_code, unused_imports)]"));
assert!(code.contains("pub mod images {"));
assert!(code.contains("use day::ImageName;"));
assert!(
code.contains(
"pub const nav_system: ImageName = ImageName::from_static(\"nav_system\");"
)
);
}
#[test]
fn keyword_symbol_becomes_raw_ident() {
let plan = ResourcePlan {
images: vec![Entry {
symbol: "type".into(),
value: "type".into(),
source: "resource/images/type.png".into(),
}],
..Default::default()
};
assert!(render(&plan).contains("pub const r#type: ImageName"));
}
#[test]
fn missing_dirs_yield_empty_plan() {
let root = tmp("missing-dirs");
std::fs::create_dir_all(&root).unwrap();
let plan = plan_resources(&root).unwrap();
assert!(plan.images.is_empty() && plan.assets.is_empty() && plan.fonts.is_empty());
assert!(plan.strings.is_empty());
std::fs::remove_dir_all(&root).ok();
}
fn ftl(root: &Path, locale: &str, body: &str) {
let dir = root.join("resource/locales").join(locale);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("app.ftl"), body).unwrap();
}
fn entry<'a>(plan: &'a ResourcePlan, key: &str) -> &'a StrEntry {
plan.strings
.iter()
.find(|e| e.key == key)
.expect("key present")
}
fn names(e: &StrEntry) -> Vec<&str> {
e.params.iter().map(|p| p.name.as_str()).collect()
}
#[test]
fn extracts_keys_params_numeric_and_doc() {
let root = tmp("str-extract");
ftl(
&root,
"en",
"nav_home = Home\n\
greeting = Hello, { $name }!\n\
counter_value = { $count ->\n [one] { $count } click\n *[other] { $count } clicks\n}\n",
);
let plan = plan_resources(&root).unwrap();
assert!(names(entry(&plan, "nav_home")).is_empty());
assert_eq!(names(entry(&plan, "greeting")), vec!["name"]);
assert_eq!(entry(&plan, "greeting").doc, "Hello, { $name }!"); assert!(!entry(&plan, "greeting").params[0].numeric);
assert_eq!(names(entry(&plan, "counter_value")), vec!["count"]);
assert!(entry(&plan, "counter_value").params[0].numeric);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn string_select_selector_is_not_numeric() {
let root = tmp("str-gender");
ftl(
&root,
"en",
"hi = { $gender ->\n [male] Mr\n [female] Ms\n *[other] Mx\n} { $name }\n",
);
let plan = plan_resources(&root).unwrap();
let g = entry(&plan, "hi");
assert!(
!g.params
.iter()
.find(|p| p.name == "gender")
.unwrap()
.numeric
);
assert!(!g.params.iter().find(|p| p.name == "name").unwrap().numeric);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn numeric_is_ored_across_locales() {
let root = tmp("str-numeric-or");
ftl(
&root,
"en",
"n = { $count ->\n [one] one\n *[other] many\n}\n",
);
ftl(&root, "zh", "n = { $count } times\n");
let plan = plan_resources(&root).unwrap();
assert!(entry(&plan, "n").params[0].numeric);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn message_keys_lists_message_ids_only() {
let keys = message_keys("a = x\n# comment\n-term = y\nb = { $v }\n");
assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn kebab_key_is_rejected() {
let root = tmp("str-kebab");
ftl(&root, "en", "nav-home = Home\n");
let err = plan_resources(&root).unwrap_err();
assert!(err.contains("not a valid Rust identifier"), "{err}");
assert!(err.contains("nav_home"), "{err}"); std::fs::remove_dir_all(&root).ok();
}
#[test]
fn cross_locale_param_disagreement_is_rejected() {
let root = tmp("str-params");
ftl(&root, "en", "greeting = Hello, { $name }!\n");
ftl(&root, "fr", "greeting = Bonjour, { $nom }!\n");
let err = plan_resources(&root).unwrap_err();
assert!(err.contains("different parameters"), "{err}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn renders_param_typed_functions() {
let p = |name: &str, numeric: bool| StrParam {
name: name.into(),
numeric,
};
let plan = ResourcePlan {
strings: vec![
StrEntry {
key: "hello_world".into(),
params: vec![],
doc: "Hello!".into(),
},
StrEntry {
key: "counter_value".into(),
params: vec![p("count", true)], doc: "{ $count -> … }".into(),
},
StrEntry {
key: "deviceinfo_system".into(),
params: vec![p("name", false), p("version", false)],
doc: String::new(),
},
],
..Default::default()
};
let code = render(&plan);
assert!(code.contains("pub mod str {"));
assert!(code.contains("/// `hello_world` — `Hello!`")); assert!(
code.contains(
"pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
)
);
assert!(code.contains(
"pub fn counter_value<M0>(count: impl day::IntoNumberFArg<M0>) -> day::LocalizedText { day::tr(\"counter_value\").arg(\"count\", count) }"
));
assert!(code.contains(
"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) }"
));
}
#[test]
fn locales_are_discovered_and_sorted() {
let root = tmp("locales-discover");
ftl(&root, "en", "hello = Hello");
ftl(&root, "fr", "hello = Bonjour");
ftl(&root, "zh-CN", "hello = 你好");
let plan = plan_resources(&root).unwrap();
let tags: Vec<_> = plan.locales.iter().map(|l| l.locale.as_str()).collect();
assert_eq!(tags, vec!["en", "fr", "zh-CN"]); assert!(plan.locales.iter().all(|l| l.sources.len() == 1));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn locale_catalog_renders_embedded_sources() {
let root = tmp("locales-render");
ftl(&root, "en", "hello = Hello");
ftl(&root, "fr", "hello = Bonjour");
let plan = plan_resources(&root).unwrap();
let code = render(&plan);
assert!(code.contains("pub mod locales {"));
assert!(code.contains("pub const DEFAULT: &str = \"en\";"));
assert!(code.contains("pub const CATALOG: &[(&str, &str)] = &["));
let en = &plan
.locales
.iter()
.find(|l| l.locale == "en")
.unwrap()
.sources[0];
assert!(
en.is_absolute() && en.ends_with("en/app.ftl"),
"{}",
en.display()
);
assert!(
code.contains(&format!(
"(\"en\", include_str!({:?}))",
en.display().to_string()
)),
"{code}"
);
assert!(code.contains("day::install_locales(DEFAULT, CATALOG);"));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn several_ftl_files_in_one_locale_concatenate() {
let root = tmp("locales-multifile");
ftl(&root, "en", "hello = Hello"); let dir = root.join("resource/locales/en");
std::fs::write(dir.join("errors.ftl"), "oops = Oops").unwrap();
let plan = plan_resources(&root).unwrap();
assert_eq!(plan.locales.len(), 1, "one bundle per locale, not per file");
assert_eq!(plan.locales[0].sources.len(), 2);
let keys: Vec<_> = plan.strings.iter().map(|e| e.key.as_str()).collect();
assert_eq!(keys, vec!["hello", "oops"]);
let code = render(&plan);
assert_eq!(code.matches("(\"en\", concat!(").count(), 1);
assert!(code.contains("concat!(include_str!("), "{code}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn default_locale_prefers_en_then_first() {
let root = tmp("locales-default-en");
ftl(&root, "fr", "hello = Bonjour");
ftl(&root, "en", "hello = Hello");
assert_eq!(
default_locale(&plan_resources(&root).unwrap().locales),
"en"
);
std::fs::remove_dir_all(&root).ok();
let root = tmp("locales-default-noen");
ftl(&root, "fr", "hello = Bonjour");
ftl(&root, "ar", "hello = مرحبا");
assert_eq!(
default_locale(&plan_resources(&root).unwrap().locales),
"ar"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn no_locales_yields_an_empty_catalog() {
let root = tmp("locales-none");
touch(&root.join("resource/images"), "logo.png", b"x");
let plan = plan_resources(&root).unwrap();
assert!(plan.locales.is_empty());
let code = render(&plan);
assert!(code.contains("pub const DEFAULT: &str = \"en\";"));
assert!(code.contains("pub const CATALOG: &[(&str, &str)] = &[\n ];"));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn stray_ftl_outside_a_locale_dir_is_ignored() {
let root = tmp("locales-stray");
ftl(&root, "en", "hello = Hello");
std::fs::write(root.join("resource/locales/loose.ftl"), "stray = Stray").unwrap();
let plan = plan_resources(&root).unwrap();
let tags: Vec<_> = plan.locales.iter().map(|l| l.locale.as_str()).collect();
assert_eq!(tags, vec!["en"]);
std::fs::remove_dir_all(&root).ok();
}
}