use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::scaffold::{is_raw_string_start, skip_raw_string};
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}")
}
#[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()
}
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 {
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
}
const STATIC_ANCHOR: &str =
"static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();";
const INSTALL_ANCHOR: &str = " let app = app.embedded_static(&EMBEDDED_STATIC);\n";
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 embedded_in_production = identifier_offsets(main_rs, "EMBEDDED_LOCALES")
.into_iter()
.any(|at| !in_cfg_test(main_rs, at));
if embedded_in_production {
let stale_macro = locales_static_item(main_rs).and_then(|(start, end)| {
let item = &main_rs[start..end];
if !real_offsets(item, ¯o_call).is_empty() {
return None;
}
let rel = real_offsets(item, "autumn_web::embed_locales!")
.first()
.copied()?;
Some((start + rel, end))
});
let out = match stale_macro {
Some((call_at, end)) => {
format!("{}{macro_call}{}", &main_rs[..call_at], &main_rs[end..])
}
None => main_rs.to_owned(),
};
return insert_locale_install(&out);
}
let static_at = real_offsets(main_rs, STATIC_ANCHOR)
.into_iter()
.find(|&at| !in_cfg_test(main_rs, at))?;
let end = static_at + STATIC_ANCHOR.len();
let with_static = format!(
"{}{STATIC_ANCHOR}\n#[cfg(feature = \"embed-assets\")]\n\
static EMBEDDED_LOCALES: autumn_web::include_dir::Dir = {macro_call};{}",
&main_rs[..static_at],
&main_rs[end..]
);
insert_locale_install(&with_static)
}
fn insert_locale_install(src: &str) -> Option<String> {
let scopes = production_scopes(src);
let anchors: Vec<usize> = real_offsets(src, INSTALL_ANCHOR)
.into_iter()
.filter(|&at| in_production(src, &scopes, at))
.collect();
let pending: Vec<usize> = anchors
.iter()
.copied()
.filter(|&at| !locales_installed_beside(src, at))
.collect();
if pending.is_empty() {
let served = !anchors.is_empty()
|| method_call_offsets(src, ".embedded_locales")
.any(|at| in_production(src, &scopes, at));
return served.then(|| src.to_owned());
}
let mut out = src.to_owned();
for at in pending.into_iter().rev() {
let end = at + INSTALL_ANCHOR.len();
out = format!(
"{}{INSTALL_ANCHOR} #[cfg(feature = \"embed-assets\")]\n\
\x20 let app = app.embedded_locales(&EMBEDDED_LOCALES);\n{}",
&out[..at],
&out[end..]
);
}
Some(out)
}
fn locales_installed_beside(src: &str, at: usize) -> bool {
let start = enclosing_block_start(src, at);
let end = start
.checked_sub(1)
.and_then(|brace| brace_block(src, brace))
.map_or(src.len(), |(_, close)| close);
method_call_offsets(&src[start..end], ".embedded_locales")
.next()
.is_some()
}
fn production_scopes(src: &str) -> Vec<(usize, usize)> {
production_routes_anchors(src)
.into_iter()
.filter_map(|at| enclosing_body(src, at))
.collect()
}
fn in_production(src: &str, scopes: &[(usize, usize)], at: usize) -> bool {
if scopes.is_empty() {
!in_cfg_test(src, at)
} else {
scopes.iter().any(|&(open, close)| open < at && at < close)
}
}
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
}
pub(super) fn real_offsets(src: &str, needle: &str) -> Vec<usize> {
let bytes = src.as_bytes();
let n = needle.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
let mut block_depth = 0usize;
while i < bytes.len() {
if block_depth > 0 {
if bytes[i..].starts_with(b"/*") {
block_depth += 1;
i += 2;
} else if bytes[i..].starts_with(b"*/") {
block_depth -= 1;
i += 2;
} else {
i += 1;
}
continue;
}
if bytes[i..].starts_with(b"//") {
i += src[i..].find('\n').map_or(src.len() - i, |nl| nl + 1);
continue;
}
if bytes[i..].starts_with(b"/*") {
block_depth = 1;
i += 2;
continue;
}
if is_raw_string_start(bytes, i) {
i = skip_raw_string(bytes, i);
continue;
}
if bytes[i] == b'"' {
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
i += usize::from(bytes[i] == b'\\') + 1;
}
i += 1;
continue;
}
if bytes[i] == b'\'' {
if let Some(end) = char_literal_end(bytes, i) {
i = end + 1;
continue;
}
i += 1;
continue;
}
if bytes[i..].starts_with(n) {
out.push(i);
i += n.len();
continue;
}
i += 1;
}
out
}
fn json_str(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn identifier_offsets(src: &str, name: &str) -> Vec<usize> {
let ident_char = |c: char| c.is_alphanumeric() || c == '_';
real_offsets(src, name)
.into_iter()
.filter(|&at| {
!src[..at].chars().next_back().is_some_and(ident_char)
&& !src[at + name.len()..]
.chars()
.next()
.is_some_and(ident_char)
})
.collect()
}
fn locales_static_item(src: &str) -> Option<(usize, usize)> {
identifier_offsets(src, "EMBEDDED_LOCALES")
.into_iter()
.filter(|&at| !in_cfg_test(src, at))
.find_map(|at| {
let before = src[..at].trim_end();
let keyword = before.strip_suffix("static")?;
if keyword
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
{
return None;
}
let semi = real_offsets(&src[at..], ";").first().copied()?;
Some((at, at + semi))
})
}
fn method_call_offsets<'a>(src: &'a str, method: &'a str) -> impl Iterator<Item = usize> + 'a {
real_offsets(src, method).into_iter().filter(move |&at| {
let rest = &src[at + method.len()..];
if rest
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
{
return false;
}
real_offsets(rest, "(").first() == Some(&ignorable_prefix_len(rest))
})
}
pub(super) fn ignorable_prefix_len(src: &str) -> usize {
let bytes = src.as_bytes();
let mut i = 0;
loop {
let rest = &src[i..];
let trimmed = rest.trim_start();
i += rest.len() - trimmed.len();
if let Some(after) = src[i..].strip_prefix("//") {
i += 2 + after.find('\n').map_or(after.len(), |nl| nl + 1);
continue;
}
if src[i..].starts_with("/*") {
let mut depth = 1usize;
i += 2;
while i < bytes.len() && depth > 0 {
if bytes[i..].starts_with(b"/*") {
depth += 1;
i += 2;
} else if bytes[i..].starts_with(b"*/") {
depth -= 1;
i += 2;
} else {
i += 1;
}
}
continue;
}
return i;
}
}
fn char_literal_end(bytes: &[u8], at: usize) -> Option<usize> {
let mut i = at + 1;
if bytes.get(i)? == &b'\\' {
i += 1;
let selector = *bytes.get(i)?;
i += 1;
match selector {
b'u' => {
while bytes.get(i).is_some_and(|b| *b != b'}') {
i += 1;
}
i += 1;
}
b'x' => i += 2,
_ => {}
}
return (bytes.get(i) == Some(&b'\'')).then_some(i);
}
let limit = (i + 4).min(bytes.len());
while i < limit {
if bytes[i] == b'\'' {
return Some(i);
}
i += 1;
}
None
}
fn brace_block(src: &str, from: usize) -> Option<(usize, usize)> {
let open = real_offsets(&src[from..], "{").first().copied()? + from;
let tail = &src[open..];
let mut braces: Vec<(usize, bool)> = real_offsets(tail, "{")
.into_iter()
.map(|o| (o, true))
.chain(real_offsets(tail, "}").into_iter().map(|o| (o, false)))
.collect();
braces.sort_unstable();
let mut depth = 0usize;
for (at, is_open) in braces {
if is_open {
depth += 1;
} else {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some((open, open + at));
}
}
}
None
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum I18nAutoWiring {
Wired(String),
CustomBundle,
NoAnchor,
}
fn enclosing_body(src: &str, at: usize) -> Option<(usize, usize)> {
real_offsets(src, "fn ")
.into_iter()
.filter(|&fn_at| fn_at < at)
.filter_map(|fn_at| brace_block(src, fn_at))
.filter(|&(open, close)| open < at && at < close)
.max_by_key(|&(open, _)| open)
}
fn production_routes_anchors(main_rs: &str) -> Vec<usize> {
let offsets: Vec<usize> = method_call_offsets(main_rs, ".routes").collect();
let main_bodies: Vec<(usize, usize)> = real_offsets(main_rs, "fn main")
.into_iter()
.filter(|&at| {
main_rs[at + "fn main".len()..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric() && c != '_')
})
.filter(|&at| !in_cfg_test(main_rs, at))
.filter_map(|at| brace_block(main_rs, at))
.collect();
if !main_bodies.is_empty() {
let inside: Vec<usize> = offsets
.iter()
.copied()
.filter(|&o| {
main_bodies
.iter()
.any(|&(open, close)| o > open && o < close)
})
.collect();
if !inside.is_empty() {
return inside;
}
}
let candidates: Vec<usize> = offsets
.iter()
.copied()
.filter(|&o| !in_cfg_test(main_rs, o))
.collect();
match candidates.as_slice() {
[only] => vec![*only],
_ => Vec::new(),
}
}
fn in_cfg_test(src: &str, at: usize) -> bool {
real_offsets(src, "#[cfg(test)]")
.into_iter()
.filter_map(|attr| cfg_item_span(src, attr))
.any(|(start, end)| start < at && at < end)
}
fn cfg_item_span(src: &str, attr: usize) -> Option<(usize, usize)> {
const ATTR: &str = "#[cfg(test)]";
let after = attr + ATTR.len();
let rest = &src[after..];
let brace = real_offsets(rest, "{").first().copied();
let semi = real_offsets(rest, ";").first().copied();
match (brace, semi) {
(Some(b), Some(s)) if s < b => Some((attr, after + s)),
(None, Some(s)) => Some((attr, after + s)),
(Some(_), _) => brace_block(src, after).map(|(_, close)| (attr, close)),
(None, None) => None,
}
}
pub(super) fn ensure_i18n_auto(main_rs: &str) -> I18nAutoWiring {
let anchors = production_routes_anchors(main_rs);
if anchors.is_empty() {
return I18nAutoWiring::NoAnchor;
}
let mut custom = false;
let mut pending = Vec::new();
for at in anchors {
if custom_bundle_before(main_rs, at) {
custom = true;
} else if !chain_already_wired(main_rs, at) {
pending.push(at);
}
}
if pending.is_empty() {
return if custom {
I18nAutoWiring::CustomBundle
} else {
I18nAutoWiring::Wired(main_rs.to_owned())
};
}
let mut out = main_rs.to_owned();
for at in pending.into_iter().rev() {
let line_start = out[..at].rfind('\n').map_or(0, |i| i + 1);
if out[line_start..at].trim_start().is_empty() {
let indent: String = out[line_start..at]
.chars()
.take_while(|c| c.is_whitespace())
.collect();
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 chain_already_wired(src: &str, at: usize) -> bool {
method_called_before(src, at, ".i18n_auto")
}
fn custom_bundle_before(src: &str, at: usize) -> bool {
method_called_before(src, at, ".i18n")
}
fn method_called_before(src: &str, at: usize, method: &str) -> bool {
let start = statement_start(src, at);
if calls_at_own_depth(&src[start..at], method) {
return true;
}
let statement = &src[start..at];
let mut frontier: Vec<(&str, usize)> =
value_roots(binds(statement).map_or(statement, |(_, init)| init))
.into_iter()
.map(|name| (name, start))
.collect();
for _ in 0..64 {
let Some((name, before)) = frontier.pop() else {
return false;
};
let Some((bind_start, bind_end)) = binding_of(src, before, name) else {
continue;
};
let statement = &src[bind_start..bind_end];
let initialiser = binds(statement).map_or(statement, |(_, init)| init);
if calls_at_own_depth(initialiser, method) {
return true;
}
frontier.extend(
value_roots(initialiser)
.into_iter()
.map(|next| (next, bind_start)),
);
}
false
}
fn value_roots(expr: &str) -> Vec<&str> {
if let Some(root) = value_root(expr) {
return vec![root];
}
brace_groups(expr)
.into_iter()
.filter_map(|(open, close)| value_root(&expr[open..close]))
.collect()
}
fn brace_groups(expr: &str) -> Vec<(usize, usize)> {
let marks = delimiter_marks(expr, expr.len());
let mut out = Vec::new();
let mut paren = 0i32;
let mut brace = 0i32;
let mut open = 0usize;
for &(offset, byte) in &marks {
match byte {
b'(' | b'[' => paren += 1,
b')' | b']' => paren -= 1,
b'{' if paren <= 0 => {
if brace == 0 {
open = offset + 1;
}
brace += 1;
}
b'}' if paren <= 0 => {
brace -= 1;
if brace == 0 {
out.push((open, offset));
}
}
_ => {}
}
}
out
}
fn binding_of(src: &str, before: usize, name: &str) -> Option<(usize, usize)> {
let mut end = before;
loop {
let level = enclosing_block_start(src, end);
if let Some(found) = statements_in(src, level, end)
.into_iter()
.rev()
.find(|&(s, e)| binds(&src[s..e]).is_some_and(|(bound, _)| bound == name))
{
return Some(found);
}
if level == 0 {
return None;
}
end = level - 1;
}
}
fn statements_in(src: &str, start: usize, end: usize) -> Vec<(usize, usize)> {
let region = &src[start..end];
let mut out = Vec::new();
let mut depth = 0i32;
let mut statement = 0usize;
for &(offset, byte) in &delimiter_marks(region, region.len()) {
match byte {
b'{' | b'(' | b'[' => depth += 1,
b'}' | b')' | b']' => depth -= 1,
b';' if depth <= 0 => {
out.push((start + statement, start + offset));
statement = offset + 1;
}
_ => {}
}
}
out
}
fn binds(statement: &str) -> Option<(&str, &str)> {
let_parts(statement).or_else(|| assignment_parts(statement))
}
fn assignment_parts(statement: &str) -> Option<(&str, &str)> {
let rest = &statement[ignorable_prefix_len(statement)..];
let len = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let (name, after) = rest.split_at(len);
if name.is_empty()
|| name.starts_with(|c: char| c.is_ascii_digit())
|| matches!(
name,
"let" | "if" | "match" | "while" | "for" | "loop" | "return" | "unsafe" | "async"
)
{
return None;
}
let after = &after[ignorable_prefix_len(after)..];
let value = after.strip_prefix('=')?;
(!value.starts_with(['=', '>'])).then_some((name, value))
}
fn let_parts(statement: &str) -> Option<(&str, &str)> {
let rest = statement.trim_start();
let rest = rest.strip_prefix("let")?;
if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
return None;
}
let rest = rest.trim_start();
let rest = rest.strip_prefix("mut").map_or(rest, |after| {
if after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
rest
} else {
after
}
});
let rest = rest.trim_start();
let name_len = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let (name, after) = rest.split_at(name_len);
if name.is_empty() {
return None;
}
let eq = real_offsets(after, "=")
.into_iter()
.find(|&at| !after[at + 1..].starts_with(['=', '>']))?;
Some((name, &after[eq + 1..]))
}
fn value_root(expr: &str) -> Option<&str> {
let rest = &expr[ignorable_prefix_len(expr)..];
let len = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let ident = &rest[..len];
if ident.is_empty() || ident.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
let after = &rest[len..];
let after = &after[ignorable_prefix_len(after)..];
if after.starts_with("::")
|| after.starts_with('!')
|| after.starts_with('(')
|| matches!(
ident,
"if" | "match"
| "loop"
| "while"
| "for"
| "let"
| "unsafe"
| "async"
| "move"
| "return"
)
{
return None;
}
Some(ident)
}
fn statement_start(src: &str, at: usize) -> usize {
let mut depth = 0usize;
for &(offset, byte) in delimiter_marks(src, at).iter().rev() {
match byte {
b')' | b']' | b'}' => depth += 1,
b'(' | b'[' | b'{' => {
let Some(next) = depth.checked_sub(1) else {
return offset + 1;
};
depth = next;
}
b';' if depth == 0 => return offset + 1,
_ => {}
}
}
0
}
fn calls_at_own_depth(region: &str, method: &str) -> bool {
let marks = delimiter_marks(region, region.len());
let mut marks = marks.iter().peekable();
let mut depth = 0i32;
for at in method_call_offsets(region, method) {
while let Some(&&(offset, byte)) = marks.peek() {
if offset >= at {
break;
}
match byte {
b'(' | b'[' => depth += 1,
b')' | b']' => depth -= 1,
_ => {}
}
marks.next();
}
if depth <= 0 {
return true;
}
}
false
}
fn enclosing_block_start(src: &str, at: usize) -> usize {
let mut depth = 0usize;
for &(offset, byte) in delimiter_marks(src, at).iter().rev() {
match byte {
b')' | b']' | b'}' => depth += 1,
b'(' | b'[' | b'{' => {
let Some(next) = depth.checked_sub(1) else {
return offset + 1;
};
depth = next;
}
_ => {}
}
}
0
}
fn delimiter_marks(src: &str, at: usize) -> Vec<(usize, u8)> {
let mut marks: Vec<(usize, u8)> = Vec::new();
for (needle, byte) in [
(";", b';'),
("{", b'{'),
("}", b'}'),
("(", b'('),
(")", b')'),
("[", b'['),
("]", b']'),
] {
marks.extend(
real_offsets(&src[..at], needle)
.into_iter()
.map(|o| (o, byte)),
);
}
marks.sort_unstable();
marks
}
#[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_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 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 a_lifetime_is_not_read_as_a_char_literal() {
let cases: &[(&str, Option<usize>)] = &[
("'{'", Some(2)),
("'\"'", Some(2)),
("'\\''", Some(3)),
("'\\n'", Some(3)),
("'\\u{1F600}'", Some(10)),
("'a>(s: &str)", None),
("'static", None),
];
for (src, expected) in cases {
assert_eq!(
char_literal_end(src.as_bytes(), 0),
*expected,
"char_literal_end({src:?})"
);
}
}
#[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",
" let app = app.embedded_static(&EMBEDDED_STATIC);\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 src = concat!(
"#[cfg(test)]\n",
"static EMBEDDED_LOCALES: Dir = autumn_web::embed_locales!(\"fixtures\");\n",
"\n",
"fn production() {\n",
" let app = App::new().routes(routes![index]);\n",
"}\n",
);
let static_at = src.find("EMBEDDED_LOCALES").unwrap();
assert!(
in_cfg_test(src, static_at),
"the semicolon-terminated static is test-only"
);
let prod_at = src.find("routes![index]").unwrap();
assert!(
!in_cfg_test(src, prod_at),
"the following item must NOT be dragged into cfg(test)"
);
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",
" let app = app.embedded_static(&EMBEDDED_STATIC);\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",
" let app = app.embedded_static(&EMBEDDED_STATIC);\n",
" let app = app.embedded_locales (&EMBEDDED_LOCALES);\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 ignorable_prefix_len_counts_nested_block_comments() {
let src = "/* outer /* nested */ tail */ (";
assert_eq!(&src[ignorable_prefix_len(src)..], "(");
let open = "/* outer /* nested */ never closed";
assert_eq!(ignorable_prefix_len(open), open.len());
}
#[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",
" let app = app.embedded_static(&EMBEDDED_STATIC);\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 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",
" let app = app.embedded_static(&EMBEDDED_STATIC);\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);
}
}