use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use syn::spanned::Spanned as _;
use syn::visit::Visit as _;
const COMMON_HEADER: &str = "# Shared chrome — reused by every scaffolded resource.";
const RESOURCE_HEADER_PREFIX: &str = "# ";
const RESOURCE_HEADER_SUFFIX: &str = " — generated by `autumn generate scaffold --i18n`.";
const COMMON_FOOTER: &str = "# — end shared chrome —";
const RESOURCE_FOOTER_PREFIX: &str = "# — end ";
const RESOURCE_FOOTER_SUFFIX: &str = " —";
fn block_footer(header: &str) -> String {
if header == COMMON_HEADER {
return COMMON_FOOTER.to_owned();
}
let pascal = header
.strip_prefix(RESOURCE_HEADER_PREFIX)
.and_then(|rest| rest.strip_suffix(RESOURCE_HEADER_SUFFIX))
.unwrap_or_default();
format!("{RESOURCE_FOOTER_PREFIX}{pascal}{RESOURCE_FOOTER_SUFFIX}")
}
pub(super) const NUL_MESSAGE_KEY: &str = "common.error.nul_character";
pub(super) const NUL_MESSAGE_ENGLISH: &str = "Cannot contain the NUL character (0x00)";
#[derive(Debug)]
pub(super) struct ViewLabels {
enabled: bool,
used: RefCell<BTreeMap<String, String>>,
}
impl ViewLabels {
pub(super) const fn disabled() -> Self {
Self {
enabled: false,
used: RefCell::new(BTreeMap::new()),
}
}
pub(super) const fn new(enabled: bool) -> Self {
Self {
enabled,
used: RefCell::new(BTreeMap::new()),
}
}
pub(super) const fn enabled(&self) -> bool {
self.enabled
}
pub(super) fn used_keys(&self) -> BTreeMap<String, String> {
self.used.borrow().clone()
}
pub(super) fn framework_key(&self, key: &str, ftl: &str) {
if self.enabled {
self.used
.borrow_mut()
.insert(key.to_owned(), ftl.to_owned());
}
}
fn record(&self, key: &str, ftl: &str, args: &[(&str, &str)]) -> String {
self.used
.borrow_mut()
.insert(key.to_owned(), ftl.to_owned());
if args.is_empty() {
format!("t!(locale, \"{key}\")")
} else {
let rendered: Vec<String> = args
.iter()
.map(|(name, expr)| format!("{name} = {expr}"))
.collect();
format!("t!(locale, \"{key}\", {})", rendered.join(", "))
}
}
pub(super) fn lit(&self, key: &str, english: &str) -> String {
if self.enabled {
self.record(key, english, &[])
} else {
format!("\"{english}\"")
}
}
pub(super) fn lit_ref(&self, key: &str, english: &str) -> String {
if self.enabled {
format!("&{}", self.record(key, english, &[]))
} else {
format!("\"{english}\"")
}
}
pub(super) fn markup(&self, key: &str, english: &str) -> String {
if self.enabled {
format!("({})", self.record(key, english, &[]))
} else {
format!("\"{english}\"")
}
}
pub(super) fn markup_expr(
&self,
key: &str,
ftl: &str,
plain: &str,
args: &[(&str, &str)],
) -> String {
if self.enabled {
format!("({})", self.record(key, ftl, args))
} else {
plain.to_owned()
}
}
pub(super) fn expr(&self, key: &str, ftl: &str, plain: &str, args: &[(&str, &str)]) -> String {
if self.enabled {
self.record(key, ftl, args)
} else {
plain.to_owned()
}
}
pub(super) fn expr_ref(
&self,
key: &str,
ftl: &str,
plain: &str,
args: &[(&str, &str)],
) -> String {
if self.enabled {
format!("&{}", self.record(key, ftl, args))
} else {
plain.to_owned()
}
}
}
pub(super) fn defined_keys(src: &str) -> BTreeMap<String, String> {
src.lines()
.filter(|line| {
!line.trim_start().starts_with('#')
&& !line.trim().is_empty()
&& !line.starts_with(' ')
&& !line.starts_with('\t')
})
.filter_map(|line| line.split_once('='))
.map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
.collect()
}
pub(super) fn merge_en_ftl_keeping(
existing: &str,
pascal_name: &str,
resource: &str,
keys: &BTreeMap<String, String>,
still_referenced: &std::collections::HashSet<String>,
) -> String {
use std::fmt::Write as _;
let already = defined_keys(existing);
let resource_prefix = format!("{resource}.");
let resource_header = format!("{RESOURCE_HEADER_PREFIX}{pascal_name}{RESOURCE_HEADER_SUFFIX}");
let missing_common: Vec<(&String, &String)> = keys
.iter()
.filter(|(k, _)| k.starts_with("common.") && !already.contains_key(*k))
.collect();
let missing_resource: Vec<(&String, &String)> = keys
.iter()
.filter(|(k, _)| {
k.starts_with(&resource_prefix)
&& !k.starts_with("common.")
&& !already.contains_key(*k)
})
.collect();
let mut wanted = keys.clone();
for key in still_referenced {
if key.starts_with(&resource_prefix)
&& !key.starts_with("common.")
&& already.contains_key(key)
{
wanted.entry(key.clone()).or_default();
}
}
if let Some(reconciled) = reconcile_block(existing, &resource_header, &resource_prefix, &wanted)
{
return merge_en_ftl_keeping(&reconciled, pascal_name, resource, keys, still_referenced);
}
if missing_common.is_empty() && missing_resource.is_empty() {
return existing.to_owned();
}
if !missing_common.is_empty()
&& let Some(insert_at) = block_end(existing, COMMON_HEADER)
{
let mut out = String::with_capacity(existing.len() + missing_common.len() * 48);
out.push_str(&existing[..insert_at]);
for (key, value) in &missing_common {
let _ = writeln!(out, "{key} = {value}");
}
out.push_str(&existing[insert_at..]);
return merge_en_ftl_keeping(&out, pascal_name, resource, keys, still_referenced);
}
let mut out = String::with_capacity(existing.len() + (keys.len() + 4) * 48);
if existing.is_empty() {
out.push_str(
"# Generated by `autumn generate scaffold --i18n`.\n\
#\n\
# This is the DEFAULT locale — leave these values in their own language.\n\
# To add a locale, copy this file to `i18n/<tag>.ftl` and translate THERE.\n\
# Keys are grouped by resource; `common.*` is shared chrome every\n\
# scaffolded resource reuses.\n\n",
);
} else {
out.push_str(existing);
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
if !missing_common.is_empty() {
out.push_str(COMMON_HEADER);
out.push('\n');
for (key, value) in missing_common {
let _ = writeln!(out, "{key} = {value}");
}
out.push_str(COMMON_FOOTER);
out.push('\n');
if !missing_resource.is_empty() {
out.push('\n');
}
}
if !missing_resource.is_empty() {
if !existing.contains(&resource_header) {
out.push_str(&resource_header);
out.push('\n');
}
for (key, value) in missing_resource {
let _ = writeln!(out, "{key} = {value}");
}
out.push_str(&block_footer(&resource_header));
out.push('\n');
}
out
}
fn reconcile_block(
existing: &str,
header: &str,
prefix: &str,
keys: &BTreeMap<String, String>,
) -> Option<String> {
use std::fmt::Write as _;
let end = block_end(existing, header)?;
let start = existing.find(header)?;
let body_start = start + existing[start..].find('\n').map_or(0, |i| i + 1);
let wanted: BTreeMap<&str, &str> = keys
.iter()
.filter(|(k, _)| {
k.starts_with(prefix) && (prefix == "common." || !k.starts_with("common."))
})
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut body = String::new();
let mut keeping_continuations = false;
for line in existing[body_start..end].lines() {
if line.starts_with(' ') || line.starts_with('\t') {
if keeping_continuations {
body.push_str(line);
body.push('\n');
}
continue;
}
if line.trim_start().starts_with('#') {
body.push_str(line);
body.push('\n');
continue;
}
let Some((raw_key, _)) = line.split_once('=') else {
keeping_continuations = false;
body.push_str(line);
body.push('\n');
continue;
};
if wanted.contains_key(raw_key.trim()) {
keeping_continuations = true;
body.push_str(line);
body.push('\n');
} else {
keeping_continuations = false;
}
}
let defined_anywhere = defined_keys(existing);
for (key, value) in &wanted {
if !defined_anywhere.contains_key(*key) {
let _ = writeln!(body, "{key} = {value}");
}
}
if body == existing[body_start..end] {
return None;
}
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..body_start]);
out.push_str(&body);
out.push_str(&existing[end..]);
Some(out)
}
fn block_end(existing: &str, header: &str) -> Option<usize> {
let start = existing.find(header)?;
let mut cursor = start + header.len();
cursor += existing[cursor..].find('\n').map_or(0, |i| i + 1);
let footer = block_footer(header);
let mut end = cursor;
for line in existing[cursor..].split_inclusive('\n') {
if line.trim_end() == footer {
return Some(end);
}
end += line.len();
}
let mut end = cursor;
for line in existing[cursor..].split_inclusive('\n') {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
break;
}
end += line.len();
}
Some(end)
}
pub(super) fn remove_en_ftl_keys(
existing: &str,
pascal_name: &str,
resource: &str,
surviving: &std::collections::HashSet<String>,
) -> String {
let stripped = prune_resource_block(existing, pascal_name, resource, surviving);
prune_chrome_block(&stripped, surviving)
}
fn prune_chrome_block(existing: &str, surviving: &std::collections::HashSet<String>) -> String {
let mut surviving = surviving.clone();
if surviving.iter().any(|key| key.starts_with("common.")) {
surviving.insert(NUL_MESSAGE_KEY.to_owned());
}
prune_block(existing, COMMON_HEADER, "common.", &surviving)
}
fn prune_resource_block(
existing: &str,
pascal_name: &str,
resource: &str,
surviving: &std::collections::HashSet<String>,
) -> String {
let header = format!("{RESOURCE_HEADER_PREFIX}{pascal_name}{RESOURCE_HEADER_SUFFIX}");
prune_block(existing, &header, &format!("{resource}."), surviving)
}
fn prune_block(
existing: &str,
header: &str,
prefix: &str,
surviving: &std::collections::HashSet<String>,
) -> String {
let defined = defined_keys(existing);
let kept: BTreeMap<String, String> = surviving
.iter()
.filter(|key| key.starts_with(prefix) && defined.contains_key(*key))
.map(|key| (key.clone(), String::new()))
.collect();
if kept.is_empty() {
return remove_marked_block(existing, header);
}
reconcile_block(existing, header, prefix, &kept).unwrap_or_else(|| existing.to_owned())
}
fn remove_marked_block(existing: &str, header: &str) -> String {
let Some(end) = block_end(existing, header) else {
return existing.to_owned();
};
let start = existing.find(header).expect("block_end found the header");
let footer = block_footer(header);
let end = existing[end..]
.strip_prefix(footer.as_str())
.map_or(end, |rest| {
end + footer.len() + usize::from(rest.starts_with('\n'))
});
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..start]);
out.push_str(&existing[end..]);
let lines: Vec<&str> = out.lines().collect();
collapse_blank_runs(&lines)
}
fn collapse_blank_runs(lines: &[&str]) -> String {
let mut joined = String::with_capacity(lines.iter().map(|l| l.len() + 1).sum());
let mut prev_blank = false;
for line in lines {
let blank = line.trim().is_empty();
if blank && prev_blank {
continue;
}
prev_blank = blank;
joined.push_str(line);
joined.push('\n');
}
while joined.ends_with("\n\n") {
joined.pop();
}
joined
}
fn toml_table_name(line: &str) -> Option<&str> {
let trimmed = line.trim();
let rest = trimmed.strip_prefix('[')?;
let close = rest.find(']')?;
let tail = rest[close + 1..].trim();
if !tail.is_empty() && !tail.starts_with('#') {
return None;
}
let name = rest[..close].trim();
Some(
name.strip_prefix('"')
.and_then(|n| n.strip_suffix('"'))
.or_else(|| name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')))
.unwrap_or(name),
)
}
fn toml_key_name(raw: &str) -> &str {
let key = raw.trim();
key.strip_prefix('"')
.and_then(|k| k.strip_suffix('"'))
.or_else(|| key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
.unwrap_or(key)
}
fn toml_scalar_value(raw: &str) -> Option<String> {
let trimmed = raw.trim_start();
let quote = trimmed.chars().next()?;
let value = if quote == '"' || quote == '\'' {
let rest = &trimmed[quote.len_utf8()..];
if quote == '"' {
let mut end = None;
let mut escaped = false;
for (i, c) in rest.char_indices() {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
end = Some(i);
break;
}
}
decode_toml_escapes(&rest[..end?])
} else {
rest[..rest.find(quote)?].to_owned()
}
} else {
trimmed
.split_once('#')
.map_or(trimmed, |(before, _)| before)
.trim_end()
.to_owned()
};
(!value.is_empty()).then_some(value)
}
fn split_inline_members(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
for c in body.chars() {
match quote {
Some(q) => {
current.push(c);
if escaped {
escaped = false;
} else if c == '\\' && q == '"' {
escaped = true;
} else if c == q {
quote = None;
}
}
None if c == '"' || c == '\'' => {
quote = Some(c);
current.push(c);
}
None if c == ',' => out.push(std::mem::take(&mut current)),
None => current.push(c),
}
}
out.push(current);
out
}
fn rust_string_escape(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn decode_toml_escapes(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let Some(esc) = chars.next() else {
out.push('\\');
break;
};
match esc {
'b' => out.push('\u{8}'),
't' => out.push('\t'),
'n' => out.push('\n'),
'f' => out.push('\u{c}'),
'r' => out.push('\r'),
'"' => out.push('"'),
'\\' => out.push('\\'),
'u' | 'U' => {
let width = if esc == 'u' { 4 } else { 8 };
let digits: String = chars.clone().take(width).collect();
if let Some(decoded) = u32::from_str_radix(&digits, 16)
.ok()
.filter(|_| digits.len() == width)
.and_then(char::from_u32)
{
out.push(decoded);
for _ in 0..width {
chars.next();
}
} else {
out.push('\\');
out.push(esc);
}
}
other => {
out.push('\\');
out.push(other);
}
}
}
out
}
fn defines_i18n(autumn_toml: &str) -> bool {
autumn_toml.parse::<toml::Table>().map_or_else(
|_| defines_i18n_by_scan(autumn_toml),
|table| table.contains_key("i18n"),
)
}
pub(super) fn configured_i18n(autumn_toml: &str) -> (Option<String>, Option<String>) {
let Ok(table) = autumn_toml.parse::<toml::Table>() else {
return configured_i18n_by_scan(autumn_toml);
};
let i18n = table.get("i18n");
let member = |key: &str| {
i18n.and_then(|v| v.get(key))
.and_then(toml::Value::as_str)
.map(str::to_owned)
};
(member("default_locale"), member("dir"))
}
fn defines_i18n_by_scan(autumn_toml: &str) -> bool {
let mut at_top_level = true;
for line in autumn_toml.lines() {
if let Some(name) = toml_table_name(line) {
if name == "i18n" {
return true;
}
at_top_level = false;
continue;
}
if !at_top_level {
continue;
}
let trimmed = line.trim();
if let Some((key, _)) = trimmed.split_once('=') {
let key = key.trim();
if key == "i18n" || key.starts_with("i18n.") {
return true;
}
}
}
false
}
fn configured_i18n_by_scan(autumn_toml: &str) -> (Option<String>, Option<String>) {
let mut in_i18n = false;
let mut at_top_level = true;
let (mut locale, mut dir) = (None, None);
for line in autumn_toml.lines() {
if let Some(name) = toml_table_name(line) {
in_i18n = name == "i18n";
at_top_level = false;
continue;
}
let trimmed = line.trim();
let Some((raw_key, value)) = trimmed.split_once('=') else {
continue;
};
let (key, value) = if in_i18n {
(raw_key, value)
} else if at_top_level {
match raw_key.trim().strip_prefix("i18n.") {
Some(rest) => (rest, value),
None if raw_key.trim() == "i18n" => {
for member in
split_inline_members(value.trim().trim_matches(|c| c == '{' || c == '}'))
{
let member = member.as_str();
let Some((k, v)) = member.split_once('=') else {
continue;
};
let Some(v) = toml_scalar_value(v) else {
continue;
};
match toml_key_name(k) {
"default_locale" => locale = Some(v.clone()),
"dir" => dir = Some(v.clone()),
_ => {}
}
}
continue;
}
None => continue,
}
} else {
continue;
};
let Some(value) = toml_scalar_value(value) else {
continue;
};
match toml_key_name(key) {
"default_locale" => locale = Some(value.clone()),
"dir" => dir = Some(value.clone()),
_ => {}
}
}
(locale, dir)
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct MacroEnv {
pub(super) file: Option<String>,
pub(super) default_locale: Option<String>,
}
impl MacroEnv {
pub(super) fn resolve(project_root: &Path) -> Self {
let mut configured: BTreeMap<String, (String, bool)> = BTreeMap::new();
for (base, path) in cargo_config_files(project_root) {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
for (name, entry) in cargo_config_env(&text, &base) {
configured.entry(name).or_insert(entry);
}
}
let read = |name: &str| layer_cargo_env(configured.get(name), std::env::var(name).ok());
Self {
file: read("AUTUMN_I18N_FILE"),
default_locale: read("AUTUMN_I18N_DEFAULT_LOCALE"),
}
}
}
fn cargo_config_files(project_root: &Path) -> Vec<(PathBuf, PathBuf)> {
let mut out: Vec<(PathBuf, PathBuf)> = project_root
.ancestors()
.filter_map(|dir| {
cargo_config_in(&dir.join(".cargo")).map(|path| (dir.to_path_buf(), path))
})
.collect();
if let Some(home) = cargo_home()
&& let Some(path) = cargo_config_in(&home)
{
let base = home.parent().unwrap_or(&home).to_path_buf();
if !out.iter().any(|(_, existing)| *existing == path) {
out.push((base, path));
}
}
out
}
fn cargo_config_in(dir: &Path) -> Option<PathBuf> {
["config", "config.toml"]
.into_iter()
.map(|name| dir.join(name))
.find(|path| path.is_file())
}
fn cargo_home() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("CARGO_HOME") {
return Some(PathBuf::from(home));
}
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".cargo"))
}
fn layer_cargo_env(
configured: Option<&(String, bool)>,
from_process: Option<String>,
) -> Option<String> {
match configured {
Some((value, force)) if *force || from_process.is_none() => Some(value.clone()),
_ => from_process,
}
}
fn cargo_config_env(config: &str, base: &Path) -> BTreeMap<String, (String, bool)> {
let Ok(table) = config.parse::<toml::Table>() else {
return BTreeMap::new();
};
let Some(env) = table.get("env").and_then(toml::Value::as_table) else {
return BTreeMap::new();
};
env.iter()
.filter_map(|(name, entry)| {
let (value, force, relative) = match entry {
toml::Value::String(value) => (value.clone(), false, false),
toml::Value::Table(fields) => (
fields.get("value")?.as_str()?.to_owned(),
fields.get("force").and_then(toml::Value::as_bool) == Some(true),
fields.get("relative").and_then(toml::Value::as_bool) == Some(true),
),
_ => return None,
};
let value = if relative {
base.join(value).to_string_lossy().into_owned()
} else {
value
};
Some((name.clone(), (value, force)))
})
.collect()
}
pub(super) fn validator_bundle_path(project_root: &Path, env: &MacroEnv) -> Option<PathBuf> {
if let Some(file) = &env.file {
let path = project_root.join(file);
if path.is_file() {
return Some(path);
}
}
let locale = env.default_locale.as_deref().unwrap_or("en");
let path = project_root.join("i18n").join(format!("{locale}.ftl"));
path.is_file().then_some(path)
}
pub(super) fn locale_bundles(project_root: &Path, dir: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(project_root.join(dir)) else {
return Vec::new();
};
let mut out: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "ftl"))
.collect();
out.sort();
out
}
pub(super) fn ensure_dockerfile_i18n_copy(dockerfile: &str, dir: &str) -> Option<String> {
let builder_anchor = "COPY migrations ./migrations";
let runtime_anchor = "COPY --from=builder /app/migrations /app/migrations";
let (builder_line, runtime_line) = if dir.contains(char::is_whitespace) {
(
format!(
"COPY [{}, {}]",
json_str(dir),
json_str(&format!("./{dir}"))
),
format!(
"COPY --from=builder [{}, {}]",
json_str(&format!("/app/{dir}")),
json_str(&format!("/app/{dir}"))
),
)
} else {
(
format!("COPY {dir} ./{dir}"),
format!("COPY --from=builder /app/{dir} /app/{dir}"),
)
};
if has_active_line(dockerfile, &builder_line) && has_active_line(dockerfile, &runtime_line) {
return Some(dockerfile.to_owned());
}
let out = insert_after_active_line(dockerfile, runtime_anchor, &runtime_line)?;
insert_after_active_line(&out, builder_anchor, &builder_line)
}
fn has_active_line(dockerfile: &str, instruction: &str) -> bool {
dockerfile.lines().any(|line| line.trim() == instruction)
}
fn insert_after_active_line(dockerfile: &str, anchor: &str, new_line: &str) -> Option<String> {
let mut out = String::with_capacity(dockerfile.len() + new_line.len() + 1);
let mut inserted = false;
for line in dockerfile.split_inclusive('\n') {
out.push_str(line);
if !inserted && line.trim() == anchor {
if !line.ends_with('\n') {
out.push('\n');
}
out.push_str(new_line);
out.push('\n');
inserted = true;
}
}
inserted.then_some(out)
}
pub(super) fn stale_dockerfile_i18n_dir(dockerfile: &str, dir: &str) -> Option<String> {
fn copy_operands(line: &str, from_builder: bool) -> Option<(String, String)> {
let rest = line.trim().strip_prefix("COPY ")?.trim_start();
let rest = if from_builder {
rest.strip_prefix("--from=builder")?.trim_start()
} else {
if rest.starts_with("--") {
return None;
}
rest
};
if let Some(inner) = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
let mut parts = inner.split(',').map(|p| {
p.trim()
.trim_matches('"')
.replace("\\\"", "\"")
.replace("\\\\", "\\")
});
return Some((parts.next()?, parts.next()?));
}
let parts: Vec<&str> = rest.split_whitespace().collect();
match parts.as_slice() {
[src, dest] => Some(((*src).to_owned(), (*dest).to_owned())),
_ => None,
}
}
fn builder_copy_dir(line: &str) -> Option<String> {
let (src, dest) = copy_operands(line, false)?;
(dest.strip_prefix("./") == Some(src.as_str())).then_some(src)
}
fn runtime_copy_dir(line: &str) -> Option<String> {
let (src, dest) = copy_operands(line, true)?;
if src != dest {
return None;
}
src.strip_prefix("/app/")
.filter(|d| !d.is_empty())
.map(str::to_owned)
}
let after = |anchor: &str, extract: fn(&str) -> Option<String>| -> Option<String> {
let mut lines = dockerfile.lines().skip_while(|l| l.trim() != anchor);
lines.next()?;
extract(lines.next()?)
};
let builder = after("COPY migrations ./migrations", builder_copy_dir)?;
let runtime = after(
"COPY --from=builder /app/migrations /app/migrations",
runtime_copy_dir,
)?;
(builder == runtime && builder != dir).then_some(builder)
}
pub(super) fn profile_file_i18n_overrides(
files: &[(String, String)],
base_dir: &str,
base_locale: &str,
) -> Vec<(String, String)> {
let mut out = Vec::new();
for (profile, contents) in files {
let (locale, dir) = configured_i18n(contents);
let differs = dir.as_deref().is_some_and(|d| d != base_dir)
|| locale.as_deref().is_some_and(|l| l != base_locale);
if !differs {
continue;
}
out.push((
profile.clone(),
format!(
"{}/{}.ftl",
dir.as_deref().unwrap_or(base_dir),
locale.as_deref().unwrap_or(base_locale)
),
));
}
out
}
pub(super) fn profile_i18n_overrides(
autumn_toml: &str,
base_dir: &str,
base_locale: &str,
) -> Vec<(String, String)> {
if let Ok(table) = autumn_toml.parse::<toml::Table>() {
let mut out = Vec::new();
let profiles = table.get("profile").and_then(toml::Value::as_table);
for (profile, value) in profiles.into_iter().flatten() {
let Some(i18n) = value.get("i18n") else {
continue;
};
let member = |key: &str| i18n.get(key).and_then(toml::Value::as_str);
let (dir, locale) = (member("dir"), member("default_locale"));
let differs =
dir.is_some_and(|d| d != base_dir) || locale.is_some_and(|l| l != base_locale);
if !differs {
continue;
}
out.push((
profile.clone(),
format!(
"{}/{}.ftl",
dir.unwrap_or(base_dir),
locale.unwrap_or(base_locale)
),
));
}
out.sort();
return out;
}
profile_i18n_overrides_by_scan(autumn_toml, base_dir, base_locale)
}
fn profile_i18n_overrides_by_scan(
autumn_toml: &str,
base_dir: &str,
base_locale: &str,
) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut current: Option<String> = None;
let (mut dir, mut locale) = (None, None);
let mut flush = |profile: Option<String>, dir: Option<String>, locale: Option<String>| {
let Some(profile) = profile else { return };
let differs = dir.as_deref().is_some_and(|d| d != base_dir)
|| locale.as_deref().is_some_and(|l| l != base_locale);
if !differs {
return;
}
let path = format!(
"{}/{}.ftl",
dir.as_deref().unwrap_or(base_dir),
locale.as_deref().unwrap_or(base_locale)
);
out.push((profile, path));
};
for line in autumn_toml.lines() {
if let Some(name) = toml_table_name(line) {
flush(current.take(), dir.take(), locale.take());
current = name
.strip_prefix("profile.")
.and_then(|rest| rest.strip_suffix(".i18n"))
.map(str::to_owned);
continue;
}
if current.is_none() {
continue;
}
let trimmed = line.trim();
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let Some(value) = toml_scalar_value(value) else {
continue;
};
match toml_key_name(key) {
"default_locale" => locale = Some(value.clone()),
"dir" => dir = Some(value.clone()),
_ => {}
}
}
flush(current, dir, locale);
out
}
pub(super) fn ensure_i18n_config_block(existing: &str) -> String {
if defines_i18n(existing) {
return existing.to_owned();
}
let mut out = existing.to_owned();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() {
out.push('\n');
}
out.push_str("[i18n]\ndefault_locale = \"en\"\nsupported_locales = [\"en\"]\n");
out
}
fn json_str(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
struct SourceIndex<'a> {
src: &'a str,
line_starts: Vec<usize>,
}
impl<'a> SourceIndex<'a> {
fn new(src: &'a str) -> Self {
let mut line_starts = vec![0];
line_starts.extend(src.match_indices('\n').map(|(i, _)| i + 1));
Self { src, line_starts }
}
fn byte_offset(&self, at: proc_macro2::LineColumn) -> usize {
let line_start = self.line_starts[at.line - 1];
let line_end = self
.line_starts
.get(at.line)
.copied()
.unwrap_or(self.src.len());
let mut offset = line_start;
for ch in self.src[line_start..line_end].chars().take(at.column) {
offset += ch.len_utf8();
}
offset
}
fn range(&self, span: proc_macro2::Span) -> std::ops::Range<usize> {
self.byte_offset(span.start())..self.byte_offset(span.end())
}
}
fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path().is_ident("cfg")
&& attr
.parse_args::<syn::Ident>()
.is_ok_and(|ident| ident == "test")
})
}
fn item_attrs(item: &syn::Item) -> &[syn::Attribute] {
use syn::Item;
match item {
Item::Const(i) => &i.attrs,
Item::Enum(i) => &i.attrs,
Item::ExternCrate(i) => &i.attrs,
Item::Fn(i) => &i.attrs,
Item::ForeignMod(i) => &i.attrs,
Item::Impl(i) => &i.attrs,
Item::Macro(i) => &i.attrs,
Item::Mod(i) => &i.attrs,
Item::Static(i) => &i.attrs,
Item::Struct(i) => &i.attrs,
Item::Trait(i) => &i.attrs,
Item::TraitAlias(i) => &i.attrs,
Item::Type(i) => &i.attrs,
Item::Union(i) => &i.attrs,
Item::Use(i) => &i.attrs,
_ => &[],
}
}
#[derive(Clone, Copy)]
struct Frame<'ast> {
block: &'ast syn::Block,
before: usize,
}
struct CallSite<'ast> {
call: &'ast syn::Expr,
dot_span: proc_macro2::Span,
is_cfg_test: bool,
enclosing_fns: Vec<*const syn::ItemFn>,
frames: Vec<Frame<'ast>>,
}
#[derive(Default)]
struct Collector<'ast> {
cfg_test_depth: u32,
fn_stack: Vec<*const syn::ItemFn>,
frames: Vec<Frame<'ast>>,
main_fns: Vec<(&'ast syn::ItemFn, bool)>,
routes_calls: Vec<CallSite<'ast>>,
embedded_static_calls: Vec<CallSite<'ast>>,
embedded_locales_calls: Vec<CallSite<'ast>>,
statics: Vec<(&'ast syn::ItemStatic, bool)>,
embedded_locales_idents: Vec<bool>,
}
impl<'ast> Collector<'ast> {
fn collect(file: &'ast syn::File) -> Self {
let mut collector = Self::default();
collector.visit_file(file);
collector
}
}
impl<'ast> syn::visit::Visit<'ast> for Collector<'ast> {
fn visit_item(&mut self, item: &'ast syn::Item) {
let is_test = has_cfg_test(item_attrs(item));
if is_test {
self.cfg_test_depth += 1;
}
syn::visit::visit_item(self, item);
if is_test {
self.cfg_test_depth -= 1;
}
}
fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) {
if item.sig.ident == "main" {
self.main_fns.push((item, self.cfg_test_depth > 0));
}
self.fn_stack.push(std::ptr::from_ref(item));
syn::visit::visit_item_fn(self, item);
self.fn_stack.pop();
}
fn visit_item_static(&mut self, item: &'ast syn::ItemStatic) {
self.statics.push((item, self.cfg_test_depth > 0));
syn::visit::visit_item_static(self, item);
}
fn visit_ident(&mut self, ident: &'ast syn::Ident) {
if ident == "EMBEDDED_LOCALES" {
self.embedded_locales_idents.push(self.cfg_test_depth > 0);
}
}
fn visit_block(&mut self, block: &'ast syn::Block) {
let base = self.frames.len();
for (i, stmt) in block.stmts.iter().enumerate() {
self.frames.push(Frame { block, before: i });
self.visit_stmt(stmt);
self.frames.pop();
}
self.frames.truncate(base);
}
fn visit_expr(&mut self, expr: &'ast syn::Expr) {
if let syn::Expr::MethodCall(mc) = expr {
let bucket = if mc.method == "routes" {
Some(&mut self.routes_calls)
} else if mc.method == "embedded_static" {
Some(&mut self.embedded_static_calls)
} else if mc.method == "embedded_locales" {
Some(&mut self.embedded_locales_calls)
} else {
None
};
if let Some(bucket) = bucket {
bucket.push(CallSite {
call: expr,
dot_span: mc.dot_token.span(),
is_cfg_test: self.cfg_test_depth > 0,
enclosing_fns: self.fn_stack.clone(),
frames: self.frames.clone(),
});
}
}
syn::visit::visit_expr(self, expr);
}
}
fn production_anchors<'a, 'ast>(collector: &'a Collector<'ast>) -> Vec<&'a CallSite<'ast>> {
let main_fns: Vec<*const syn::ItemFn> = collector
.main_fns
.iter()
.filter(|(_, is_test)| !is_test)
.map(|(item, _)| std::ptr::from_ref(*item))
.collect();
if !main_fns.is_empty() {
let inside: Vec<&CallSite<'ast>> = collector
.routes_calls
.iter()
.filter(|call| call.enclosing_fns.iter().any(|f| main_fns.contains(f)))
.collect();
if !inside.is_empty() {
return inside;
}
}
let candidates: Vec<&CallSite<'ast>> = collector
.routes_calls
.iter()
.filter(|c| !c.is_cfg_test)
.collect();
match candidates.as_slice() {
[only] => vec![*only],
_ => Vec::new(),
}
}
fn production_fn_ptrs(anchors: &[&CallSite<'_>]) -> Vec<*const syn::ItemFn> {
let mut out: Vec<*const syn::ItemFn> = anchors
.iter()
.filter_map(|c| c.enclosing_fns.last().copied())
.collect();
out.sort_unstable();
out.dedup();
out
}
fn in_production(call: &CallSite<'_>, scopes: &[*const syn::ItemFn]) -> bool {
if scopes.is_empty() {
!call.is_cfg_test
} else {
call.enclosing_fns.iter().any(|f| scopes.contains(f))
}
}
enum ChainOutcome<'ast> {
Found,
Root(&'ast syn::Ident),
Dead,
}
fn chain_walk<'ast>(mut expr: &'ast syn::Expr, method: &str) -> ChainOutcome<'ast> {
loop {
match expr {
syn::Expr::MethodCall(mc) => {
if mc.method == method {
return ChainOutcome::Found;
}
expr = &mc.receiver;
}
syn::Expr::Path(p) => {
return p
.path
.get_ident()
.map_or(ChainOutcome::Dead, ChainOutcome::Root);
}
_ => return ChainOutcome::Dead,
}
}
}
fn block_tail(block: &syn::Block) -> Option<&syn::Expr> {
match block.stmts.last()? {
syn::Stmt::Expr(expr, None) => Some(expr),
_ => None,
}
}
fn branch_candidates(expr: &syn::Expr) -> Vec<&syn::Expr> {
match expr {
syn::Expr::If(e) => {
let mut out = block_tail(&e.then_branch)
.map(branch_candidates)
.unwrap_or_default();
if let Some((_, else_expr)) = &e.else_branch {
out.extend(branch_candidates(else_expr));
}
out
}
syn::Expr::Match(e) => e
.arms
.iter()
.flat_map(|arm| branch_candidates(&arm.body))
.collect(),
syn::Expr::Block(e) => block_tail(&e.block)
.map(branch_candidates)
.unwrap_or_default(),
_ => vec![expr],
}
}
fn stmt_binds<'ast>(stmt: &'ast syn::Stmt, name: &str) -> Option<&'ast syn::Expr> {
match stmt {
syn::Stmt::Local(local) => {
let pat = match &local.pat {
syn::Pat::Ident(pat) => pat,
syn::Pat::Type(pat_type) => match &*pat_type.pat {
syn::Pat::Ident(pat) => pat,
_ => return None,
},
_ => return None,
};
if pat.ident != name {
return None;
}
local.init.as_ref().map(|init| &*init.expr)
}
syn::Stmt::Expr(syn::Expr::Assign(assign), _) => match &*assign.left {
syn::Expr::Path(path) if path.path.get_ident().is_some_and(|id| id == name) => {
Some(&*assign.right)
}
_ => None,
},
_ => None,
}
}
fn reachable_reassignments<'ast>(
expr: &'ast syn::Expr,
name: &str,
out: &mut Vec<&'ast syn::Expr>,
) {
match expr {
syn::Expr::If(e) => {
reachable_reassignments_in_block(&e.then_branch, name, out);
if let Some((_, else_expr)) = &e.else_branch {
reachable_reassignments(else_expr, name, out);
}
}
syn::Expr::Block(e) => reachable_reassignments_in_block(&e.block, name, out),
syn::Expr::Match(e) => {
for arm in &e.arms {
reachable_reassignments(&arm.body, name, out);
}
}
_ => {}
}
}
fn reachable_reassignments_in_block<'ast>(
block: &'ast syn::Block,
name: &str,
out: &mut Vec<&'ast syn::Expr>,
) {
for stmt in &block.stmts {
if let Some(rhs) = stmt_binds(stmt, name) {
out.push(rhs);
}
if let syn::Stmt::Expr(expr, _) = stmt {
reachable_reassignments(expr, name, out);
}
}
}
fn resolve_bindings<'ast>(
frames: &[Frame<'ast>],
name: &str,
) -> Vec<(&'ast syn::Expr, Vec<Frame<'ast>>)> {
let mut out = Vec::new();
for depth in (0..frames.len()).rev() {
let frame = frames[depth];
for (i, stmt) in frame.block.stmts[..frame.before].iter().enumerate().rev() {
let mut narrowed = frames[..=depth].to_vec();
narrowed[depth] = Frame {
block: frame.block,
before: i,
};
if let Some(rhs) = stmt_binds(stmt, name) {
out.push((rhs, narrowed));
return out;
}
if let syn::Stmt::Expr(expr, _) = stmt {
let mut conditional = Vec::new();
reachable_reassignments(expr, name, &mut conditional);
out.extend(conditional.into_iter().map(|rhs| (rhs, narrowed.clone())));
}
}
}
out
}
fn value_already_called<'ast>(
expr: &'ast syn::Expr,
frames: &[Frame<'ast>],
method: &str,
depth: u32,
) -> bool {
if depth > 256 {
return false;
}
branch_candidates(expr)
.into_iter()
.any(|candidate| match chain_walk(candidate, method) {
ChainOutcome::Found => true,
ChainOutcome::Dead => false,
ChainOutcome::Root(name) => resolve_bindings(frames, &name.to_string())
.into_iter()
.any(|(rhs, next)| value_already_called(rhs, &next, method, depth + 1)),
})
}
fn method_called_before(call: &CallSite<'_>, method: &str) -> bool {
value_already_called(call.call, &call.frames, method, 0)
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum I18nAutoWiring {
Wired(String),
CustomBundle,
NoAnchor,
}
fn line_indent(src: &str, at: usize) -> &str {
let line_start = src[..at].rfind('\n').map_or(0, |i| i + 1);
let line = &src[line_start..];
let indent_len = line.len() - line.trim_start().len();
&line[..indent_len]
}
pub(super) fn ensure_i18n_auto(main_rs: &str) -> I18nAutoWiring {
let Ok(file) = syn::parse_file(main_rs) else {
return I18nAutoWiring::NoAnchor;
};
let collector = Collector::collect(&file);
let anchors = production_anchors(&collector);
if anchors.is_empty() {
return I18nAutoWiring::NoAnchor;
}
let mut custom = false;
let mut pending = Vec::new();
for anchor in anchors {
if method_called_before(anchor, "i18n") {
custom = true;
} else if !method_called_before(anchor, "i18n_auto") {
pending.push(anchor);
}
}
if pending.is_empty() {
return if custom {
I18nAutoWiring::CustomBundle
} else {
I18nAutoWiring::Wired(main_rs.to_owned())
};
}
let index = SourceIndex::new(main_rs);
let mut sites: Vec<usize> = pending
.iter()
.map(|anchor| index.byte_offset(anchor.dot_span.start()))
.collect();
sites.sort_unstable();
let mut out = main_rs.to_owned();
for at in sites.into_iter().rev() {
let line_start = out[..at].rfind('\n').map_or(0, |i| i + 1);
let indent = line_indent(&out, at);
if line_start + indent.len() == at {
let indent = indent.to_owned();
out = format!(
"{}{indent}.i18n_auto()\n{}",
&out[..line_start],
&out[line_start..]
);
} else {
out = format!("{}.i18n_auto(){}", &out[..at], &out[at..]);
}
}
I18nAutoWiring::Wired(out)
}
fn is_embed_locales_macro(expr: &syn::Expr) -> bool {
let syn::Expr::Macro(mac) = expr else {
return false;
};
let segments: Vec<&syn::Ident> = mac.mac.path.segments.iter().map(|s| &s.ident).collect();
matches!(segments.as_slice(), [a, b] if *a == "autumn_web" && *b == "embed_locales")
}
pub(super) fn ensure_embedded_locales(main_rs: &str, dir: &str) -> Option<String> {
let macro_call = if dir == "i18n" {
"autumn_web::embed_locales!()".to_owned()
} else {
format!(
"autumn_web::embed_locales!(\"{}\")",
rust_string_escape(dir)
)
};
let file = syn::parse_file(main_rs).ok()?;
let collector = Collector::collect(&file);
let index = SourceIndex::new(main_rs);
let embedded_in_production = collector
.embedded_locales_idents
.iter()
.any(|is_test| !is_test);
if embedded_in_production {
let production_decl = collector
.statics
.iter()
.find(|(item, is_test)| !is_test && item.ident == "EMBEDDED_LOCALES");
let out = match production_decl {
Some((item, _)) if is_embed_locales_macro(&item.expr) => {
let range = index.range(item.expr.span());
format!(
"{}{macro_call}{}",
&main_rs[..range.start],
&main_rs[range.end..]
)
}
_ => main_rs.to_owned(),
};
return insert_locale_install(&out);
}
let (production_static, _) = collector
.statics
.iter()
.find(|(item, is_test)| !is_test && item.ident == "EMBEDDED_STATIC")?;
let at = index.range(production_static.span()).end;
let with_static = format!(
"{}\n#[cfg(feature = \"embed-assets\")]\nstatic EMBEDDED_LOCALES: autumn_web::include_dir::Dir = {macro_call};{}",
&main_rs[..at],
&main_rs[at..]
);
insert_locale_install(&with_static)
}
fn locales_installed_beside(
anchor: &CallSite<'_>,
embedded_locales_calls: &[CallSite<'_>],
) -> bool {
let Some(&block) = anchor.frames.last().map(|f| &f.block) else {
return false;
};
embedded_locales_calls.iter().any(|c| {
c.frames
.last()
.is_some_and(|f| std::ptr::eq(f.block, block))
})
}
fn statement_end(src: &str, end: usize) -> usize {
let mut at = src[end..].find(';').map_or(src.len(), |i| end + i + 1);
if src[at..].starts_with('\n') {
at += 1;
}
at
}
fn insert_locale_install(src: &str) -> Option<String> {
let file = syn::parse_file(src).ok()?;
let collector = Collector::collect(&file);
let index = SourceIndex::new(src);
let routes_anchors = production_anchors(&collector);
let scopes = production_fn_ptrs(&routes_anchors);
let anchors: Vec<&CallSite<'_>> = collector
.embedded_static_calls
.iter()
.filter(|c| in_production(c, &scopes))
.collect();
let pending: Vec<&CallSite<'_>> = anchors
.iter()
.filter(|c| !locales_installed_beside(c, &collector.embedded_locales_calls))
.copied()
.collect();
if pending.is_empty() {
let served = !anchors.is_empty()
|| collector
.embedded_locales_calls
.iter()
.any(|c| in_production(c, &scopes));
return served.then(|| src.to_owned());
}
let mut sites: Vec<usize> = pending
.iter()
.map(|c| index.range(c.call.span()).end)
.collect();
sites.sort_unstable();
let mut out = src.to_owned();
for call_end in sites.into_iter().rev() {
let indent = line_indent(&out, call_end).to_owned();
let at = statement_end(&out, call_end);
out = format!(
"{}{indent}#[cfg(feature = \"embed-assets\")]\n{indent}let app = app.embedded_locales(&EMBEDDED_LOCALES);\n{}",
&out[..at],
&out[at..]
);
}
Some(out)
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
fn merge_en_ftl(
existing: &str,
pascal_name: &str,
resource: &str,
keys: &BTreeMap<String, String>,
) -> String {
merge_en_ftl_keeping(existing, pascal_name, resource, keys, &HashSet::new())
}
fn chrome(keys: &[&str]) -> HashSet<String> {
keys.iter().map(|k| (*k).to_owned()).collect()
}
fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn disabled_emitter_is_an_identity_over_the_plain_expression() {
let labels = ViewLabels::disabled();
assert_eq!(labels.lit("common.create", "Create"), "\"Create\"");
assert_eq!(
labels.lit_ref("post.index.title", "Post index"),
"\"Post index\""
);
assert_eq!(
labels.expr(
"post.show.title",
"Post #{ $id }",
"&format!(\"Post #{}\", id)",
&[]
),
"&format!(\"Post #{}\", id)"
);
assert!(labels.used_keys().is_empty());
}
#[test]
fn enabled_emitter_records_keys_and_emits_lookups() {
let labels = ViewLabels::new(true);
assert_eq!(
labels.lit("common.create", "Create"),
"t!(locale, \"common.create\")"
);
assert_eq!(
labels.lit_ref("post.index.title", "Post index"),
"&t!(locale, \"post.index.title\")"
);
assert_eq!(
labels.expr(
"common.new",
"New { $resource }",
"\"New Post\"",
&[("resource", "&l_resource")]
),
"t!(locale, \"common.new\", resource = &l_resource)"
);
let used = labels.used_keys();
assert_eq!(used.get("common.create").unwrap(), "Create");
assert_eq!(used.get("common.new").unwrap(), "New { $resource }");
}
#[test]
fn merge_writes_a_header_and_both_blocks_into_an_empty_file() {
let out = merge_en_ftl(
"",
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.name", "Post")]),
);
assert!(out.contains(COMMON_HEADER), "{out}");
assert!(out.contains("common.create = Create"), "{out}");
assert!(out.contains("post.name = Post"), "{out}");
assert!(out.contains("Post — generated by"), "{out}");
}
#[test]
fn merge_never_rewrites_an_existing_value() {
let existing = "common.create = Add\n";
let out = merge_en_ftl(
existing,
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.name", "Post")]),
);
assert!(out.contains("common.create = Add"), "{out}");
assert!(!out.contains("common.create = Create"), "{out}");
assert!(out.contains("post.name = Post"), "{out}");
}
#[test]
fn merge_is_idempotent() {
let pairs = keys(&[("common.create", "Create"), ("post.name", "Post")]);
let once = merge_en_ftl("", "Post", "post", &pairs);
let twice = merge_en_ftl(&once, "Post", "post", &pairs);
assert_eq!(once, twice);
}
#[test]
fn merge_shares_the_common_block_across_resources() {
let first = merge_en_ftl(
"",
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.name", "Post")]),
);
let second = merge_en_ftl(
&first,
"Comment",
"comment",
&keys(&[("common.create", "Create"), ("comment.name", "Comment")]),
);
assert_eq!(second.matches("common.create =").count(), 1, "{second}");
assert_eq!(second.matches(COMMON_HEADER).count(), 1, "{second}");
assert!(second.contains("comment.name = Comment"), "{second}");
assert!(second.contains("post.name = Post"), "{second}");
}
#[test]
fn remove_takes_only_the_resource_block() {
let ftl = merge_en_ftl(
&merge_en_ftl(
"",
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.name", "Post")]),
),
"Comment",
"comment",
&keys(&[("common.create", "Create"), ("comment.name", "Comment")]),
);
let out = remove_en_ftl_keys(&ftl, "Post", "post", &chrome(&["common.create"]));
assert!(out.contains("common.create = Create"), "{out}");
assert!(!out.contains("post.name"), "{out}");
assert!(!out.contains("Post — generated by"), "{out}");
assert!(out.contains("comment.name = Comment"), "{out}");
assert_eq!(
remove_en_ftl_keys(&out, "Post", "post", &chrome(&["common.create"])),
out
);
}
#[test]
fn remove_keeps_the_framework_key_while_a_sibling_survives() {
let nul = (
"common.error.nul_character",
"Cannot contain the NUL character (0x00)",
);
let ftl = merge_en_ftl(
&merge_en_ftl(
"",
"Post",
"post",
&keys(&[("common.create", "Create"), nul, ("post.name", "Post")]),
),
"Comment",
"comment",
&keys(&[
("common.create", "Create"),
nul,
("comment.name", "Comment"),
]),
);
let out = remove_en_ftl_keys(&ftl, "Post", "post", &chrome(&["common.create"]));
assert!(out.contains("common.error.nul_character = "), "{out}");
assert!(out.contains("comment.name = Comment"), "{out}");
}
#[test]
fn removing_the_last_resource_takes_the_framework_key_too() {
let nul = (
"common.error.nul_character",
"Cannot contain the NUL character (0x00)",
);
let ftl = merge_en_ftl(
"",
"Post",
"post",
&keys(&[("common.create", "Create"), nul, ("post.name", "Post")]),
);
let out = remove_en_ftl_keys(&ftl, "Post", "post", &HashSet::new());
assert!(!out.contains("common.error.nul_character"), "{out}");
}
#[test]
fn remove_leaves_a_sibling_resource_intact() {
let ftl = merge_en_ftl(
&merge_en_ftl("", "Post", "post", &keys(&[("post.name", "Post")])),
"Comment",
"comment",
&keys(&[("comment.name", "Comment")]),
);
let out = remove_en_ftl_keys(&ftl, "Post", "post", &HashSet::new());
assert!(out.contains("comment.name = Comment"), "{out}");
assert!(!out.contains("post.name"), "{out}");
}
#[test]
fn config_block_is_added_once() {
let once = ensure_i18n_config_block("[server]\nport = 3000\n");
assert!(once.contains("[i18n]"), "{once}");
assert!(once.contains("default_locale = \"en\""), "{once}");
assert_eq!(ensure_i18n_config_block(&once), once);
}
#[test]
fn config_block_respects_an_existing_i18n_section() {
let existing = "[i18n]\ndefault_locale = \"fr\"\n";
assert_eq!(ensure_i18n_config_block(existing), existing);
}
#[test]
fn i18n_auto_is_inserted_before_the_routes_call() {
let main_rs = "fn main() {\n autumn_web::app()\n .routes(routes![index])\n .run();\n}\n";
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected an anchor");
};
assert!(
out.contains(" .i18n_auto()\n .routes(routes![index])"),
"{out}"
);
assert_eq!(
ensure_i18n_auto(&out),
I18nAutoWiring::Wired(out.clone()),
"must be idempotent"
);
}
#[test]
fn i18n_auto_reports_a_hand_installed_bundle_rather_than_claiming_success() {
let main_rs = "fn main() {\n autumn_web::app()\n .i18n(my_bundle())\n .routes(routes![index]);\n}\n";
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::CustomBundle);
}
#[test]
fn i18n_auto_ignores_a_routes_call_inside_a_comment() {
let main_rs = concat!(
"// Register new endpoints by adding them to the .routes(routes![...]) list.\n",
"fn main() {\n",
" autumn_web::app()\n",
" .routes(routes![index])\n",
" .run();\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected the real anchor to be found");
};
assert!(
out.contains(" .i18n_auto()\n .routes(routes![index])"),
"must anchor on the real builder call:\n{out}"
);
assert!(
out.lines().next().unwrap().ends_with("list."),
"the comment must be left alone:\n{out}"
);
}
#[test]
fn i18n_auto_ignores_a_mention_inside_a_doc_comment() {
let main_rs = concat!(
"/// Call `.i18n_auto()` to load translations.\n",
"fn main() {\n",
" autumn_web::app()\n",
" .routes(routes![index]);\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected an anchor");
};
assert!(
out.contains(" .i18n_auto()\n .routes(routes![index])"),
"{out}"
);
}
#[test]
fn i18n_auto_targets_the_builder_the_binary_runs() {
let main_rs = concat!(
"fn preview_app() -> App {\n",
" autumn_web::app()\n",
" .routes(routes![preview])\n",
"}\n",
"\n",
"#[autumn_web::main]\n",
"async fn main() {\n",
" autumn_web::app()\n",
" .routes(routes![index])\n",
" .run()\n",
" .await;\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected an anchor");
};
assert!(
out.contains(".i18n_auto()\n .routes(routes![index])"),
"must wire the builder `main` runs:\n{out}"
);
assert!(
!out.contains(".i18n_auto()\n .routes(routes![preview])"),
"must not wire the preview builder:\n{out}"
);
}
#[test]
fn i18n_auto_falls_back_to_the_only_builder_when_it_precedes_main() {
let main_rs = concat!(
"fn build_app() -> App {\n",
" autumn_web::app()\n",
" .routes(routes![index])\n",
"}\n",
"\n",
"#[autumn_web::main]\n",
"async fn main() {\n",
" build_app().run().await;\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected an anchor");
};
assert!(out.contains(".i18n_auto()"), "{out}");
}
#[test]
fn locales_are_embedded_for_embed_builds() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"fn main() {\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
assert!(out.contains("static EMBEDDED_LOCALES"), "{out}");
assert!(out.contains("autumn_web::embed_locales!()"), "{out}");
assert!(
out.contains("app.embedded_locales(&EMBEDDED_LOCALES)"),
"{out}"
);
assert_eq!(
out.matches("#[cfg(feature = \"embed-assets\")]").count(),
2,
"both edits stay behind the feature the template already gates on:\n{out}"
);
assert_eq!(
ensure_embedded_locales(&out, "i18n").as_deref(),
Some(out.as_str())
);
let custom = ensure_embedded_locales(main_rs, "translations").unwrap();
assert!(
custom.contains("embed_locales!(\"translations\")"),
"{custom}"
);
assert_eq!(ensure_embedded_locales("fn main() {}\n", "i18n"), None);
}
#[test]
fn i18n_auto_reports_no_anchor_rather_than_guessing() {
let main_rs = "fn main() {\n println!(\"no builder here\");\n}\n";
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::NoAnchor);
}
#[test]
fn a_file_that_fails_to_parse_is_left_alone() {
let broken = "fn main( {\n app.routes(routes![index]);\n";
assert_eq!(ensure_i18n_auto(broken), I18nAutoWiring::NoAnchor);
assert_eq!(ensure_embedded_locales(broken, "i18n"), None);
}
#[test]
fn config_block_detects_a_spaced_section_header() {
let existing = "[ i18n ]\ndefault_locale = \"fr\"\n";
assert_eq!(ensure_i18n_config_block(existing), existing);
}
#[test]
fn the_configured_locale_and_directory_are_read_from_the_config() {
let (locale, dir) = configured_i18n("[i18n]\ndefault_locale = \"fr\"\n");
assert_eq!(locale.as_deref(), Some("fr"));
assert_eq!(dir, None);
let (locale, dir) = configured_i18n(
"[ i18n ] # locale settings\ndefault_locale = 'pt-BR' # ours\ndir = \"translations\"\n",
);
assert_eq!(locale.as_deref(), Some("pt-BR"));
assert_eq!(dir.as_deref(), Some("translations"));
let (_, dir) = configured_i18n("[i18n]\ndir = \"i18n#v2\"\n");
assert_eq!(dir.as_deref(), Some("i18n#v2"));
assert_eq!(
configured_i18n("[server]\ndefault_locale = \"fr\"\ndir = \"x\"\n"),
(None, None)
);
assert_eq!(configured_i18n("[server]\nport = 3000\n"), (None, None));
}
#[test]
fn profile_overlays_that_repoint_i18n_are_reported() {
let toml = concat!(
"[i18n]\ndefault_locale = \"en\"\n\n",
"[profile.staging.i18n]\ndefault_locale = \"en\"\n\n",
"[profile.prod.i18n]\ndir = \"translations\"\ndefault_locale = \"fr\"\n",
);
let out = profile_i18n_overrides(toml, "i18n", "en");
assert_eq!(
out,
vec![("prod".to_owned(), "translations/fr.ftl".to_owned())]
);
assert!(
profile_i18n_overrides("[profile.prod.server]\nport = 80\n", "i18n", "en").is_empty()
);
}
#[test]
fn config_block_detects_the_inline_and_dotted_spellings() {
for existing in [
"i18n = { default_locale = \"fr\", supported_locales = [\"fr\"] }\n",
"i18n.default_locale = \"fr\"\n",
"[i18n]\ndefault_locale = \"fr\"\n",
] {
assert_eq!(
ensure_i18n_config_block(existing),
existing,
"must not append a second definition to: {existing}"
);
}
let scoped = "[server]\ni18n.enabled = true\n";
assert!(ensure_i18n_config_block(scoped).contains("[i18n]"));
}
#[test]
fn the_configured_values_are_read_from_the_inline_and_dotted_spellings() {
let (locale, dir) =
configured_i18n("i18n = { default_locale = \"fr\", dir = \"translations\" }\n");
assert_eq!(locale.as_deref(), Some("fr"));
assert_eq!(dir.as_deref(), Some("translations"));
let (locale, dir) = configured_i18n("i18n.default_locale = \"pt-BR\"\ni18n.dir = 'tr'\n");
assert_eq!(locale.as_deref(), Some("pt-BR"));
assert_eq!(dir.as_deref(), Some("tr"));
}
#[test]
fn config_block_detects_a_header_with_a_trailing_comment() {
let existing = "[i18n] # locale settings\ndefault_locale = \"fr\"\n";
assert_eq!(ensure_i18n_config_block(existing), existing);
}
#[test]
fn removal_leaves_hand_authored_keys_on_the_same_prefix_alone() {
let generated = merge_en_ftl("", "Post", "post", &keys(&[("post.new", "New Post")]));
let with_hand_authored = format!("{generated}\n# App copy\npost.email.subject = Welcome\n");
let out = remove_en_ftl_keys(&with_hand_authored, "Post", "post", &HashSet::new());
assert!(
out.contains("post.email.subject = Welcome"),
"a hand-authored key must survive destroy:\n{out}"
);
assert!(!out.contains("post.new"), "{out}");
}
#[test]
fn regeneration_does_not_redefine_a_key_authored_above_the_block() {
let generated = merge_en_ftl("", "Post", "post", &keys(&[("post.new", "New Post")]));
let with_hand_authored = format!("post.field.body = Corps du texte\n\n{generated}");
let out = merge_en_ftl(
&with_hand_authored,
"Post",
"post",
&keys(&[("post.new", "New Post"), ("post.field.body", "Body")]),
);
assert_eq!(
out.matches("post.field.body =").count(),
1,
"the key must be defined exactly once — a second, later definition \
wins in `parse_ftl` and would replace the translation:\n{out}"
);
assert!(
out.contains("post.field.body = Corps du texte"),
"the hand-authored value must survive:\n{out}"
);
assert!(
!out.contains("post.field.body = Body"),
"the generated English must not be written at all:\n{out}"
);
}
#[test]
fn a_comment_inside_a_value_does_not_truncate_it_on_regeneration() {
let wanted = keys(&[("post.help", "First"), ("post.new", "New Post")]);
let generated = merge_en_ftl("", "Post", "post", &wanted);
let with_note = generated.replace(
"post.help = First\n",
"post.help = First\n# translator note\n second\n",
);
assert!(with_note.contains("second"), "fixture: {with_note}");
let out = merge_en_ftl(&with_note, "Post", "post", &wanted);
assert!(
out.contains("second"),
"the continuation belongs to `post.help` and must survive:\n{out}"
);
assert!(out.contains("# translator note"), "{out}");
}
#[test]
fn a_comment_containing_an_equals_sign_is_not_pruned_as_a_key() {
let note = "# careful: post.legacy = Old Post is served elsewhere";
let wanted = keys(&[("post.new", "New Post"), ("post.help", "First")]);
let generated = merge_en_ftl("", "Post", "post", &wanted);
let with_note = generated.replace(
"post.new = New Post\n",
&format!("post.new = New Post\n{note}\n"),
);
assert!(with_note.contains(note), "fixture: {with_note}");
let out = merge_en_ftl(&with_note, "Post", "post", &wanted);
assert!(
out.contains(note),
"a translator's note must survive regeneration:\n{out}"
);
}
#[test]
fn the_anchor_stays_inside_mains_body() {
let main_rs = concat!(
"fn build_app() -> App {\n",
" App::new()\n",
" .routes(routes![index])\n",
"}\n",
"\n",
"fn main() {\n",
" build_app().serve();\n",
"}\n",
"\n",
"#[cfg(test)]\n",
"fn test_app() -> App {\n",
" App::new()\n",
" .routes(routes![fixture])\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected a wiring");
};
let real_at = out.find("routes![index]").unwrap();
let test_at = out.find("routes![fixture]").unwrap();
let call_at = out.find(".i18n_auto()").expect("inserted");
assert!(
call_at < real_at,
"`main` has no builder of its own, so the only real one wins — \
never the `#[cfg(test)]` one:\n{out}"
);
assert!(call_at < test_at, "{out}");
}
#[test]
fn a_char_literal_does_not_break_the_body_bounds() {
let main_rs = concat!(
"fn preview() -> App {\n",
" App::new()\n",
" .routes(routes![preview])\n",
"}\n",
"\n",
"fn main() {\n",
" let open = '{';\n",
" let quote = '\"';\n",
" let tick = '\\'';\n",
" let app = App::new()\n",
" .routes(routes![index]);\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected a wiring");
};
let preview_at = out.find("routes![preview]").unwrap();
let call_at = out.find(".i18n_auto()").expect("inserted");
assert!(
call_at > preview_at,
"the builder in `main` is still the one that runs:\n{out}"
);
}
#[test]
fn every_spelling_of_a_profile_override_is_reported() {
for src in [
"[profile.prod.i18n]\ndir = \"translations\"\ndefault_locale = \"fr\"\n",
"[profile.prod]\ni18n = { dir = \"translations\", default_locale = \"fr\" }\n",
"[profile.prod]\ni18n.dir = \"translations\"\ni18n.default_locale = \"fr\"\n",
"profile.prod.i18n.dir = \"translations\"\nprofile.prod.i18n.default_locale = \"fr\"\n",
"[\"profile\".\"prod\".\"i18n\"]\ndir = \"translations\"\ndefault_locale = \"fr\"\n",
] {
assert_eq!(
profile_i18n_overrides(src, "i18n", "en"),
vec![("prod".to_owned(), "translations/fr.ftl".to_owned())],
"for {src:?}"
);
}
assert!(
profile_i18n_overrides("[profile.prod.i18n]\ndir = \"i18n\"\n", "i18n", "en")
.is_empty()
);
}
#[test]
fn a_stale_json_form_copy_is_still_recognised() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"COPY migrations ./migrations\n",
"COPY [\"translations v2\", \"./translations v2\"]\n",
"\n",
"FROM debian AS runtime\n",
"COPY --from=builder /app/migrations /app/migrations\n",
"COPY --from=builder [\"/app/translations v2\", \"/app/translations v2\"]\n",
);
assert_eq!(
stale_dockerfile_i18n_dir(dockerfile, "i18n").as_deref(),
Some("translations v2")
);
assert_eq!(
stale_dockerfile_i18n_dir(dockerfile, "translations v2"),
None
);
}
#[test]
fn every_production_branch_inside_main_is_wired() {
let main_rs = concat!(
"fn main() {\n",
" let app = if cfg!(feature = \"admin\") {\n",
" App::new()\n",
" .routes(routes![admin]);\n",
" } else {\n",
" App::new()\n",
" .routes(routes![index]);\n",
" };\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected a wiring");
};
assert_eq!(
out.matches(".i18n_auto()").count(),
2,
"both branches must be wired:\n{out}"
);
assert_eq!(ensure_i18n_auto(&out), I18nAutoWiring::Wired(out.clone()));
}
#[test]
fn a_custom_bundle_in_one_branch_does_not_block_the_other() {
let main_rs = concat!(
"fn main() {\n",
" let app = if flag {\n",
" App::new()\n",
" .i18n(tms_bundle())\n",
" .routes(routes![admin]);\n",
" } else {\n",
" App::new()\n",
" .routes(routes![index]);\n",
" };\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("the plain branch must still be wired");
};
assert_eq!(
out.matches(".i18n_auto()").count(),
1,
"exactly the unconfigured chain gets it:\n{out}"
);
assert!(out.contains(".i18n(tms_bundle())"), "{out}");
let custom_at = out.find(".i18n(tms_bundle())").unwrap();
let auto_at = out.find(".i18n_auto()").unwrap();
assert!(auto_at > custom_at, "wired the wrong chain:\n{out}");
}
#[test]
fn a_sole_custom_bundled_chain_is_still_reported() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new()\n",
" .i18n(my_bundle())\n",
" .routes(routes![index]);\n",
"}\n",
);
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::CustomBundle);
}
#[test]
fn a_partly_wired_main_is_completed() {
let main_rs = concat!(
"fn main() {\n",
" let app = if flag {\n",
" App::new()\n",
" .i18n_auto()\n",
" .routes(routes![admin]);\n",
" } else {\n",
" App::new()\n",
" .routes(routes![index]);\n",
" };\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected a wiring");
};
assert_eq!(
out.matches(".i18n_auto()").count(),
2,
"the unwired branch must be completed:\n{out}"
);
}
#[test]
fn a_cfg_test_mention_does_not_count_as_embedded_locales() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"fn main() {\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
"\n",
"#[cfg(test)]\n",
"mod tests {\n",
" static EMBEDDED_LOCALES: Dir = autumn_web::embed_locales!(\"fixtures\");\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
assert!(
out.contains("#[cfg(feature = \"embed-assets\")]"),
"production embedding must still be added:\n{out}"
);
assert!(
out.contains(".embedded_locales(&EMBEDDED_LOCALES)"),
"{out}"
);
}
#[test]
fn an_ambiguous_delegating_main_is_refused_rather_than_guessed() {
let main_rs = concat!(
"fn preview() -> App {\n",
" App::new().routes(routes![preview])\n",
"}\n",
"\n",
"fn build_app() -> App {\n",
" App::new().routes(routes![index])\n",
"}\n",
"\n",
"fn main() {\n",
" build_app().serve();\n",
"}\n",
);
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::NoAnchor);
}
#[test]
fn a_cfg_test_builder_is_not_a_candidate() {
let main_rs = concat!(
"fn build_app() -> App {\n",
" App::new().routes(routes![index])\n",
"}\n",
"\n",
"fn main() {\n",
" build_app().serve();\n",
"}\n",
"\n",
"#[cfg(test)]\n",
"mod tests {\n",
" fn test_app() -> App {\n",
" App::new().routes(routes![fixture])\n",
" }\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected a wiring");
};
let real_at = out.find("routes![index]").unwrap();
let fixture_at = out.find("routes![fixture]").unwrap();
let call_at = out.find(".i18n_auto()").expect("inserted");
assert!(call_at < real_at && call_at < fixture_at, "{out}");
}
#[test]
fn a_custom_bundle_on_a_test_builder_does_not_block_production() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new()\n",
" .routes(routes![index]);\n",
"}\n",
"\n",
"#[cfg(test)]\n",
"mod tests {\n",
" fn test_app() -> App {\n",
" App::new().i18n(test_bundle()).routes(routes![fixture])\n",
" }\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected production to be wired, not reported as custom-bundled");
};
assert!(out.contains(".i18n_auto()"), "{out}");
}
#[test]
fn a_custom_bundle_is_seen_through_whitespace_and_comments() {
for call in [
".i18n(my_bundle())",
".i18n (my_bundle())",
".i18n\n (my_bundle())",
".i18n /* keep ours */ (my_bundle())",
] {
let main_rs = format!(
"fn main() {{\n let app = App::new()\n {call}\n .routes(routes![index]);\n}}\n"
);
assert_eq!(
ensure_i18n_auto(&main_rs),
I18nAutoWiring::CustomBundle,
"for {call:?}"
);
}
let wired = "fn main() {\n let app = App::new()\n .i18n_auto()\n .routes(routes![index]);\n}\n";
assert!(matches!(ensure_i18n_auto(wired), I18nAutoWiring::Wired(_)));
}
#[test]
fn a_cfg_test_static_is_scoped_to_its_own_semicolon() {
let main_rs = concat!(
"#[cfg(test)]\n",
"static EMBEDDED_LOCALES: Dir = autumn_web::embed_locales!(\"fixtures\");\n",
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"fn main() {\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
assert!(
out.contains("#[cfg(feature = \"embed-assets\")]"),
"production embedding must be added:\n{out}"
);
}
#[test]
fn method_detections_all_tolerate_spacing() {
for call in [".routes(routes![index])", ".routes (routes![index])"] {
let main_rs = format!("fn main() {{\n let app = App::new()\n {call};\n}}\n");
assert!(
matches!(ensure_i18n_auto(&main_rs), I18nAutoWiring::Wired(out) if out.contains(".i18n_auto()")),
"no anchor found for {call:?}"
);
}
let spaced = concat!(
"fn main() {\n",
" let app = App::new()\n",
" .i18n_auto ()\n",
" .routes(routes![index]);\n",
"}\n",
);
assert_eq!(
ensure_i18n_auto(spaced),
I18nAutoWiring::Wired(spaced.to_owned()),
"a spaced `.i18n_auto ()` must count as wired"
);
let embedded = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"#[cfg(feature = \"embed-assets\")]\n",
"static EMBEDDED_LOCALES: autumn_web::include_dir::Dir = autumn_web::embed_locales!();\n",
"fn main() {\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
" let app = app.embedded_locales (&EMBEDDED_LOCALES);\n",
"}\n",
);
let out = ensure_embedded_locales(embedded, "i18n").expect("anchors present");
assert_eq!(
out.matches("embedded_locales").count(),
embedded.matches("embedded_locales").count(),
"a spaced install must not be duplicated:\n{out}"
);
}
#[test]
fn an_install_in_a_helper_does_not_count_as_production() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"#[cfg(feature = \"embed-assets\")]\n",
"static EMBEDDED_LOCALES: autumn_web::include_dir::Dir = autumn_web::embed_locales!();\n",
"\n",
"fn preview() -> App {\n",
" let app = App::new().routes(routes![preview]);\n",
" let app = app.embedded_locales(&EMBEDDED_LOCALES);\n",
" app\n",
"}\n",
"\n",
"fn main() {\n",
" let app = App::new().routes(routes![index]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
assert!(
out.matches(".embedded_locales(&EMBEDDED_LOCALES)").count() >= 2,
"production must get its own install, not inherit the helper's:\n{out}"
);
}
#[test]
fn the_validator_bundle_follows_the_macros_own_environment() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("i18n")).unwrap();
assert_eq!(validator_bundle_path(root, &MacroEnv::default()), None);
std::fs::write(root.join("i18n").join("en.ftl"), "a = A\n").unwrap();
assert_eq!(
validator_bundle_path(root, &MacroEnv::default()),
Some(root.join("i18n").join("en.ftl"))
);
let fr = MacroEnv {
file: None,
default_locale: Some("fr".to_owned()),
};
assert_eq!(
validator_bundle_path(root, &fr),
None,
"the macro degrades to a runtime lookup when its bundle is absent"
);
std::fs::write(root.join("i18n").join("fr.ftl"), "a = A\n").unwrap();
assert_eq!(
validator_bundle_path(root, &fr),
Some(root.join("i18n").join("fr.ftl")),
"not `en.ftl`, which the macro would never read here"
);
std::fs::write(root.join("custom.ftl"), "a = A\n").unwrap();
let explicit = MacroEnv {
file: Some("custom.ftl".to_owned()),
default_locale: Some("fr".to_owned()),
};
assert_eq!(
validator_bundle_path(root, &explicit),
Some(root.join("custom.ftl"))
);
}
#[test]
fn an_absent_explicit_bundle_falls_through_to_the_locale_candidate() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("i18n")).unwrap();
std::fs::write(root.join("i18n").join("fr.ftl"), "a = A\n").unwrap();
let env = MacroEnv {
file: Some("gone.ftl".to_owned()),
default_locale: Some("fr".to_owned()),
};
assert_eq!(
validator_bundle_path(root, &env),
Some(root.join("i18n").join("fr.ftl"))
);
let nothing = MacroEnv {
file: Some("gone.ftl".to_owned()),
default_locale: Some("de".to_owned()),
};
assert_eq!(validator_bundle_path(root, ¬hing), None);
}
#[test]
fn cargo_config_env_reads_both_entry_forms() {
let root = Path::new("/project");
let config = concat!(
"[env]\n",
"AUTUMN_I18N_DEFAULT_LOCALE = \"fr\"\n",
"AUTUMN_I18N_FILE = { value = \"bundles/app.ftl\", relative = true }\n",
"FORCED = { value = \"yes\", force = true }\n",
);
let env = cargo_config_env(config, root);
assert_eq!(
env.get("AUTUMN_I18N_DEFAULT_LOCALE"),
Some(&("fr".to_owned(), false))
);
assert_eq!(
env.get("AUTUMN_I18N_FILE"),
Some(&(
root.join("bundles/app.ftl").to_string_lossy().into_owned(),
false
)),
"`relative` resolves against the directory holding `.cargo`"
);
assert_eq!(env.get("FORCED"), Some(&("yes".to_owned(), true)));
assert!(cargo_config_env("not = [toml", root).is_empty());
assert!(cargo_config_env("[build]\njobs = 2\n", root).is_empty());
}
#[test]
fn the_cargo_config_chain_is_walked_nearest_first() {
let tmp = tempfile::TempDir::new().unwrap();
let workspace = tmp.path();
let app = workspace.join("apps").join("web");
std::fs::create_dir_all(app.join(".cargo")).unwrap();
std::fs::create_dir_all(workspace.join(".cargo")).unwrap();
std::fs::write(
workspace.join(".cargo").join("config.toml"),
"[env]\nAUTUMN_I18N_DEFAULT_LOCALE = \"de\"\n\
AUTUMN_I18N_FILE = { value = \"root.ftl\", relative = true }\n",
)
.unwrap();
let files = cargo_config_files(&app);
assert!(
files.iter().any(|(base, path)| base == workspace
&& path == &workspace.join(".cargo").join("config.toml")),
"the workspace config applies to the app: {files:?}"
);
std::fs::write(
app.join(".cargo").join("config.toml"),
"[env]\nAUTUMN_I18N_DEFAULT_LOCALE = \"es\"\n",
)
.unwrap();
let mut merged: BTreeMap<String, (String, bool)> = BTreeMap::new();
for (base, path) in cargo_config_files(&app) {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
for (name, entry) in cargo_config_env(&text, &base) {
merged.entry(name).or_insert(entry);
}
}
assert_eq!(
merged.get("AUTUMN_I18N_DEFAULT_LOCALE"),
Some(&("es".to_owned(), false)),
"nearest wins"
);
assert_eq!(
merged.get("AUTUMN_I18N_FILE"),
Some(&(
workspace.join("root.ftl").to_string_lossy().into_owned(),
false
)),
"an unshadowed entry still comes through, and a `relative` one \
resolves against ITS OWN base — the workspace, not the app"
);
}
#[test]
fn a_cargo_config_entry_yields_to_the_process_unless_forced() {
let plain = ("config".to_owned(), false);
let forced = ("config".to_owned(), true);
assert_eq!(
layer_cargo_env(Some(&plain), None),
Some("config".to_owned())
);
assert_eq!(
layer_cargo_env(Some(&plain), Some("process".to_owned())),
Some("process".to_owned())
);
assert_eq!(
layer_cargo_env(Some(&forced), Some("process".to_owned())),
Some("config".to_owned())
);
assert_eq!(
layer_cargo_env(None, Some("process".to_owned())),
Some("process".to_owned())
);
assert_eq!(layer_cargo_env(None, None), None);
}
#[test]
fn a_longer_identifier_is_not_this_static() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"#[cfg(feature = \"embed-assets\")]\n",
"static ADMIN_EMBEDDED_LOCALES: autumn_web::include_dir::Dir =\n",
" autumn_web::embed_locales!(\"admin\");\n",
"\n",
"fn main() {\n",
" let app = App::new().routes(routes![index]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
assert!(
out.contains("static EMBEDDED_LOCALES:"),
"this static must be declared, not assumed:\n{out}"
);
assert!(
out.contains("autumn_web::embed_locales!(\"admin\")"),
"the unrelated bundle must keep its own directory:\n{out}"
);
}
#[test]
fn only_this_statics_own_macro_is_repointed() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"#[cfg(feature = \"embed-assets\")]\n",
"static ADMIN_EMBEDDED_LOCALES: autumn_web::include_dir::Dir =\n",
" autumn_web::embed_locales!(\"admin\");\n",
"#[cfg(feature = \"embed-assets\")]\n",
"static EMBEDDED_LOCALES: autumn_web::include_dir::Dir =\n",
" autumn_web::embed_locales!(\"old\");\n",
"\n",
"fn main() {\n",
" let app = App::new().routes(routes![index]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
" let app = app.embedded_locales(&EMBEDDED_LOCALES);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "translations").expect("already embedded");
assert!(
out.contains("autumn_web::embed_locales!(\"admin\")"),
"the neighbour is not this static's to rewrite:\n{out}"
);
assert!(
out.contains("autumn_web::embed_locales!(\"translations\")"),
"this static must follow the configured directory:\n{out}"
);
assert!(
!out.contains("\"old\""),
"the stale directory must go:\n{out}"
);
}
#[test]
fn a_nested_block_comment_does_not_hide_a_bundle() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new()\n",
" .i18n /* outer /* nested */ tail */ (tms_bundle())\n",
" .routes(routes![index]);\n",
"}\n",
);
assert_eq!(
ensure_i18n_auto(main_rs),
I18nAutoWiring::CustomBundle,
"the commented-through `.i18n(...)` is still a call"
);
}
#[test]
fn the_install_is_written_into_the_production_builder() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"\n",
"fn preview() -> App {\n",
" let app = App::new().routes(routes![preview]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
" app\n",
"}\n",
"\n",
"fn main() {\n",
" let app = App::new().routes(routes![index]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
let main_at = out.find("fn main").expect("`fn main` survives");
let install_at = out
.find(".embedded_locales(&EMBEDDED_LOCALES)")
.expect("the install is written");
assert!(
install_at > main_at,
"the install belongs to the production builder, not the helper:\n{out}"
);
assert_eq!(
out.matches(".embedded_locales(&EMBEDDED_LOCALES)").count(),
1,
"exactly one install:\n{out}"
);
}
#[test]
fn a_static_without_its_install_is_not_complete() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"#[cfg(feature = \"embed-assets\")]\n",
"static EMBEDDED_LOCALES: autumn_web::include_dir::Dir = autumn_web::embed_locales!();\n",
"fn main() {\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
assert!(
out.contains(".embedded_locales(&EMBEDDED_LOCALES)"),
"the missing install must be added:\n{out}"
);
assert_eq!(
ensure_embedded_locales(&out, "i18n").as_deref(),
Some(out.as_str()),
"must be idempotent"
);
}
#[test]
fn a_nested_block_does_not_truncate_the_chain() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new()\n",
" .i18n(tms_bundle())\n",
" .layer(from_fn(|req, next| async move { next.run(req).await }))\n",
" .routes(routes![index]);\n",
"}\n",
);
assert_eq!(
ensure_i18n_auto(main_rs),
I18nAutoWiring::CustomBundle,
"the earlier `.i18n(...)` must still be visible past the closure"
);
}
#[test]
fn a_bundle_installed_by_an_earlier_rebinding_is_still_found() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new();\n",
" let app = app.i18n(tms_bundle());\n",
" let app = app.routes(routes![index]);\n",
" app.serve();\n",
"}\n",
);
assert_eq!(
ensure_i18n_auto(main_rs),
I18nAutoWiring::CustomBundle,
"rebinding the same `app` is one builder, not two unrelated chains"
);
}
#[test]
fn a_bundle_inherited_from_an_enclosing_statement_is_still_found() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new().i18n(tms_bundle());\n",
" let app = if flag {\n",
" app.routes(routes![admin])\n",
" } else {\n",
" app.routes(routes![index])\n",
" };\n",
" app.serve();\n",
"}\n",
);
assert_eq!(
ensure_i18n_auto(main_rs),
I18nAutoWiring::CustomBundle,
"every branch continues the app the bundle was installed on"
);
}
#[test]
fn a_sibling_branchs_bundle_is_still_not_this_branchs() {
let main_rs = concat!(
"fn main() {\n",
" let app = if flag {\n",
" App::new().i18n(tms_bundle()).routes(routes![admin])\n",
" } else {\n",
" App::new().routes(routes![index])\n",
" };\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("the plain branch must still be wired");
};
assert_eq!(
out.matches(".i18n_auto()").count(),
1,
"exactly the unconfigured branch gets it:\n{out}"
);
let custom_at = out.find(".i18n(tms_bundle())").unwrap();
let auto_at = out.find(".i18n_auto()").unwrap();
assert!(auto_at > custom_at, "wired the wrong branch:\n{out}");
}
#[test]
fn an_inherited_auto_wiring_is_not_repeated() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new().i18n_auto();\n",
" let app = app.routes(routes![index]);\n",
" app.serve();\n",
"}\n",
);
assert_eq!(
ensure_i18n_auto(main_rs),
I18nAutoWiring::Wired(main_rs.to_owned()),
"the app already loads its bundle; nothing to add"
);
}
#[test]
fn a_call_on_another_binding_does_not_speak_for_this_app() {
let wired = concat!(
"fn main() {\n",
" let preview = App::new();\n",
" let preview = preview.i18n_auto();\n",
" let app = App::new();\n",
" let app = app.routes(routes![index]);\n",
" app.serve();\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(wired) else {
panic!("production is not wired by the preview builder");
};
assert_eq!(
out.matches(".i18n_auto()").count(),
2,
"production needs its own:\n{out}"
);
let bundled = wired.replace(".i18n_auto()", ".i18n(tms_bundle())");
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(&bundled) else {
panic!("the preview's bundle is not production's either");
};
assert!(out.contains(".i18n_auto()"), "{out}");
}
#[test]
fn a_bundle_installed_by_a_mutable_reassignment_is_still_found() {
let main_rs = concat!(
"fn main() {\n",
" let mut app = build_app();\n",
" app = app.i18n(tms_bundle());\n",
" app = app.routes(routes![index]);\n",
" app.serve();\n",
"}\n",
);
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::CustomBundle);
}
#[test]
fn a_bundle_installed_in_one_branch_of_an_initialiser_is_still_found() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new();\n",
" let app = if use_tms { app.i18n(tms_bundle()) } else { app };\n",
" let app = app.routes(routes![index]);\n",
"}\n",
);
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::CustomBundle);
}
#[test]
fn a_bundle_installed_by_a_bare_conditional_reassignment_is_still_found() {
let main_rs = concat!(
"fn main() {\n",
" let mut app = App::new();\n",
" if use_tms {\n",
" app = app.i18n(tms_bundle());\n",
" }\n",
" app = app.routes(routes![index]);\n",
" app.serve();\n",
"}\n",
);
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::CustomBundle);
}
#[test]
fn every_branch_of_an_initialiser_is_followed_back() {
let main_rs = concat!(
"fn main() {\n",
" let plain = App::new();\n",
" let tms = App::new().i18n(tms_bundle());\n",
" let app = if use_tms { tms } else { plain };\n",
" let app = app.routes(routes![index]);\n",
"}\n",
);
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::CustomBundle);
}
#[test]
fn a_call_inside_middleware_is_not_the_apps_own_bundle() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new()\n",
" .layer(from_fn(|req, next| async move { other.i18n(b); next.run(req).await }))\n",
" .routes(routes![index]);\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("middleware internals are not this app's bundle");
};
assert!(out.contains(".i18n_auto()"), "{out}");
}
#[test]
fn every_production_branch_gets_the_locale_install() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"\n",
"fn main() {\n",
" if flag {\n",
" let app = App::new().routes(routes![admin]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
" } else {\n",
" let app = App::new().routes(routes![index]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
" }\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
assert_eq!(
out.matches(".embedded_locales(&EMBEDDED_LOCALES)").count(),
2,
"both branches must install them:\n{out}"
);
assert_eq!(
ensure_embedded_locales(&out, "i18n").as_deref(),
Some(out.as_str()),
"and a re-run adds nothing"
);
}
#[test]
fn an_assignment_to_another_name_does_not_speak_for_this_app() {
let main_rs = concat!(
"fn main() {\n",
" let mut preview = build_app();\n",
" preview = preview.i18n(tms_bundle());\n",
" let mut app = build_app();\n",
" app = app.routes(routes![index]);\n",
" app.serve();\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("the preview's bundle is not production's");
};
assert!(out.contains(".i18n_auto()"), "{out}");
}
#[test]
fn a_bundle_reached_through_several_rebindings_is_still_found() {
let main_rs = concat!(
"fn main() {\n",
" let base = App::new().i18n(tms_bundle());\n",
" let configured = base;\n",
" let app = configured;\n",
" let app = app.routes(routes![index]);\n",
"}\n",
);
assert_eq!(ensure_i18n_auto(main_rs), I18nAutoWiring::CustomBundle);
}
#[test]
fn the_locale_static_is_declared_beside_the_production_one() {
let main_rs = concat!(
"#[cfg(test)]\n",
"mod tests {\n",
" static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"}\n",
"\n",
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"\n",
"fn main() {\n",
" let app = App::new().routes(routes![index]);\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
let mod_end = out
.find("}\n\nstatic EMBEDDED_STATIC")
.expect("test module");
let locales_at = out
.find("static EMBEDDED_LOCALES")
.expect("the static is declared");
assert!(
locales_at > mod_end,
"the locale static must sit outside `#[cfg(test)]`:\n{out}"
);
}
#[test]
fn a_test_fixtures_bundle_does_not_speak_for_production() {
let main_rs = concat!(
"fn main() {\n",
" let app = App::new();\n",
" let app = app.routes(routes![index]);\n",
" app.serve();\n",
"}\n",
"\n",
"#[cfg(test)]\n",
"mod tests {\n",
" fn test_app() -> App {\n",
" App::new().i18n(fixture_bundle()).routes(routes![index])\n",
" }\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("production must still be wired");
};
assert!(out.contains(".i18n_auto()"), "{out}");
}
#[test]
fn a_locale_dir_with_spaces_uses_dockers_json_copy_form() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"COPY migrations ./migrations\n",
"\n",
"FROM debian AS runtime\n",
"COPY --from=builder /app/migrations /app/migrations\n",
);
let out = ensure_dockerfile_i18n_copy(dockerfile, "translations v2").expect("anchors");
assert!(
out.contains(r#"COPY ["translations v2", "./translations v2"]"#),
"{out}"
);
assert!(
out.contains(r#"COPY --from=builder ["/app/translations v2", "/app/translations v2"]"#),
"{out}"
);
let plain = ensure_dockerfile_i18n_copy(dockerfile, "i18n").expect("anchors");
assert!(plain.contains("COPY i18n ./i18n"), "{plain}");
}
#[test]
fn every_toml_spelling_of_the_i18n_table_reads_alike() {
let expected = (Some("fr".to_owned()), Some("translations".to_owned()));
for src in [
"[i18n]\ndefault_locale = \"fr\"\ndir = \"translations\"\n",
"[\"i18n\"]\ndefault_locale = \"fr\"\ndir = \"translations\"\n",
"[i18n]\n\"default_locale\" = \"fr\"\n'dir' = \"translations\"\n",
"i18n = { default_locale = \"fr\", dir = \"translations\" }\n",
"\"i18n\" = { default_locale = \"fr\", dir = \"translations\" }\n",
"i18n.default_locale = \"fr\"\ni18n.dir = \"translations\"\n",
"\"i18n\".default_locale = \"fr\"\n\"i18n\".dir = \"translations\"\n",
"[i18n]\ndefault_locale = \"\\u0066r\"\ndir = \"translations\"\n",
"[i18n]\ndefault_locale = \"fr\" # the default\ndir = \"translations\"\n",
] {
assert_eq!(configured_i18n(src), expected, "for {src:?}");
assert!(defines_i18n(src), "for {src:?}");
}
assert_eq!(
configured_i18n("i18n = { dir = \"translations,v2\", default_locale = \"fr\" }\n").1,
Some("translations,v2".to_owned())
);
assert!(!defines_i18n("[server]\nport = 3000\n"));
assert!(defines_i18n("[i18n]\nthis is not valid toml\n"));
}
#[test]
fn a_builder_inside_main_is_preferred_to_an_earlier_one() {
let main_rs = concat!(
"fn preview() -> App {\n",
" App::new()\n",
" .routes(routes![preview])\n",
"}\n",
"\n",
"fn main() {\n",
" let app = App::new()\n",
" .routes(routes![index]);\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected a wiring");
};
let preview_at = out.find("routes![preview]").unwrap();
let call_at = out.find(".i18n_auto()").expect("inserted");
assert!(
call_at > preview_at,
"the builder in `main` is the one that runs:\n{out}"
);
}
#[test]
fn the_main_anchor_matches_on_an_identifier_boundary() {
let main_rs = concat!(
"fn main_preview() {\n",
" let app = App::new()\n",
" .routes(routes![preview]);\n",
"}\n",
"\n",
"fn main() {\n",
" let app = App::new()\n",
" .routes(routes![index]);\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("expected a wiring");
};
let preview_at = out.find("routes![preview]").unwrap();
let real_at = out.find("routes![index]").unwrap();
let call_at = out.find(".i18n_auto()").expect("inserted");
assert!(
call_at > preview_at && call_at < real_at,
"`.i18n_auto()` must land in `fn main`'s builder, not the preview \
helper's:\n{out}"
);
}
#[test]
fn removal_takes_a_multi_line_value_with_its_key() {
let ftl = concat!(
"common.show = Show\n",
"\n",
"# Comment — generated by `autumn generate scaffold --i18n`.\n",
"comment.new = New Comment\n",
"\n",
"# Post — generated by `autumn generate scaffold --i18n`.\n",
"post.index.title = Post index\n",
" continued here\n",
"post.new = New Post\n",
);
let out = remove_en_ftl_keys(ftl, "Post", "post", &chrome(&["common.show"]));
assert!(
!out.contains("continued here"),
"an orphaned continuation either fails the loader — and `.i18n_auto()` \
turns that into a startup panic — or silently glues onto the key above \
it:\n{out}"
);
assert!(!out.contains("post."), "{out}");
assert!(out.contains("common.show = Show"), "{out}");
assert!(out.contains("comment.new = New Comment"), "{out}");
}
#[test]
fn chrome_is_pruned_per_key_not_all_or_nothing() {
let ftl = merge_en_ftl(
"",
"Post",
"post",
&keys(&[
("common.create", "Create"),
("common.save", "Save"),
("common.trash", "Trash"),
("common.restore", "Restore"),
("post.new", "New Post"),
]),
);
let out = remove_en_ftl_keys(
&ftl,
"Post",
"post",
&chrome(&["common.create", "common.save"]),
);
assert!(out.contains("common.create = Create"), "{out}");
assert!(out.contains("common.save = Save"), "{out}");
assert!(
!out.contains("common.trash") && !out.contains("common.restore"),
"chrome nothing references any more fails `--strict`:\n{out}"
);
assert!(!out.contains("post.new"), "{out}");
}
#[test]
fn a_resource_key_something_still_references_survives_its_block() {
let ftl = merge_en_ftl(
"",
"Post",
"post",
&keys(&[
("common.create", "Create"),
("post.index.title", "Posts"),
("post.new.title", "New Post"),
]),
);
let out = remove_en_ftl_keys(
&ftl,
"Post",
"post",
&chrome(&["common.create", "post.index.title"]),
);
assert!(
out.contains("post.index.title = Posts"),
"a live call site keeps its key:\n{out}"
);
assert!(
!out.contains("post.new.title"),
"keys nothing references still go — `--strict` fails on them:\n{out}"
);
assert!(out.contains("common.create = Create"), "{out}");
}
#[test]
fn a_referenced_key_the_bundle_never_defined_is_not_invented() {
let ftl = merge_en_ftl(
"",
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.index.title", "Posts")]),
);
let out = remove_en_ftl_keys(
&ftl,
"Post",
"post",
&chrome(&["common.create", "post.index.title", "post.never.defined"]),
);
assert!(!out.contains("post.never.defined"), "{out}");
}
#[test]
fn removing_the_last_resource_takes_the_now_unreferenced_chrome_with_it() {
let ftl = merge_en_ftl(
"",
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.new", "New Post")]),
);
let out = remove_en_ftl_keys(&ftl, "Post", "post", &HashSet::new());
assert!(
!out.contains("common.create"),
"chrome nothing references any more fails `i18n check --strict`:\n{out}"
);
let two = merge_en_ftl(
&ftl,
"Comment",
"comment",
&keys(&[("common.create", "Create"), ("comment.new", "New Comment")]),
);
let still = remove_en_ftl_keys(&two, "Post", "post", &chrome(&["common.create"]));
assert!(still.contains("common.create = Create"), "{still}");
assert!(still.contains("comment.new = New Comment"), "{still}");
}
#[test]
fn a_regenerated_key_lands_inside_its_own_block() {
let first = merge_en_ftl("", "Post", "post", &keys(&[("post.new", "New Post")]));
let second = merge_en_ftl(
&first,
"Post",
"post",
&keys(&[("post.new", "New Post"), ("post.field.body", "Body")]),
);
let header_at = second.find("Post — generated by").expect("header");
let key_at = second.find("post.field.body").expect("new key");
assert!(
key_at > header_at,
"the new key must sit under the header:\n{second}"
);
let footer_at = second.find("# — end Post —").expect("footer");
assert!(
key_at < footer_at,
"the new key must sit INSIDE the block:\n{second}"
);
}
#[test]
fn regeneration_prunes_keys_whose_surfaces_are_gone() {
let before = merge_en_ftl(
"",
"Post",
"post",
&keys(&[
("common.create", "Create"),
("post.new", "New Post"),
("post.field.body", "Body"),
("post.search.title", "Search Posts"),
]),
);
let edited = before
.replace("post.new = New Post", "post.new = Nouvel article")
.replace(
"post.field.body = Body\n",
"# on the edit form\npost.field.body = Body\n",
);
let after = merge_en_ftl(
&edited,
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.new", "New Post")]),
);
assert!(!after.contains("post.field.body"), "{after}");
assert!(!after.contains("post.search.title"), "{after}");
assert!(
after.contains("post.new = Nouvel article"),
"a translated value must survive:\n{after}"
);
assert!(
after.contains("# on the edit form"),
"a translator's note must survive:\n{after}"
);
assert!(after.contains("common.create = Create"), "{after}");
assert_eq!(
merge_en_ftl(
&after,
"Post",
"post",
&keys(&[("common.create", "Create"), ("post.new", "New Post")])
),
after
);
}
#[test]
fn regeneration_drops_continuation_lines_with_their_pruned_key() {
let before = merge_en_ftl(
"",
"Post",
"post",
&keys(&[("post.new", "New Post"), ("post.field.body", "Body")]),
);
let edited = before
.replace(
"post.field.body = Body\n",
"post.field.body = Body\n spilling over onto a second line\n",
)
.replace(
"post.new = New Post\n",
"post.new = New Post\n also wrapped\n",
);
let after = merge_en_ftl(&edited, "Post", "post", &keys(&[("post.new", "New Post")]));
assert!(!after.contains("post.field.body"), "{after}");
assert!(
!after.contains("spilling over"),
"the pruned key's continuation must go with it:\n{after}"
);
assert!(
after.contains("also wrapped"),
"a surviving key keeps its own continuation:\n{after}"
);
}
#[test]
fn an_existing_embed_is_repointed_when_the_bundle_directory_changes() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"fn main() {\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let embedded = ensure_embedded_locales(main_rs, "i18n").expect("anchors present");
let moved = ensure_embedded_locales(&embedded, "translations").expect("already embedded");
assert!(
moved.contains("embed_locales!(\"translations\")"),
"{moved}"
);
assert!(!moved.contains("embed_locales!()"), "{moved}");
assert_eq!(
moved.matches("EMBEDDED_LOCALES").count(),
2,
"repointing must not duplicate the wiring:\n{moved}"
);
let back = ensure_embedded_locales(&moved, "i18n").expect("already embedded");
assert!(back.contains("embed_locales!()"), "{back}");
assert!(!back.contains("translations"), "{back}");
}
#[test]
fn a_comment_or_blank_line_inside_a_block_does_not_truncate_it() {
let ftl = merge_en_ftl(
"",
"Post",
"post",
&keys(&[("post.new", "New Post"), ("post.index.title", "Post index")]),
);
let edited = ftl.replace(
"post.index.title = Post index\n",
"\n# careful: this one is on a button\npost.index.title = Post index\n",
);
assert_ne!(edited, ftl, "fixture must actually insert the separator");
let out = remove_en_ftl_keys(&edited, "Post", "post", &HashSet::new());
assert!(
!out.contains("post.index.title"),
"a key below the separator is still the generator's:\n{out}"
);
assert!(!out.contains("post.new"), "{out}");
assert!(!out.contains("# — end Post —"), "{out}");
}
#[test]
fn a_block_without_a_closing_marker_still_round_trips() {
let legacy = concat!(
"# Post — generated by `autumn generate scaffold --i18n`.\n",
"post.new = New Post\n",
"\n",
"# App copy\n",
"post.email.subject = Welcome\n",
);
let out = remove_en_ftl_keys(legacy, "Post", "post", &chrome(&["common.create"]));
assert!(!out.contains("post.new"), "{out}");
assert!(out.contains("post.email.subject = Welcome"), "{out}");
}
#[test]
fn a_moved_locale_dir_is_reported_against_the_dockerfile() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"COPY migrations ./migrations\n",
"COPY i18n ./i18n\n",
"\n",
"FROM debian AS runtime\n",
"COPY --from=builder /app/migrations /app/migrations\n",
"COPY --from=builder /app/i18n /app/i18n\n",
);
assert_eq!(
stale_dockerfile_i18n_dir(dockerfile, "translations").as_deref(),
Some("i18n")
);
assert_eq!(stale_dockerfile_i18n_dir(dockerfile, "i18n"), None);
assert_eq!(
stale_dockerfile_i18n_dir(
"FROM rust AS builder\nCOPY migrations ./migrations\n",
"i18n"
),
None
);
}
#[test]
fn a_commented_copy_is_not_a_wired_dockerfile() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"# COPY i18n ./i18n\n",
"COPY migrations ./migrations\n",
"\n",
"FROM debian AS runtime\n",
"# COPY --from=builder /app/i18n /app/i18n\n",
"COPY --from=builder /app/migrations /app/migrations\n",
);
let out = ensure_dockerfile_i18n_copy(dockerfile, "i18n").expect("anchors present");
let active = |needle: &str| out.lines().any(|line| line.trim() == needle);
assert!(
active("COPY i18n ./i18n"),
"the builder stage needs a real instruction:\n{out}"
);
assert!(
active("COPY --from=builder /app/i18n /app/i18n"),
"and so does the runtime stage:\n{out}"
);
assert_eq!(out.matches("# COPY i18n ./i18n").count(), 1, "{out}");
}
#[test]
fn insertion_follows_the_active_anchor_not_a_commented_one() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"# COPY migrations ./migrations\n",
"COPY migrations ./migrations\n",
"\n",
"FROM debian AS runtime\n",
"COPY --from=builder /app/migrations /app/migrations\n",
);
let out = ensure_dockerfile_i18n_copy(dockerfile, "i18n").expect("anchors present");
let commented = out.find("# COPY migrations").expect("comment kept");
let active = out
.find("\nCOPY migrations ./migrations")
.expect("active anchor kept");
let inserted = out.find("COPY i18n ./i18n").expect("line inserted");
assert!(
inserted > active && active > commented,
"the copy must follow the instruction, not the example:\n{out}"
);
}
#[test]
fn every_cfg_selected_main_is_wired() {
let main_rs = concat!(
"#[cfg(unix)]\n",
"fn main() {\n",
" App::new().routes(routes![index]).serve();\n",
"}\n",
"\n",
"#[cfg(windows)]\n",
"fn main() {\n",
" App::new().routes(routes![index]).serve();\n",
"}\n",
);
let I18nAutoWiring::Wired(out) = ensure_i18n_auto(main_rs) else {
panic!("both entry points are production");
};
assert_eq!(
out.matches(".i18n_auto()").count(),
2,
"each `main` needs its own:\n{out}"
);
assert_eq!(
ensure_i18n_auto(&out),
I18nAutoWiring::Wired(out.clone()),
"and a re-run adds nothing"
);
}
#[test]
fn a_moved_nested_locale_dir_is_reported_too() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"COPY migrations ./migrations\n",
"COPY locales/v1 ./locales/v1\n",
"\n",
"FROM debian AS runtime\n",
"COPY --from=builder /app/migrations /app/migrations\n",
"COPY --from=builder /app/locales/v1 /app/locales/v1\n",
);
assert_eq!(
stale_dockerfile_i18n_dir(dockerfile, "locales/v2").as_deref(),
Some("locales/v1"),
"the old pair is stale once `dir` moves"
);
assert_eq!(
stale_dockerfile_i18n_dir(dockerfile, "locales/v1"),
None,
"and not stale while it is still the configured one"
);
}
#[test]
fn an_unrelated_copy_in_one_stage_is_not_read_as_a_moved_bundle() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"COPY migrations ./migrations\n",
"COPY static ./static\n",
"\n",
"FROM debian AS runtime\n",
"COPY --from=builder /app/migrations /app/migrations\n",
"COPY --from=builder /app/assets /app/assets\n",
);
assert_eq!(stale_dockerfile_i18n_dir(dockerfile, "i18n"), None);
}
#[test]
fn a_quoted_table_header_names_the_same_table() {
assert_eq!(toml_table_name(r#"["i18n"]"#), Some("i18n"));
assert_eq!(toml_table_name("['i18n']"), Some("i18n"));
assert_eq!(toml_table_name("[i18n]"), Some("i18n"));
assert_eq!(toml_table_name(r#"["i18n"] # notes"#), Some("i18n"));
assert!(defines_i18n(
r#"["i18n"]
default_locale = "fr"
"#
));
assert_eq!(toml_table_name("i18n = 1"), None);
}
#[test]
fn an_inline_table_splits_on_separators_not_on_commas_in_values() {
let toml = r#"i18n = { dir = "translations,v2", default_locale = "fr" }"#;
assert_eq!(
configured_i18n(toml),
(Some("fr".to_owned()), Some("translations,v2".to_owned()))
);
let escaped = r#"i18n = { dir = "od\"d", default_locale = "fr" }"#;
assert_eq!(configured_i18n(escaped).1.as_deref(), Some(r#"od"d"#));
}
#[test]
fn the_embedded_locale_dir_is_escaped_as_a_rust_literal() {
let main_rs = concat!(
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();\n",
"fn main() {\n",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
"}\n",
);
let out = ensure_embedded_locales(main_rs, r"translations\bundles").expect("anchors");
assert!(
out.contains(r#"embed_locales!("translations\\bundles")"#),
"a backslash must reach Rust as an escaped backslash, not as `\\b`:\n{out}"
);
let quoted = ensure_embedded_locales(main_rs, r#"od"d"#).expect("anchors");
assert!(
quoted.contains(r#"embed_locales!("od\"d")"#),
"a quote must not terminate the literal:\n{quoted}"
);
}
#[test]
fn toml_values_are_decoded_before_they_become_paths() {
assert_eq!(
toml_scalar_value(r#" "fr" "#).as_deref(),
Some("fr"),
"a `\\u` escape must resolve"
);
assert_eq!(
toml_scalar_value(r#" "i18n\\bundles" "#).as_deref(),
Some(r"i18n\bundles"),
"an escaped backslash is one backslash"
);
assert_eq!(
toml_scalar_value(r" 'i18n\\bundles' ").as_deref(),
Some(r"i18n\\bundles")
);
assert_eq!(toml_scalar_value(r#" "a\qb" "#).as_deref(), Some(r"a\qb"));
assert_eq!(toml_scalar_value(r#" "plain" "#).as_deref(), Some("plain"));
}
#[test]
fn dockerfile_gains_the_bundle_in_both_stages() {
let dockerfile = concat!(
"FROM rust AS builder\n",
"COPY migrations ./migrations\n",
"\n",
"FROM debian AS runtime\n",
"COPY --from=builder /app/migrations /app/migrations\n",
);
let out = ensure_dockerfile_i18n_copy(dockerfile, "i18n").expect("anchors present");
assert!(out.contains("COPY i18n ./i18n"), "{out}");
assert!(
out.contains("COPY --from=builder /app/i18n /app/i18n"),
"{out}"
);
assert_eq!(
ensure_dockerfile_i18n_copy(&out, "i18n").as_deref(),
Some(out.as_str()),
"must be idempotent"
);
assert_eq!(ensure_dockerfile_i18n_copy("FROM scratch\n", "i18n"), None);
}
}