use std::borrow::Cow;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use mf2_catalog::writer::{self, Options};
use mf2_catalog::{Dir, Manifest};
use mf2_locale_data::{CLDR_VERSION, direction, locale_entries};
use mf2_model::{Message, Pattern, PatternMessage};
use crate::config::{Config, Missing, Strip};
use crate::error::{Error, Result};
use crate::slice::Slice;
const BROTLI_QUALITY: u32 = 11;
const BROTLI_WINDOW: u32 = 22;
const BROTLI_FAST_QUALITY: u32 = 5;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Compress {
No,
Fast,
Best,
}
#[derive(Clone)]
pub struct Catalog {
pub tag: String,
pub bytes: Vec<u8>,
pub br: Vec<u8>,
pub gz: Vec<u8>,
pub hash: String,
pub missing: usize,
pub fallbacks: usize,
pub locale_entries: Vec<(u32, usize)>,
pub slice: Slice,
}
impl Catalog {
pub fn file_name(&self) -> String {
format!("{}.{}.mf2b", self.tag, self.hash)
}
}
impl core::fmt::Debug for Catalog {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Catalog")
.field("tag", &self.tag)
.field("hash", &self.hash)
.field("raw", &self.bytes.len())
.field("br", &self.br.len())
.field("gz", &self.gz.len())
.field("missing", &self.missing)
.field("fallbacks", &self.fallbacks)
.field("locale_entries", &self.locale_entries)
.field("slice", &self.slice)
.finish()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Origin {
Own,
Fallback(usize),
Id,
Empty,
}
pub struct Resolved<'a> {
pub messages: Vec<Option<&'a Message<'a>>>,
pub origins: Vec<Origin>,
}
#[derive(Debug, Default)]
pub struct Filler {
messages: Vec<Message<'static>>,
}
impl Filler {
pub fn new(ids: &[String], missing: Missing) -> Filler {
let messages = match missing {
Missing::Id => ids
.iter()
.map(|id| {
Message::Pattern(PatternMessage {
declarations: Vec::new(),
pattern: Pattern::from_text(Cow::Owned(id.clone())),
})
})
.collect(),
Missing::Empty => ids
.iter()
.map(|_| {
Message::Pattern(PatternMessage {
declarations: Vec::new(),
pattern: Pattern::from_text(Cow::Borrowed("")),
})
})
.collect(),
Missing::Fallback => Vec::new(),
};
Filler { messages }
}
fn get(&self, index: usize) -> Option<&Message<'static>> {
self.messages.get(index)
}
}
pub fn resolve<'a>(
ids: &[String],
own: &[Option<&'a Message<'a>>],
chain: &[&[Option<&'a Message<'a>>]],
missing: Missing,
filler: &'a Filler,
) -> Resolved<'a> {
let mut messages = Vec::with_capacity(ids.len());
let mut origins = Vec::with_capacity(ids.len());
for i in 0..ids.len() {
if let Some(message) = own.get(i).copied().flatten() {
messages.push(Some(message));
origins.push(Origin::Own);
continue;
}
match missing {
Missing::Fallback => {
let found = chain
.iter()
.enumerate()
.find_map(|(step, models)| models.get(i).copied().flatten().map(|m| (step, m)));
if let Some((step, message)) = found {
messages.push(Some(message));
origins.push(Origin::Fallback(step));
} else {
messages.push(None);
origins.push(Origin::Empty);
}
}
Missing::Id => {
messages.push(filler.get(i));
origins.push(Origin::Id);
}
Missing::Empty => {
messages.push(filler.get(i));
origins.push(Origin::Empty);
}
}
}
Resolved { messages, origins }
}
pub fn write(
tag: &str,
manifest: &Manifest,
resolved: &Resolved<'_>,
chain_tags: &[String],
slice: Slice,
config: &Config,
compress: Compress,
) -> Result<Catalog> {
let dir = direction(tag).map_err(|source| Error::Locale {
locale: tag.to_owned(),
source,
})?;
let mut options = Options::new(tag, dir);
options.strip_cold = config.catalog.strip.contains(&Strip::Cold);
options.strip_ids = config.catalog.strip.contains(&Strip::Ids);
options.cldr_version = Some(CLDR_VERSION);
options.locale_entries = locale_entries(tag, &slice.needs)?;
#[cfg(feature = "icu-blob")]
if let Some(entry) = crate::slice::icu_entry(tag, &slice)? {
options.locale_entries.push(entry);
}
let mut missing = 0;
let mut fallbacks = 0;
for (i, origin) in resolved.origins.iter().enumerate() {
match origin {
Origin::Own => {}
Origin::Fallback(step) => {
missing += 1;
fallbacks += 1;
let from = chain_tags
.get(*step)
.cloned()
.unwrap_or_else(|| tag.to_owned());
options.fallback.push((
u32::try_from(i).map_err(|_| {
Error::Layout("more messages than a catalog can hold".to_owned())
})?,
from,
));
}
Origin::Id | Origin::Empty => missing += 1,
}
}
let bytes =
writer::catalog(manifest, &resolved.messages, &options).map_err(|source| Error::Write {
locale: tag.to_owned(),
source,
})?;
let (br, gz) = match compress {
Compress::No => (Vec::new(), Vec::new()),
Compress::Fast => (
brotli_at(tag, &bytes, BROTLI_FAST_QUALITY)?,
gzip_at(tag, &bytes, flate2::Compression::fast())?,
),
Compress::Best => (brotli(tag, &bytes)?, gzip(tag, &bytes)?),
};
let hash = content_hash(&bytes);
let mut locale_entries: Vec<(u32, usize)> = options
.locale_entries
.iter()
.map(|(key, payload)| (*key, payload.len()))
.collect();
locale_entries.sort_unstable();
Ok(Catalog {
tag: tag.to_owned(),
bytes,
br,
gz,
hash,
missing,
fallbacks,
locale_entries,
slice,
})
}
pub use mf2_catalog::content_hash;
pub fn brotli(locale: &str, bytes: &[u8]) -> Result<Vec<u8>> {
brotli_at(locale, bytes, BROTLI_QUALITY)
}
fn brotli_at(locale: &str, bytes: &[u8], quality: u32) -> Result<Vec<u8>> {
let fail = |source| Error::Compress {
locale: locale.to_owned(),
format: "brotli",
source,
};
let mut out = Vec::new();
{
let mut writer = brotli::CompressorWriter::new(&mut out, 4096, quality, BROTLI_WINDOW);
writer.write_all(bytes).map_err(fail)?;
writer.flush().map_err(fail)?;
}
Ok(out)
}
pub fn gzip(locale: &str, bytes: &[u8]) -> Result<Vec<u8>> {
gzip_at(locale, bytes, flate2::Compression::best())
}
fn gzip_at(locale: &str, bytes: &[u8], level: flate2::Compression) -> Result<Vec<u8>> {
let fail = |source| Error::Compress {
locale: locale.to_owned(),
format: "gzip",
source,
};
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), level);
encoder.write_all(bytes).map_err(fail)?;
encoder.finish().map_err(fail)
}
pub fn write_if_changed(path: &Path, bytes: &[u8]) -> Result<bool> {
if let Ok(existing) = std::fs::read(path)
&& existing == bytes
{
return Ok(false);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|source| Error::io(parent.to_path_buf(), source))?;
}
std::fs::write(path, bytes).map_err(|source| Error::io(path.to_path_buf(), source))?;
Ok(true)
}
pub fn remove_stale(dir: &Path, keep: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut removed = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(removed);
};
for entry in entries.flatten() {
let path = entry.path();
let is_catalog = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(".mf2b"));
if is_catalog && !keep.contains(&path) {
std::fs::remove_file(&path).map_err(|source| Error::io(path.clone(), source))?;
removed.push(path);
}
}
removed.sort();
Ok(removed)
}
pub fn dir_of(tag: &str) -> Result<Dir> {
direction(tag).map_err(|source| Error::Locale {
locale: tag.to_owned(),
source,
})
}