Skip to main content

frankensearch_embed/
model_manifest.rs

1//! Model manifest definitions and verification helpers.
2//!
3//! This module is intentionally synchronous and runtime-agnostic:
4//! it performs filesystem and hashing work only, and leaves transport/network
5//! to higher-level download orchestration.
6
7use std::collections::BTreeMap;
8use std::fmt::Write as _;
9use std::fs::{self, File};
10use std::io::{BufReader, Read};
11use std::path::{Path, PathBuf};
12use std::sync::{OnceLock, RwLock};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use frankensearch_core::error::{SearchError, SearchResult};
19
20/// Environment variable for explicit model-download consent.
21pub const DOWNLOAD_CONSENT_ENV: &str = "FRANKENSEARCH_ALLOW_DOWNLOAD";
22
23/// Placeholder checksum used until a model file is downloaded and verified.
24pub const PLACEHOLDER_VERIFY_AFTER_DOWNLOAD: &str = "PLACEHOLDER_VERIFY_AFTER_DOWNLOAD";
25
26/// Placeholder revision used by built-in manifests until pinned revisions are filled in.
27pub const PLACEHOLDER_PINNED_REVISION: &str = "UNPINNED_VERIFY_AFTER_DOWNLOAD";
28
29/// Schema version for the manifest catalog format.
30///
31/// Bump this when the manifest structure changes in a backwards-incompatible way.
32/// Consumers compare the embedded schema version against the cached manifest to
33/// detect model upgrades that require re-download.
34pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
35
36/// Which search tier a model serves.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ModelTier {
40    /// Fast tier (~0.57ms per query), lower dimension.
41    Fast,
42    /// Quality tier (~128ms per query), higher dimension.
43    Quality,
44    /// Cross-encoder reranker, applied to top-K results.
45    Reranker,
46}
47
48const HASH_BUFFER_SIZE: usize = 8 * 1024;
49
50/// One file required by a model manifest.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ModelFile {
53    /// Relative path inside the model directory.
54    pub name: String,
55    /// Expected lowercase SHA256 hex digest.
56    pub sha256: String,
57    /// Expected size in bytes.
58    pub size: u64,
59    /// Explicit download URL. When `None`, the URL is derived from the parent
60    /// manifest's `repo` + `revision` using the `HuggingFace` `/resolve/` path.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub url: Option<String>,
63}
64
65impl ModelFile {
66    /// Returns true when the file still uses the placeholder checksum.
67    #[must_use]
68    pub fn uses_placeholder_checksum(&self) -> bool {
69        self.sha256 == PLACEHOLDER_VERIFY_AFTER_DOWNLOAD
70    }
71
72    /// Returns true when checksum is usable for production verification.
73    #[must_use]
74    pub fn has_verified_checksum(&self) -> bool {
75        is_valid_sha256_hex(&self.sha256) && !self.uses_placeholder_checksum()
76    }
77
78    /// Get the local filename (basename) for saving.
79    ///
80    /// For paths like `"onnx/model.onnx"`, returns `"model.onnx"`.
81    /// This handles `HuggingFace` repos that restructure files into subdirectories.
82    #[must_use]
83    pub fn local_name(&self) -> &str {
84        self.name.rsplit('/').next().unwrap_or(&self.name)
85    }
86
87    /// Return the download URL for this file, preferring the explicit `url`
88    /// field and falling back to the standard `HuggingFace` `/resolve/` path.
89    #[must_use]
90    pub fn download_url(&self, repo: &str, revision: &str) -> String {
91        self.url.as_ref().map_or_else(
92            || {
93                format!(
94                    "https://huggingface.co/{repo}/resolve/{revision}/{}",
95                    self.name
96                )
97            },
98            Clone::clone,
99        )
100    }
101}
102
103/// Manifest for one downloadable model bundle.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ModelManifest {
106    /// Stable model identifier.
107    pub id: String,
108    /// Human-readable version tag for manifest-managed model assets.
109    #[serde(default)]
110    pub version: String,
111    /// Human-readable display name (e.g., "Potion Base 128M (fast tier)").
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub display_name: Option<String>,
114    /// Optional longer description for CLI/help output.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub description: Option<String>,
117    /// `HuggingFace` repository slug.
118    pub repo: String,
119    /// Pinned revision (commit SHA).
120    pub revision: String,
121    /// Required files for this model.
122    pub files: Vec<ModelFile>,
123    /// SPDX-style license identifier.
124    pub license: String,
125    /// Output embedding dimension (e.g., 256 for potion, 384 for `MiniLM`).
126    /// `None` for models that don't produce fixed-dim embeddings.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub dimension: Option<u32>,
129    /// Which search tier this model serves.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub tier: Option<ModelTier>,
132    /// Optional precomputed aggregate download size in bytes.
133    #[serde(
134        default,
135        rename = "total_size_bytes",
136        skip_serializing_if = "is_zero_u64"
137    )]
138    pub download_size_bytes: u64,
139}
140
141#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires &T signature
142const fn is_zero_u64(value: &u64) -> bool {
143    *value == 0
144}
145
146impl ModelManifest {
147    /// Built-in manifest for MiniLM-L6-v2 (quality tier).
148    #[must_use]
149    pub fn minilm_v2() -> Self {
150        const REVISION: &str = "c9745ed1d9f207416be6d2e6f8de32d1f16199bf";
151        const REPO: &str = "sentence-transformers/all-MiniLM-L6-v2";
152        Self {
153            id: "all-minilm-l6-v2".to_owned(),
154            version: "v1".to_owned(),
155            display_name: Some("All MiniLM L6 v2 (quality tier)".to_owned()),
156            description: Some(
157                "MiniLM-L6-v2 ONNX sentence embedding model for quality-tier semantic search"
158                    .to_owned(),
159            ),
160            repo: REPO.to_owned(),
161            revision: REVISION.to_owned(),
162            files: vec![
163                ModelFile {
164                    name: "onnx/model.onnx".to_owned(),
165                    sha256: "6fd5d72fe4589f189f8ebc006442dbb529bb7ce38f8082112682524616046452"
166                        .to_owned(),
167                    size: 90_405_214,
168                    url: Some(
169                        "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/onnx/model.onnx"
170                            .to_owned(),
171                    ),
172                },
173                ModelFile {
174                    name: "tokenizer.json".to_owned(),
175                    sha256: "be50c3628f2bf5bb5e3a7f17b1f74611b2561a3a27eeab05e5aa30f411572037"
176                        .to_owned(),
177                    size: 466_247,
178                    url: Some(
179                        "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/tokenizer.json"
180                            .to_owned(),
181                    ),
182                },
183                ModelFile {
184                    name: "config.json".to_owned(),
185                    sha256: "953f9c0d463486b10a6871cc2fd59f223b2c70184f49815e7efbcab5d8908b41"
186                        .to_owned(),
187                    size: 612,
188                    url: Some(
189                        "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/config.json"
190                            .to_owned(),
191                    ),
192                },
193                ModelFile {
194                    name: "special_tokens_map.json".to_owned(),
195                    sha256: "303df45a03609e4ead04bc3dc1536d0ab19b5358db685b6f3da123d05ec200e3"
196                        .to_owned(),
197                    size: 112,
198                    url: Some(
199                        "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/special_tokens_map.json"
200                            .to_owned(),
201                    ),
202                },
203                ModelFile {
204                    name: "tokenizer_config.json".to_owned(),
205                    sha256: "acb92769e8195aabd29b7b2137a9e6d6e25c476a4f15aa4355c233426c61576b"
206                        .to_owned(),
207                    size: 350,
208                    url: Some(
209                        "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/tokenizer_config.json"
210                            .to_owned(),
211                    ),
212                },
213            ],
214            license: "Apache-2.0".to_owned(),
215            dimension: Some(384),
216            tier: Some(ModelTier::Quality),
217            download_size_bytes: 90_872_535,
218        }
219    }
220
221    /// Built-in manifest for potion-128M style `Model2Vec` assets (fast tier).
222    #[must_use]
223    pub fn potion_128m() -> Self {
224        const REVISION: &str = "a28f4eebecd4dc585034f605e52d414878a0417c";
225        const REPO: &str = "minishlab/potion-multilingual-128M";
226        Self {
227            id: "potion-multilingual-128m".to_owned(),
228            version: "v1".to_owned(),
229            display_name: Some("Potion Multilingual 128M (fast tier)".to_owned()),
230            description: Some(
231                "Model2Vec static embedding model for fast-tier multilingual retrieval".to_owned(),
232            ),
233            repo: REPO.to_owned(),
234            revision: REVISION.to_owned(),
235            files: vec![
236                ModelFile {
237                    name: "tokenizer.json".to_owned(),
238                    sha256: "19f1909063da3cfe3bd83a782381f040dccea475f4816de11116444a73e1b6a1"
239                        .to_owned(),
240                    size: 18_616_131,
241                    url: Some(
242                        "https://huggingface.co/minishlab/potion-multilingual-128M/resolve/a28f4eebecd4dc585034f605e52d414878a0417c/tokenizer.json"
243                            .to_owned(),
244                    ),
245                },
246                ModelFile {
247                    name: "model.safetensors".to_owned(),
248                    sha256: "14b5eb39cb4ce5666da8ad1f3dc6be4346e9b2d601c073302fa0a31bf7943397"
249                        .to_owned(),
250                    size: 512_361_560,
251                    url: Some(
252                        "https://huggingface.co/minishlab/potion-multilingual-128M/resolve/a28f4eebecd4dc585034f605e52d414878a0417c/model.safetensors"
253                            .to_owned(),
254                    ),
255                },
256            ],
257            license: "Apache-2.0".to_owned(),
258            dimension: Some(256),
259            tier: Some(ModelTier::Fast),
260            download_size_bytes: 530_977_691,
261        }
262    }
263
264    /// Built-in manifest for flashrank-nano (cross-encoder reranker).
265    #[must_use]
266    pub fn flashrank_nano() -> Self {
267        const REVISION: &str = PLACEHOLDER_PINNED_REVISION;
268        const REPO: &str = "prithivida/flashrank-nano";
269        Self {
270            id: "flashrank-nano".to_owned(),
271            version: "v1".to_owned(),
272            display_name: Some("FlashRank Nano (Reranker)".to_owned()),
273            description: Some("FlashRank compact ONNX cross-encoder reranker model".to_owned()),
274            repo: REPO.to_owned(),
275            revision: REVISION.to_owned(),
276            files: vec![
277                ModelFile {
278                    name: "onnx/model.onnx".to_owned(),
279                    sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
280                    size: 0,
281                    url: None,
282                },
283                ModelFile {
284                    name: "tokenizer.json".to_owned(),
285                    sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
286                    size: 0,
287                    url: None,
288                },
289            ],
290            license: "Apache-2.0".to_owned(),
291            dimension: None,
292            tier: Some(ModelTier::Reranker),
293            download_size_bytes: 0,
294        }
295    }
296
297    /// Built-in manifest for MS MARCO `MiniLM` reranker (cross-encoder).
298    #[must_use]
299    pub fn ms_marco_reranker() -> Self {
300        const REVISION: &str = "c5ee24cb16019beea0893ab7796b1df96625c6b8";
301        const REPO: &str = "cross-encoder/ms-marco-MiniLM-L-6-v2";
302        Self {
303            id: "ms-marco-minilm-l-6-v2".to_owned(),
304            version: "v1".to_owned(),
305            display_name: Some("MS MARCO MiniLM L-6 v2 (reranker)".to_owned()),
306            description: Some(
307                "MS MARCO cross-encoder reranker model for final relevance scoring".to_owned(),
308            ),
309            repo: REPO.to_owned(),
310            revision: REVISION.to_owned(),
311            files: vec![
312                ModelFile {
313                    name: "onnx/model.onnx".to_owned(),
314                    sha256: "5d3e70fd0c9ff14b9b5169a51e957b7a9c74897afd0a35ce4bd318150c1d4d4a"
315                        .to_owned(),
316                    size: 91_011_230,
317                    url: Some(
318                        "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/onnx/model.onnx"
319                            .to_owned(),
320                    ),
321                },
322                ModelFile {
323                    name: "tokenizer.json".to_owned(),
324                    sha256: "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
325                        .to_owned(),
326                    size: 711_396,
327                    url: Some(
328                        "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/tokenizer.json"
329                            .to_owned(),
330                    ),
331                },
332                ModelFile {
333                    name: "config.json".to_owned(),
334                    sha256: "380e02c93f431831be65d99a4e7e5f67c133985bf2e77d9d4eba46847190bacc"
335                        .to_owned(),
336                    size: 794,
337                    url: Some(
338                        "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/config.json"
339                            .to_owned(),
340                    ),
341                },
342                ModelFile {
343                    name: "special_tokens_map.json".to_owned(),
344                    sha256: "3c3507f36dff57bce437223db3b3081d1e2b52ec3e56ee55438193ecb2c94dd6"
345                        .to_owned(),
346                    size: 132,
347                    url: Some(
348                        "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/special_tokens_map.json"
349                            .to_owned(),
350                    ),
351                },
352                ModelFile {
353                    name: "tokenizer_config.json".to_owned(),
354                    sha256: "a5c2e5a7b1a29a0702cd28c08a399b5ecc110c263009d17f7e3b415f25905fd8"
355                        .to_owned(),
356                    size: 1_330,
357                    url: Some(
358                        "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/tokenizer_config.json"
359                            .to_owned(),
360                    ),
361                },
362            ],
363            license: "Apache-2.0".to_owned(),
364            dimension: None, // Cross-encoder produces scores, not embeddings
365            tier: Some(ModelTier::Reranker),
366            download_size_bytes: 91_724_882,
367        }
368    }
369
370    // ==================== Bake-off Eligible Models ====================
371
372    /// Snowflake Arctic Embed S manifest.
373    ///
374    /// Dimension: 384. Small, fast model with MiniLM-compatible dimension.
375    /// Verified checksums from `HuggingFace`.
376    #[must_use]
377    pub fn snowflake_arctic_s() -> Self {
378        const REVISION: &str = "e596f507467533e48a2e17c007f0e1dacc837b33";
379        const REPO: &str = "Snowflake/snowflake-arctic-embed-s";
380        Self {
381            id: "snowflake-arctic-embed-s".to_owned(),
382            version: "v1".to_owned(),
383            display_name: Some("Snowflake Arctic Embed S".to_owned()),
384            description: Some(
385                "Small, fast embedding model with MiniLM-compatible 384 dimensions".to_owned(),
386            ),
387            repo: REPO.to_owned(),
388            revision: REVISION.to_owned(),
389            files: vec![
390                ModelFile {
391                    name: "onnx/model.onnx".to_owned(),
392                    sha256: "579c1f1778a0993eb0d2a1403340ffb491c769247fb46acc4f5cf8ac5b89c1e1"
393                        .to_owned(),
394                    size: 133_093_492,
395                    url: None,
396                },
397                ModelFile {
398                    name: "tokenizer.json".to_owned(),
399                    sha256: "91f1def9b9391fdabe028cd3f3fcc4efd34e5d1f08c3bf2de513ebb5911a1854"
400                        .to_owned(),
401                    size: 711_649,
402                    url: None,
403                },
404                ModelFile {
405                    name: "config.json".to_owned(),
406                    sha256: "4e519aa92ec40943356032afe458c8829d70c5766b109e4a57490b82f72dcfb7"
407                        .to_owned(),
408                    size: 703,
409                    url: None,
410                },
411                ModelFile {
412                    name: "special_tokens_map.json".to_owned(),
413                    sha256: "5d5b662e421ea9fac075174bb0688ee0d9431699900b90662acd44b2a350503a"
414                        .to_owned(),
415                    size: 695,
416                    url: None,
417                },
418                ModelFile {
419                    name: "tokenizer_config.json".to_owned(),
420                    sha256: "9ca59277519f6e3692c8685e26b94d4afca2d5438deff66483db495e48735810"
421                        .to_owned(),
422                    size: 1_433,
423                    url: None,
424                },
425            ],
426            license: "Apache-2.0".to_owned(),
427            dimension: Some(384),
428            tier: Some(ModelTier::Quality),
429            download_size_bytes: 133_807_972,
430        }
431    }
432
433    /// Nomic Embed Text v1.5 manifest.
434    ///
435    /// Dimension: 768. Long context support with Matryoshka embedding capability.
436    /// Verified checksums from `HuggingFace`.
437    #[must_use]
438    pub fn nomic_embed() -> Self {
439        const REVISION: &str = "e5cf08aadaa33385f5990def41f7a23405aec398";
440        const REPO: &str = "nomic-ai/nomic-embed-text-v1.5";
441        Self {
442            id: "nomic-embed-text-v1.5".to_owned(),
443            version: "v1".to_owned(),
444            display_name: Some("Nomic Embed Text v1.5".to_owned()),
445            description: Some(
446                "Long context embedding model with Matryoshka capability (768 dims)".to_owned(),
447            ),
448            repo: REPO.to_owned(),
449            revision: REVISION.to_owned(),
450            files: vec![
451                ModelFile {
452                    name: "onnx/model.onnx".to_owned(),
453                    sha256: "147d5aa88c2101237358e17796cf3a227cead1ec304ec34b465bb08e9d952965"
454                        .to_owned(),
455                    size: 547_310_275,
456                    url: None,
457                },
458                ModelFile {
459                    name: "tokenizer.json".to_owned(),
460                    sha256: "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
461                        .to_owned(),
462                    size: 711_396,
463                    url: None,
464                },
465                ModelFile {
466                    name: "config.json".to_owned(),
467                    sha256: "0168e0883705b0bf8f2b381e10f45a9f3e1ef4b13869b43c160e4c8a70ddf442"
468                        .to_owned(),
469                    size: 2_331,
470                    url: None,
471                },
472                ModelFile {
473                    name: "special_tokens_map.json".to_owned(),
474                    sha256: "5d5b662e421ea9fac075174bb0688ee0d9431699900b90662acd44b2a350503a"
475                        .to_owned(),
476                    size: 695,
477                    url: None,
478                },
479                ModelFile {
480                    name: "tokenizer_config.json".to_owned(),
481                    sha256: "d7e0000bcc80134debd2222220427e6bf5fa20a669f40a0d0d1409cc18e0a9bc"
482                        .to_owned(),
483                    size: 1_191,
484                    url: None,
485                },
486            ],
487            license: "Apache-2.0".to_owned(),
488            dimension: Some(768),
489            tier: Some(ModelTier::Quality),
490            download_size_bytes: 548_025_888,
491        }
492    }
493
494    /// Jina Reranker v1 Turbo EN manifest.
495    ///
496    /// Fast, optimized for English. Verified checksums from `HuggingFace`.
497    #[must_use]
498    pub fn jina_reranker_turbo() -> Self {
499        const REVISION: &str = "b8c14f4e723d9e0aab4732a7b7b93741eeeb77c2";
500        const REPO: &str = "jinaai/jina-reranker-v1-turbo-en";
501        Self {
502            id: "jina-reranker-v1-turbo-en".to_owned(),
503            version: "v1".to_owned(),
504            display_name: Some("Jina Reranker v1 Turbo EN".to_owned()),
505            description: Some("Fast cross-encoder reranker optimized for English".to_owned()),
506            repo: REPO.to_owned(),
507            revision: REVISION.to_owned(),
508            files: vec![
509                ModelFile {
510                    name: "onnx/model.onnx".to_owned(),
511                    sha256: "c1296c66c119de645fa9cdee536d8637740efe85224cfa270281e50f213aa565"
512                        .to_owned(),
513                    size: 151_296_975,
514                    url: None,
515                },
516                ModelFile {
517                    name: "tokenizer.json".to_owned(),
518                    sha256: "0046da43cc8c424b317f56b092b0512aaaa65c4f925d2f16af9d9eeb4d0ef902"
519                        .to_owned(),
520                    size: 2_030_772,
521                    url: None,
522                },
523                ModelFile {
524                    name: "config.json".to_owned(),
525                    sha256: "e050ff6a15ae9295e84882fa0e98051bd8754856cd5201395ebf00ce9f2d609b"
526                        .to_owned(),
527                    size: 1_206,
528                    url: None,
529                },
530                ModelFile {
531                    name: "special_tokens_map.json".to_owned(),
532                    sha256: "06e405a36dfe4b9604f484f6a1e619af1a7f7d09e34a8555eb0b77b66318067f"
533                        .to_owned(),
534                    size: 280,
535                    url: None,
536                },
537                ModelFile {
538                    name: "tokenizer_config.json".to_owned(),
539                    sha256: "d291c6652d96d56ffdbcf1ea19d9bae5ed79003f7648c627e725a619227ce8fa"
540                        .to_owned(),
541                    size: 1_215,
542                    url: None,
543                },
544            ],
545            license: "Apache-2.0".to_owned(),
546            dimension: None, // Cross-encoder produces scores, not embeddings
547            tier: Some(ModelTier::Reranker),
548            download_size_bytes: 153_330_448,
549        }
550    }
551
552    // ==================== Lookup & Listing Functions ====================
553
554    /// Get manifest by embedder name.
555    #[must_use]
556    pub fn for_embedder(name: &str) -> Option<Self> {
557        match name {
558            "minilm" => Some(Self::minilm_v2()),
559            "snowflake-arctic-s" => Some(Self::snowflake_arctic_s()),
560            "nomic-embed" => Some(Self::nomic_embed()),
561            "potion-128m" => Some(Self::potion_128m()),
562            _ => None,
563        }
564    }
565
566    /// Get manifest by reranker name.
567    #[must_use]
568    pub fn for_reranker(name: &str) -> Option<Self> {
569        match name {
570            "ms-marco" => Some(Self::ms_marco_reranker()),
571            "jina-reranker-turbo" => Some(Self::jina_reranker_turbo()),
572            _ => None,
573        }
574    }
575
576    /// Get all bake-off eligible embedder manifests.
577    #[must_use]
578    pub fn bakeoff_embedder_candidates() -> Vec<Self> {
579        vec![Self::snowflake_arctic_s(), Self::nomic_embed()]
580    }
581
582    /// Get all bake-off eligible reranker manifests.
583    #[must_use]
584    pub fn bakeoff_reranker_candidates() -> Vec<Self> {
585        vec![Self::jina_reranker_turbo()]
586    }
587
588    /// Get all bake-off eligible model manifests (embedders + rerankers).
589    #[must_use]
590    pub fn bakeoff_candidates() -> Vec<Self> {
591        let mut candidates = Self::bakeoff_embedder_candidates();
592        candidates.extend(Self::bakeoff_reranker_candidates());
593        candidates
594    }
595
596    /// Return the compiled-in catalog of all built-in model manifests.
597    ///
598    /// This is the single source of truth for what models frankensearch needs.
599    /// The binary always knows what models it requires without network access.
600    #[must_use]
601    pub fn builtin_catalog() -> ModelManifestCatalog {
602        ModelManifestCatalog {
603            schema_version: MANIFEST_SCHEMA_VERSION,
604            models: vec![
605                Self::potion_128m(),
606                Self::minilm_v2(),
607                Self::ms_marco_reranker(),
608                Self::snowflake_arctic_s(),
609                Self::nomic_embed(),
610                Self::jina_reranker_turbo(),
611                Self::flashrank_nano(),
612            ],
613        }
614    }
615
616    /// Parse a manifest from JSON and validate basic structure.
617    ///
618    /// # Errors
619    ///
620    /// Returns `SearchError::InvalidConfig` if JSON parsing or validation fails.
621    pub fn from_json_str(raw: &str) -> SearchResult<Self> {
622        let manifest =
623            serde_json::from_str::<Self>(raw).map_err(|source| SearchError::InvalidConfig {
624                field: "manifest_json".to_owned(),
625                value: truncate_for_error(raw),
626                reason: format!("failed to parse manifest JSON: {source}"),
627            })?;
628        manifest.validate()?;
629        Ok(manifest)
630    }
631
632    /// Serialize this manifest to pretty JSON.
633    ///
634    /// # Errors
635    ///
636    /// Returns `SearchError::InvalidConfig` if serialization fails.
637    pub fn to_pretty_json(&self) -> SearchResult<String> {
638        serde_json::to_string_pretty(self).map_err(|source| SearchError::InvalidConfig {
639            field: "manifest_json".to_owned(),
640            value: self.id.clone(),
641            reason: format!("failed to serialize manifest: {source}"),
642        })
643    }
644
645    /// Returns true when all files have non-placeholder concrete checksums.
646    #[must_use]
647    pub fn has_verified_checksums(&self) -> bool {
648        !self.files.is_empty() && self.files.iter().all(ModelFile::has_verified_checksum)
649    }
650
651    /// Returns true when revision appears pinned (not empty and not floating aliases).
652    #[must_use]
653    pub fn has_pinned_revision(&self) -> bool {
654        let revision = self.revision.trim();
655        !(revision.is_empty()
656            || revision.eq_ignore_ascii_case("main")
657            || revision.eq_ignore_ascii_case("master")
658            || revision.eq_ignore_ascii_case("latest")
659            || revision.eq_ignore_ascii_case("head")
660            || revision == PLACEHOLDER_PINNED_REVISION)
661    }
662
663    /// Returns true when this manifest is ready for production-grade verification.
664    #[must_use]
665    pub fn is_production_ready(&self) -> bool {
666        self.has_verified_checksums() && self.has_pinned_revision()
667    }
668
669    /// Sum of expected bytes for all files.
670    #[must_use]
671    pub fn total_size_bytes(&self) -> u64 {
672        if self.download_size_bytes > 0 {
673            return self.download_size_bytes;
674        }
675        self.files.iter().map(|file| file.size).sum()
676    }
677
678    /// Alias for [`total_size_bytes`](Self::total_size_bytes).
679    #[must_use]
680    pub fn total_size(&self) -> u64 {
681        self.total_size_bytes()
682    }
683
684    /// `HuggingFace` download URL for a specific file in this manifest.
685    #[must_use]
686    pub fn download_url(&self, file: &ModelFile) -> String {
687        file.download_url(&self.repo, &self.revision)
688    }
689
690    /// Validate manifest fields for shape and checksum format.
691    ///
692    /// # Errors
693    ///
694    /// Returns `SearchError::InvalidConfig` for malformed fields.
695    pub fn validate(&self) -> SearchResult<()> {
696        if self.id.trim().is_empty() {
697            return Err(invalid_manifest_field("id", &self.id, "must not be empty"));
698        }
699        if self.repo.trim().is_empty() {
700            return Err(invalid_manifest_field(
701                "repo",
702                &self.repo,
703                "must not be empty",
704            ));
705        }
706        if self.revision.trim().is_empty() {
707            return Err(invalid_manifest_field(
708                "revision",
709                &self.revision,
710                "must not be empty",
711            ));
712        }
713        if self.license.trim().is_empty() {
714            return Err(invalid_manifest_field(
715                "license",
716                &self.license,
717                "must not be empty",
718            ));
719        }
720
721        for file in &self.files {
722            validate_model_file_name(&file.name)?;
723            if file.uses_placeholder_checksum() {
724                continue;
725            }
726            if !is_valid_sha256_hex(&file.sha256) {
727                return Err(invalid_manifest_field(
728                    "files[].sha256",
729                    &file.sha256,
730                    "must be lowercase 64-char SHA256 hex or placeholder",
731                ));
732            }
733        }
734
735        if self.download_size_bytes > 0 {
736            let computed_size: u64 = self.files.iter().map(|file| file.size).sum();
737            if computed_size != self.download_size_bytes {
738                return Err(invalid_manifest_field(
739                    "total_size_bytes",
740                    &self.download_size_bytes.to_string(),
741                    "must match the sum of files[].size",
742                ));
743            }
744        }
745
746        Ok(())
747    }
748
749    /// Enforce checksum policy; placeholder checksums are rejected in release mode.
750    ///
751    /// # Errors
752    ///
753    /// Returns `SearchError::InvalidConfig` if a release policy violation is detected.
754    pub fn validate_checksum_policy(&self) -> SearchResult<()> {
755        self.validate_checksum_policy_for(cfg!(not(debug_assertions)))
756    }
757
758    /// Enforce checksum policy with explicit release-mode toggle (useful for tests).
759    ///
760    /// # Errors
761    ///
762    /// Returns `SearchError::InvalidConfig` if release-mode requires concrete checksums.
763    pub fn validate_checksum_policy_for(&self, release_mode: bool) -> SearchResult<()> {
764        if release_mode && self.files.iter().any(ModelFile::uses_placeholder_checksum) {
765            return Err(invalid_manifest_field(
766                "files[].sha256",
767                PLACEHOLDER_VERIFY_AFTER_DOWNLOAD,
768                "placeholder checksums are forbidden in release mode",
769            ));
770        }
771        Ok(())
772    }
773
774    /// Verify all manifest files in `model_dir` using streaming SHA256 checks.
775    ///
776    /// # Errors
777    ///
778    /// Returns `SearchError` when any file is missing or hash/size verification fails.
779    pub fn verify_dir(&self, model_dir: &Path) -> SearchResult<()> {
780        for file in &self.files {
781            let path = resolve_model_file_path(model_dir, &file.name)?;
782            verify_file_sha256(&path, &file.sha256, file.size)?;
783        }
784        Ok(())
785    }
786
787    /// Promote a staged model directory to final destination atomically after verification.
788    ///
789    /// Returns the backup path when an existing install was moved out of the way.
790    ///
791    /// # Errors
792    ///
793    /// Returns `SearchError` if verification or filesystem rename operations fail.
794    pub fn promote_verified_installation(
795        &self,
796        staged_dir: &Path,
797        destination_dir: &Path,
798    ) -> SearchResult<Option<PathBuf>> {
799        self.verify_dir(staged_dir)?;
800        promote_atomically(staged_dir, destination_dir)
801    }
802
803    /// Return `UpdateAvailable` when installed revision differs from pinned revision.
804    #[must_use]
805    pub fn detect_update_state(&self, installed_revision: &str) -> Option<ModelState> {
806        if !self.has_pinned_revision() {
807            return None;
808        }
809        let current = installed_revision.trim();
810        if current == self.revision {
811            return None;
812        }
813        Some(ModelState::UpdateAvailable {
814            current_revision: if current.is_empty() {
815                "unknown".to_owned()
816            } else {
817                current.to_owned()
818            },
819            latest_revision: self.revision.clone(),
820        })
821    }
822
823    /// Register this manifest in the in-process registry.
824    ///
825    /// # Errors
826    ///
827    /// Returns `SearchError` if validation fails or registry lock is poisoned.
828    pub fn register(self) -> SearchResult<()> {
829        self.validate()?;
830        manifest_registry()
831            .write()
832            .map_err(|_| manifest_registry_lock_error("write"))?
833            .insert(self.id.clone(), self);
834        Ok(())
835    }
836
837    /// Look up a registered manifest by id.
838    #[must_use]
839    pub fn lookup(id: &str) -> Option<Self> {
840        let guard = manifest_registry().read().unwrap_or_else(|poisoned| {
841            tracing::warn!(
842                "model manifest registry lock poisoned on read during lookup; using recovered state"
843            );
844            poisoned.into_inner()
845        });
846        guard.get(id).cloned()
847    }
848
849    /// Return all registered manifests in deterministic id order.
850    #[must_use]
851    pub fn registered() -> Vec<Self> {
852        let guard = manifest_registry().read().unwrap_or_else(|poisoned| {
853            tracing::warn!(
854                "model manifest registry lock poisoned on read during listing; using recovered state"
855            );
856            poisoned.into_inner()
857        });
858        guard.values().cloned().collect()
859    }
860}
861
862/// Model manifest catalog for bulk load/validation.
863#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
864pub struct ModelManifestCatalog {
865    /// Schema version for forward compatibility.
866    #[serde(default = "default_schema_version")]
867    pub schema_version: u32,
868    /// Manifests contained in this catalog.
869    #[serde(default)]
870    pub models: Vec<ModelManifest>,
871}
872
873const fn default_schema_version() -> u32 {
874    MANIFEST_SCHEMA_VERSION
875}
876
877impl Default for ModelManifestCatalog {
878    fn default() -> Self {
879        Self {
880            schema_version: MANIFEST_SCHEMA_VERSION,
881            models: Vec::new(),
882        }
883    }
884}
885
886impl ModelManifestCatalog {
887    /// Parse a catalog from JSON.
888    ///
889    /// # Errors
890    ///
891    /// Returns `SearchError::InvalidConfig` if parsing fails.
892    pub fn from_json_str(raw: &str) -> SearchResult<Self> {
893        serde_json::from_str::<Self>(raw).map_err(|source| SearchError::InvalidConfig {
894            field: "manifest_catalog_json".to_owned(),
895            value: truncate_for_error(raw),
896            reason: format!("failed to parse manifest catalog JSON: {source}"),
897        })
898    }
899
900    /// Validate every manifest in the catalog.
901    ///
902    /// # Errors
903    ///
904    /// Returns `SearchError` if any contained manifest is invalid.
905    pub fn validate(&self) -> SearchResult<()> {
906        for model in &self.models {
907            model.validate()?;
908        }
909        Ok(())
910    }
911}
912
913/// Runtime state of model availability and lifecycle.
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915pub enum ModelState {
916    NotInstalled,
917    NeedsConsent,
918    Downloading {
919        progress_pct: u8,
920        bytes_downloaded: u64,
921        total_bytes: u64,
922    },
923    Verifying,
924    Ready,
925    Disabled {
926        reason: String,
927    },
928    VerificationFailed {
929        reason: String,
930    },
931    UpdateAvailable {
932        current_revision: String,
933        latest_revision: String,
934    },
935    Cancelled,
936}
937
938impl ModelState {
939    /// Whether the model is ready for use.
940    #[must_use]
941    pub fn is_ready(&self) -> bool {
942        matches!(self, Self::Ready)
943    }
944
945    /// Whether a download is in progress.
946    #[must_use]
947    pub fn is_downloading(&self) -> bool {
948        matches!(self, Self::Downloading { .. })
949    }
950
951    /// Whether user consent is needed.
952    #[must_use]
953    pub fn needs_consent(&self) -> bool {
954        matches!(self, Self::NeedsConsent)
955    }
956
957    /// Human-readable summary of the state.
958    #[must_use]
959    pub fn summary(&self) -> String {
960        match self {
961            Self::NotInstalled => "not installed".into(),
962            Self::NeedsConsent => "needs consent".into(),
963            Self::Downloading { progress_pct, .. } => {
964                format!("downloading ({progress_pct}%)")
965            }
966            Self::Verifying => "verifying".into(),
967            Self::Ready => "ready".into(),
968            Self::Disabled { reason } => format!("disabled: {reason}"),
969            Self::VerificationFailed { reason } => format!("verification failed: {reason}"),
970            Self::UpdateAvailable {
971                current_revision,
972                latest_revision,
973            } => {
974                format!("update available: {current_revision} -> {latest_revision}")
975            }
976            Self::Cancelled => "cancelled".into(),
977        }
978    }
979}
980
981/// Where a consent decision came from.
982#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
983pub enum ConsentSource {
984    Programmatic,
985    Environment,
986    Interactive,
987    ConfigFile,
988}
989
990/// Resolved consent decision for model downloads.
991#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
992pub struct DownloadConsent {
993    /// Whether downloads are allowed.
994    pub granted: bool,
995    /// Origin of the consent signal.
996    pub source: Option<ConsentSource>,
997}
998
999impl DownloadConsent {
1000    /// Explicitly granted consent.
1001    #[must_use]
1002    pub const fn granted(source: ConsentSource) -> Self {
1003        Self {
1004            granted: true,
1005            source: Some(source),
1006        }
1007    }
1008
1009    /// Explicitly denied consent.
1010    #[must_use]
1011    pub const fn denied(source: Option<ConsentSource>) -> Self {
1012        Self {
1013            granted: false,
1014            source,
1015        }
1016    }
1017}
1018
1019/// Resolve download consent using priority:
1020/// programmatic > environment > interactive > config.
1021#[must_use]
1022pub fn resolve_download_consent(
1023    programmatic: Option<bool>,
1024    interactive: Option<bool>,
1025    config_file: Option<bool>,
1026) -> DownloadConsent {
1027    let env_value = std::env::var(DOWNLOAD_CONSENT_ENV).ok();
1028    resolve_download_consent_with_env(programmatic, env_value.as_deref(), interactive, config_file)
1029}
1030
1031fn resolve_download_consent_with_env(
1032    programmatic: Option<bool>,
1033    env_value: Option<&str>,
1034    interactive: Option<bool>,
1035    config_file: Option<bool>,
1036) -> DownloadConsent {
1037    if let Some(granted) = programmatic {
1038        return DownloadConsent {
1039            granted,
1040            source: Some(ConsentSource::Programmatic),
1041        };
1042    }
1043
1044    if let Some(raw) = env_value
1045        && let Some(granted) = parse_bool_flag(raw)
1046    {
1047        return DownloadConsent {
1048            granted,
1049            source: Some(ConsentSource::Environment),
1050        };
1051    }
1052
1053    if let Some(granted) = interactive {
1054        return DownloadConsent {
1055            granted,
1056            source: Some(ConsentSource::Interactive),
1057        };
1058    }
1059
1060    if let Some(granted) = config_file {
1061        return DownloadConsent {
1062            granted,
1063            source: Some(ConsentSource::ConfigFile),
1064        };
1065    }
1066
1067    DownloadConsent::denied(None)
1068}
1069
1070/// Stateful lifecycle helper for model installation progress.
1071#[derive(Debug, Clone)]
1072pub struct ModelLifecycle {
1073    manifest: ModelManifest,
1074    state: ModelState,
1075    consent: DownloadConsent,
1076}
1077
1078impl ModelLifecycle {
1079    /// Create lifecycle state for a manifest.
1080    #[must_use]
1081    pub const fn new(manifest: ModelManifest, consent: DownloadConsent) -> Self {
1082        let state = if consent.granted {
1083            ModelState::NotInstalled
1084        } else {
1085            ModelState::NeedsConsent
1086        };
1087        Self {
1088            manifest,
1089            state,
1090            consent,
1091        }
1092    }
1093
1094    /// Current lifecycle state.
1095    #[must_use]
1096    pub const fn state(&self) -> &ModelState {
1097        &self.state
1098    }
1099
1100    /// Underlying manifest for this lifecycle.
1101    #[must_use]
1102    pub const fn manifest(&self) -> &ModelManifest {
1103        &self.manifest
1104    }
1105
1106    /// Mark consent as granted (e.g., after explicit user approval).
1107    pub fn approve_consent(&mut self, source: ConsentSource) {
1108        self.consent = DownloadConsent::granted(source);
1109        if matches!(self.state, ModelState::NeedsConsent) {
1110            self.state = ModelState::NotInstalled;
1111        }
1112    }
1113
1114    /// Start the download state.
1115    ///
1116    /// # Errors
1117    ///
1118    /// Returns `SearchError::InvalidConfig` on invalid transition or zero total bytes.
1119    pub fn begin_download(&mut self, total_bytes: u64) -> SearchResult<()> {
1120        if !self.consent.granted {
1121            self.state = ModelState::NeedsConsent;
1122            return Err(SearchError::EmbedderUnavailable {
1123                model: self.manifest.id.clone(),
1124                reason: "download consent required".to_owned(),
1125            });
1126        }
1127        if total_bytes == 0 {
1128            return Err(SearchError::InvalidConfig {
1129                field: "total_bytes".to_owned(),
1130                value: "0".to_owned(),
1131                reason: "must be greater than zero".to_owned(),
1132            });
1133        }
1134
1135        match self.state {
1136            ModelState::NotInstalled
1137            | ModelState::Cancelled
1138            | ModelState::VerificationFailed { .. } => {
1139                self.state = ModelState::Downloading {
1140                    progress_pct: 0,
1141                    bytes_downloaded: 0,
1142                    total_bytes,
1143                };
1144                Ok(())
1145            }
1146            _ => Err(invalid_state_transition(
1147                &self.state,
1148                "begin_download",
1149                "expected NotInstalled/Cancelled/VerificationFailed",
1150            )),
1151        }
1152    }
1153
1154    /// Update bytes downloaded and recompute bounded percent.
1155    ///
1156    /// # Errors
1157    ///
1158    /// Returns `SearchError::InvalidConfig` if not currently downloading.
1159    pub fn update_download_progress(&mut self, bytes_downloaded: u64) -> SearchResult<()> {
1160        let (progress_pct, total_bytes, bounded_bytes) = match self.state {
1161            ModelState::Downloading { total_bytes, .. } => {
1162                let bounded = bytes_downloaded.min(total_bytes);
1163                let pct_u64 = bounded.saturating_mul(100) / total_bytes;
1164                #[allow(clippy::cast_possible_truncation)]
1165                let pct = pct_u64 as u8;
1166                (pct.min(100), total_bytes, bounded)
1167            }
1168            _ => {
1169                return Err(invalid_state_transition(
1170                    &self.state,
1171                    "update_download_progress",
1172                    "expected Downloading",
1173                ));
1174            }
1175        };
1176
1177        self.state = ModelState::Downloading {
1178            progress_pct,
1179            bytes_downloaded: bounded_bytes,
1180            total_bytes,
1181        };
1182        Ok(())
1183    }
1184
1185    /// Move from downloading to verifying.
1186    ///
1187    /// # Errors
1188    ///
1189    /// Returns `SearchError::InvalidConfig` if not currently downloading.
1190    pub fn begin_verification(&mut self) -> SearchResult<()> {
1191        if matches!(self.state, ModelState::Downloading { .. }) {
1192            self.state = ModelState::Verifying;
1193            return Ok(());
1194        }
1195        Err(invalid_state_transition(
1196            &self.state,
1197            "begin_verification",
1198            "expected Downloading",
1199        ))
1200    }
1201
1202    /// Mark install ready.
1203    pub fn mark_ready(&mut self) {
1204        self.state = ModelState::Ready;
1205    }
1206
1207    /// Mark install verification failed.
1208    pub fn fail_verification(&mut self, reason: impl Into<String>) {
1209        self.state = ModelState::VerificationFailed {
1210            reason: reason.into(),
1211        };
1212    }
1213
1214    /// Mark model disabled.
1215    pub fn disable(&mut self, reason: impl Into<String>) {
1216        self.state = ModelState::Disabled {
1217            reason: reason.into(),
1218        };
1219    }
1220
1221    /// Mark update available.
1222    pub fn mark_update_available(
1223        &mut self,
1224        current_revision: impl Into<String>,
1225        latest_revision: impl Into<String>,
1226    ) {
1227        self.state = ModelState::UpdateAvailable {
1228            current_revision: current_revision.into(),
1229            latest_revision: latest_revision.into(),
1230        };
1231    }
1232
1233    /// Cancel current operation.
1234    pub fn cancel(&mut self) {
1235        self.state = ModelState::Cancelled;
1236    }
1237
1238    /// Recover from cancelled state so a new download can start.
1239    ///
1240    /// # Errors
1241    ///
1242    /// Returns `SearchError::InvalidConfig` if current state is not `Cancelled`.
1243    pub fn recover_after_cancel(&mut self) -> SearchResult<()> {
1244        if !matches!(self.state, ModelState::Cancelled) {
1245            return Err(invalid_state_transition(
1246                &self.state,
1247                "recover_after_cancel",
1248                "expected Cancelled",
1249            ));
1250        }
1251        self.state = if self.consent.granted {
1252            ModelState::NotInstalled
1253        } else {
1254            ModelState::NeedsConsent
1255        };
1256        Ok(())
1257    }
1258}
1259
1260/// Verify file size + SHA256 using streaming read.
1261///
1262/// # Errors
1263///
1264/// Returns `SearchError` when file is missing, unreadable, or hash/size mismatch occurs.
1265pub fn verify_file_sha256(
1266    path: &Path,
1267    expected_sha256: &str,
1268    expected_size: u64,
1269) -> SearchResult<()> {
1270    if expected_sha256 == PLACEHOLDER_VERIFY_AFTER_DOWNLOAD {
1271        return Err(SearchError::InvalidConfig {
1272            field: "sha256".to_owned(),
1273            value: expected_sha256.to_owned(),
1274            reason: "placeholder checksum cannot be verified".to_owned(),
1275        });
1276    }
1277    if !is_valid_sha256_hex(expected_sha256) {
1278        return Err(SearchError::InvalidConfig {
1279            field: "sha256".to_owned(),
1280            value: expected_sha256.to_owned(),
1281            reason: "expected lowercase 64-char SHA256 hex".to_owned(),
1282        });
1283    }
1284    if !path.exists() {
1285        return Err(SearchError::ModelNotFound {
1286            name: format!("missing model file: {}", path.display()),
1287        });
1288    }
1289
1290    let metadata = fs::metadata(path).map_err(|source| SearchError::ModelLoadFailed {
1291        path: path.to_path_buf(),
1292        source: Box::new(source),
1293    })?;
1294    if !metadata.is_file() {
1295        return Err(SearchError::ModelLoadFailed {
1296            path: path.to_path_buf(),
1297            source: "expected a regular file".into(),
1298        });
1299    }
1300
1301    let file = File::open(path).map_err(|source| SearchError::ModelLoadFailed {
1302        path: path.to_path_buf(),
1303        source: Box::new(source),
1304    })?;
1305    let mut reader = BufReader::new(file);
1306    let mut buffer = [0_u8; HASH_BUFFER_SIZE];
1307    let mut hasher = Sha256::new();
1308    let mut bytes_read = 0_u64;
1309
1310    loop {
1311        let read = reader
1312            .read(&mut buffer)
1313            .map_err(|source| SearchError::ModelLoadFailed {
1314                path: path.to_path_buf(),
1315                source: Box::new(source),
1316            })?;
1317        if read == 0 {
1318            break;
1319        }
1320        let read_u64 = u64::try_from(read).map_err(|_| SearchError::InvalidConfig {
1321            field: "read_size".to_owned(),
1322            value: read.to_string(),
1323            reason: "read size does not fit u64".to_owned(),
1324        })?;
1325        bytes_read = bytes_read.saturating_add(read_u64);
1326        hasher.update(&buffer[..read]);
1327    }
1328
1329    let actual_sha256 = to_hex_lowercase(&hasher.finalize());
1330    let expected_lower = expected_sha256.to_ascii_lowercase();
1331    if bytes_read != expected_size || actual_sha256 != expected_lower {
1332        return Err(SearchError::HashMismatch {
1333            path: path.to_path_buf(),
1334            expected: format!("sha256={expected_lower},size={expected_size}"),
1335            actual: format!("sha256={actual_sha256},size={bytes_read}"),
1336        });
1337    }
1338
1339    Ok(())
1340}
1341
1342// ─── Verification Cache ────────────────────────────────────────────────────
1343
1344/// Name of the verification marker file within a model directory.
1345const VERIFIED_MARKER_FILE: &str = ".verified";
1346
1347/// Lightweight filesystem fingerprint for one verified model file.
1348///
1349/// This is intentionally cheap to read and compare:
1350/// - file size (bytes)
1351/// - last-modified timestamp (unix nanos)
1352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1353pub struct FileVerificationState {
1354    /// File size in bytes.
1355    pub size_bytes: u64,
1356    /// Last-modified timestamp since unix epoch (nanoseconds).
1357    pub modified_unix_nanos: u64,
1358}
1359
1360fn capture_file_verification_state(path: &Path) -> Option<FileVerificationState> {
1361    let metadata = fs::metadata(path).ok()?;
1362    if !metadata.is_file() {
1363        return None;
1364    }
1365    let modified = metadata
1366        .modified()
1367        .ok()?
1368        .duration_since(UNIX_EPOCH)
1369        .ok()?
1370        .as_nanos();
1371    let modified_unix_nanos = u64::try_from(modified).ok()?;
1372    Some(FileVerificationState {
1373        size_bytes: metadata.len(),
1374        modified_unix_nanos,
1375    })
1376}
1377
1378fn resolve_model_file_path(model_dir: &Path, file_name: &str) -> SearchResult<PathBuf> {
1379    validate_model_file_name(file_name)?;
1380    Ok(model_dir.join(file_name))
1381}
1382
1383fn validate_model_file_name(file_name: &str) -> SearchResult<()> {
1384    if file_name.trim().is_empty() {
1385        return Err(invalid_manifest_field(
1386            "files[].name",
1387            file_name,
1388            "must not be empty",
1389        ));
1390    }
1391    for component in Path::new(file_name).components() {
1392        match component {
1393            std::path::Component::ParentDir => {
1394                return Err(invalid_manifest_field(
1395                    "files[].name",
1396                    file_name,
1397                    "must not contain '..' path traversal",
1398                ));
1399            }
1400            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
1401                return Err(invalid_manifest_field(
1402                    "files[].name",
1403                    file_name,
1404                    "must be a relative path without root",
1405                ));
1406            }
1407            _ => {}
1408        }
1409    }
1410    Ok(())
1411}
1412
1413/// Cached verification result stored as a small JSON file alongside model files.
1414///
1415/// When a model directory passes SHA-256 verification, a `.verified` marker is written
1416/// containing the manifest ID, schema version, and lightweight file fingerprints
1417/// captured at verification time. Subsequent loads check whether the marker is still
1418/// valid (all fingerprints unchanged) and skip re-hashing when it is.
1419#[derive(Debug, Clone, Serialize, Deserialize)]
1420pub struct VerificationMarker {
1421    /// Manifest identifier that was verified against.
1422    pub manifest_id: String,
1423    /// Manifest schema version at time of verification.
1424    pub schema_version: u32,
1425    /// Unix timestamp (seconds) when verification was performed.
1426    pub verified_at: u64,
1427    /// Per-file lightweight fingerprint at verification time, keyed by file name.
1428    pub file_states: BTreeMap<String, FileVerificationState>,
1429}
1430
1431impl VerificationMarker {
1432    /// Create a new marker for a successfully verified model directory.
1433    fn new_for(manifest: &ModelManifest, model_dir: &Path) -> Self {
1434        let now = SystemTime::now()
1435            .duration_since(UNIX_EPOCH)
1436            .map_or(0, |d| d.as_secs());
1437
1438        let mut file_states = BTreeMap::new();
1439        for file in &manifest.files {
1440            if let Ok(path) = resolve_model_file_path(model_dir, &file.name)
1441                && let Some(state) = capture_file_verification_state(&path)
1442            {
1443                file_states.insert(file.name.clone(), state);
1444            }
1445        }
1446
1447        Self {
1448            manifest_id: manifest.id.clone(),
1449            schema_version: MANIFEST_SCHEMA_VERSION,
1450            verified_at: now,
1451            file_states,
1452        }
1453    }
1454
1455    /// Check whether this cached marker is still valid for the given manifest and directory.
1456    ///
1457    /// Returns `true` when:
1458    /// 1. The manifest ID matches.
1459    /// 2. The schema version matches.
1460    /// 3. No model file metadata fingerprint has changed since verification.
1461    fn is_valid_for(&self, manifest: &ModelManifest, model_dir: &Path) -> bool {
1462        if self.manifest_id != manifest.id || self.schema_version != MANIFEST_SCHEMA_VERSION {
1463            return false;
1464        }
1465
1466        for file in &manifest.files {
1467            let Some(expected_state) = self.file_states.get(&file.name) else {
1468                return false;
1469            };
1470            let Ok(path) = resolve_model_file_path(model_dir, &file.name) else {
1471                return false;
1472            };
1473            let Some(current_state) = capture_file_verification_state(&path) else {
1474                return false;
1475            };
1476            if current_state != *expected_state {
1477                return false;
1478            }
1479        }
1480
1481        true
1482    }
1483}
1484
1485/// Write a `.verified` marker after successful verification of a model directory.
1486///
1487/// The marker is best-effort: failures to write are logged but do not propagate errors.
1488pub fn write_verification_marker(manifest: &ModelManifest, model_dir: &Path) {
1489    let marker = VerificationMarker::new_for(manifest, model_dir);
1490    let Ok(json) = serde_json::to_string_pretty(&marker) else {
1491        return;
1492    };
1493    let _ = (|| -> std::io::Result<()> {
1494        let mut file = File::create(model_dir.join(VERIFIED_MARKER_FILE))?;
1495        std::io::Write::write_all(&mut file, json.as_bytes())?;
1496        file.sync_all()?;
1497        Ok(())
1498    })();
1499}
1500
1501/// Check whether a valid verification marker exists for the given manifest and directory.
1502///
1503/// Returns `true` when a `.verified` file exists, parses correctly, and all file mtimes
1504/// match. In that case, the caller can skip full SHA-256 re-verification.
1505#[must_use]
1506pub fn is_verification_cached(manifest: &ModelManifest, model_dir: &Path) -> bool {
1507    let path = model_dir.join(VERIFIED_MARKER_FILE);
1508    let Ok(raw) = fs::read_to_string(&path) else {
1509        return false;
1510    };
1511    let Ok(marker) = serde_json::from_str::<VerificationMarker>(&raw) else {
1512        return false;
1513    };
1514    marker.is_valid_for(manifest, model_dir)
1515}
1516
1517/// Verify a model directory, using cached results when available.
1518///
1519/// If a valid `.verified` marker exists (matching manifest ID, schema version, and
1520/// file mtimes), verification succeeds immediately without re-hashing. Otherwise,
1521/// full SHA-256 verification is performed via [`ModelManifest::verify_dir`], and
1522/// on success a new marker is written for future loads.
1523///
1524/// # Errors
1525///
1526/// Returns `SearchError` when the manifest has verified checksums and full
1527/// verification fails (hash mismatch, missing files, etc.).
1528pub fn verify_dir_cached(manifest: &ModelManifest, model_dir: &Path) -> SearchResult<()> {
1529    if !manifest.has_verified_checksums() {
1530        return Ok(());
1531    }
1532
1533    if is_verification_cached(manifest, model_dir) {
1534        return Ok(());
1535    }
1536
1537    manifest.verify_dir(model_dir)?;
1538    write_verification_marker(manifest, model_dir);
1539    Ok(())
1540}
1541
1542fn promote_atomically(staged_dir: &Path, destination_dir: &Path) -> SearchResult<Option<PathBuf>> {
1543    let destination_parent =
1544        destination_dir
1545            .parent()
1546            .ok_or_else(|| SearchError::InvalidConfig {
1547                field: "destination_dir".to_owned(),
1548                value: destination_dir.display().to_string(),
1549                reason: "destination must have a parent directory".to_owned(),
1550            })?;
1551    fs::create_dir_all(destination_parent).map_err(SearchError::from)?;
1552
1553    let stage_name = destination_dir.file_name().map_or_else(
1554        || "model".to_owned(),
1555        |part| part.to_string_lossy().into_owned(),
1556    );
1557    let timestamp = SystemTime::now()
1558        .duration_since(UNIX_EPOCH)
1559        .map_or(0, |duration| duration.as_nanos());
1560    let pid = std::process::id();
1561    let stage_target =
1562        destination_parent.join(format!(".{stage_name}.installing.{timestamp}.{pid}"));
1563    fs::rename(staged_dir, &stage_target).map_err(SearchError::from)?;
1564
1565    let backup_path = if destination_dir.exists() {
1566        let backup = destination_parent.join(format!("{stage_name}.backup.{timestamp}.{pid}"));
1567        fs::rename(destination_dir, &backup).map_err(SearchError::from)?;
1568        Some(backup)
1569    } else {
1570        None
1571    };
1572
1573    fs::rename(&stage_target, destination_dir).map_err(SearchError::from)?;
1574    Ok(backup_path)
1575}
1576
1577fn manifest_registry() -> &'static RwLock<BTreeMap<String, ModelManifest>> {
1578    static REGISTRY: OnceLock<RwLock<BTreeMap<String, ModelManifest>>> = OnceLock::new();
1579    REGISTRY.get_or_init(|| {
1580        let catalog = ModelManifest::builtin_catalog();
1581        let mut data = BTreeMap::new();
1582        for manifest in catalog.models {
1583            data.insert(manifest.id.clone(), manifest);
1584        }
1585        RwLock::new(data)
1586    })
1587}
1588
1589fn manifest_registry_lock_error(action: &str) -> SearchError {
1590    SearchError::SubsystemError {
1591        subsystem: "model_manifest",
1592        source: std::io::Error::other(format!("manifest registry {action} lock poisoned")).into(),
1593    }
1594}
1595
1596fn invalid_manifest_field(field: &str, value: &str, reason: &str) -> SearchError {
1597    SearchError::InvalidConfig {
1598        field: field.to_owned(),
1599        value: value.to_owned(),
1600        reason: reason.to_owned(),
1601    }
1602}
1603
1604fn invalid_state_transition(state: &ModelState, operation: &str, reason: &str) -> SearchError {
1605    SearchError::InvalidConfig {
1606        field: "model_state".to_owned(),
1607        value: format!("{state:?}"),
1608        reason: format!("invalid transition for {operation}: {reason}"),
1609    }
1610}
1611
1612fn truncate_for_error(value: &str) -> String {
1613    const MAX: usize = 120;
1614    let mut chars = value.chars();
1615    let truncated: String = chars.by_ref().take(MAX).collect();
1616    if chars.next().is_none() {
1617        return truncated;
1618    }
1619    let mut out = truncated;
1620    out.push_str("...");
1621    out
1622}
1623
1624fn parse_bool_flag(raw: &str) -> Option<bool> {
1625    let value = raw.trim();
1626    if value == "1"
1627        || value.eq_ignore_ascii_case("true")
1628        || value.eq_ignore_ascii_case("yes")
1629        || value.eq_ignore_ascii_case("on")
1630    {
1631        return Some(true);
1632    }
1633    if value == "0"
1634        || value.eq_ignore_ascii_case("false")
1635        || value.eq_ignore_ascii_case("no")
1636        || value.eq_ignore_ascii_case("off")
1637    {
1638        return Some(false);
1639    }
1640    None
1641}
1642
1643fn is_valid_sha256_hex(value: &str) -> bool {
1644    value.len() == 64
1645        && value
1646            .bytes()
1647            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1648}
1649
1650fn to_hex_lowercase(bytes: &[u8]) -> String {
1651    let mut output = String::with_capacity(bytes.len() * 2);
1652    for byte in bytes {
1653        let _ = write!(&mut output, "{byte:02x}");
1654    }
1655    output
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660    use super::*;
1661    use std::io::Write;
1662
1663    fn write_temp_file(path: &Path, bytes: &[u8]) {
1664        let mut file = File::create(path).unwrap();
1665        file.write_all(bytes).unwrap();
1666        file.flush().unwrap();
1667    }
1668
1669    #[test]
1670    fn invalid_manifest_json_returns_clear_error() {
1671        let err = ModelManifest::from_json_str("{not-valid-json]").unwrap_err();
1672        assert!(matches!(err, SearchError::InvalidConfig { .. }));
1673        assert!(err.to_string().contains("manifest JSON"));
1674    }
1675
1676    #[test]
1677    fn valid_manifest_json_round_trips_expected_fields() {
1678        let manifest = ModelManifest::from_json_str(
1679            r#"{
1680                "id":"test-model",
1681                "repo":"acme/test-model",
1682                "revision":"0123456789abcdef0123456789abcdef01234567",
1683                "files":[
1684                    {
1685                        "name":"model.bin",
1686                        "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1687                        "size":42
1688                    }
1689                ],
1690                "license":"MIT"
1691            }"#,
1692        )
1693        .unwrap();
1694
1695        assert_eq!(manifest.id, "test-model");
1696        assert_eq!(manifest.repo, "acme/test-model");
1697        assert_eq!(manifest.total_size_bytes(), 42);
1698        assert!(manifest.has_verified_checksums());
1699        assert!(manifest.has_pinned_revision());
1700        assert!(manifest.is_production_ready());
1701    }
1702
1703    #[test]
1704    fn missing_required_manifest_field_surfaces_field_name() {
1705        let err = ModelManifest::from_json_str(
1706            r#"{
1707                "id":"test-model",
1708                "repo":"acme/test-model",
1709                "revision":"0123456789abcdef0123456789abcdef01234567",
1710                "files":[]
1711            }"#,
1712        )
1713        .unwrap_err();
1714
1715        assert!(matches!(err, SearchError::InvalidConfig { .. }));
1716        assert!(err.to_string().contains("license"));
1717    }
1718
1719    #[test]
1720    fn verify_file_sha256_success_wrong_hash_and_truncated() {
1721        let temp = tempfile::tempdir().unwrap();
1722        let path = temp.path().join("model.bin");
1723        let bytes = b"model-bytes";
1724        write_temp_file(&path, bytes);
1725
1726        let expected_hash = to_hex_lowercase(&Sha256::digest(bytes));
1727        let expected_size = u64::try_from(bytes.len()).unwrap();
1728        verify_file_sha256(&path, &expected_hash, expected_size).unwrap();
1729
1730        let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000";
1731        let err = verify_file_sha256(&path, wrong_hash, expected_size).unwrap_err();
1732        assert!(matches!(err, SearchError::HashMismatch { .. }));
1733
1734        let err = verify_file_sha256(&path, &expected_hash, expected_size + 1).unwrap_err();
1735        assert!(matches!(err, SearchError::HashMismatch { .. }));
1736    }
1737
1738    #[test]
1739    fn verify_file_sha256_rejects_placeholder_invalid_hash_and_missing_file() {
1740        let temp = tempfile::tempdir().unwrap();
1741        let missing_path = temp.path().join("missing.bin");
1742
1743        let err =
1744            verify_file_sha256(&missing_path, PLACEHOLDER_VERIFY_AFTER_DOWNLOAD, 1).unwrap_err();
1745        assert!(matches!(err, SearchError::InvalidConfig { .. }));
1746
1747        let err = verify_file_sha256(&missing_path, "NOT-A-HASH", 1).unwrap_err();
1748        assert!(matches!(err, SearchError::InvalidConfig { .. }));
1749
1750        let valid_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1751        let err = verify_file_sha256(&missing_path, valid_hash, 1).unwrap_err();
1752        assert!(matches!(err, SearchError::ModelNotFound { .. }));
1753    }
1754
1755    #[test]
1756    fn catalog_validate_reports_invalid_nested_manifest() {
1757        let catalog = ModelManifestCatalog::from_json_str(
1758            r#"{
1759                "models":[
1760                    {
1761                        "id":"bad-model",
1762                        "repo":"acme/bad-model",
1763                        "revision":"0123456789abcdef0123456789abcdef01234567",
1764                        "files":[
1765                            {"name":"model.bin","sha256":"bad-hash","size":10}
1766                        ],
1767                        "license":"MIT"
1768                    }
1769                ]
1770            }"#,
1771        )
1772        .unwrap();
1773
1774        let err = catalog.validate().unwrap_err();
1775        assert!(matches!(err, SearchError::InvalidConfig { .. }));
1776    }
1777
1778    #[test]
1779    fn lifecycle_state_machine_success_path() {
1780        let manifest = ModelManifest::potion_128m();
1781        let mut lifecycle = ModelLifecycle::new(
1782            manifest,
1783            DownloadConsent::granted(ConsentSource::Programmatic),
1784        );
1785
1786        assert_eq!(lifecycle.state(), &ModelState::NotInstalled);
1787
1788        lifecycle.begin_download(100).unwrap();
1789        lifecycle.update_download_progress(40).unwrap();
1790        lifecycle.begin_verification().unwrap();
1791        lifecycle.mark_ready();
1792
1793        assert_eq!(lifecycle.state(), &ModelState::Ready);
1794    }
1795
1796    #[test]
1797    fn lifecycle_state_machine_failure_path() {
1798        let manifest = ModelManifest::potion_128m();
1799        let mut lifecycle = ModelLifecycle::new(
1800            manifest,
1801            DownloadConsent::granted(ConsentSource::Programmatic),
1802        );
1803
1804        lifecycle.begin_download(100).unwrap();
1805        lifecycle.fail_verification("checksum mismatch");
1806        assert!(matches!(
1807            lifecycle.state(),
1808            ModelState::VerificationFailed { .. }
1809        ));
1810    }
1811
1812    #[test]
1813    fn download_progress_percent_is_bounded_to_100() {
1814        let manifest = ModelManifest::minilm_v2();
1815        let mut lifecycle = ModelLifecycle::new(
1816            manifest,
1817            DownloadConsent::granted(ConsentSource::Programmatic),
1818        );
1819        lifecycle.begin_download(10).unwrap();
1820        lifecycle.update_download_progress(10_000).unwrap();
1821
1822        let progress_pct = match lifecycle.state() {
1823            ModelState::Downloading { progress_pct, .. } => *progress_pct,
1824            _ => 0,
1825        };
1826        assert!(progress_pct <= 100);
1827        assert_eq!(progress_pct, 100);
1828    }
1829
1830    #[test]
1831    fn placeholder_checksums_are_rejected_in_release_policy_mode() {
1832        let mut manifest = ModelManifest::minilm_v2();
1833        manifest.files[0].sha256 = PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned();
1834        manifest.files[0].size = 0;
1835        manifest.files[0].url = None;
1836        let err = manifest.validate_checksum_policy_for(true).unwrap_err();
1837        assert!(matches!(err, SearchError::InvalidConfig { .. }));
1838    }
1839
1840    #[test]
1841    fn cancelled_state_can_recover() {
1842        let manifest = ModelManifest::potion_128m();
1843        let mut lifecycle = ModelLifecycle::new(
1844            manifest,
1845            DownloadConsent::granted(ConsentSource::Programmatic),
1846        );
1847        lifecycle.begin_download(10).unwrap();
1848        lifecycle.cancel();
1849        lifecycle.recover_after_cancel().unwrap();
1850        assert_eq!(lifecycle.state(), &ModelState::NotInstalled);
1851    }
1852
1853    #[test]
1854    fn empty_manifest_catalog_is_valid() {
1855        let catalog = ModelManifestCatalog::from_json_str(r#"{"models":[]}"#).unwrap();
1856        assert!(catalog.models.is_empty());
1857        catalog.validate().unwrap();
1858    }
1859
1860    #[test]
1861    fn unreadable_model_file_returns_clear_error() {
1862        let temp = tempfile::tempdir().unwrap();
1863        let model_root = temp.path();
1864        let bogus_path = model_root.join("tokenizer.json");
1865        fs::create_dir_all(&bogus_path).unwrap();
1866
1867        let manifest = ModelManifest {
1868            id: "test".to_owned(),
1869            version: "test-v1".to_owned(),
1870            display_name: None,
1871            description: None,
1872            repo: "owner/repo".to_owned(),
1873            revision: "abcdef1".to_owned(),
1874            files: vec![ModelFile {
1875                name: "tokenizer.json".to_owned(),
1876                sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1877                    .to_owned(),
1878                size: 1,
1879                url: None,
1880            }],
1881            license: "MIT".to_owned(),
1882            dimension: None,
1883            tier: None,
1884            download_size_bytes: 0,
1885        };
1886
1887        let err = manifest.verify_dir(model_root).unwrap_err();
1888        assert!(matches!(err, SearchError::ModelLoadFailed { .. }));
1889        assert!(err.to_string().contains("regular file"));
1890    }
1891
1892    #[test]
1893    fn verify_dir_rejects_traversal_file_names_without_needing_validate_call() {
1894        let temp = tempfile::tempdir().expect("tempdir");
1895        let manifest = ModelManifest {
1896            id: "test".to_owned(),
1897            version: "test-v1".to_owned(),
1898            display_name: None,
1899            description: None,
1900            repo: "owner/repo".to_owned(),
1901            revision: "abcdef1".to_owned(),
1902            files: vec![ModelFile {
1903                name: "../escape.bin".to_owned(),
1904                sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1905                    .to_owned(),
1906                size: 0,
1907                url: None,
1908            }],
1909            license: "MIT".to_owned(),
1910            dimension: None,
1911            tier: None,
1912            download_size_bytes: 0,
1913        };
1914
1915        let err = manifest
1916            .verify_dir(temp.path())
1917            .expect_err("must reject traversal");
1918        assert!(matches!(
1919            err,
1920            SearchError::InvalidConfig { ref field, .. } if field == "files[].name"
1921        ));
1922        assert!(err.to_string().contains("path traversal"));
1923    }
1924
1925    #[test]
1926    fn can_register_and_lookup_custom_manifest() {
1927        let unique_id = format!(
1928            "custom-{}-{}",
1929            std::process::id(),
1930            SystemTime::now()
1931                .duration_since(UNIX_EPOCH)
1932                .unwrap()
1933                .as_nanos()
1934        );
1935        let manifest = ModelManifest {
1936            id: unique_id.clone(),
1937            version: "test-v1".to_owned(),
1938            display_name: None,
1939            description: None,
1940            repo: "acme/custom".to_owned(),
1941            revision: "0123456789abcdef0123456789abcdef01234567".to_owned(),
1942            files: vec![ModelFile {
1943                name: "weights.bin".to_owned(),
1944                sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1945                    .to_owned(),
1946                size: 42,
1947                url: None,
1948            }],
1949            license: "MIT".to_owned(),
1950            dimension: None,
1951            tier: None,
1952            download_size_bytes: 0,
1953        };
1954
1955        manifest.clone().register().unwrap();
1956        let loaded = ModelManifest::lookup(&unique_id).unwrap();
1957        assert_eq!(loaded, manifest);
1958    }
1959
1960    #[test]
1961    fn resolve_download_consent_priority_order() {
1962        let consent =
1963            resolve_download_consent_with_env(Some(false), Some("1"), Some(true), Some(true));
1964        assert_eq!(consent.source, Some(ConsentSource::Programmatic));
1965        assert!(!consent.granted);
1966
1967        let consent = resolve_download_consent_with_env(None, Some("1"), Some(false), Some(true));
1968        assert_eq!(consent.source, Some(ConsentSource::Environment));
1969        assert!(consent.granted);
1970
1971        let consent = resolve_download_consent_with_env(None, None, Some(false), Some(true));
1972        assert_eq!(consent.source, Some(ConsentSource::Interactive));
1973        assert!(!consent.granted);
1974    }
1975
1976    // ── bd-3un.51: Additional coverage ───────────────────────────────
1977
1978    #[test]
1979    fn valid_manifest_parses_all_fields() {
1980        let json = r#"{
1981            "id": "test-model",
1982            "repo": "owner/test-model",
1983            "revision": "abc123def456",
1984            "files": [
1985                {"name": "model.onnx", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1024},
1986                {"name": "tokenizer.json", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 512}
1987            ],
1988            "license": "Apache-2.0"
1989        }"#;
1990        let manifest = ModelManifest::from_json_str(json).unwrap();
1991        assert_eq!(manifest.id, "test-model");
1992        assert_eq!(manifest.repo, "owner/test-model");
1993        assert_eq!(manifest.revision, "abc123def456");
1994        assert_eq!(manifest.files.len(), 2);
1995        assert_eq!(manifest.files[0].name, "model.onnx");
1996        assert_eq!(manifest.files[1].size, 512);
1997        assert_eq!(manifest.license, "Apache-2.0");
1998    }
1999
2000    #[test]
2001    fn missing_id_field_returns_clear_error() {
2002        let json = r#"{"id": "", "repo": "r", "revision": "v", "files": [], "license": "MIT"}"#;
2003        let err = ModelManifest::from_json_str(json).unwrap_err();
2004        assert!(err.to_string().contains("must not be empty"));
2005    }
2006
2007    #[test]
2008    fn missing_repo_field_returns_clear_error() {
2009        let json = r#"{"id": "m", "repo": " ", "revision": "v", "files": [], "license": "MIT"}"#;
2010        let err = ModelManifest::from_json_str(json).unwrap_err();
2011        assert!(err.to_string().contains("must not be empty"));
2012    }
2013
2014    #[test]
2015    fn missing_revision_field_returns_clear_error() {
2016        let json = r#"{"id": "m", "repo": "r", "revision": "", "files": [], "license": "MIT"}"#;
2017        let err = ModelManifest::from_json_str(json).unwrap_err();
2018        assert!(err.to_string().contains("must not be empty"));
2019    }
2020
2021    #[test]
2022    fn missing_license_field_returns_clear_error() {
2023        let json = r#"{"id": "m", "repo": "r", "revision": "v", "files": [], "license": ""}"#;
2024        let err = ModelManifest::from_json_str(json).unwrap_err();
2025        assert!(err.to_string().contains("must not be empty"));
2026    }
2027
2028    #[test]
2029    fn invalid_sha256_format_rejected() {
2030        let json = r#"{
2031            "id": "m", "repo": "r", "revision": "v", "license": "MIT",
2032            "files": [{"name": "f.bin", "sha256": "not-a-valid-hash", "size": 1}]
2033        }"#;
2034        let err = ModelManifest::from_json_str(json).unwrap_err();
2035        assert!(err.to_string().contains("SHA256 hex"));
2036    }
2037
2038    #[test]
2039    fn file_with_zero_size_and_valid_hash_accepted() {
2040        let json = r#"{
2041            "id": "m", "repo": "r", "revision": "v", "license": "MIT",
2042            "files": [{"name": "f.bin", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 0}]
2043        }"#;
2044        let manifest = ModelManifest::from_json_str(json).unwrap();
2045        assert_eq!(manifest.files[0].size, 0);
2046    }
2047
2048    #[test]
2049    fn empty_file_name_rejected() {
2050        let json = r#"{
2051            "id": "m", "repo": "r", "revision": "v", "license": "MIT",
2052            "files": [{"name": "", "sha256": "PLACEHOLDER_VERIFY_AFTER_DOWNLOAD", "size": 0}]
2053        }"#;
2054        let err = ModelManifest::from_json_str(json).unwrap_err();
2055        assert!(err.to_string().contains("must not be empty"));
2056    }
2057
2058    #[test]
2059    fn verify_missing_file_returns_model_not_found() {
2060        let temp = tempfile::tempdir().unwrap();
2061        let path = temp.path().join("does_not_exist.bin");
2062        let hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2063        let err = verify_file_sha256(&path, hash, 100).unwrap_err();
2064        assert!(matches!(err, SearchError::ModelNotFound { .. }));
2065    }
2066
2067    #[test]
2068    fn verify_placeholder_checksum_rejected() {
2069        let temp = tempfile::tempdir().unwrap();
2070        let path = temp.path().join("file.bin");
2071        write_temp_file(&path, b"data");
2072        let err = verify_file_sha256(&path, PLACEHOLDER_VERIFY_AFTER_DOWNLOAD, 4).unwrap_err();
2073        assert!(matches!(err, SearchError::InvalidConfig { .. }));
2074        assert!(err.to_string().contains("placeholder"));
2075    }
2076
2077    #[test]
2078    fn verify_zero_expected_size_accepts_empty_file() {
2079        let temp = tempfile::tempdir().unwrap();
2080        let path = temp.path().join("empty.bin");
2081        write_temp_file(&path, b"");
2082        let hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
2083        verify_file_sha256(&path, hash, 0).unwrap();
2084    }
2085
2086    #[test]
2087    fn verify_zero_expected_size_still_rejects_non_empty_file() {
2088        let temp = tempfile::tempdir().unwrap();
2089        let path = temp.path().join("file.bin");
2090        write_temp_file(&path, b"data");
2091        let hash = to_hex_lowercase(&Sha256::digest(b"data"));
2092        let err = verify_file_sha256(&path, &hash, 0).unwrap_err();
2093        assert!(matches!(err, SearchError::HashMismatch { .. }));
2094    }
2095
2096    #[test]
2097    fn to_pretty_json_roundtrip() {
2098        let manifest = ModelManifest::potion_128m();
2099        let json = manifest.to_pretty_json().unwrap();
2100        let restored = ModelManifest::from_json_str(&json).unwrap();
2101        assert_eq!(restored.id, manifest.id);
2102        assert_eq!(restored.files.len(), manifest.files.len());
2103    }
2104
2105    #[test]
2106    fn builtin_manifests_validate() {
2107        ModelManifest::minilm_v2().validate().unwrap();
2108        ModelManifest::potion_128m().validate().unwrap();
2109    }
2110
2111    #[test]
2112    fn builtin_manifests_are_production_ready() {
2113        assert!(ModelManifest::minilm_v2().is_production_ready());
2114        assert!(ModelManifest::potion_128m().is_production_ready());
2115        assert!(ModelManifest::ms_marco_reranker().is_production_ready());
2116    }
2117
2118    #[test]
2119    fn has_pinned_revision_rejects_floating_aliases() {
2120        for alias in &[
2121            "main",
2122            "master",
2123            "latest",
2124            "HEAD",
2125            PLACEHOLDER_PINNED_REVISION,
2126        ] {
2127            let m = ModelManifest {
2128                revision: alias.to_string(),
2129                ..ModelManifest::potion_128m()
2130            };
2131            assert!(
2132                !m.has_pinned_revision(),
2133                "'{alias}' should not be considered pinned"
2134            );
2135        }
2136    }
2137
2138    #[test]
2139    fn has_pinned_revision_accepts_commit_sha() {
2140        let m = ModelManifest {
2141            revision: "0123456789abcdef0123456789abcdef01234567".to_owned(),
2142            ..ModelManifest::potion_128m()
2143        };
2144        assert!(m.has_pinned_revision());
2145    }
2146
2147    #[test]
2148    fn total_size_bytes_sums_all_files() {
2149        let m = ModelManifest {
2150            files: vec![
2151                ModelFile {
2152                    name: "a".to_owned(),
2153                    sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2154                    size: 100,
2155                    url: None,
2156                },
2157                ModelFile {
2158                    name: "b".to_owned(),
2159                    sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2160                    size: 200,
2161                    url: None,
2162                },
2163            ],
2164            download_size_bytes: 0,
2165            ..ModelManifest::potion_128m()
2166        };
2167        assert_eq!(m.total_size_bytes(), 300);
2168    }
2169
2170    #[test]
2171    fn model_state_serde_roundtrip() {
2172        let states = vec![
2173            ModelState::NotInstalled,
2174            ModelState::NeedsConsent,
2175            ModelState::Downloading {
2176                progress_pct: 50,
2177                bytes_downloaded: 1000,
2178                total_bytes: 2000,
2179            },
2180            ModelState::Verifying,
2181            ModelState::Ready,
2182            ModelState::Disabled {
2183                reason: "out of disk".to_owned(),
2184            },
2185            ModelState::VerificationFailed {
2186                reason: "hash mismatch".to_owned(),
2187            },
2188            ModelState::UpdateAvailable {
2189                current_revision: "old".to_owned(),
2190                latest_revision: "new".to_owned(),
2191            },
2192            ModelState::Cancelled,
2193        ];
2194        for state in &states {
2195            let json = serde_json::to_string(state).unwrap();
2196            let decoded: ModelState = serde_json::from_str(&json).unwrap();
2197            assert_eq!(&decoded, state);
2198        }
2199    }
2200
2201    #[test]
2202    fn consent_source_serde_roundtrip() {
2203        for source in &[
2204            ConsentSource::Programmatic,
2205            ConsentSource::Environment,
2206            ConsentSource::Interactive,
2207            ConsentSource::ConfigFile,
2208        ] {
2209            let json = serde_json::to_string(source).unwrap();
2210            let decoded: ConsentSource = serde_json::from_str(&json).unwrap();
2211            assert_eq!(&decoded, source);
2212        }
2213    }
2214
2215    #[test]
2216    fn lifecycle_needs_consent_when_not_granted() {
2217        let manifest = ModelManifest::potion_128m();
2218        let lifecycle = ModelLifecycle::new(manifest, DownloadConsent::denied(None));
2219        assert_eq!(lifecycle.state(), &ModelState::NeedsConsent);
2220    }
2221
2222    #[test]
2223    fn lifecycle_begin_download_without_consent_fails() {
2224        let manifest = ModelManifest::potion_128m();
2225        let mut lifecycle = ModelLifecycle::new(manifest, DownloadConsent::denied(None));
2226        let err = lifecycle.begin_download(100).unwrap_err();
2227        assert!(matches!(err, SearchError::EmbedderUnavailable { .. }));
2228    }
2229
2230    #[test]
2231    fn lifecycle_begin_download_zero_bytes_fails() {
2232        let manifest = ModelManifest::potion_128m();
2233        let mut lifecycle = ModelLifecycle::new(
2234            manifest,
2235            DownloadConsent::granted(ConsentSource::Programmatic),
2236        );
2237        let err = lifecycle.begin_download(0).unwrap_err();
2238        assert!(matches!(err, SearchError::InvalidConfig { .. }));
2239    }
2240
2241    #[test]
2242    fn lifecycle_approve_consent_transitions() {
2243        let manifest = ModelManifest::potion_128m();
2244        let mut lifecycle = ModelLifecycle::new(manifest, DownloadConsent::denied(None));
2245        assert_eq!(lifecycle.state(), &ModelState::NeedsConsent);
2246
2247        lifecycle.approve_consent(ConsentSource::Interactive);
2248        assert_eq!(lifecycle.state(), &ModelState::NotInstalled);
2249    }
2250
2251    #[test]
2252    fn lifecycle_disable_and_update() {
2253        let manifest = ModelManifest::potion_128m();
2254        let mut lifecycle = ModelLifecycle::new(
2255            manifest,
2256            DownloadConsent::granted(ConsentSource::Programmatic),
2257        );
2258
2259        lifecycle.disable("maintenance");
2260        assert!(matches!(lifecycle.state(), ModelState::Disabled { .. }));
2261
2262        lifecycle.mark_update_available("v1", "v2");
2263        assert!(matches!(
2264            lifecycle.state(),
2265            ModelState::UpdateAvailable { .. }
2266        ));
2267    }
2268
2269    #[test]
2270    fn lifecycle_recovery_from_non_cancelled_fails() {
2271        let manifest = ModelManifest::potion_128m();
2272        let mut lifecycle = ModelLifecycle::new(
2273            manifest,
2274            DownloadConsent::granted(ConsentSource::Programmatic),
2275        );
2276        let err = lifecycle.recover_after_cancel().unwrap_err();
2277        assert!(matches!(err, SearchError::InvalidConfig { .. }));
2278    }
2279
2280    #[test]
2281    fn lifecycle_begin_verification_from_not_downloading_fails() {
2282        let manifest = ModelManifest::potion_128m();
2283        let mut lifecycle = ModelLifecycle::new(
2284            manifest,
2285            DownloadConsent::granted(ConsentSource::Programmatic),
2286        );
2287        let err = lifecycle.begin_verification().unwrap_err();
2288        assert!(matches!(err, SearchError::InvalidConfig { .. }));
2289    }
2290
2291    #[test]
2292    fn lifecycle_update_progress_from_not_downloading_fails() {
2293        let manifest = ModelManifest::potion_128m();
2294        let mut lifecycle = ModelLifecycle::new(
2295            manifest,
2296            DownloadConsent::granted(ConsentSource::Programmatic),
2297        );
2298        let err = lifecycle.update_download_progress(50).unwrap_err();
2299        assert!(matches!(err, SearchError::InvalidConfig { .. }));
2300    }
2301
2302    #[test]
2303    fn detect_update_state_same_revision_returns_none() {
2304        let m = ModelManifest {
2305            revision: "abc123".to_owned(),
2306            ..ModelManifest::potion_128m()
2307        };
2308        assert!(m.detect_update_state("abc123").is_none());
2309    }
2310
2311    #[test]
2312    fn detect_update_state_different_revision_returns_update() {
2313        let m = ModelManifest {
2314            revision: "new_rev".to_owned(),
2315            ..ModelManifest::potion_128m()
2316        };
2317        let state = m.detect_update_state("old_rev").unwrap();
2318        assert!(matches!(state, ModelState::UpdateAvailable { .. }));
2319    }
2320
2321    #[test]
2322    fn detect_update_state_unpinned_returns_none() {
2323        let manifest = ModelManifest {
2324            revision: PLACEHOLDER_PINNED_REVISION.to_owned(),
2325            ..ModelManifest::potion_128m()
2326        };
2327        assert!(manifest.detect_update_state("anything").is_none());
2328    }
2329
2330    #[test]
2331    fn resolve_consent_config_file_path() {
2332        let consent = resolve_download_consent_with_env(None, None, None, Some(true));
2333        assert_eq!(consent.source, Some(ConsentSource::ConfigFile));
2334        assert!(consent.granted);
2335    }
2336
2337    #[test]
2338    fn resolve_consent_no_source_denies() {
2339        let consent = resolve_download_consent_with_env(None, None, None, None);
2340        assert!(!consent.granted);
2341        assert!(consent.source.is_none());
2342    }
2343
2344    #[test]
2345    fn resolve_consent_env_values() {
2346        for (val, expected) in &[
2347            ("1", true),
2348            ("true", true),
2349            ("yes", true),
2350            ("on", true),
2351            ("0", false),
2352            ("false", false),
2353            ("no", false),
2354            ("off", false),
2355        ] {
2356            let consent = resolve_download_consent_with_env(None, Some(val), None, None);
2357            assert_eq!(consent.granted, *expected, "env={val}");
2358        }
2359    }
2360
2361    #[test]
2362    fn resolve_consent_invalid_env_skipped() {
2363        let consent = resolve_download_consent_with_env(None, Some("maybe"), Some(true), None);
2364        assert_eq!(consent.source, Some(ConsentSource::Interactive));
2365        assert!(consent.granted);
2366    }
2367
2368    #[test]
2369    fn model_file_placeholder_detection() {
2370        let file = ModelFile {
2371            name: "f.bin".to_owned(),
2372            sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2373            size: 0,
2374            url: None,
2375        };
2376        assert!(file.uses_placeholder_checksum());
2377        assert!(!file.has_verified_checksum());
2378    }
2379
2380    #[test]
2381    fn model_file_verified_checksum_detection() {
2382        let file = ModelFile {
2383            name: "f.bin".to_owned(),
2384            sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
2385            size: 42,
2386            url: None,
2387        };
2388        assert!(!file.uses_placeholder_checksum());
2389        assert!(file.has_verified_checksum());
2390    }
2391
2392    #[test]
2393    fn model_file_zero_byte_verified_checksum_detection() {
2394        let file = ModelFile {
2395            name: "empty.bin".to_owned(),
2396            sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
2397            size: 0,
2398            url: None,
2399        };
2400        assert!(!file.uses_placeholder_checksum());
2401        assert!(file.has_verified_checksum());
2402    }
2403
2404    #[test]
2405    fn promote_verified_installation_success() {
2406        let temp = tempfile::tempdir().unwrap();
2407        let staged = temp.path().join("staged");
2408        let dest = temp.path().join("final");
2409        fs::create_dir_all(&staged).unwrap();
2410
2411        let data = b"model data";
2412        write_temp_file(&staged.join("model.bin"), data);
2413        let hash = to_hex_lowercase(&Sha256::digest(data));
2414        let size = u64::try_from(data.len()).unwrap();
2415
2416        let manifest = ModelManifest {
2417            id: "test".to_owned(),
2418            version: "test-v1".to_owned(),
2419            display_name: None,
2420            description: None,
2421            repo: "owner/repo".to_owned(),
2422            revision: "abc".to_owned(),
2423            files: vec![ModelFile {
2424                name: "model.bin".to_owned(),
2425                sha256: hash,
2426                size,
2427                url: None,
2428            }],
2429            license: "MIT".to_owned(),
2430            dimension: None,
2431            tier: None,
2432            download_size_bytes: 0,
2433        };
2434
2435        let backup = manifest
2436            .promote_verified_installation(&staged, &dest)
2437            .unwrap();
2438        assert!(backup.is_none());
2439        assert!(dest.join("model.bin").exists());
2440    }
2441
2442    #[test]
2443    fn promote_verified_creates_backup_of_existing() {
2444        let temp = tempfile::tempdir().unwrap();
2445        let staged = temp.path().join("staged");
2446        let dest = temp.path().join("final");
2447        fs::create_dir_all(&staged).unwrap();
2448        fs::create_dir_all(&dest).unwrap();
2449        write_temp_file(&dest.join("old.bin"), b"old");
2450
2451        let data = b"new model";
2452        write_temp_file(&staged.join("model.bin"), data);
2453        let hash = to_hex_lowercase(&Sha256::digest(data));
2454        let size = u64::try_from(data.len()).unwrap();
2455
2456        let manifest = ModelManifest {
2457            id: "test".to_owned(),
2458            version: "test-v1".to_owned(),
2459            display_name: None,
2460            description: None,
2461            repo: "owner/repo".to_owned(),
2462            revision: "abc".to_owned(),
2463            files: vec![ModelFile {
2464                name: "model.bin".to_owned(),
2465                sha256: hash,
2466                size,
2467                url: None,
2468            }],
2469            license: "MIT".to_owned(),
2470            dimension: None,
2471            tier: None,
2472            download_size_bytes: 0,
2473        };
2474
2475        let backup = manifest
2476            .promote_verified_installation(&staged, &dest)
2477            .unwrap();
2478        assert!(backup.is_some());
2479        assert!(dest.join("model.bin").exists());
2480    }
2481
2482    #[test]
2483    fn manifest_catalog_with_multiple_models() {
2484        let json = r#"{"models": [
2485            {"id": "m1", "repo": "r1", "revision": "v1", "files": [], "license": "MIT"},
2486            {"id": "m2", "repo": "r2", "revision": "v2", "files": [], "license": "Apache-2.0"}
2487        ]}"#;
2488        let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2489        assert_eq!(catalog.models.len(), 2);
2490        catalog.validate().unwrap();
2491    }
2492
2493    #[test]
2494    fn manifest_catalog_invalid_model_fails_validation() {
2495        let json = r#"{"models": [
2496            {"id": "", "repo": "r", "revision": "v", "files": [], "license": "MIT"}
2497        ]}"#;
2498        let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2499        let err = catalog.validate().unwrap_err();
2500        assert!(err.to_string().contains("must not be empty"));
2501    }
2502
2503    #[test]
2504    fn is_valid_sha256_hex_checks() {
2505        assert!(is_valid_sha256_hex(
2506            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2507        ));
2508        assert!(!is_valid_sha256_hex("short"));
2509        // Uppercase rejected.
2510        assert!(!is_valid_sha256_hex(
2511            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
2512        ));
2513        // Invalid hex chars rejected.
2514        assert!(!is_valid_sha256_hex(
2515            "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"
2516        ));
2517    }
2518
2519    #[test]
2520    fn download_consent_constructors() {
2521        let granted = DownloadConsent::granted(ConsentSource::Programmatic);
2522        assert!(granted.granted);
2523        assert_eq!(granted.source, Some(ConsentSource::Programmatic));
2524
2525        let denied = DownloadConsent::denied(Some(ConsentSource::Environment));
2526        assert!(!denied.granted);
2527        assert_eq!(denied.source, Some(ConsentSource::Environment));
2528
2529        let denied_none = DownloadConsent::denied(None);
2530        assert!(!denied_none.granted);
2531        assert!(denied_none.source.is_none());
2532    }
2533
2534    #[test]
2535    fn lifecycle_can_restart_after_verification_failure() {
2536        let manifest = ModelManifest::potion_128m();
2537        let mut lifecycle = ModelLifecycle::new(
2538            manifest,
2539            DownloadConsent::granted(ConsentSource::Programmatic),
2540        );
2541
2542        lifecycle.begin_download(100).unwrap();
2543        lifecycle.fail_verification("bad hash");
2544        assert!(matches!(
2545            lifecycle.state(),
2546            ModelState::VerificationFailed { .. }
2547        ));
2548
2549        lifecycle.begin_download(100).unwrap();
2550        assert!(matches!(lifecycle.state(), ModelState::Downloading { .. }));
2551    }
2552
2553    #[test]
2554    fn lifecycle_double_begin_download_from_ready_fails() {
2555        let manifest = ModelManifest::potion_128m();
2556        let mut lifecycle = ModelLifecycle::new(
2557            manifest,
2558            DownloadConsent::granted(ConsentSource::Programmatic),
2559        );
2560
2561        lifecycle.begin_download(100).unwrap();
2562        lifecycle.begin_verification().unwrap();
2563        lifecycle.mark_ready();
2564
2565        let err = lifecycle.begin_download(200).unwrap_err();
2566        assert!(matches!(err, SearchError::InvalidConfig { .. }));
2567    }
2568
2569    #[test]
2570    fn truncate_for_error_short_passthrough() {
2571        let short = "hello world";
2572        assert_eq!(truncate_for_error(short), "hello world");
2573    }
2574
2575    #[test]
2576    fn truncate_for_error_long_truncated() {
2577        let long = "x".repeat(200);
2578        let result = truncate_for_error(&long);
2579        assert!(result.ends_with("..."));
2580        assert!(result.len() < 200);
2581    }
2582
2583    // ── bd-2w7x.5: Model manifest enrichment tests ─────────────────────
2584
2585    #[test]
2586    fn manifest_schema_version_is_two() {
2587        assert_eq!(MANIFEST_SCHEMA_VERSION, 2);
2588    }
2589
2590    #[test]
2591    fn model_tier_serde_roundtrip() {
2592        for tier in &[ModelTier::Fast, ModelTier::Quality, ModelTier::Reranker] {
2593            let json = serde_json::to_string(tier).unwrap();
2594            let decoded: ModelTier = serde_json::from_str(&json).unwrap();
2595            assert_eq!(&decoded, tier);
2596        }
2597    }
2598
2599    #[test]
2600    fn model_tier_serde_uses_snake_case() {
2601        assert_eq!(serde_json::to_string(&ModelTier::Fast).unwrap(), "\"fast\"");
2602        assert_eq!(
2603            serde_json::to_string(&ModelTier::Quality).unwrap(),
2604            "\"quality\""
2605        );
2606        assert_eq!(
2607            serde_json::to_string(&ModelTier::Reranker).unwrap(),
2608            "\"reranker\""
2609        );
2610    }
2611
2612    #[test]
2613    fn builtin_potion_has_correct_metadata() {
2614        let m = ModelManifest::potion_128m();
2615        assert_eq!(m.dimension, Some(256));
2616        assert_eq!(m.tier, Some(ModelTier::Fast));
2617        assert!(m.display_name.is_some());
2618        assert!(m.display_name.as_deref().unwrap().contains("fast"));
2619    }
2620
2621    #[test]
2622    fn builtin_minilm_has_correct_metadata() {
2623        let m = ModelManifest::minilm_v2();
2624        assert_eq!(m.dimension, Some(384));
2625        assert_eq!(m.tier, Some(ModelTier::Quality));
2626        assert!(m.display_name.is_some());
2627        assert!(m.display_name.as_deref().unwrap().contains("quality"));
2628    }
2629
2630    #[test]
2631    fn builtin_reranker_has_correct_metadata() {
2632        let m = ModelManifest::ms_marco_reranker();
2633        assert_eq!(m.id, "ms-marco-minilm-l-6-v2");
2634        assert_eq!(m.dimension, None); // Cross-encoder, no embedding dim
2635        assert_eq!(m.tier, Some(ModelTier::Reranker));
2636        assert!(m.display_name.is_some());
2637        assert!(m.display_name.as_deref().unwrap().contains("reranker"));
2638        m.validate().unwrap();
2639    }
2640
2641    #[test]
2642    fn builtin_catalog_contains_all_models() {
2643        let catalog = ModelManifest::builtin_catalog();
2644        assert_eq!(catalog.schema_version, MANIFEST_SCHEMA_VERSION);
2645        assert_eq!(catalog.models.len(), 7);
2646
2647        let ids: Vec<&str> = catalog.models.iter().map(|m| m.id.as_str()).collect();
2648        assert!(ids.contains(&"potion-multilingual-128m"));
2649        assert!(ids.contains(&"all-minilm-l6-v2"));
2650        assert!(ids.contains(&"ms-marco-minilm-l-6-v2"));
2651        assert!(ids.contains(&"snowflake-arctic-embed-s"));
2652        assert!(ids.contains(&"nomic-embed-text-v1.5"));
2653        assert!(ids.contains(&"jina-reranker-v1-turbo-en"));
2654        assert!(ids.contains(&"flashrank-nano"));
2655
2656        catalog.validate().unwrap();
2657    }
2658
2659    #[test]
2660    fn builtin_catalog_covers_all_tiers() {
2661        let catalog = ModelManifest::builtin_catalog();
2662        let tiers: Vec<Option<ModelTier>> = catalog.models.iter().map(|m| m.tier).collect();
2663        assert!(tiers.contains(&Some(ModelTier::Fast)));
2664        assert!(tiers.contains(&Some(ModelTier::Quality)));
2665        assert!(tiers.contains(&Some(ModelTier::Reranker)));
2666    }
2667
2668    #[test]
2669    fn builtin_manifests_include_version_description_and_size_metadata() {
2670        let manifests = [
2671            ModelManifest::potion_128m(),
2672            ModelManifest::minilm_v2(),
2673            ModelManifest::ms_marco_reranker(),
2674            ModelManifest::snowflake_arctic_s(),
2675            ModelManifest::nomic_embed(),
2676            ModelManifest::jina_reranker_turbo(),
2677            ModelManifest::flashrank_nano(),
2678        ];
2679
2680        for manifest in manifests {
2681            assert!(!manifest.version.is_empty());
2682            assert!(manifest.description.is_some());
2683            // Some built-in manifests intentionally use placeholder metadata
2684            // (sha256=PLACEHOLDER_VERIFY_AFTER_DOWNLOAD, size=0) so that file
2685            // sizes are confirmed at runtime during the first download rather
2686            // than baked into source code. For those, we only assert the
2687            // size-sum invariant, not a non-zero total.
2688            let all_placeholder = manifest
2689                .files
2690                .iter()
2691                .all(|file| file.sha256 == PLACEHOLDER_VERIFY_AFTER_DOWNLOAD);
2692            if !all_placeholder {
2693                assert!(manifest.download_size_bytes > 0);
2694            }
2695            let summed_size: u64 = manifest.files.iter().map(|file| file.size).sum();
2696            assert_eq!(manifest.download_size_bytes, summed_size);
2697        }
2698    }
2699
2700    #[test]
2701    fn model_file_download_url_uses_explicit_when_present() {
2702        let file = ModelFile {
2703            name: "model.onnx".to_owned(),
2704            sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2705            size: 0,
2706            url: Some("https://mirror.example.com/model.onnx".to_owned()),
2707        };
2708        let url = file.download_url("owner/repo", "abc123");
2709        assert_eq!(url, "https://mirror.example.com/model.onnx");
2710    }
2711
2712    #[test]
2713    fn model_file_download_url_derives_from_repo_when_none() {
2714        let file = ModelFile {
2715            name: "onnx/model.onnx".to_owned(),
2716            sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2717            size: 0,
2718            url: None,
2719        };
2720        let url = file.download_url("sentence-transformers/all-MiniLM-L6-v2", "abc123");
2721        assert_eq!(
2722            url,
2723            "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/abc123/onnx/model.onnx"
2724        );
2725    }
2726
2727    #[test]
2728    fn model_file_url_field_is_optional_in_json() {
2729        // URL absent: should deserialize with url=None
2730        let json = r#"{"name":"f.bin","sha256":"PLACEHOLDER_VERIFY_AFTER_DOWNLOAD","size":0}"#;
2731        let file: ModelFile = serde_json::from_str(json).unwrap();
2732        assert!(file.url.is_none());
2733
2734        // URL present: should deserialize
2735        let json = r#"{"name":"f.bin","sha256":"PLACEHOLDER_VERIFY_AFTER_DOWNLOAD","size":0,"url":"https://example.com/f.bin"}"#;
2736        let file: ModelFile = serde_json::from_str(json).unwrap();
2737        assert_eq!(file.url.as_deref(), Some("https://example.com/f.bin"));
2738    }
2739
2740    #[test]
2741    fn model_file_url_skipped_in_serialization_when_none() {
2742        let file = ModelFile {
2743            name: "f.bin".to_owned(),
2744            sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2745            size: 0,
2746            url: None,
2747        };
2748        let json = serde_json::to_string(&file).unwrap();
2749        assert!(!json.contains("url"));
2750    }
2751
2752    #[test]
2753    fn manifest_display_name_optional_in_json() {
2754        let json = r#"{
2755            "id": "test", "repo": "r", "revision": "v",
2756            "files": [], "license": "MIT"
2757        }"#;
2758        let m: ModelManifest = serde_json::from_str(json).unwrap();
2759        assert!(m.display_name.is_none());
2760        assert!(m.dimension.is_none());
2761        assert!(m.tier.is_none());
2762    }
2763
2764    #[test]
2765    fn manifest_with_all_new_fields_roundtrips() {
2766        let m = ModelManifest {
2767            id: "test".to_owned(),
2768            version: "test-v1".to_owned(),
2769            display_name: Some("Test Model".to_owned()),
2770            description: Some("test manifest".to_owned()),
2771            repo: "owner/repo".to_owned(),
2772            revision: "abc123".to_owned(),
2773            files: vec![ModelFile {
2774                name: "model.onnx".to_owned(),
2775                sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2776                size: 0,
2777                url: Some("https://example.com/model.onnx".to_owned()),
2778            }],
2779            license: "MIT".to_owned(),
2780            dimension: Some(384),
2781            tier: Some(ModelTier::Quality),
2782            download_size_bytes: 0,
2783        };
2784        let json = m.to_pretty_json().unwrap();
2785        let restored = ModelManifest::from_json_str(&json).unwrap();
2786        assert_eq!(restored.display_name, m.display_name);
2787        assert_eq!(restored.dimension, m.dimension);
2788        assert_eq!(restored.tier, m.tier);
2789        assert_eq!(restored.files[0].url, m.files[0].url);
2790    }
2791
2792    #[test]
2793    fn catalog_schema_version_defaults_on_missing() {
2794        let json = r#"{"models":[]}"#;
2795        let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2796        assert_eq!(catalog.schema_version, MANIFEST_SCHEMA_VERSION);
2797    }
2798
2799    #[test]
2800    fn catalog_schema_version_preserved_from_json() {
2801        let json = r#"{"schema_version": 42, "models":[]}"#;
2802        let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2803        assert_eq!(catalog.schema_version, 42);
2804    }
2805
2806    #[test]
2807    fn builtin_catalog_json_roundtrip() {
2808        let catalog = ModelManifest::builtin_catalog();
2809        let json = serde_json::to_string_pretty(&catalog).unwrap();
2810        let restored = ModelManifestCatalog::from_json_str(&json).unwrap();
2811        assert_eq!(restored.schema_version, catalog.schema_version);
2812        assert_eq!(restored.models.len(), catalog.models.len());
2813        for (orig, rest) in catalog.models.iter().zip(restored.models.iter()) {
2814            assert_eq!(orig.id, rest.id);
2815            assert_eq!(orig.dimension, rest.dimension);
2816            assert_eq!(orig.tier, rest.tier);
2817        }
2818    }
2819
2820    #[test]
2821    fn registry_includes_reranker() {
2822        let all = ModelManifest::registered();
2823        let ids: Vec<&str> = all.iter().map(|m| m.id.as_str()).collect();
2824        assert!(
2825            ids.contains(&"ms-marco-minilm-l-6-v2"),
2826            "registry should contain ms-marco reranker, got: {ids:?}"
2827        );
2828    }
2829
2830    // ─── Verification Cache Tests ──────────────────────────────────────
2831
2832    fn make_test_manifest(file_name: &str, content: &[u8]) -> ModelManifest {
2833        use sha2::{Digest, Sha256};
2834        let mut hasher = Sha256::new();
2835        hasher.update(content);
2836        let sha = to_hex_lowercase(&hasher.finalize());
2837        ModelManifest {
2838            id: "test-model".to_owned(),
2839            repo: "test/repo".to_owned(),
2840            revision: "abc".to_owned(),
2841            files: vec![ModelFile {
2842                name: file_name.to_owned(),
2843                sha256: sha,
2844                size: u64::try_from(content.len()).unwrap(),
2845                url: None,
2846            }],
2847            license: "MIT".to_owned(),
2848            tier: None,
2849            dimension: None,
2850            display_name: None,
2851            version: String::new(),
2852            description: None,
2853            download_size_bytes: u64::try_from(content.len()).unwrap(),
2854        }
2855    }
2856
2857    #[test]
2858    fn verification_marker_roundtrip() {
2859        let tmp = tempfile::tempdir().unwrap();
2860        let content = b"hello model";
2861        let manifest = make_test_manifest("model.bin", content);
2862        write_temp_file(&tmp.path().join("model.bin"), content);
2863
2864        let marker = VerificationMarker::new_for(&manifest, tmp.path());
2865        let json = serde_json::to_string_pretty(&marker).unwrap();
2866        let restored: VerificationMarker = serde_json::from_str(&json).unwrap();
2867        assert_eq!(restored.manifest_id, "test-model");
2868        assert_eq!(restored.schema_version, MANIFEST_SCHEMA_VERSION);
2869        let state = restored.file_states.get("model.bin").unwrap();
2870        assert_eq!(state.size_bytes, u64::try_from(content.len()).unwrap());
2871        assert!(state.modified_unix_nanos > 0);
2872    }
2873
2874    #[test]
2875    fn verification_cache_hit_when_files_unchanged() {
2876        let tmp = tempfile::tempdir().unwrap();
2877        let content = b"model data";
2878        let manifest = make_test_manifest("model.bin", content);
2879        write_temp_file(&tmp.path().join("model.bin"), content);
2880
2881        assert!(!is_verification_cached(&manifest, tmp.path()));
2882        write_verification_marker(&manifest, tmp.path());
2883        assert!(is_verification_cached(&manifest, tmp.path()));
2884    }
2885
2886    #[test]
2887    fn verification_cache_miss_when_manifest_id_changes() {
2888        let tmp = tempfile::tempdir().unwrap();
2889        let content = b"model data";
2890        let manifest = make_test_manifest("model.bin", content);
2891        write_temp_file(&tmp.path().join("model.bin"), content);
2892
2893        write_verification_marker(&manifest, tmp.path());
2894        let mut changed = manifest;
2895        changed.id = "different-model".to_owned();
2896        assert!(!is_verification_cached(&changed, tmp.path()));
2897    }
2898
2899    #[test]
2900    fn verification_cache_miss_when_file_state_differs() {
2901        let tmp = tempfile::tempdir().unwrap();
2902        let content = b"model data";
2903        let manifest = make_test_manifest("model.bin", content);
2904        write_temp_file(&tmp.path().join("model.bin"), content);
2905
2906        // Write marker, then tamper with the recorded file state.
2907        write_verification_marker(&manifest, tmp.path());
2908        assert!(is_verification_cached(&manifest, tmp.path()));
2909
2910        let marker_path = tmp.path().join(VERIFIED_MARKER_FILE);
2911        let raw = std::fs::read_to_string(&marker_path).unwrap();
2912        let mut marker: VerificationMarker = serde_json::from_str(&raw).unwrap();
2913        // Change recorded metadata so it no longer matches the actual file.
2914        marker.file_states.insert(
2915            "model.bin".to_owned(),
2916            FileVerificationState {
2917                size_bytes: 1,
2918                modified_unix_nanos: 1,
2919            },
2920        );
2921        let tampered = serde_json::to_string_pretty(&marker).unwrap();
2922        std::fs::write(&marker_path, tampered).unwrap();
2923
2924        assert!(!is_verification_cached(&manifest, tmp.path()));
2925    }
2926
2927    #[test]
2928    fn verify_dir_cached_writes_marker_on_success() {
2929        let tmp = tempfile::tempdir().unwrap();
2930        let content = b"model data for verify";
2931        let manifest = make_test_manifest("model.bin", content);
2932        write_temp_file(&tmp.path().join("model.bin"), content);
2933
2934        assert!(!tmp.path().join(VERIFIED_MARKER_FILE).exists());
2935        verify_dir_cached(&manifest, tmp.path()).unwrap();
2936        assert!(tmp.path().join(VERIFIED_MARKER_FILE).exists());
2937        assert!(is_verification_cached(&manifest, tmp.path()));
2938    }
2939
2940    #[test]
2941    fn verify_dir_cached_skips_rehash_on_cached_hit() {
2942        let tmp = tempfile::tempdir().unwrap();
2943        let content = b"model data cached";
2944        let manifest = make_test_manifest("model.bin", content);
2945        write_temp_file(&tmp.path().join("model.bin"), content);
2946
2947        // First call: full verification + writes marker
2948        verify_dir_cached(&manifest, tmp.path()).unwrap();
2949
2950        // Second call: should succeed from cache (no rehash)
2951        verify_dir_cached(&manifest, tmp.path()).unwrap();
2952    }
2953
2954    #[test]
2955    fn verify_dir_cached_skips_when_no_verified_checksums() {
2956        let tmp = tempfile::tempdir().unwrap();
2957        let manifest = ModelManifest {
2958            id: "test".to_owned(),
2959            repo: "r".to_owned(),
2960            revision: "v".to_owned(),
2961            files: vec![ModelFile {
2962                name: "f.bin".to_owned(),
2963                sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2964                size: 0,
2965                url: None,
2966            }],
2967            license: "MIT".to_owned(),
2968            tier: None,
2969            dimension: None,
2970            display_name: None,
2971            version: String::new(),
2972            description: None,
2973            download_size_bytes: 0,
2974        };
2975        // Should not error even though file doesn't exist — skips verification
2976        verify_dir_cached(&manifest, tmp.path()).unwrap();
2977    }
2978}