1use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15
16use anyhow::{anyhow, bail, Context, Result};
17use clap::{Parser, ValueEnum};
18use hf_hub::{api::sync::Api, Repo, RepoType};
19use sha2::{Digest, Sha256};
20
21use crate::voice::models::{ModelSource, ModelSpec, SPEAKER_WESPEAKER_EN, WHISPER_TINY_EN};
22
23#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
29pub enum Variant {
30 #[default]
32 #[value(name = "whisper-tiny.en")]
33 WhisperTinyEn,
34 #[value(name = "speaker-wespeaker-en")]
36 SpeakerWespeakerEn,
37}
38
39impl Variant {
40 pub fn spec(self) -> &'static ModelSpec {
42 match self {
43 Self::WhisperTinyEn => &WHISPER_TINY_EN,
44 Self::SpeakerWespeakerEn => &SPEAKER_WESPEAKER_EN,
45 }
46 }
47}
48
49#[derive(Parser)]
57pub struct InstallModelCommand {
58 #[arg(long)]
61 pub dest: Option<PathBuf>,
62
63 #[arg(long)]
65 pub force: bool,
66
67 #[arg(long, value_enum, default_value_t = Variant::WhisperTinyEn)]
69 pub variant: Variant,
70}
71
72impl InstallModelCommand {
73 pub fn execute(self) -> Result<()> {
77 let mut err = std::io::stderr().lock();
78 self.run(&mut err)
79 }
80
81 fn run<W: Write>(self, w: &mut W) -> Result<()> {
84 let home = dirs::home_dir();
85 self.run_in(w, home.as_deref())
86 }
87
88 fn run_in<W: Write>(self, w: &mut W, home: Option<&Path>) -> Result<()> {
94 let spec = self.variant.spec();
95 let dest = if let Some(p) = self.dest {
96 p
97 } else {
98 let home = home
99 .ok_or_else(|| anyhow!("could not determine home directory; pass --dest <path>"))?;
100 spec.default_dir_from(home)
101 };
102
103 if !self.force && all_present(spec, &dest) {
104 writeln!(w, "model already installed at {}", dest.display())?;
105 return Ok(());
106 }
107
108 match spec.source {
109 ModelSource::HfHub { repo_id, revision } => {
110 download_hf_hub(spec, repo_id, revision, &dest, w)
111 }
112 ModelSource::HttpReleaseAsset { url, sha256, bytes } => {
113 download_release_asset(spec, url, sha256, bytes, &dest, w)
114 }
115 }
116 }
117}
118
119fn all_present(spec: &ModelSpec, dir: &Path) -> bool {
120 spec.required_files_in(dir)
121 .iter()
122 .all(|p| p.is_file() && p.metadata().is_ok_and(|m| m.len() > 0))
123}
124
125fn download_hf_hub<W: Write>(
126 spec: &ModelSpec,
127 repo_id: &str,
128 revision: &str,
129 dest: &Path,
130 w: &mut W,
131) -> Result<()> {
132 writeln!(
133 w,
134 "Installing {repo_id} (revision {revision}) -> {}",
135 dest.display()
136 )?;
137 std::fs::create_dir_all(dest)
138 .with_context(|| format!("create install directory at {}", dest.display()))?;
139
140 let api = Api::new().context("initialise HuggingFace Hub client")?;
141 let repo = api.repo(Repo::with_revision(
142 repo_id.to_string(),
143 RepoType::Model,
144 revision.to_string(),
145 ));
146
147 for file in spec.required_files {
148 let start = Instant::now();
149 write!(w, " fetching {file}... ")?;
150 w.flush()?;
151 let downloaded = repo.get(file).with_context(|| {
152 format!(
153 "download {file} from {repo_id} (revision {revision}). \
154 Check your network or set HTTPS_PROXY"
155 )
156 })?;
157 let target = dest.join(file);
158 atomic_install_copy(&downloaded, &target).with_context(|| {
159 format!(
160 "install {file} into {} (atomic rename failed)",
161 target.display()
162 )
163 })?;
164 let bytes = std::fs::metadata(&target).map_or(0, |m| m.len());
165 writeln!(
166 w,
167 "done ({bytes} bytes in {:.1}s)",
168 start.elapsed().as_secs_f64()
169 )?;
170 }
171
172 writeln!(
173 w,
174 "{} model installed at {}",
175 spec.kind_label,
176 dest.display()
177 )?;
178 Ok(())
179}
180
181fn download_release_asset<W: Write>(
182 spec: &ModelSpec,
183 url: &str,
184 expected_sha256: &str,
185 expected_bytes: u64,
186 dest: &Path,
187 w: &mut W,
188) -> Result<()> {
189 if spec.required_files.len() != 1 {
194 bail!(
195 "HttpReleaseAsset source expects exactly one required_file, \
196 got {} for variant {}",
197 spec.required_files.len(),
198 spec.variant
199 );
200 }
201 let file_name = spec.required_files[0];
202 let target = dest.join(file_name);
203
204 writeln!(
205 w,
206 "Installing {file_name} ({expected_bytes} B) -> {}",
207 dest.display()
208 )?;
209 std::fs::create_dir_all(dest)
210 .with_context(|| format!("create install directory at {}", dest.display()))?;
211
212 let start = Instant::now();
213 write!(w, " fetching {url}... ")?;
214 w.flush()?;
215
216 let resp = ureq::get(url)
217 .call()
218 .with_context(|| format!("HTTP GET {url}"))?;
219 let status = resp.status();
220 if !status.is_success() {
221 bail!(
222 "HTTP {} fetching {url}: {}",
223 status.as_u16(),
224 status.canonical_reason().unwrap_or("Unknown"),
225 );
226 }
227 let bytes = resp
228 .into_body()
229 .read_to_vec()
230 .with_context(|| format!("read response body for {url}"))?;
231
232 let actual_sha = {
233 let mut hasher = Sha256::new();
234 hasher.update(&bytes);
235 let digest = hasher.finalize();
236 let mut hex = String::with_capacity(digest.len() * 2);
237 for byte in digest {
238 use std::fmt::Write as _;
239 let _ = write!(&mut hex, "{byte:02x}");
241 }
242 hex
243 };
244 if !actual_sha.eq_ignore_ascii_case(expected_sha256) {
245 bail!("SHA-256 mismatch for {file_name}: expected {expected_sha256}, got {actual_sha}");
246 }
247
248 atomic_install_bytes(&bytes, &target).with_context(|| {
249 format!(
250 "install {file_name} into {} (atomic rename failed)",
251 target.display()
252 )
253 })?;
254 writeln!(
255 w,
256 "done ({} bytes in {:.1}s; sha256 verified)",
257 bytes.len(),
258 start.elapsed().as_secs_f64()
259 )?;
260 writeln!(
261 w,
262 "{} model installed at {}",
263 spec.kind_label,
264 dest.display()
265 )?;
266 Ok(())
267}
268
269fn atomic_install_bytes(bytes: &[u8], to: &Path) -> Result<()> {
272 if let Some(parent) = to.parent() {
273 std::fs::create_dir_all(parent)
274 .with_context(|| format!("create parent dir {}", parent.display()))?;
275 }
276 let tmp = part_sibling(to)?;
277 std::fs::write(&tmp, bytes)
278 .with_context(|| format!("write {} bytes -> {}", bytes.len(), tmp.display()))?;
279 std::fs::rename(&tmp, to)
280 .with_context(|| format!("rename {} -> {}", tmp.display(), to.display()))?;
281 Ok(())
282}
283
284fn atomic_install_copy(from: &Path, to: &Path) -> Result<()> {
287 if let Some(parent) = to.parent() {
288 std::fs::create_dir_all(parent)
289 .with_context(|| format!("create parent dir {}", parent.display()))?;
290 }
291 let tmp = part_sibling(to)?;
292 std::fs::copy(from, &tmp)
293 .with_context(|| format!("copy {} -> {}", from.display(), tmp.display()))?;
294 std::fs::rename(&tmp, to)
295 .with_context(|| format!("rename {} -> {}", tmp.display(), to.display()))?;
296 Ok(())
297}
298
299fn part_sibling(to: &Path) -> Result<PathBuf> {
300 let file_name = to
301 .file_name()
302 .ok_or_else(|| anyhow!("destination path has no file name: {}", to.display()))?;
303 let mut tmp_name = std::ffi::OsString::from(".");
304 tmp_name.push(file_name);
305 tmp_name.push(".part");
306 Ok(to.with_file_name(tmp_name))
307}
308
309#[cfg(test)]
310#[allow(clippy::unwrap_used, clippy::expect_used)]
311mod tests {
312 use super::*;
313 use crate::voice::models::REQUIRED_FILES;
314
315 fn stage_complete_whisper_model(dir: &Path) {
316 std::fs::create_dir_all(dir).unwrap();
317 for f in REQUIRED_FILES {
318 std::fs::write(dir.join(f), b"placeholder").unwrap();
319 }
320 }
321
322 fn stage_complete_speaker_model(dir: &Path) {
323 std::fs::create_dir_all(dir).unwrap();
324 for f in SPEAKER_WESPEAKER_EN.required_files {
325 std::fs::write(dir.join(f), b"placeholder").unwrap();
326 }
327 }
328
329 #[test]
330 fn idempotent_when_all_files_present() {
331 let tmp = tempfile::TempDir::new().unwrap();
332 stage_complete_whisper_model(tmp.path());
333
334 let cmd = InstallModelCommand {
335 dest: Some(tmp.path().to_path_buf()),
336 force: false,
337 variant: Variant::WhisperTinyEn,
338 };
339 let mut out: Vec<u8> = Vec::new();
340 cmd.run(&mut out).unwrap();
341 let msg = String::from_utf8(out).unwrap();
342 assert!(msg.contains("already installed"), "got: {msg}");
343 }
344
345 #[test]
346 fn idempotent_when_speaker_model_present() {
347 let tmp = tempfile::TempDir::new().unwrap();
348 stage_complete_speaker_model(tmp.path());
349
350 let cmd = InstallModelCommand {
351 dest: Some(tmp.path().to_path_buf()),
352 force: false,
353 variant: Variant::SpeakerWespeakerEn,
354 };
355 let mut out: Vec<u8> = Vec::new();
356 cmd.run(&mut out).unwrap();
357 let msg = String::from_utf8(out).unwrap();
358 assert!(msg.contains("already installed"), "got: {msg}");
359 }
360
361 #[test]
362 fn idempotent_skip_treats_zero_byte_file_as_missing() {
363 let tmp = tempfile::TempDir::new().unwrap();
364 std::fs::create_dir_all(tmp.path()).unwrap();
365 for f in REQUIRED_FILES {
366 std::fs::write(tmp.path().join(f), b"").unwrap();
369 }
370 assert!(!all_present(&WHISPER_TINY_EN, tmp.path()));
371 }
372
373 #[test]
374 fn atomic_install_copy_replaces_target() {
375 let tmp = tempfile::TempDir::new().unwrap();
376 let src = tmp.path().join("src");
377 let dst = tmp.path().join("dst");
378 std::fs::write(&src, b"hello").unwrap();
379 std::fs::write(&dst, b"old").unwrap();
380 atomic_install_copy(&src, &dst).unwrap();
381 let got = std::fs::read(&dst).unwrap();
382 assert_eq!(got, b"hello");
383 let leftover = std::fs::read_dir(tmp.path())
385 .unwrap()
386 .filter_map(Result::ok)
387 .any(|e| e.file_name().to_string_lossy().ends_with(".part"));
388 assert!(!leftover, "atomic_install_copy must not leave .part files");
389 }
390
391 #[test]
392 fn atomic_install_bytes_writes_and_renames() {
393 let tmp = tempfile::TempDir::new().unwrap();
394 let dst = tmp.path().join("out");
395 atomic_install_bytes(b"hello", &dst).unwrap();
396 assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
397 let leftover = std::fs::read_dir(tmp.path())
398 .unwrap()
399 .filter_map(Result::ok)
400 .any(|e| e.file_name().to_string_lossy().ends_with(".part"));
401 assert!(!leftover, "atomic_install_bytes must not leave .part files");
402 }
403
404 #[test]
405 fn parses_no_args() {
406 #[derive(Parser)]
407 struct T {
408 #[command(flatten)]
409 c: InstallModelCommand,
410 }
411 let t = T::try_parse_from(["test"]).unwrap();
412 assert!(t.c.dest.is_none());
413 assert!(!t.c.force);
414 assert_eq!(t.c.variant, Variant::WhisperTinyEn);
415 }
416
417 #[test]
418 fn parses_dest_and_force() {
419 #[derive(Parser)]
420 struct T {
421 #[command(flatten)]
422 c: InstallModelCommand,
423 }
424 let t = T::try_parse_from(["test", "--dest", "/opt/x", "--force"]).unwrap();
425 assert_eq!(t.c.dest.as_deref(), Some(Path::new("/opt/x")));
426 assert!(t.c.force);
427 }
428
429 #[test]
430 fn parses_speaker_variant() {
431 #[derive(Parser)]
432 struct T {
433 #[command(flatten)]
434 c: InstallModelCommand,
435 }
436 let t = T::try_parse_from(["test", "--variant", "speaker-wespeaker-en"]).unwrap();
437 assert_eq!(t.c.variant, Variant::SpeakerWespeakerEn);
438 }
439
440 #[test]
441 fn parses_whisper_variant_explicit() {
442 #[derive(Parser)]
443 struct T {
444 #[command(flatten)]
445 c: InstallModelCommand,
446 }
447 let t = T::try_parse_from(["test", "--variant", "whisper-tiny.en"]).unwrap();
448 assert_eq!(t.c.variant, Variant::WhisperTinyEn);
449 }
450
451 #[test]
452 fn rejects_unknown_variant() {
453 #[derive(Parser)]
454 struct T {
455 #[command(flatten)]
456 c: InstallModelCommand,
457 }
458 let err = T::try_parse_from(["test", "--variant", "klingon"]);
459 assert!(err.is_err(), "unknown variant should fail to parse");
460 }
461
462 #[test]
463 fn run_with_dest_none_resolves_default_install_dir_from_home() {
464 let tmp = tempfile::TempDir::new().unwrap();
470 let default_dir = WHISPER_TINY_EN.default_dir_from(tmp.path());
471 stage_complete_whisper_model(&default_dir);
472
473 let cmd = InstallModelCommand {
474 dest: None,
475 force: false,
476 variant: Variant::WhisperTinyEn,
477 };
478 let mut out: Vec<u8> = Vec::new();
479 cmd.run_in(&mut out, Some(tmp.path())).unwrap();
480
481 let msg = String::from_utf8(out).unwrap();
482 assert!(msg.contains("already installed"), "got: {msg}");
483 assert!(
484 msg.contains("whisper-tiny.en"),
485 "expected resolved default dir in message, got: {msg}"
486 );
487 }
488
489 #[test]
490 fn run_speaker_variant_with_dest_none_resolves_default() {
491 let tmp = tempfile::TempDir::new().unwrap();
492 let default_dir = SPEAKER_WESPEAKER_EN.default_dir_from(tmp.path());
493 stage_complete_speaker_model(&default_dir);
494
495 let cmd = InstallModelCommand {
496 dest: None,
497 force: false,
498 variant: Variant::SpeakerWespeakerEn,
499 };
500 let mut out: Vec<u8> = Vec::new();
501 cmd.run_in(&mut out, Some(tmp.path())).unwrap();
502
503 let msg = String::from_utf8(out).unwrap();
504 assert!(msg.contains("already installed"), "got: {msg}");
505 assert!(
506 msg.contains("wespeaker-en-voxceleb-resnet34-LM"),
507 "expected resolved default dir in message, got: {msg}"
508 );
509 }
510
511 #[test]
512 fn run_in_errors_when_home_unavailable_and_dest_none() {
513 let cmd = InstallModelCommand {
516 dest: None,
517 force: false,
518 variant: Variant::WhisperTinyEn,
519 };
520 let mut out: Vec<u8> = Vec::new();
521 let err = cmd.run_in(&mut out, None).unwrap_err();
522 assert!(
523 format!("{err:#}").contains("could not determine home directory"),
524 "got: {err:#}"
525 );
526 }
527
528 #[test]
529 fn variant_spec_returns_correct_spec() {
530 assert_eq!(
531 Variant::WhisperTinyEn.spec().variant,
532 WHISPER_TINY_EN.variant
533 );
534 assert_eq!(
535 Variant::SpeakerWespeakerEn.spec().variant,
536 SPEAKER_WESPEAKER_EN.variant
537 );
538 }
539}