Skip to main content

forge_dictate/
catalogue.rs

1//! The transcribe.cpp catalogue: the per-variant feed the runtime
2//! publishes.
3//!
4//! Each variant is one JSON document (`catalog/<variant>.json`) carrying
5//! the model's identity, its downloads, and the measured accuracy and
6//! speed rows. Parsing is tolerant by design: upstream grows the schema,
7//! so unknown fields are ignored and the optional blocks default, while a
8//! document from another schema is refused by name rather than
9//! half-read.
10
11use std::io::Read as _;
12use std::path::Path;
13use std::time::{Duration, SystemTime};
14
15use serde::{Deserialize, Serialize};
16
17use crate::Error;
18
19/// The schema string every entry this build reads carries.
20pub const SCHEMA: &str = "transcribe-catalog-v1";
21
22/// The quants a headline accuracy figure is read at, most preferred
23/// first: the reference quants the catalogue measures its headline rows
24/// at, so a comparison across variants reads the same axis.
25const REFERENCE_QUANTS: [&str; 3] = ["Q8_0", "F16", "BF16"];
26
27/// The quantisations this runtime runs, best first: the order a download is
28/// picked in when a source offers several, and the set a cleanup candidate
29/// must offer at least one of. One ladder, so a row the feed admits and the
30/// download that row resolves to can never disagree.
31pub const PREFERRED_DOWNLOADS: [&str; 4] = ["Q4_K_M", "Q8_0", "F16", "BF16"];
32
33/// One variant's catalogue entry.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct CatalogueEntry {
36    pub variant: String,
37    /// Missing falls back to the variant itself.
38    #[serde(default)]
39    pub display_name: String,
40    #[serde(default)]
41    pub family: String,
42    /// Parameter count of the upstream checkpoint, absent when the source
43    /// publishes none - the Hub's listing does not carry one. A count of
44    /// zero is not a fact, and a row printing `0M params` claims one.
45    #[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    /// The feed's published Hugging Face repo for this variant
56    /// (`owner/name`), which is where its README's own download table lives
57    /// when the docs tree has no per-variant page.
58    #[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    /// What this entry is for. The speech feed's documents never spell it -
67    /// they are all speech models - and the Hub's feed sets it for every
68    /// entry it builds.
69    #[serde(default)]
70    pub kind: EntryKind,
71    /// How many times the Hub has served the repo. **The only pre-run signal
72    /// a cleanup candidate has** - the feed publishes no speed and no error
73    /// for a normalizer - so it is what orders a sweep. The speech feed
74    /// carries no count and leaves this `None`.
75    #[serde(default)]
76    pub download_count: Option<u64>,
77}
78
79impl CatalogueEntry {
80    /// Whether the variant supports streaming recognition.
81    pub fn streaming(&self) -> bool {
82        self.capabilities.streaming.supported
83    }
84
85    /// The download carrying this exact file name, which is how a pinned
86    /// model is joined to its entry.
87    pub fn download_for(&self, filename: &str) -> Option<&Download> {
88        self.downloads.iter().find(|download| download.filename == filename)
89    }
90
91    /// The m4-max Metal row carrying the best wall-clock realtime factor,
92    /// so the machine, backend and quant cross beside the number.
93    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    /// The best m4-max Metal wall-clock realtime factor across the
105    /// quantised speed rows. `None` when the feed carries no such row.
106    pub fn m4_metal_xrt_wall(&self) -> Option<f64> {
107        self.m4_metal_speed_row().and_then(|row| row.xrt_wall)
108    }
109
110    /// Word error rate on FLEURS English at the reference quant.
111    pub fn fleurs_en_wer(&self) -> Option<f64> {
112        self.wer_for("fleurs", "test", "en")
113    }
114
115    /// Word error rate on the entry's own headline benchmark.
116    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/// The licence block, in the two forms the feed carries.
137#[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/// What a variant can do, as far as the feed verifies it.
146#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147pub struct Capabilities {
148    #[serde(default)]
149    pub streaming: Support,
150}
151
152/// One capability's support flag.
153#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154pub struct Support {
155    #[serde(default)]
156    pub supported: bool,
157}
158
159/// The benchmark a variant's headline figure comes from.
160#[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/// What one catalogue entry is FOR: the slot it can fill. **Exhaustive on
173/// purpose** - a consumer that ignores it does not compile, so the two feeds'
174/// entries can never be mixed up by an assumption.
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum EntryKind {
178    /// A speech model: what transcribes.
179    #[default]
180    Asr,
181    /// A normalizer: what cleans a transcript up. The Hub's feed.
182    Normalizer,
183}
184
185/// One downloadable quantisation of the variant.
186#[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    /// The file's own digest, where its host publishes one: the Hub's blobs
195    /// carry a sha256 per file, and the speech feed's documents carry none -
196    /// which is the difference the install records as a fact.
197    #[serde(default)]
198    pub sha256: Option<String>,
199    /// The file itself, when the feed names it directly rather than through
200    /// a document's link table. The Hub's blobs carry no URL, so this is the
201    /// resolve path its own convention spells; a speech feed's row leaves
202    /// this `None` and the install reads the doc's table by file name.
203    #[serde(default)]
204    pub url: Option<String>,
205}
206
207/// One measured speed row. Many fields cross that nothing reads; the
208/// wall-clock realtime factor is the one the page compares on.
209#[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/// One measured accuracy row.
222#[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
236/// Parse one catalogue document, refusing anything that is not an entry
237/// of the schema this build reads.
238pub 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
266/// Bytes any single catalogue response may carry before the fetch
267/// refuses to buffer more. The feed's documents are about 13 KiB; the
268/// cap is what keeps a broken mirror from growing this process.
269pub(crate) const MAX_RESPONSE_BYTES: u64 = 4 << 20;
270
271/// How many entry documents are fetched at once. The feed is roughly 75
272/// small files, so this is what turns a ten-second serial walk into one
273/// that finishes while the page is still drawing its first frame.
274const FETCH_WORKERS: usize = 6;
275
276/// Where the feed is enumerated, fetched and versioned.
277#[derive(Debug, Clone)]
278pub struct CatalogueSource {
279    /// The endpoint listing the catalogue directory's files.
280    pub listing: String,
281    /// URL prefix an entry's own file name is appended to.
282    pub entry_base: String,
283    /// The project's latest release, whose tag labels the feed.
284    pub release: String,
285    /// URL prefix a variant's per-model doc is appended to, which is where
286    /// the download links for its files live.
287    pub doc_base: String,
288    /// Where the feed's published model repos live, for the README that
289    /// carries a variant's own download table when the docs tree has no
290    /// per-variant page: `<repo_base><published_repo>/raw/main/README.md`.
291    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
312/// The markers the feed wraps its machine-readable regions in.
313const DOWNLOADS_OPEN: &str = "<!-- catalog:downloads -->";
314const TABLE_CLOSE: &str = "<!-- /catalog -->";
315
316/// The download links one variant's doc carries, by file name.
317///
318/// **Read from the doc's marked region, never the whole prose.** A doc's
319/// intro may link the upstream repository holding the ORIGINAL weights,
320/// which is not the GGUF this runtime loads, so taking any URL on the page
321/// would offer an install that cannot work. A file name is the URL's own
322/// last segment, so the name a download is recorded under and the URL it
323/// came from cannot disagree.
324///
325/// A doc this build does not understand yields nothing rather than a guess,
326/// and the caller reports that the doc carried no table.
327pub 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
338/// Every `.gguf` link in a whole document, by file name.
339///
340/// The published repo's README is not a generated page and carries no
341/// marked region - its table IS the page - so this is the scan for it,
342/// with the same file-name rule [`doc_links`] uses.
343pub 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
364/// Every markdown link target on one line: the text between `](` and `)`.
365fn 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/// The feed as one fetch found it.
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct Catalogue {
382    /// RFC 3339, stamped when the fetch ran.
383    pub fetched_at: String,
384    /// The upstream release tag the feed stood at, when it answered.
385    pub release: Option<String>,
386    pub entries: Vec<CatalogueEntry>,
387    /// How many listed documents did not become entries: a bad document
388    /// never fails the feed, and this is what keeps the loss visible.
389    pub skipped: usize,
390}
391
392/// Fetch the whole catalogue. Blocking, like everything else here.
393///
394/// The listing is the spine: without it this refuses. A single document
395/// that cannot be read is skipped and counted, and a feed whose every
396/// document failed is refused rather than answered empty - a page drawn
397/// over an empty catalogue reads the same as a page over a healthy one.
398pub 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    // The release is a label, not the feed: an answer this build cannot
440    // read costs the tag and nothing else.
441    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
448/// The entry documents a listing names: JSON files, never the
449/// underscore-prefixed schema and profile records beside them.
450fn 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
465/// Fetch and parse one slice of the listing; a document that cannot be
466/// read is counted, not returned.
467fn 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
492/// Fetch one variant's per-model doc, where its own download links live.
493///
494/// Blocking, like the feed's fetch, and worth calling only when a model is
495/// being installed or a config key names a variant that is not on disk.
496pub 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
502/// The download links a variant's own documents carry, by file name.
503///
504/// **Two documents can carry them and either may answer.** A variant whose
505/// docs-tree page exists has its table there, read through the marked
506/// region; the language-specific fine-tunes - moonshine's Arabic and
507/// Japanese builds, breeze - have no page in the tree at all, and keep
508/// their table in the published repo's README instead. The doc is read
509/// first; a doc that is absent, or carries no table, falls through to the
510/// README. Both are parsed for their links, never constructed from the
511/// file name.
512///
513/// Blocking, like the feed's fetch, and worth calling only when a model is
514/// being installed or a config key names one that is not on disk.
515pub 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
554/// The client every feed request goes through: the timeouts, and the
555/// User-Agent api.github.com refuses a request without.
556pub(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
565/// GET one response, bounded: a status that is not success is its own
566/// error, and a body past `cap` is refused rather than buffered.
567pub(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
590/// The tag of a GitHub release document.
591fn 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
599/// Now, in RFC 3339 UTC. A clock before the epoch formats as the epoch
600/// rather than failing: a wrong label beats no feed.
601fn 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
609/// The record of the last fetched feed under [`read_catalogue_cache`]'s
610/// directory.
611const CATALOGUE_CACHE_FILE: &str = "catalogue.json";
612
613/// The record's own layout version. A file written by a layout this
614/// build does not know is ignored rather than misread.
615///
616/// **Bump this whenever the entry shape grows a field the download path
617/// reads.** Every entry field defaults, so a cache written by an older
618/// build parses cleanly and answers the default - and a default is a
619/// silent wrong answer where a refetch is the right one. Bumped to 2 for
620/// `published_repo`: a cache written without it cannot find the download
621/// links of the variants whose only document is their repo's README, and
622/// the install failed a 404 on a doc that never existed.
623const 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
634/// Read the last fetched catalogue under `dir`, when there is one this
635/// build wrote.
636pub 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    // The version is read before the shape: a file another layout wrote
648    // is no catalogue, not a corrupt one, and its fields are not this
649    // build's to require.
650    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
667/// Write the feed under `dir`, so a page reads the last-known rows
668/// without the network.
669pub 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    // Written beside and renamed over, like the model files: a process
682    // that dies mid-write leaves the previous cache standing rather
683    // than a truncated one the offline read would find.
684    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    /// The identity facts the page's IN USE and FIND A MODEL rows draw.
700    #[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    /// The pinned file is joined to the catalogue by name and size: this is
718    /// what makes an update check possible at all.
719    #[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    /// A document from a schema this build does not know is refused by
739    /// name: half-reading a future entry is how a page draws wrong facts.
740    #[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    /// Anything that is not an entry at all is a refusal, never a
751    /// half-defaulted row on a page. Each refusal names what it saw.
752    #[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    /// The measured numbers the page compares on come off the rows
771    /// themselves, and both fixtures pin the real figures.
772    #[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    /// The row the page's speed line draws is the row the number came
798    /// from: machine, backend and quant cross with it.
799    #[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    /// Upstream grows the schema; an entry carrying only the required
810    /// fields must still parse, with the optional facts empty.
811    #[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    /// **The doc's marked table is the only place a download URL comes
826    /// from.** The granite doc's own intro links an upstream Hugging Face
827    /// repo, which is not a file forge can fetch, so a parser that took any
828    /// URL in the prose would offer a dead install; the `catalog:downloads`
829    /// markers are the feed's own statement of where its files are.
830    #[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    /// The second real doc, so the convention is pinned beyond one sample.
855    #[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    /// A doc shape this build does not understand offers no links rather
868    /// than a guess - the caller reports that the doc carried no table.
869    #[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    /// **The doc is fetched from the feed's own docs tree**, one URL the
892    /// source owns. A variant with no doc there answers with the HTTP
893    /// failure, naming the URL, rather than an empty doc - an install
894    /// reports why it could not find the file.
895    #[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    /// A variant with no page in the docs tree answers from its published
922    /// repo's README instead - the shape moonshine's language fine-tunes
923    /// have - and a variant whose documents both fail names both URLs.
924    #[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    /// Loopback HTTP/1.1 server answering fixed paths, one request per
964    /// connection. Anything unrouted answers 404. Every request head it
965    /// saw is readable from the returned list, which is how the
966    /// User-Agent a real host demands is asserted rather than assumed.
967    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    /// The real feed shape: a listing of files, one document each, plus
1025    /// the release the page stamps as the catalogue's version.
1026    #[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    /// One unreadable document must not cost the whole feed: it is
1048    /// skipped and counted, and the count crosses.
1049    #[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    /// A feed where nothing parses is a refusal: answering an empty
1070    /// catalogue would draw a healthy page over a broken fetch.
1071    #[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    /// The listing is the feed's spine; without it there is nothing to
1086    /// fetch and the status is what a reader needs to see.
1087    #[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    /// The release tag is a label; a feed whose release endpoint is
1099    /// missing still has all its entries.
1100    #[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            // /release unrouted: 404.
1106        ]);
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    /// A response past the cap is refused for that entry rather than
1114    /// buffered: the fetch runs on a machine-local daemon, and a broken
1115    /// mirror must not be able to grow its memory.
1116    #[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    /// A listing wide enough for several workers, with one unreadable
1131    /// document mid-list, must still assemble the rest and count the
1132    /// loss: a break in the per-chunk error arm would cost the whole
1133    /// worker's slice, and every narrow fixture would stay green while it
1134    /// did.
1135    #[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            // Unreadable, and deliberately NOT last in its worker's
1148            // chunk: an error arm that abandons the slice would lose the
1149            // documents behind it.
1150            ("/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    /// api.github.com refuses any request with no User-Agent, and the
1172    /// listing is the spine of the fetch: a client without one reads
1173    /// Unreachable forever, with every call refused before a single entry
1174    /// is read.
1175    #[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    /// The cache is what lets a page read the last-known feed offline,
1197    /// so it round-trips whole and an absent one is simply no catalogue.
1198    #[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    /// A write that cannot complete must leave the last good cache
1224    /// standing: the offline read is what the cache exists for, and a
1225    /// truncated file is exactly what it would find after a crash
1226    /// mid-write.
1227    ///
1228    /// The sidecar is junked into a directory - the shape a killed
1229    /// process leaves - so the write cannot land. A writer that writes
1230    /// the cache in place would replace the good file anyway and report
1231    /// success.
1232    #[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    /// A cache this build cannot read is refused by name; the caller
1258    /// decides whether that costs a refresh or only a warning.
1259    #[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        // A layout from another build is ignored rather than misread.
1271        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}