use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{anyhow, bail, Context, Result};
use clap::{Parser, ValueEnum};
use hf_hub::{api::sync::Api, Repo, RepoType};
use sha2::{Digest, Sha256};
use crate::voice::models::{ModelSource, ModelSpec, SPEAKER_WESPEAKER_EN, WHISPER_TINY_EN};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum Variant {
#[default]
#[value(name = "whisper-tiny.en")]
WhisperTinyEn,
#[value(name = "speaker-wespeaker-en")]
SpeakerWespeakerEn,
}
impl Variant {
pub fn spec(self) -> &'static ModelSpec {
match self {
Self::WhisperTinyEn => &WHISPER_TINY_EN,
Self::SpeakerWespeakerEn => &SPEAKER_WESPEAKER_EN,
}
}
}
#[derive(Parser)]
pub struct InstallModelCommand {
#[arg(long)]
pub dest: Option<PathBuf>,
#[arg(long)]
pub force: bool,
#[arg(long, value_enum, default_value_t = Variant::WhisperTinyEn)]
pub variant: Variant,
}
impl InstallModelCommand {
pub fn execute(self) -> Result<()> {
let mut err = std::io::stderr().lock();
self.run(&mut err)
}
fn run<W: Write>(self, w: &mut W) -> Result<()> {
let spec = self.variant.spec();
let dest = match self.dest {
Some(p) => p,
None => spec
.default_dir()
.ok_or_else(|| anyhow!("could not determine home directory; pass --dest <path>"))?,
};
if !self.force && all_present(spec, &dest) {
writeln!(w, "model already installed at {}", dest.display())?;
return Ok(());
}
match spec.source {
ModelSource::HfHub { repo_id, revision } => {
download_hf_hub(spec, repo_id, revision, &dest, w)
}
ModelSource::HttpReleaseAsset { url, sha256, bytes } => {
download_release_asset(spec, url, sha256, bytes, &dest, w)
}
}
}
}
fn all_present(spec: &ModelSpec, dir: &Path) -> bool {
spec.required_files_in(dir)
.iter()
.all(|p| p.is_file() && p.metadata().is_ok_and(|m| m.len() > 0))
}
fn download_hf_hub<W: Write>(
spec: &ModelSpec,
repo_id: &str,
revision: &str,
dest: &Path,
w: &mut W,
) -> Result<()> {
writeln!(
w,
"Installing {repo_id} (revision {revision}) -> {}",
dest.display()
)?;
std::fs::create_dir_all(dest)
.with_context(|| format!("create install directory at {}", dest.display()))?;
let api = Api::new().context("initialise HuggingFace Hub client")?;
let repo = api.repo(Repo::with_revision(
repo_id.to_string(),
RepoType::Model,
revision.to_string(),
));
for file in spec.required_files {
let start = Instant::now();
write!(w, " fetching {file}... ")?;
w.flush()?;
let downloaded = repo.get(file).with_context(|| {
format!(
"download {file} from {repo_id} (revision {revision}). \
Check your network or set HTTPS_PROXY"
)
})?;
let target = dest.join(file);
atomic_install_copy(&downloaded, &target).with_context(|| {
format!(
"install {file} into {} (atomic rename failed)",
target.display()
)
})?;
let bytes = std::fs::metadata(&target).map_or(0, |m| m.len());
writeln!(
w,
"done ({bytes} bytes in {:.1}s)",
start.elapsed().as_secs_f64()
)?;
}
writeln!(
w,
"{} model installed at {}",
spec.kind_label,
dest.display()
)?;
Ok(())
}
fn download_release_asset<W: Write>(
spec: &ModelSpec,
url: &str,
expected_sha256: &str,
expected_bytes: u64,
dest: &Path,
w: &mut W,
) -> Result<()> {
if spec.required_files.len() != 1 {
bail!(
"HttpReleaseAsset source expects exactly one required_file, \
got {} for variant {}",
spec.required_files.len(),
spec.variant
);
}
let file_name = spec.required_files[0];
let target = dest.join(file_name);
writeln!(
w,
"Installing {file_name} ({expected_bytes} B) -> {}",
dest.display()
)?;
std::fs::create_dir_all(dest)
.with_context(|| format!("create install directory at {}", dest.display()))?;
let start = Instant::now();
write!(w, " fetching {url}... ")?;
w.flush()?;
let resp = ureq::get(url)
.call()
.with_context(|| format!("HTTP GET {url}"))?;
let status = resp.status();
if !status.is_success() {
bail!(
"HTTP {} fetching {url}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown"),
);
}
let bytes = resp
.into_body()
.read_to_vec()
.with_context(|| format!("read response body for {url}"))?;
let actual_sha = {
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(&mut hex, "{byte:02x}");
}
hex
};
if !actual_sha.eq_ignore_ascii_case(expected_sha256) {
bail!("SHA-256 mismatch for {file_name}: expected {expected_sha256}, got {actual_sha}");
}
atomic_install_bytes(&bytes, &target).with_context(|| {
format!(
"install {file_name} into {} (atomic rename failed)",
target.display()
)
})?;
writeln!(
w,
"done ({} bytes in {:.1}s; sha256 verified)",
bytes.len(),
start.elapsed().as_secs_f64()
)?;
writeln!(
w,
"{} model installed at {}",
spec.kind_label,
dest.display()
)?;
Ok(())
}
fn atomic_install_bytes(bytes: &[u8], to: &Path) -> Result<()> {
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
}
let tmp = part_sibling(to)?;
std::fs::write(&tmp, bytes)
.with_context(|| format!("write {} bytes -> {}", bytes.len(), tmp.display()))?;
std::fs::rename(&tmp, to)
.with_context(|| format!("rename {} -> {}", tmp.display(), to.display()))?;
Ok(())
}
fn atomic_install_copy(from: &Path, to: &Path) -> Result<()> {
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent dir {}", parent.display()))?;
}
let tmp = part_sibling(to)?;
std::fs::copy(from, &tmp)
.with_context(|| format!("copy {} -> {}", from.display(), tmp.display()))?;
std::fs::rename(&tmp, to)
.with_context(|| format!("rename {} -> {}", tmp.display(), to.display()))?;
Ok(())
}
fn part_sibling(to: &Path) -> Result<PathBuf> {
let file_name = to
.file_name()
.ok_or_else(|| anyhow!("destination path has no file name: {}", to.display()))?;
let mut tmp_name = std::ffi::OsString::from(".");
tmp_name.push(file_name);
tmp_name.push(".part");
Ok(to.with_file_name(tmp_name))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::voice::models::REQUIRED_FILES;
use std::sync::{Mutex, MutexGuard};
static ENV_GUARD: Mutex<()> = Mutex::new(());
fn env_guard() -> MutexGuard<'static, ()> {
match ENV_GUARD.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
fn stage_complete_whisper_model(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
for f in REQUIRED_FILES {
std::fs::write(dir.join(f), b"placeholder").unwrap();
}
}
fn stage_complete_speaker_model(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
for f in SPEAKER_WESPEAKER_EN.required_files {
std::fs::write(dir.join(f), b"placeholder").unwrap();
}
}
#[test]
fn idempotent_when_all_files_present() {
let tmp = tempfile::TempDir::new().unwrap();
stage_complete_whisper_model(tmp.path());
let cmd = InstallModelCommand {
dest: Some(tmp.path().to_path_buf()),
force: false,
variant: Variant::WhisperTinyEn,
};
let mut out: Vec<u8> = Vec::new();
cmd.run(&mut out).unwrap();
let msg = String::from_utf8(out).unwrap();
assert!(msg.contains("already installed"), "got: {msg}");
}
#[test]
fn idempotent_when_speaker_model_present() {
let tmp = tempfile::TempDir::new().unwrap();
stage_complete_speaker_model(tmp.path());
let cmd = InstallModelCommand {
dest: Some(tmp.path().to_path_buf()),
force: false,
variant: Variant::SpeakerWespeakerEn,
};
let mut out: Vec<u8> = Vec::new();
cmd.run(&mut out).unwrap();
let msg = String::from_utf8(out).unwrap();
assert!(msg.contains("already installed"), "got: {msg}");
}
#[test]
fn idempotent_skip_treats_zero_byte_file_as_missing() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path()).unwrap();
for f in REQUIRED_FILES {
std::fs::write(tmp.path().join(f), b"").unwrap();
}
assert!(!all_present(&WHISPER_TINY_EN, tmp.path()));
}
#[test]
fn atomic_install_copy_replaces_target() {
let tmp = tempfile::TempDir::new().unwrap();
let src = tmp.path().join("src");
let dst = tmp.path().join("dst");
std::fs::write(&src, b"hello").unwrap();
std::fs::write(&dst, b"old").unwrap();
atomic_install_copy(&src, &dst).unwrap();
let got = std::fs::read(&dst).unwrap();
assert_eq!(got, b"hello");
let leftover = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().ends_with(".part"));
assert!(!leftover, "atomic_install_copy must not leave .part files");
}
#[test]
fn atomic_install_bytes_writes_and_renames() {
let tmp = tempfile::TempDir::new().unwrap();
let dst = tmp.path().join("out");
atomic_install_bytes(b"hello", &dst).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
let leftover = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().ends_with(".part"));
assert!(!leftover, "atomic_install_bytes must not leave .part files");
}
#[test]
fn parses_no_args() {
#[derive(Parser)]
struct T {
#[command(flatten)]
c: InstallModelCommand,
}
let t = T::try_parse_from(["test"]).unwrap();
assert!(t.c.dest.is_none());
assert!(!t.c.force);
assert_eq!(t.c.variant, Variant::WhisperTinyEn);
}
#[test]
fn parses_dest_and_force() {
#[derive(Parser)]
struct T {
#[command(flatten)]
c: InstallModelCommand,
}
let t = T::try_parse_from(["test", "--dest", "/opt/x", "--force"]).unwrap();
assert_eq!(t.c.dest.as_deref(), Some(Path::new("/opt/x")));
assert!(t.c.force);
}
#[test]
fn parses_speaker_variant() {
#[derive(Parser)]
struct T {
#[command(flatten)]
c: InstallModelCommand,
}
let t = T::try_parse_from(["test", "--variant", "speaker-wespeaker-en"]).unwrap();
assert_eq!(t.c.variant, Variant::SpeakerWespeakerEn);
}
#[test]
fn parses_whisper_variant_explicit() {
#[derive(Parser)]
struct T {
#[command(flatten)]
c: InstallModelCommand,
}
let t = T::try_parse_from(["test", "--variant", "whisper-tiny.en"]).unwrap();
assert_eq!(t.c.variant, Variant::WhisperTinyEn);
}
#[test]
fn rejects_unknown_variant() {
#[derive(Parser)]
struct T {
#[command(flatten)]
c: InstallModelCommand,
}
let err = T::try_parse_from(["test", "--variant", "klingon"]);
assert!(err.is_err(), "unknown variant should fail to parse");
}
#[test]
fn run_with_dest_none_resolves_default_install_dir_from_home() {
let _g = env_guard();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let default_dir = WHISPER_TINY_EN.default_dir().unwrap();
stage_complete_whisper_model(&default_dir);
let cmd = InstallModelCommand {
dest: None,
force: false,
variant: Variant::WhisperTinyEn,
};
let mut out: Vec<u8> = Vec::new();
let result = cmd.run(&mut out);
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
result.unwrap();
let msg = String::from_utf8(out).unwrap();
assert!(msg.contains("already installed"), "got: {msg}");
assert!(
msg.contains("whisper-tiny.en"),
"expected resolved default dir in message, got: {msg}"
);
}
#[test]
fn run_speaker_variant_with_dest_none_resolves_default() {
let _g = env_guard();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let default_dir = SPEAKER_WESPEAKER_EN.default_dir().unwrap();
stage_complete_speaker_model(&default_dir);
let cmd = InstallModelCommand {
dest: None,
force: false,
variant: Variant::SpeakerWespeakerEn,
};
let mut out: Vec<u8> = Vec::new();
let result = cmd.run(&mut out);
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
result.unwrap();
let msg = String::from_utf8(out).unwrap();
assert!(msg.contains("already installed"), "got: {msg}");
assert!(
msg.contains("wespeaker-en-voxceleb-resnet34-LM"),
"expected resolved default dir in message, got: {msg}"
);
}
#[test]
fn variant_spec_returns_correct_spec() {
assert_eq!(
Variant::WhisperTinyEn.spec().variant,
WHISPER_TINY_EN.variant
);
assert_eq!(
Variant::SpeakerWespeakerEn.spec().variant,
SPEAKER_WESPEAKER_EN.variant
);
}
}