1use std::io::Read as _;
12use std::path::Path;
13use std::time::{Duration, SystemTime};
14
15use serde::{Deserialize, Serialize};
16
17use crate::Error;
18
19pub const SCHEMA: &str = "transcribe-catalog-v1";
21
22const REFERENCE_QUANTS: [&str; 3] = ["Q8_0", "F16", "BF16"];
26
27pub const PREFERRED_DOWNLOADS: [&str; 4] = ["Q4_K_M", "Q8_0", "F16", "BF16"];
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct CatalogueEntry {
36 pub variant: String,
37 #[serde(default)]
39 pub display_name: String,
40 #[serde(default)]
41 pub family: String,
42 #[serde(default)]
46 pub params: Option<u64>,
47 #[serde(default)]
48 pub license: Option<License>,
49 #[serde(default)]
50 pub languages: Vec<String>,
51 #[serde(default)]
52 pub capabilities: Capabilities,
53 #[serde(default)]
54 pub headline_benchmark: Option<HeadlineBenchmark>,
55 #[serde(default)]
59 pub published_repo: Option<String>,
60 #[serde(default)]
61 pub downloads: Vec<Download>,
62 #[serde(default)]
63 pub speed_benchmarks: Vec<SpeedRow>,
64 #[serde(default)]
65 pub accuracy_benchmarks: Vec<AccuracyRow>,
66 #[serde(default)]
70 pub kind: EntryKind,
71 #[serde(default)]
76 pub download_count: Option<u64>,
77}
78
79impl CatalogueEntry {
80 pub fn streaming(&self) -> bool {
82 self.capabilities.streaming.supported
83 }
84
85 pub fn download_for(&self, filename: &str) -> Option<&Download> {
88 self.downloads.iter().find(|download| download.filename == filename)
89 }
90
91 pub fn m4_metal_speed_row(&self) -> Option<&SpeedRow> {
94 self.speed_benchmarks
95 .iter()
96 .filter(|row| {
97 row.machine == "m4-max" && row.backend == "metal" && row.xrt_wall.is_some()
98 })
99 .max_by(|a, b| {
100 a.xrt_wall.unwrap_or_default().total_cmp(&b.xrt_wall.unwrap_or_default())
101 })
102 }
103
104 pub fn m4_metal_xrt_wall(&self) -> Option<f64> {
107 self.m4_metal_speed_row().and_then(|row| row.xrt_wall)
108 }
109
110 pub fn fleurs_en_wer(&self) -> Option<f64> {
112 self.wer_for("fleurs", "test", "en")
113 }
114
115 pub fn headline_wer(&self) -> Option<f64> {
117 let headline = self.headline_benchmark.as_ref()?;
118 self.wer_for(&headline.dataset, &headline.split, &headline.language)
119 }
120
121 fn wer_for(&self, dataset: &str, split: &str, language: &str) -> Option<f64> {
122 REFERENCE_QUANTS.iter().find_map(|quant| {
123 self.accuracy_benchmarks
124 .iter()
125 .find(|row| {
126 row.dataset == dataset
127 && row.split == split
128 && row.language == language
129 && &row.quant == quant
130 })
131 .and_then(|row| row.err_pct)
132 })
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct License {
139 #[serde(default)]
140 pub spdx: String,
141 #[serde(default)]
142 pub display: String,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147pub struct Capabilities {
148 #[serde(default)]
149 pub streaming: Support,
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154pub struct Support {
155 #[serde(default)]
156 pub supported: bool,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct HeadlineBenchmark {
162 #[serde(default)]
163 pub dataset: String,
164 #[serde(default)]
165 pub split: String,
166 #[serde(default)]
167 pub language: String,
168 #[serde(default)]
169 pub metric: String,
170}
171
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum EntryKind {
178 #[default]
180 Asr,
181 Normalizer,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct Download {
188 #[serde(default)]
189 pub quant: String,
190 #[serde(default)]
191 pub filename: String,
192 #[serde(default)]
193 pub size_bytes: u64,
194 #[serde(default)]
198 pub sha256: Option<String>,
199 #[serde(default)]
204 pub url: Option<String>,
205}
206
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct SpeedRow {
211 #[serde(default)]
212 pub machine: String,
213 #[serde(default)]
214 pub backend: String,
215 #[serde(default)]
216 pub quant: String,
217 #[serde(default)]
218 pub xrt_wall: Option<f64>,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct AccuracyRow {
224 #[serde(default)]
225 pub dataset: String,
226 #[serde(default)]
227 pub split: String,
228 #[serde(default)]
229 pub language: String,
230 #[serde(default)]
231 pub quant: String,
232 #[serde(default)]
233 pub err_pct: Option<f64>,
234}
235
236pub fn parse_entry(raw: &str) -> Result<CatalogueEntry, Error> {
239 let value: serde_json::Value = serde_json::from_str(raw).map_err(|source| {
240 Error::Catalogue { message: format!("the document is not JSON: {source}") }
241 })?;
242 match value.get("schema").and_then(serde_json::Value::as_str) {
243 Some(SCHEMA) => {}
244 Some(other) => {
245 return Err(Error::Catalogue {
246 message: format!(
247 "the document's schema is {other:?}, and this build reads {SCHEMA:?}"
248 ),
249 });
250 }
251 None => {
252 return Err(Error::Catalogue {
253 message: format!("the document carries no {SCHEMA:?} schema"),
254 });
255 }
256 }
257 let mut entry: CatalogueEntry = serde_json::from_value(value).map_err(|source| {
258 Error::Catalogue { message: format!("the entry does not parse: {source}") }
259 })?;
260 if entry.display_name.is_empty() {
261 entry.display_name.clone_from(&entry.variant);
262 }
263 Ok(entry)
264}
265
266pub(crate) const MAX_RESPONSE_BYTES: u64 = 4 << 20;
270
271const FETCH_WORKERS: usize = 6;
275
276#[derive(Debug, Clone)]
278pub struct CatalogueSource {
279 pub listing: String,
281 pub entry_base: String,
283 pub release: String,
285 pub doc_base: String,
288 pub repo_base: String,
292}
293
294impl Default for CatalogueSource {
295 fn default() -> Self {
296 Self {
297 listing: "https://api.github.com/repos/handy-computer/transcribe.cpp/contents/catalog"
298 .to_owned(),
299 entry_base:
300 "https://raw.githubusercontent.com/handy-computer/transcribe.cpp/main/catalog/"
301 .to_owned(),
302 release: "https://api.github.com/repos/handy-computer/transcribe.cpp/releases/latest"
303 .to_owned(),
304 doc_base:
305 "https://raw.githubusercontent.com/handy-computer/transcribe.cpp/main/docs/models/"
306 .to_owned(),
307 repo_base: "https://huggingface.co/".to_owned(),
308 }
309 }
310}
311
312const DOWNLOADS_OPEN: &str = "<!-- catalog:downloads -->";
314const TABLE_CLOSE: &str = "<!-- /catalog -->";
315
316pub fn doc_links(raw: &str) -> Vec<(String, String)> {
328 let Some(region) = raw
329 .split_once(DOWNLOADS_OPEN)
330 .and_then(|(_, rest)| rest.split_once(TABLE_CLOSE))
331 .map(|(table, _)| table)
332 else {
333 return Vec::new();
334 };
335 gguf_links(region)
336}
337
338pub fn file_links(raw: &str) -> Vec<(String, String)> {
344 gguf_links(raw)
345}
346
347fn gguf_links(region: &str) -> Vec<(String, String)> {
348 let mut links = Vec::new();
349 for target in region.lines().flat_map(link_targets) {
350 let Some((_, file)) = target.rsplit_once('/') else {
351 continue;
352 };
353 let is_gguf = std::path::Path::new(file)
354 .extension()
355 .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf"));
356 if !is_gguf {
357 continue;
358 }
359 links.push((file.to_owned(), target));
360 }
361 links
362}
363
364fn link_targets(line: &str) -> Vec<String> {
366 let mut targets = Vec::new();
367 let mut rest = line;
368 while let Some(at) = rest.find("](") {
369 let after = &rest[at + 2..];
370 let Some(end) = after.find(')') else {
371 break;
372 };
373 targets.push(after[..end].to_owned());
374 rest = &after[end..];
375 }
376 targets
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct Catalogue {
382 pub fetched_at: String,
384 pub release: Option<String>,
386 pub entries: Vec<CatalogueEntry>,
387 pub skipped: usize,
390}
391
392pub fn fetch_catalogue(source: &CatalogueSource) -> Result<Catalogue, Error> {
399 let client = feed_client(&source.listing)?;
400 let listing = get_bounded_text(&client, &source.listing, MAX_RESPONSE_BYTES)?;
401 let names = listed_files(&listing);
402 if names.is_empty() {
403 return Err(Error::Catalogue {
404 message: format!("the listing at {} named no entry documents", source.listing),
405 });
406 }
407
408 let chunk_size = names.len().div_ceil(FETCH_WORKERS).max(1);
409 let (entries, skipped) = std::thread::scope(|scope| {
410 let workers: Vec<_> = names
411 .chunks(chunk_size)
412 .map(|chunk| {
413 let client = &client;
414 let base = source.entry_base.as_str();
415 scope.spawn(move || fetch_chunk(client, base, chunk))
416 })
417 .collect();
418 let mut entries = Vec::new();
419 let mut skipped = 0_usize;
420 for worker in workers {
421 let (chunk_entries, chunk_skipped) =
422 worker.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic));
423 entries.extend(chunk_entries);
424 skipped += chunk_skipped;
425 }
426 (entries, skipped)
427 });
428
429 if entries.is_empty() {
430 return Err(Error::Catalogue {
431 message: format!(
432 "none of the {} catalogue entries listed under {} could be read",
433 names.len(),
434 source.entry_base
435 ),
436 });
437 }
438
439 let release = get_bounded_text(&client, &source.release, MAX_RESPONSE_BYTES)
442 .ok()
443 .and_then(|raw| release_tag(&raw));
444
445 Ok(Catalogue { fetched_at: rfc3339_now(), release, entries, skipped })
446}
447
448fn listed_files(listing: &str) -> Vec<String> {
451 let Ok(files) = serde_json::from_str::<Vec<serde_json::Value>>(listing) else {
452 return Vec::new();
453 };
454 files
455 .iter()
456 .filter(|file| file.get("type").and_then(serde_json::Value::as_str) == Some("file"))
457 .filter_map(|file| file.get("name").and_then(serde_json::Value::as_str))
458 .filter(|name| {
459 Path::new(name).extension().is_some_and(|ext| ext == "json") && !name.starts_with('_')
460 })
461 .map(str::to_owned)
462 .collect()
463}
464
465fn fetch_chunk(
468 client: &reqwest::blocking::Client,
469 base: &str,
470 names: &[String],
471) -> (Vec<CatalogueEntry>, usize) {
472 let mut entries = Vec::new();
473 let mut skipped = 0_usize;
474 for name in names {
475 let url = format!("{base}{name}");
476 match get_bounded_text(client, &url, MAX_RESPONSE_BYTES).and_then(|raw| parse_entry(&raw)) {
477 Ok(entry) => entries.push(entry),
478 Err(error) => {
479 skipped += 1;
480 tracing::debug!(
481 event_name = "dictate_catalogue_entry_skipped",
482 %url,
483 %error,
484 "a catalogue entry could not be read; the rest of the feed stands"
485 );
486 }
487 }
488 }
489 (entries, skipped)
490}
491
492pub fn fetch_doc(source: &CatalogueSource, variant: &str) -> Result<String, Error> {
497 let client = feed_client(&source.doc_base)?;
498 let url = format!("{}{variant}.md", source.doc_base);
499 get_bounded_text(&client, &url, MAX_RESPONSE_BYTES)
500}
501
502pub fn download_links(
516 source: &CatalogueSource,
517 variant: &str,
518 published_repo: Option<&str>,
519) -> Result<Vec<(String, String)>, Error> {
520 let mut tried = Vec::new();
521
522 let doc_url = format!("{}{variant}.md", source.doc_base);
523 match fetch_doc(source, variant) {
524 Ok(raw) => {
525 let links = doc_links(&raw);
526 if !links.is_empty() {
527 return Ok(links);
528 }
529 tried.push(format!("{doc_url} carries no download table"));
530 }
531 Err(error) => tried.push(error.to_string()),
532 }
533
534 if let Some(repo) = published_repo {
535 let readme_url = format!("{}{repo}/raw/main/README.md", source.repo_base);
536 let client = feed_client(&source.repo_base)?;
537 match get_bounded_text(&client, &readme_url, MAX_RESPONSE_BYTES) {
538 Ok(raw) => {
539 let links = file_links(&raw);
540 if !links.is_empty() {
541 return Ok(links);
542 }
543 tried.push(format!("{readme_url} carries no .gguf link"));
544 }
545 Err(error) => tried.push(error.to_string()),
546 }
547 }
548
549 Err(Error::Catalogue {
550 message: format!("no download links for {variant}: {}", tried.join("; ")),
551 })
552}
553
554pub(crate) fn feed_client(for_url: &str) -> Result<reqwest::blocking::Client, Error> {
557 reqwest::blocking::Client::builder()
558 .connect_timeout(Duration::from_secs(30))
559 .timeout(Duration::from_secs(60))
560 .user_agent(concat!("forge-dictate/", env!("CARGO_PKG_VERSION")))
561 .build()
562 .map_err(|error| Error::Http { url: for_url.to_owned(), source: error })
563}
564
565pub(crate) fn get_bounded_text(
568 client: &reqwest::blocking::Client,
569 url: &str,
570 cap: u64,
571) -> Result<String, Error> {
572 let response =
573 client.get(url).send().map_err(|source| Error::Http { url: url.to_owned(), source })?;
574 let status = response.status();
575 if !status.is_success() {
576 return Err(Error::HttpStatus { url: url.to_owned(), status: status.as_u16() });
577 }
578 let mut body = String::new();
579 response.take(cap + 1).read_to_string(&mut body).map_err(|source| Error::Catalogue {
580 message: format!("{url} could not be read: {source}"),
581 })?;
582 if body.len() as u64 > cap {
583 return Err(Error::Catalogue {
584 message: format!("{url} answered more than {cap} bytes; refused unread"),
585 });
586 }
587 Ok(body)
588}
589
590fn release_tag(raw: &str) -> Option<String> {
592 serde_json::from_str::<serde_json::Value>(raw)
593 .ok()?
594 .get("tag_name")?
595 .as_str()
596 .map(str::to_owned)
597}
598
599fn rfc3339_now() -> String {
602 let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
603 time::OffsetDateTime::from_unix_timestamp(i64::try_from(now.as_secs()).unwrap_or(0))
604 .ok()
605 .and_then(|at| at.format(&time::format_description::well_known::Rfc3339).ok())
606 .unwrap_or_default()
607}
608
609const CATALOGUE_CACHE_FILE: &str = "catalogue.json";
612
613const CATALOGUE_CACHE_VERSION: u32 = 2;
624
625#[derive(Serialize, Deserialize)]
626struct CatalogueFile {
627 version: u32,
628 fetched_at: String,
629 release: Option<String>,
630 entries: Vec<CatalogueEntry>,
631 skipped: usize,
632}
633
634pub fn read_catalogue_cache(dir: &Path) -> Result<Option<Catalogue>, Error> {
637 let file = dir.join(CATALOGUE_CACHE_FILE);
638 let raw = match std::fs::read_to_string(&file) {
639 Ok(raw) => raw,
640 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
641 Err(source) => return Err(Error::Io { path: file, source }),
642 };
643 let value: serde_json::Value =
644 serde_json::from_str(&raw).map_err(|source| Error::Catalogue {
645 message: format!("{} is not a catalogue cache: {source}", file.display()),
646 })?;
647 if value.get("version").and_then(serde_json::Value::as_u64)
651 != Some(u64::from(CATALOGUE_CACHE_VERSION))
652 {
653 return Ok(None);
654 }
655 let parsed: CatalogueFile =
656 serde_json::from_value(value).map_err(|source| Error::Catalogue {
657 message: format!("{} is not a catalogue cache: {source}", file.display()),
658 })?;
659 Ok(Some(Catalogue {
660 fetched_at: parsed.fetched_at,
661 release: parsed.release,
662 entries: parsed.entries,
663 skipped: parsed.skipped,
664 }))
665}
666
667pub fn write_catalogue_cache(dir: &Path, catalogue: &Catalogue) -> Result<(), Error> {
670 let body = serde_json::to_string(&CatalogueFile {
671 version: CATALOGUE_CACHE_VERSION,
672 fetched_at: catalogue.fetched_at.clone(),
673 release: catalogue.release.clone(),
674 entries: catalogue.entries.clone(),
675 skipped: catalogue.skipped,
676 })
677 .map_err(|source| Error::Catalogue {
678 message: format!("the catalogue did not serialise: {source}"),
679 })?;
680 std::fs::create_dir_all(dir).map_err(|source| Error::Io { path: dir.to_path_buf(), source })?;
681 let file = dir.join(CATALOGUE_CACHE_FILE);
685 let partial = dir.join(format!("{CATALOGUE_CACHE_FILE}.tmp"));
686 std::fs::write(&partial, body).map_err(|source| Error::Io { path: partial.clone(), source })?;
687 std::fs::rename(&partial, &file).map_err(|source| Error::Io { path: file, source })
688}
689
690#[cfg(test)]
691mod tests_catalogue {
692 use super::*;
693
694 fn fixture(name: &str) -> String {
695 let path = format!("{}/fixtures/catalogue/{name}", env!("CARGO_MANIFEST_DIR"));
696 std::fs::read_to_string(&path).expect("the fixture must be readable")
697 }
698
699 #[test]
701 fn a_real_entry_parses_into_the_identity_the_page_draws() {
702 let entry = parse_entry(&fixture("cohere-transcribe-03-2026.json"))
703 .expect("the published entry must parse");
704
705 assert_eq!(entry.variant, "cohere-transcribe-03-2026");
706 assert_eq!(entry.params, Some(2_049_026_832), "the parameter count is the feed's own");
707 assert_eq!(
708 entry.license.as_ref().map(|l| l.display.as_str()),
709 Some("Apache-2.0"),
710 "the licence crosses in the form the feed displays"
711 );
712 assert_eq!(entry.languages.len(), 14, "every language the feed lists is carried");
713 assert!(entry.languages.iter().any(|l| l == "en"));
714 assert!(!entry.streaming(), "cohere-transcribe is not a streaming variant");
715 }
716
717 #[test]
720 fn the_pinned_file_is_found_among_the_downloads() {
721 let entry = parse_entry(&fixture("cohere-transcribe-03-2026.json")).expect("parse");
722
723 let download = entry
724 .download_for("cohere-transcribe-03-2026-Q4_K_M.gguf")
725 .expect("the pinned quant must be carried");
726 assert_eq!(download.quant, "Q4_K_M");
727 assert_eq!(
728 download.size_bytes, 1_558_162_944,
729 "the size is the witness the pin is joined on"
730 );
731
732 assert!(
733 entry.download_for("nothing-ships-this-name.gguf").is_none(),
734 "a name the feed does not carry must answer None, not a guess"
735 );
736 }
737
738 #[test]
741 fn an_entry_from_another_schema_is_refused_by_name() {
742 let err = parse_entry(r#"{"schema": "transcribe-catalog-v2", "variant": "x"}"#)
743 .expect_err("a foreign schema must be refused");
744 assert!(
745 err.to_string().contains("transcribe-catalog-v2"),
746 "the refusal must name the schema it saw, got: {err}"
747 );
748 }
749
750 #[test]
753 fn a_document_that_is_not_an_entry_is_refused_rather_than_half_read() {
754 let err =
755 parse_entry(r#"{"hello": 1}"#).expect_err("a document with no schema is not an entry");
756 assert!(
757 err.to_string().contains("schema"),
758 "the refusal must say the document carries no catalogue schema, got: {err}"
759 );
760
761 let err = parse_entry(r#"{"schema": "transcribe-catalog-v1"}"#)
762 .expect_err("a right-schema document with no variant is not usable");
763 assert!(
764 err.to_string().contains("variant"),
765 "the refusal must say which field was missing, got: {err}"
766 );
767 assert!(parse_entry("not json").is_err(), "a non-document must be refused too");
768 }
769
770 #[test]
773 fn the_measured_facts_come_off_the_rows_the_page_compares_on() {
774 let cohere = parse_entry(&fixture("cohere-transcribe-03-2026.json")).expect("parse");
775 assert!(
776 (cohere.m4_metal_xrt_wall().expect("a metal speed row") - 72.88).abs() < 1e-9,
777 "cohere's m4-max metal xrt_wall, got {:?}",
778 cohere.m4_metal_xrt_wall()
779 );
780 assert_eq!(cohere.fleurs_en_wer(), Some(5.08), "FLEURS-en at the reference quant");
781 assert_eq!(
782 cohere.headline_wer(),
783 Some(1.27),
784 "the headline row at the reference quant, not whichever row the file lists first"
785 );
786
787 let granite =
788 parse_entry(&fixture("granite-speech-5.0-470m-turboctc.json")).expect("parse");
789 assert!(
790 (granite.m4_metal_xrt_wall().expect("a metal speed row") - 388.76).abs() < 1e-9,
791 "granite's speed is the number the update line shows beside cohere's"
792 );
793 assert_eq!(granite.fleurs_en_wer(), Some(4.61));
794 assert_eq!(granite.headline_wer(), Some(1.33));
795 }
796
797 #[test]
800 fn the_speed_fact_is_the_row_the_best_number_came_from() {
801 let cohere = parse_entry(&fixture("cohere-transcribe-03-2026.json")).expect("parse");
802 let row = cohere.m4_metal_speed_row().expect("a metal speed row");
803 assert_eq!(row.machine, "m4-max");
804 assert_eq!(row.backend, "metal");
805 assert_eq!(row.quant, "Q8_0");
806 assert_eq!(row.xrt_wall, Some(72.88));
807 }
808
809 #[test]
812 fn an_entry_with_only_the_required_fields_parses_with_empty_optionals() {
813 let entry = parse_entry(r#"{"schema": "transcribe-catalog-v1", "variant": "tiny"}"#)
814 .expect("the required fields are all it must carry");
815
816 assert_eq!(entry.variant, "tiny");
817 assert_eq!(entry.display_name, "tiny", "a missing display name falls back to the variant");
818 assert!(entry.languages.is_empty());
819 assert!(entry.downloads.is_empty());
820 assert_eq!(entry.m4_metal_xrt_wall(), None, "no rows, no number");
821 assert_eq!(entry.fleurs_en_wer(), None);
822 assert!(!entry.streaming(), "absent capabilities are not a claim of support");
823 }
824
825 #[test]
831 fn parses_the_docs_marked_table_into_filenames_and_urls() {
832 let raw = include_str!("../tests/fixtures/docs/granite-speech-5.0-470m-turboctc.md");
833 let links = doc_links(raw);
834
835 let expected = "granite-speech-5.0-470m-turboctc-Q4_K_M.gguf";
836 let (file, url) = links
837 .iter()
838 .find(|(file, _)| file == expected)
839 .expect("the table's Q4 row did not parse");
840 assert_eq!(file, expected, "the file is the URL's own last segment");
841 assert!(
842 url.starts_with("https://huggingface.co/handy-computer/"),
843 "the URL is the table's own, got {url}"
844 );
845 assert_eq!(links.len(), 6, "one link per quant row");
846 assert!(
847 links.iter().all(|(file, _)| std::path::Path::new(file)
848 .extension()
849 .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf"))),
850 "a row without a .gguf file parsed as a download"
851 );
852 }
853
854 #[test]
856 fn parses_a_second_variants_table() {
857 let raw = include_str!("../tests/fixtures/docs/medasr.md");
858 let links = doc_links(raw);
859
860 assert_eq!(links.len(), 6);
861 assert!(
862 links.iter().any(|(file, _)| file == "medasr-Q8_0.gguf"),
863 "the second doc's own table did not parse"
864 );
865 }
866
867 #[test]
870 fn a_doc_with_no_marked_table_parses_to_nothing() {
871 assert!(doc_links("# a doc with prose only").is_empty());
872 assert!(
873 doc_links("see https://huggingface.co/some/repo for the weights").is_empty(),
874 "a URL in the prose is not a download link"
875 );
876 }
877}
878
879#[cfg(test)]
880mod tests_catalogue_fetch {
881 use super::*;
882 use std::io::{BufRead, BufReader, Write};
883 use std::net::TcpListener;
884 use std::thread;
885
886 fn fixture(name: &str) -> String {
887 let path = format!("{}/fixtures/catalogue/{name}", env!("CARGO_MANIFEST_DIR"));
888 std::fs::read_to_string(&path).expect("the fixture must be readable")
889 }
890
891 #[test]
896 fn fetches_a_variants_doc_and_refuses_a_missing_one() {
897 let (base, _seen) = serve(vec![(
898 "/docs/models/granite-speech-5.0-470m-turboctc.md",
899 200,
900 include_bytes!("../tests/fixtures/docs/granite-speech-5.0-470m-turboctc.md").to_vec(),
901 )]);
902 let source = CatalogueSource {
903 listing: format!("{base}/catalog"),
904 entry_base: format!("{base}/catalog/"),
905 release: format!("{base}/release"),
906 doc_base: format!("{base}/docs/models/"),
907 repo_base: format!("{base}/repos/"),
908 };
909
910 let doc =
911 fetch_doc(&source, "granite-speech-5.0-470m-turboctc").expect("the doc is served");
912 assert_eq!(doc_links(&doc).len(), 6, "the fetched doc's table parses");
913
914 let missing = fetch_doc(&source, "not-a-variant").expect_err("no doc, no fetch");
915 assert!(
916 format!("{missing}").contains("not-a-variant.md"),
917 "the failure must name the doc it could not read, got: {missing}"
918 );
919 }
920
921 #[test]
925 fn a_variant_with_no_doc_answers_from_its_published_readme() {
926 let readme = b"# moonshine-base-ar\n\n| Quant | Download |\n\
927 | --- | --- |\n\
928 | Q8_0 | [moonshine-base-ar-Q8_0.gguf](https://huggingface.co/handy-computer/moonshine-base-ar-gguf/resolve/main/moonshine-base-ar-Q8_0.gguf) |\n"
929 .to_vec();
930 let (base, _seen) = serve(vec![(
931 "/repos/handy-computer/moonshine-base-ar-gguf/raw/main/README.md",
932 200,
933 readme,
934 )]);
935 let source = CatalogueSource {
936 listing: format!("{base}/catalog"),
937 entry_base: format!("{base}/catalog/"),
938 release: format!("{base}/release"),
939 doc_base: format!("{base}/docs/models/"),
940 repo_base: format!("{base}/repos/"),
941 };
942
943 let links = download_links(
944 &source,
945 "moonshine-base-ar",
946 Some("handy-computer/moonshine-base-ar-gguf"),
947 )
948 .expect("the README answers with the file's own URL");
949 assert_eq!(links.len(), 1, "one row, one link");
950 assert_eq!(links[0].0, "moonshine-base-ar-Q8_0.gguf");
951 assert!(links[0].1.contains("/resolve/main/moonshine-base-ar-Q8_0.gguf"));
952
953 let nothing =
954 download_links(&source, "not-a-variant", Some("handy-computer/not-a-variant-gguf"))
955 .expect_err("neither document answers");
956 let message = nothing.to_string();
957 assert!(
958 message.contains("not-a-variant.md") && message.contains("not-a-variant-gguf"),
959 "the failure names both documents it tried, got: {message}"
960 );
961 }
962
963 type Seen = std::sync::Arc<std::sync::Mutex<Vec<String>>>;
968
969 fn serve(routes: Vec<(&'static str, u16, Vec<u8>)>) -> (String, Seen) {
970 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
971 let base = format!("http://{}", listener.local_addr().unwrap());
972 let seen: Seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
973 let recorder = std::sync::Arc::clone(&seen);
974 thread::spawn(move || {
975 for stream in listener.incoming() {
976 let Ok(mut stream) = stream else { break };
977 let mut reader = BufReader::new(stream.try_clone().unwrap());
978 let mut request = String::new();
979 if reader.read_line(&mut request).is_err() {
980 continue;
981 }
982 let path = request.split_whitespace().nth(1).unwrap_or("/").to_string();
983 let mut head = request;
984 loop {
985 let mut line = String::new();
986 if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
987 break;
988 }
989 head.push_str(&line);
990 }
991 recorder.lock().unwrap().push(head);
992 let (status, body) = match routes.iter().find(|(route, _, _)| *route == path) {
993 Some((_, status, body)) => (*status, body.clone()),
994 None => (404, Vec::new()),
995 };
996 let head = format!(
997 "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
998 body.len()
999 );
1000 let _ = stream.write_all(head.as_bytes());
1001 let _ = stream.write_all(&body);
1002 let _ = stream.flush();
1003 }
1004 });
1005 (base, seen)
1006 }
1007
1008 fn listing(names: &[&str]) -> Vec<u8> {
1009 let files: Vec<serde_json::Value> =
1010 names.iter().map(|name| serde_json::json!({"name": name, "type": "file"})).collect();
1011 serde_json::to_vec(&serde_json::Value::Array(files)).unwrap()
1012 }
1013
1014 fn source(base: &str) -> CatalogueSource {
1015 CatalogueSource {
1016 listing: format!("{base}/catalog"),
1017 entry_base: format!("{base}/catalog/"),
1018 release: format!("{base}/release"),
1019 doc_base: format!("{base}/docs/models/"),
1020 repo_base: format!("{base}/repos/"),
1021 }
1022 }
1023
1024 #[test]
1027 fn every_listed_entry_is_fetched_and_parsed() {
1028 let (base, _seen) = serve(vec![
1029 ("/catalog", 200, listing(&["a.json", "b.json", "_schema.json", "README.md"])),
1030 ("/catalog/a.json", 200, fixture("cohere-transcribe-03-2026.json").into_bytes()),
1031 ("/catalog/b.json", 200, fixture("granite-speech-5.0-470m-turboctc.json").into_bytes()),
1032 ("/release", 200, br#"{"tag_name": "v0.3.1"}"#.to_vec()),
1033 ]);
1034
1035 let catalogue = fetch_catalogue(&source(&base)).expect("the feed must assemble");
1036
1037 assert_eq!(catalogue.entries.len(), 2, "one entry per listed document");
1038 assert_eq!(catalogue.skipped, 0, "nothing was skipped");
1039 assert_eq!(catalogue.release.as_deref(), Some("v0.3.1"), "the release tag rides along");
1040 assert!(!catalogue.fetched_at.is_empty(), "the fetch stamps when it ran");
1041 assert!(
1042 catalogue.entries.iter().any(|e| e.variant == "cohere-transcribe-03-2026"),
1043 "the entries are the documents the listing named"
1044 );
1045 }
1046
1047 #[test]
1050 fn an_entry_that_does_not_parse_is_skipped_and_counted() {
1051 let (base, _seen) = serve(vec![
1052 ("/catalog", 200, listing(&["good.json", "broken.json"])),
1053 (
1054 "/catalog/good.json",
1055 200,
1056 fixture("granite-speech-5.0-470m-turboctc.json").into_bytes(),
1057 ),
1058 ("/catalog/broken.json", 200, b"{ not an entry".to_vec()),
1059 ("/release", 200, br#"{"tag_name": "v0.3.1"}"#.to_vec()),
1060 ]);
1061
1062 let catalogue = fetch_catalogue(&source(&base)).expect("the good entry still assembles");
1063
1064 assert_eq!(catalogue.entries.len(), 1);
1065 assert_eq!(catalogue.entries[0].variant, "granite-speech-5.0-470m-turboctc");
1066 assert_eq!(catalogue.skipped, 1, "the count is what tells a reader rows are missing");
1067 }
1068
1069 #[test]
1072 fn a_feed_where_nothing_parses_is_refused_rather_than_answered_empty() {
1073 let (base, _seen) = serve(vec![
1074 ("/catalog", 200, listing(&["broken.json"])),
1075 ("/catalog/broken.json", 200, b"{}".to_vec()),
1076 ]);
1077
1078 let err = fetch_catalogue(&source(&base)).expect_err("an empty result is not a feed");
1079 assert!(
1080 err.to_string().contains("none of the 1") && err.to_string().contains("/catalog/"),
1081 "the refusal must say nothing listed could be read and where it looked, got: {err}"
1082 );
1083 }
1084
1085 #[test]
1088 fn a_listing_that_answers_an_error_is_refused_with_its_status() {
1089 let (base, _seen) = serve(vec![("/catalog", 502, b"bad gateway".to_vec())]);
1090
1091 let err = fetch_catalogue(&source(&base)).expect_err("a 502 listing is not a feed");
1092 assert!(
1093 err.to_string().contains("502"),
1094 "the refusal must carry the status the server answered, got: {err}"
1095 );
1096 }
1097
1098 #[test]
1101 fn a_missing_release_is_not_a_failed_fetch() {
1102 let (base, _seen) = serve(vec![
1103 ("/catalog", 200, listing(&["a.json"])),
1104 ("/catalog/a.json", 200, fixture("cohere-transcribe-03-2026.json").into_bytes()),
1105 ]);
1107
1108 let catalogue = fetch_catalogue(&source(&base)).expect("the entries are the feed");
1109 assert_eq!(catalogue.entries.len(), 1);
1110 assert_eq!(catalogue.release, None, "no tag is None, not a failure");
1111 }
1112
1113 #[test]
1117 fn an_oversized_entry_is_skipped_rather_than_buffered() {
1118 let oversized = vec![b'x'; usize::try_from(MAX_RESPONSE_BYTES).unwrap() + 1];
1119 let (base, _seen) = serve(vec![
1120 ("/catalog", 200, listing(&["huge.json", "good.json"])),
1121 ("/catalog/huge.json", 200, oversized),
1122 ("/catalog/good.json", 200, fixture("cohere-transcribe-03-2026.json").into_bytes()),
1123 ]);
1124
1125 let catalogue = fetch_catalogue(&source(&base)).expect("the good entry assembles");
1126 assert_eq!(catalogue.entries.len(), 1, "the oversized one never becomes an entry");
1127 assert_eq!(catalogue.skipped, 1, "and it is counted as skipped");
1128 }
1129
1130 #[test]
1136 fn a_wide_listing_assembles_across_workers_with_the_loss_counted() {
1137 fn tiny(variant: &str) -> Vec<u8> {
1138 format!(r#"{{"schema": "transcribe-catalog-v1", "variant": "{variant}"}}"#).into_bytes()
1139 }
1140 let names = [
1141 "e0.json", "e1.json", "e2.json", "e3.json", "e4.json", "e5.json", "e6.json", "e7.json",
1142 ];
1143 let (base, _seen) = serve(vec![
1144 ("/catalog", 200, listing(&names)),
1145 ("/catalog/e0.json", 200, tiny("e0")),
1146 ("/catalog/e1.json", 200, tiny("e1")),
1147 ("/catalog/e2.json", 200, b"{ not an entry".to_vec()),
1151 ("/catalog/e3.json", 200, tiny("e3")),
1152 ("/catalog/e4.json", 200, tiny("e4")),
1153 ("/catalog/e5.json", 200, tiny("e5")),
1154 ("/catalog/e6.json", 200, tiny("e6")),
1155 ("/catalog/e7.json", 200, tiny("e7")),
1156 ]);
1157
1158 let catalogue = fetch_catalogue(&source(&base)).expect("the feed assembles");
1159
1160 let mut variants: Vec<&str> =
1161 catalogue.entries.iter().map(|entry| entry.variant.as_str()).collect();
1162 variants.sort_unstable();
1163 assert_eq!(
1164 variants,
1165 ["e0", "e1", "e3", "e4", "e5", "e6", "e7"],
1166 "every readable document, whichever worker's slice it fell in"
1167 );
1168 assert_eq!(catalogue.skipped, 1, "and the one unreadable document is counted, not fatal");
1169 }
1170
1171 #[test]
1176 fn every_request_carries_a_user_agent() {
1177 let (base, seen) = serve(vec![
1178 ("/catalog", 200, listing(&["a.json"])),
1179 ("/catalog/a.json", 200, fixture("cohere-transcribe-03-2026.json").into_bytes()),
1180 ("/release", 200, br#"{"tag_name": "v0.3.1"}"#.to_vec()),
1181 ]);
1182
1183 fetch_catalogue(&source(&base)).expect("the feed must assemble");
1184
1185 let heads = seen.lock().unwrap();
1186 assert_eq!(heads.len(), 3, "the listing, the entry and the release: {heads:?}");
1187 for head in heads.iter() {
1188 assert!(
1189 head.to_ascii_lowercase().contains("user-agent: forge-dictate/"),
1190 "api.github.com refuses a request with no User-Agent, so every call must carry \
1191 the crate's own: {head}"
1192 );
1193 }
1194 }
1195
1196 #[test]
1199 fn the_cache_round_trips_and_an_absent_one_is_no_catalogue() {
1200 let dir = tempfile::tempdir().unwrap();
1201 assert!(
1202 read_catalogue_cache(dir.path()).expect("absent is not a failure").is_none(),
1203 "a cache that was never written is no catalogue"
1204 );
1205
1206 let catalogue = Catalogue {
1207 fetched_at: "2026-10-06T06:12:00+05:30".to_owned(),
1208 release: Some("v0.3.1".to_owned()),
1209 entries: vec![
1210 parse_entry(&fixture("cohere-transcribe-03-2026.json")).expect("parse"),
1211 parse_entry(&fixture("granite-speech-5.0-470m-turboctc.json")).expect("parse"),
1212 ],
1213 skipped: 1,
1214 };
1215 write_catalogue_cache(dir.path(), &catalogue).expect("the write must land");
1216
1217 let back = read_catalogue_cache(dir.path())
1218 .expect("the write must be readable")
1219 .expect("a written cache is present");
1220 assert_eq!(back, catalogue, "the cache carries the feed as it was fetched");
1221 }
1222
1223 #[test]
1233 fn a_failed_cache_write_leaves_the_last_one_standing() {
1234 let dir = tempfile::tempdir().unwrap();
1235 let first = Catalogue {
1236 fetched_at: "2026-10-06T06:12:00Z".to_owned(),
1237 release: Some("v0.3.1".to_owned()),
1238 entries: vec![parse_entry(&fixture("cohere-transcribe-03-2026.json")).expect("parse")],
1239 skipped: 0,
1240 };
1241 write_catalogue_cache(dir.path(), &first).expect("the first write lands");
1242
1243 std::fs::create_dir(dir.path().join(format!("{CATALOGUE_CACHE_FILE}.tmp")))
1244 .expect("the sidecar is junked");
1245 let second = Catalogue { fetched_at: "later".to_owned(), ..first.clone() };
1246 assert!(
1247 write_catalogue_cache(dir.path(), &second).is_err(),
1248 "a write that cannot land must report it rather than claim success"
1249 );
1250
1251 let back = read_catalogue_cache(dir.path())
1252 .expect("the cache still reads")
1253 .expect("and is still there");
1254 assert_eq!(back, first, "the last good cache stands through the failed write");
1255 }
1256
1257 #[test]
1260 fn a_corrupt_cache_is_refused_naming_the_file() {
1261 let dir = tempfile::tempdir().unwrap();
1262 std::fs::write(dir.path().join("catalogue.json"), b"{ not json").unwrap();
1263
1264 let err = read_catalogue_cache(dir.path()).expect_err("a corrupt cache is not a feed");
1265 assert!(
1266 err.to_string().contains("catalogue.json"),
1267 "the refusal must name the file, got: {err}"
1268 );
1269
1270 let older = serde_json::json!({"version": 99, "fetched_at": "x", "entries": []});
1272 std::fs::write(dir.path().join("catalogue.json"), older.to_string()).unwrap();
1273 assert!(
1274 read_catalogue_cache(dir.path()).expect("a foreign layout is not an error").is_none(),
1275 "a cache written by another layout is no catalogue, not a misread one"
1276 );
1277 }
1278}