use std::cell::Cell;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::fetch::{download_retrying, Fetch, RetryPolicy};
use crate::remote::Record;
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
fn verify_hash(record: &Record, bytes: Vec<u8>) -> Result<Vec<u8>, String> {
if let Some(expected) = &record.decompressed_hash {
let got = sha256_hex(&bytes);
if &got != expected {
return Err(format!(
"hash mismatch for {} after download: got {got}, expected {expected}",
record.name
));
}
}
Ok(bytes)
}
pub fn zstd_decode(input: &[u8]) -> Result<Vec<u8>, String> {
let mut dec = ruzstd::StreamingDecoder::new(input).map_err(|e| format!("zstd init: {e}"))?;
let mut out = Vec::new();
dec.read_to_end(&mut out)
.map_err(|e| format!("zstd decode: {e}"))?;
Ok(out)
}
fn render_progress(name: &str, done: u64, total: Option<u64>) {
const MIB: f64 = 1024.0 * 1024.0;
let mut err = std::io::stderr();
let line = match total {
Some(t) if t > 0 => {
let pct = (done as f64 / t as f64 * 100.0).round() as u64;
format!(
" {name}: {:.1} / {:.1} MiB ({pct}%)",
done as f64 / MIB,
t as f64 / MIB
)
}
_ => format!(" {name}: {:.1} MiB", done as f64 / MIB),
};
let _ = write!(err, "\r{line:<60}");
let _ = err.flush();
}
#[derive(Clone, Debug)]
pub struct ModelFiles {
pub model: PathBuf,
pub src_vocab: PathBuf,
pub trg_vocab: PathBuf,
pub lex: Option<PathBuf>,
}
pub struct Cache {
root: PathBuf,
retry: RetryPolicy,
show_progress: bool,
}
impl Cache {
pub fn locate() -> Cache {
let base = dirs::cache_dir().unwrap_or_else(|| PathBuf::from(".fxtranslate-cache"));
Cache::with_root(base.join("fxtranslate").join("models"))
}
pub fn with_root(root: impl Into<PathBuf>) -> Cache {
Cache {
root: root.into(),
retry: RetryPolicy::default(),
show_progress: false,
}
}
pub fn with_progress(mut self, show: bool) -> Cache {
self.show_progress = show;
self
}
pub fn with_retry(mut self, policy: RetryPolicy) -> Cache {
self.retry = policy;
self
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn pair_dir(&self, src: &str, trg: &str) -> PathBuf {
self.root.join(format!("{src}-{trg}"))
}
pub fn ensure(&self, fetch: &dyn Fetch, record: &Record) -> Result<PathBuf, String> {
let dir = self.pair_dir(&record.src, &record.trg);
let dest = dir.join(&record.name);
if dest.is_file() {
match &record.decompressed_hash {
Some(expected) => {
let got = sha256_hex(&fs::read(&dest).map_err(|e| e.to_string())?);
if &got == expected {
return Ok(dest);
}
eprintln!(
"[cache] {} hash mismatch (have {}…, want {}…); re-fetching",
record.name,
&got[..8.min(got.len())],
&expected[..8.min(expected.len())]
);
}
None => return Ok(dest), }
}
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let url = record.cdn_url();
let download = dir.join(format!(".{}.download", record.name));
let show = self.show_progress;
let name = &record.name;
let drew = Cell::new(false);
let mut on_progress = |done: u64, total: Option<u64>| {
if show {
render_progress(name, done, total);
drew.set(true);
}
};
let mut healed = false;
let bytes = loop {
let stats = download_retrying(fetch, &url, &download, &self.retry, &mut on_progress);
if drew.replace(false) {
eprintln!(); }
let stats = stats?;
let assembled = fs::read(&download)
.map_err(|e| e.to_string())
.and_then(|compressed| zstd_decode(&compressed))
.and_then(|bytes| verify_hash(record, bytes));
match assembled {
Ok(bytes) => break bytes,
Err(e) => {
let _ = fs::remove_file(&download);
if stats.attempts > 1 && !healed {
healed = true;
eprintln!(
"[cache] {}: assembled download failed verification; re-fetching cleanly",
record.name
);
continue;
}
return Err(e);
}
}
};
let _ = fs::remove_file(&download);
let tmp = dir.join(format!(".{}.partial", record.name));
fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
Ok(dest)
}
}
pub fn ensure_model(
fetch: &dyn Fetch,
cache: &Cache,
records: &[Record],
src: &str,
trg: &str,
) -> Result<ModelFiles, String> {
use crate::remote::pick;
let model = pick(records, "model", src, trg)
.ok_or_else(|| format!("no model for {src}-{trg} in Remote Settings"))?;
let model_path = cache.ensure(fetch, model)?;
let (src_vocab, trg_vocab) = if let Some(v) = pick(records, "vocab", src, trg) {
let p = cache.ensure(fetch, v)?;
(p.clone(), p)
} else {
let sv = pick(records, "srcvocab", src, trg)
.ok_or_else(|| format!("no vocab/srcvocab for {src}-{trg}"))?;
let tv = pick(records, "trgvocab", src, trg)
.ok_or_else(|| format!("no trgvocab for {src}-{trg}"))?;
(cache.ensure(fetch, sv)?, cache.ensure(fetch, tv)?)
};
let lex = match pick(records, "lex", src, trg) {
Some(l) => Some(cache.ensure(fetch, l)?),
None => None,
};
Ok(ModelFiles {
model: model_path,
src_vocab,
trg_vocab,
lex,
})
}