Skip to main content

rlx_cli/
compat.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Model-compatibility discovery for rlx-models.
17//!
18//! Answers the same question llama.cpp and HuggingFace answer for their own
19//! ecosystems: *can this runtime load and run this weights file?* — but for
20//! rlx-models.
21//!
22//! Mirrors two upstream behaviors so reports stay aligned with what users
23//! already expect from llama.cpp / HF:
24//!
25//! * **GGUF required fields.** llama.cpp's loader (`src/llama-model.cpp::
26//!   load_hparams`, `src/llama-vocab.cpp`) treats `general.architecture`,
27//!   `<arch>.context_length`, `<arch>.embedding_length`,
28//!   `<arch>.block_count`, `tokenizer.ggml.model`, and
29//!   `tokenizer.ggml.tokens` as mandatory. If any are missing the file
30//!   isn't runnable, regardless of arch support. The same predicate drives
31//!   HuggingFace's "compatible with llama.cpp" badge
32//!   (`huggingface.js/packages/tasks/src/local-apps.ts:
33//!   isLlamaCppGgufModel = !!model.gguf?.context_length`).
34//!
35//! * **HF safetensors model_type.** `transformers` resolves the model class
36//!   from `config.json::model_type` (primary); `architectures[]` only
37//!   disambiguates which head within that class. We follow the same
38//!   precedence in [`crate::model_type_runner_name`].
39
40use anyhow::{Context, Result, anyhow};
41use rlx_core::gguf_support::resolve_weights_file;
42use rlx_gguf::{GgufFile, MetaValue};
43use std::path::{Path, PathBuf};
44
45use crate::auto_dispatch::{
46    UnimplementedArch, arch_runner_name, known_unimplemented_arch, model_type_runner_name,
47};
48
49/// Where the arch identification came from.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum CompatSource {
52    /// `general.architecture` value from a `.gguf` file.
53    GgufArch(String),
54    /// `model_type` value from a sidecar `config.json`.
55    SafetensorsConfig(String),
56}
57
58impl CompatSource {
59    pub fn arch(&self) -> &str {
60        match self {
61            Self::GgufArch(s) | Self::SafetensorsConfig(s) => s.as_str(),
62        }
63    }
64}
65
66/// llama.cpp's load-time required GGUF metadata fields. All are read by
67/// `load_hparams`; missing any throws at load time.
68#[derive(Debug, Clone, Default)]
69pub struct GgufRequiredFields {
70    pub context_length: Option<u64>,
71    pub embedding_length: Option<u64>,
72    pub block_count: Option<u64>,
73    pub tokenizer_model: Option<String>,
74    pub has_tokens: bool,
75}
76
77impl GgufRequiredFields {
78    /// Which required field is missing, if any. Order mirrors the order
79    /// llama.cpp reads them (helps line up against upstream errors).
80    pub fn missing(&self) -> Vec<&'static str> {
81        let mut out = Vec::new();
82        if self.context_length.is_none() {
83            out.push("<arch>.context_length");
84        }
85        if self.embedding_length.is_none() {
86            out.push("<arch>.embedding_length");
87        }
88        if self.block_count.is_none() {
89            out.push("<arch>.block_count");
90        }
91        if self.tokenizer_model.is_none() {
92            out.push("tokenizer.ggml.model");
93        }
94        if !self.has_tokens {
95            out.push("tokenizer.ggml.tokens");
96        }
97        out
98    }
99
100    pub fn is_complete(&self) -> bool {
101        self.missing().is_empty()
102    }
103}
104
105/// Top-level compatibility verdict.
106#[derive(Debug, Clone)]
107pub enum CompatibilityStatus {
108    /// rlx has a registered runner for this arch and all required GGUF
109    /// metadata is present (or the file is safetensors, where the
110    /// per-arch loader does the field check itself).
111    Supported { runner: &'static str },
112    /// Required GGUF metadata is missing — the file would not load even
113    /// if the arch were implemented. Lists the missing field names.
114    MissingMetadata { missing: Vec<&'static str> },
115    /// Arch is on PLAN.md but not yet implemented.
116    KnownUnimplemented(UnimplementedArch),
117    /// Arch isn't recognized.
118    Unknown,
119}
120
121impl CompatibilityStatus {
122    pub fn is_runnable(&self) -> bool {
123        matches!(self, Self::Supported { .. })
124    }
125}
126
127/// Structured report from [`check_path`]. Print directly via [`Display`]
128/// or serialize as JSON via [`CompatibilityReport::to_json`].
129#[derive(Debug, Clone)]
130pub struct CompatibilityReport {
131    pub path: PathBuf,
132    pub source: CompatSource,
133    pub status: CompatibilityStatus,
134    /// Present for `.gguf` files only.
135    pub gguf_fields: Option<GgufRequiredFields>,
136}
137
138impl CompatibilityReport {
139    /// Render as a single-line JSON object — useful for `--json` output and
140    /// for piping into shell tooling.
141    pub fn to_json(&self) -> String {
142        let (status_tag, status_detail) = match &self.status {
143            CompatibilityStatus::Supported { runner } => {
144                ("supported", serde_json::json!({ "runner": runner }))
145            }
146            CompatibilityStatus::MissingMetadata { missing } => (
147                "missing_metadata",
148                serde_json::json!({ "missing": missing }),
149            ),
150            CompatibilityStatus::KnownUnimplemented(u) => (
151                "known_unimplemented",
152                serde_json::json!({
153                    "family": u.family,
154                    "milestone": u.milestone,
155                    "note": u.note,
156                }),
157            ),
158            CompatibilityStatus::Unknown => ("unknown", serde_json::Value::Null),
159        };
160        let (source_kind, arch) = match &self.source {
161            CompatSource::GgufArch(s) => ("gguf", s.as_str()),
162            CompatSource::SafetensorsConfig(s) => ("safetensors_config", s.as_str()),
163        };
164        let gguf_fields = self.gguf_fields.as_ref().map(|f| {
165            serde_json::json!({
166                "context_length": f.context_length,
167                "embedding_length": f.embedding_length,
168                "block_count": f.block_count,
169                "tokenizer_model": f.tokenizer_model,
170                "has_tokens": f.has_tokens,
171            })
172        });
173        serde_json::json!({
174            "path": self.path.display().to_string(),
175            "source": source_kind,
176            "arch": arch,
177            "status": status_tag,
178            "detail": status_detail,
179            "gguf_fields": gguf_fields,
180        })
181        .to_string()
182    }
183}
184
185impl std::fmt::Display for CompatibilityReport {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        writeln!(f, "path:     {}", self.path.display())?;
188        match &self.source {
189            CompatSource::GgufArch(a) => {
190                writeln!(f, "source:   GGUF general.architecture = `{a}`")?;
191            }
192            CompatSource::SafetensorsConfig(a) => {
193                writeln!(f, "source:   safetensors config.json model_type = `{a}`")?;
194            }
195        }
196        if let Some(fields) = &self.gguf_fields {
197            writeln!(f, "fields:")?;
198            writeln!(
199                f,
200                "  context_length:   {}",
201                opt_display(fields.context_length)
202            )?;
203            writeln!(
204                f,
205                "  embedding_length: {}",
206                opt_display(fields.embedding_length)
207            )?;
208            writeln!(f, "  block_count:      {}", opt_display(fields.block_count))?;
209            writeln!(
210                f,
211                "  tokenizer.model:  {}",
212                fields.tokenizer_model.as_deref().unwrap_or("<missing>")
213            )?;
214            writeln!(
215                f,
216                "  tokens:           {}",
217                if fields.has_tokens {
218                    "present"
219                } else {
220                    "<missing>"
221                }
222            )?;
223        }
224        match &self.status {
225            CompatibilityStatus::Supported { runner } => {
226                writeln!(f, "status:   SUPPORTED")?;
227                writeln!(f, "runner:   {runner}")?;
228                writeln!(
229                    f,
230                    "          rlx-run {runner} --weights {}",
231                    self.path.display()
232                )?;
233            }
234            CompatibilityStatus::MissingMetadata { missing } => {
235                writeln!(f, "status:   INCOMPATIBLE (missing required GGUF metadata)")?;
236                writeln!(f, "missing:  {}", missing.join(", "))?;
237                writeln!(
238                    f,
239                    "note:     llama.cpp would also reject this file at load time"
240                )?;
241            }
242            CompatibilityStatus::KnownUnimplemented(u) => {
243                writeln!(f, "status:   NOT YET IMPLEMENTED")?;
244                writeln!(f, "family:   {}", u.family)?;
245                writeln!(f, "blocked by: PLAN.md {}", u.milestone)?;
246                writeln!(f, "note:     {}", u.note)?;
247            }
248            CompatibilityStatus::Unknown => {
249                writeln!(f, "status:   UNKNOWN ARCH")?;
250                writeln!(
251                    f,
252                    "note:     arch `{}` is not in rlx-models's recognized set or on PLAN.md",
253                    self.source.arch()
254                )?;
255            }
256        }
257        Ok(())
258    }
259}
260
261fn opt_display<T: std::fmt::Display>(v: Option<T>) -> String {
262    match v {
263        Some(v) => v.to_string(),
264        None => "<missing>".to_string(),
265    }
266}
267
268/// Read a `<arch>.<suffix>` u64 from GGUF metadata.
269fn meta_arch_u64(raw: &GgufFile, arch: &str, suffix: &str) -> Option<u64> {
270    let k = format!("{arch}.{suffix}");
271    raw.metadata.get(&k).and_then(MetaValue::as_u64)
272}
273
274fn extract_gguf_fields(raw: &GgufFile, arch: &str) -> GgufRequiredFields {
275    let tokenizer_model = raw
276        .metadata
277        .get("tokenizer.ggml.model")
278        .and_then(MetaValue::as_str)
279        .map(str::to_owned);
280    let has_tokens = matches!(
281        raw.metadata.get("tokenizer.ggml.tokens"),
282        Some(MetaValue::Array(arr)) if !arr.is_empty()
283    );
284    GgufRequiredFields {
285        context_length: meta_arch_u64(raw, arch, "context_length"),
286        embedding_length: meta_arch_u64(raw, arch, "embedding_length"),
287        block_count: meta_arch_u64(raw, arch, "block_count"),
288        tokenizer_model,
289        has_tokens,
290    }
291}
292
293fn classify(source: &CompatSource, fields: Option<&GgufRequiredFields>) -> CompatibilityStatus {
294    let arch = source.arch();
295    if let Some(runner) = match source {
296        CompatSource::GgufArch(_) => arch_runner_name(arch),
297        CompatSource::SafetensorsConfig(_) => model_type_runner_name(arch),
298    } {
299        // For GGUF we also enforce llama.cpp's load-time field check.
300        if let Some(f) = fields {
301            let missing = f.missing();
302            if !missing.is_empty() {
303                return CompatibilityStatus::MissingMetadata { missing };
304            }
305        }
306        return CompatibilityStatus::Supported { runner };
307    }
308    if let Some(u) = known_unimplemented_arch(arch) {
309        return CompatibilityStatus::KnownUnimplemented(u);
310    }
311    CompatibilityStatus::Unknown
312}
313
314/// Compatibility report for a local weights path (file or directory).
315pub fn check_path(path: &Path) -> Result<CompatibilityReport> {
316    let file = resolve_weights_file(path)?;
317    let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("");
318    match ext {
319        "gguf" => {
320            let raw =
321                GgufFile::from_path(&file).with_context(|| format!("opening GGUF {file:?}"))?;
322            let arch = raw
323                .metadata
324                .get("general.architecture")
325                .and_then(MetaValue::as_str)
326                .ok_or_else(|| anyhow!("{file:?}: GGUF has no general.architecture"))?
327                .to_string();
328            let fields = extract_gguf_fields(&raw, &arch);
329            let source = CompatSource::GgufArch(arch);
330            let status = classify(&source, Some(&fields));
331            Ok(CompatibilityReport {
332                path: file,
333                source,
334                status,
335                gguf_fields: Some(fields),
336            })
337        }
338        "safetensors" => {
339            let dir = file
340                .parent()
341                .ok_or_else(|| anyhow!("safetensors path {file:?} has no parent dir"))?;
342            let cfg = dir.join("config.json");
343            if !cfg.is_file() {
344                return Err(anyhow!(
345                    "{file:?}: no sidecar config.json — cannot determine model_type"
346                ));
347            }
348            let bytes = std::fs::read(&cfg).with_context(|| format!("reading {cfg:?}"))?;
349            let v: serde_json::Value =
350                serde_json::from_slice(&bytes).with_context(|| format!("parsing {cfg:?}"))?;
351            let model_type = v
352                .get("model_type")
353                .and_then(serde_json::Value::as_str)
354                .ok_or_else(|| anyhow!("{cfg:?}: missing `model_type`"))?
355                .to_string();
356            let source = CompatSource::SafetensorsConfig(model_type);
357            let status = classify(&source, None);
358            Ok(CompatibilityReport {
359                path: file,
360                source,
361                status,
362                gguf_fields: None,
363            })
364        }
365        other => Err(anyhow!(
366            "{file:?}: unsupported extension `.{other}` (expected .gguf or .safetensors)"
367        )),
368    }
369}
370
371/// Heuristic: does `s` look like a HuggingFace repo id (`org/name`) rather
372/// than a local filesystem path? We use the same rule HF Hub clients do:
373/// exactly one `/`, no path separators leading the string, no extension
374/// suffix.
375pub fn looks_like_hf_repo(s: &str) -> bool {
376    if s.starts_with('/') || s.starts_with('.') || s.starts_with('~') {
377        return false;
378    }
379    let slashes = s.bytes().filter(|b| *b == b'/').count();
380    if slashes != 1 {
381        return false;
382    }
383    let last = s.rsplit_once('/').map(|(_, t)| t).unwrap_or("");
384    // Reject obvious file-extension suffixes — `org/model.safetensors` is a
385    // path, not a repo.
386    !matches!(
387        last.rsplit_once('.').map(|(_, ext)| ext),
388        Some("gguf") | Some("safetensors") | Some("bin") | Some("pt") | Some("onnx"),
389    )
390}
391
392/// CLI entry point for `rlx-run check <path-or-repo> [--json]`.
393pub fn run_check(args: &[String]) -> Result<()> {
394    let mut json = false;
395    let mut input: Option<&str> = None;
396    for a in args {
397        match a.as_str() {
398            "--json" => json = true,
399            "-h" | "--help" | "help" => {
400                println!(
401                    "rlx-run check — report whether rlx-models can run a model\n\
402                     \n\
403                     USAGE:\n  rlx-run check <path-or-repo> [--json]\n\
404                     \n\
405                     Accepts a local weights path or a HuggingFace repo id\n\
406                     (e.g. `unsloth/Qwen3-7B-GGUF`). Mirrors llama.cpp's load-time\n\
407                     GGUF field check + HuggingFace's compatibility predicate, so\n\
408                     the verdict matches what users see upstream.\n\
409                     \n\
410                     HF-repo checks require the `compat-net` cargo feature."
411                );
412                return Ok(());
413            }
414            other => {
415                if input.is_some() {
416                    return Err(anyhow!("check: unexpected extra arg `{other}`"));
417                }
418                input = Some(other);
419            }
420        }
421    }
422    let input = input.ok_or_else(|| {
423        anyhow!("check: expected a weights path or HF repo id\nusage: rlx-run check <path-or-repo> [--json]")
424    })?;
425    let report = if looks_like_hf_repo(input) {
426        check_hf_repo(input)?
427    } else {
428        check_path(Path::new(input))?
429    };
430    if json {
431        println!("{}", report.to_json());
432    } else {
433        print!("{report}");
434    }
435    if !report.status.is_runnable() {
436        return Err(anyhow!("model is not runnable by rlx-models"));
437    }
438    Ok(())
439}
440
441/// Stub returned when the binary was built without the `compat-net` feature.
442#[cfg(not(feature = "compat-net"))]
443pub fn check_hf_repo(repo: &str) -> Result<CompatibilityReport> {
444    Err(anyhow!(
445        "{repo}: HF-repo checks require building rlx-cli with the `compat-net` feature \
446         (`cargo build -p rlx-cli --features compat-net`)"
447    ))
448}
449
450#[cfg(feature = "compat-net")]
451mod hf_fetch {
452    use super::*;
453    use std::io::Read;
454
455    /// URL for fetching a file's raw bytes at `main`. Public to support
456    /// testing URL construction without a live network.
457    pub fn resolve_url(repo: &str, file: &str) -> String {
458        format!("https://huggingface.co/{repo}/resolve/main/{file}")
459    }
460
461    /// HF Hub API endpoint for listing a repo's files at `main`.
462    pub fn tree_api_url(repo: &str) -> String {
463        format!("https://huggingface.co/api/models/{repo}/tree/main")
464    }
465
466    /// First 4 MB of a `.gguf` is enough to parse the header (magic +
467    /// metadata + tensor descriptors) for the catalog models we care
468    /// about. The data segment is intentionally truncated.
469    pub const GGUF_HEADER_FETCH_BYTES: usize = 4 * 1024 * 1024;
470
471    fn get(url: &str) -> Result<ureq::Response> {
472        ureq::get(url)
473            .timeout(std::time::Duration::from_secs(30))
474            .call()
475            .with_context(|| format!("GET {url}"))
476    }
477
478    fn get_range(url: &str, end_inclusive: usize) -> Result<Vec<u8>> {
479        let resp = ureq::get(url)
480            .timeout(std::time::Duration::from_secs(60))
481            .set("Range", &format!("bytes=0-{end_inclusive}"))
482            .call()
483            .with_context(|| format!("GET (range) {url}"))?;
484        let mut buf = Vec::with_capacity(end_inclusive + 1);
485        resp.into_reader()
486            .take((end_inclusive + 1) as u64)
487            .read_to_end(&mut buf)
488            .with_context(|| format!("reading range body from {url}"))?;
489        Ok(buf)
490    }
491
492    /// Try `config.json` first (covers safetensors and most modern repos
493    /// even when they also ship GGUF). Fall back to GGUF header sniffing.
494    pub fn check(repo: &str) -> Result<CompatibilityReport> {
495        let cfg_url = resolve_url(repo, "config.json");
496        if let Ok(resp) = get(&cfg_url) {
497            if resp.status() == 200 {
498                let mut bytes = Vec::new();
499                resp.into_reader().read_to_end(&mut bytes).ok();
500                if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
501                    if let Some(model_type) =
502                        v.get("model_type").and_then(serde_json::Value::as_str)
503                    {
504                        let source = CompatSource::SafetensorsConfig(model_type.to_string());
505                        let status = classify(&source, None);
506                        return Ok(CompatibilityReport {
507                            path: PathBuf::from(format!("hf://{repo}/config.json")),
508                            source,
509                            status,
510                            gguf_fields: None,
511                        });
512                    }
513                }
514            }
515        }
516
517        // No usable config.json — look for a .gguf in the repo tree.
518        let tree_url = tree_api_url(repo);
519        let resp = get(&tree_url)?;
520        if resp.status() != 200 {
521            return Err(anyhow!(
522                "{repo}: HF tree API returned status {} (is the repo public?)",
523                resp.status()
524            ));
525        }
526        let listing: serde_json::Value = resp
527            .into_json()
528            .with_context(|| format!("parsing HF tree JSON for {repo}"))?;
529        let arr = listing
530            .as_array()
531            .ok_or_else(|| anyhow!("{repo}: HF tree API did not return a JSON array"))?;
532        let gguf_path = arr
533            .iter()
534            .filter_map(|v| v.get("path").and_then(serde_json::Value::as_str))
535            .find(|p| p.ends_with(".gguf"))
536            .ok_or_else(|| {
537                anyhow!(
538                    "{repo}: no config.json with model_type and no .gguf file at root — \
539                     cannot determine architecture"
540                )
541            })?
542            .to_owned();
543
544        let gguf_url = resolve_url(repo, &gguf_path);
545        let bytes = get_range(&gguf_url, GGUF_HEADER_FETCH_BYTES - 1)?;
546        let mut cursor = std::io::Cursor::new(bytes);
547        let raw = GgufFile::from_reader(&mut cursor)
548            .with_context(|| format!("parsing GGUF header from {gguf_url}"))?;
549        let arch = raw
550            .metadata
551            .get("general.architecture")
552            .and_then(MetaValue::as_str)
553            .ok_or_else(|| anyhow!("{gguf_url}: GGUF has no general.architecture"))?
554            .to_string();
555        let fields = extract_gguf_fields(&raw, &arch);
556        let source = CompatSource::GgufArch(arch);
557        let status = classify(&source, Some(&fields));
558        Ok(CompatibilityReport {
559            path: PathBuf::from(format!("hf://{repo}/{gguf_path}")),
560            source,
561            status,
562            gguf_fields: Some(fields),
563        })
564    }
565}
566
567/// Check whether a HuggingFace repo is runnable by rlx-models.
568///
569/// Fetches `config.json` first (one HTTPS GET); falls back to listing the
570/// repo tree and Range-fetching the first 4 MB of any `.gguf` file to
571/// parse the header — the same approach `huggingface.js/packages/gguf`
572/// uses to drive HF Hub's compatibility badge.
573///
574/// Requires the `compat-net` cargo feature.
575#[cfg(feature = "compat-net")]
576pub fn check_hf_repo(repo: &str) -> Result<CompatibilityReport> {
577    hf_fetch::check(repo)
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    /// Builds a GGUF file in a temp path with arbitrary metadata; missing
585    /// arch fields are simply omitted so the test can probe the
586    /// MissingMetadata path.
587    fn write_test_gguf(arch: &str, fields: &[(&str, MetaValueOwned)]) -> PathBuf {
588        let mut buf: Vec<u8> = Vec::new();
589        buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
590        buf.extend_from_slice(&3u32.to_le_bytes());
591        buf.extend_from_slice(&1u64.to_le_bytes()); // tensor count
592        let kv_count = 1 + fields.len(); // architecture + the rest
593        buf.extend_from_slice(&(kv_count as u64).to_le_bytes());
594
595        write_string_kv(&mut buf, "general.architecture", arch);
596        for (k, v) in fields {
597            match v {
598                MetaValueOwned::Str(s) => write_string_kv(&mut buf, k, s),
599                MetaValueOwned::U64(n) => {
600                    buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
601                    buf.extend_from_slice(k.as_bytes());
602                    buf.extend_from_slice(&10u32.to_le_bytes()); // U64 type
603                    buf.extend_from_slice(&n.to_le_bytes());
604                }
605                MetaValueOwned::StringArray(items) => {
606                    buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
607                    buf.extend_from_slice(k.as_bytes());
608                    buf.extend_from_slice(&9u32.to_le_bytes()); // Array
609                    buf.extend_from_slice(&8u32.to_le_bytes()); // of String
610                    buf.extend_from_slice(&(items.len() as u64).to_le_bytes());
611                    for s in items {
612                        buf.extend_from_slice(&(s.len() as u64).to_le_bytes());
613                        buf.extend_from_slice(s.as_bytes());
614                    }
615                }
616            }
617        }
618        // one tiny f32 tensor so the GGUF loader accepts the file
619        let name = "w";
620        buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
621        buf.extend_from_slice(name.as_bytes());
622        buf.extend_from_slice(&1u32.to_le_bytes());
623        buf.extend_from_slice(&4u64.to_le_bytes());
624        buf.extend_from_slice(&(rlx_gguf::GgmlType::F32 as u32).to_le_bytes());
625        buf.extend_from_slice(&0u64.to_le_bytes());
626        while !buf
627            .len()
628            .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
629        {
630            buf.push(0);
631        }
632        for _ in 0..4 {
633            buf.extend_from_slice(&1.0f32.to_le_bytes());
634        }
635        use std::sync::atomic::{AtomicU64, Ordering};
636        static SEQ: AtomicU64 = AtomicU64::new(0);
637        let path = std::env::temp_dir().join(format!(
638            "rlx_compat_{}_{}_{}.gguf",
639            arch,
640            std::process::id(),
641            SEQ.fetch_add(1, Ordering::Relaxed),
642        ));
643        std::fs::write(&path, &buf).unwrap();
644        path
645    }
646
647    enum MetaValueOwned {
648        Str(String),
649        U64(u64),
650        StringArray(Vec<String>),
651    }
652
653    fn write_string_kv(buf: &mut Vec<u8>, k: &str, v: &str) {
654        buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
655        buf.extend_from_slice(k.as_bytes());
656        buf.extend_from_slice(&8u32.to_le_bytes());
657        buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
658        buf.extend_from_slice(v.as_bytes());
659    }
660
661    #[test]
662    fn supported_when_arch_known_and_all_required_fields_present() {
663        let path = write_test_gguf(
664            "qwen3",
665            &[
666                ("qwen3.context_length", MetaValueOwned::U64(8192)),
667                ("qwen3.embedding_length", MetaValueOwned::U64(4096)),
668                ("qwen3.block_count", MetaValueOwned::U64(32)),
669                ("tokenizer.ggml.model", MetaValueOwned::Str("gpt2".into())),
670                (
671                    "tokenizer.ggml.tokens",
672                    MetaValueOwned::StringArray(vec!["a".into(), "b".into()]),
673                ),
674            ],
675        );
676        let r = check_path(&path).unwrap();
677        match r.status {
678            CompatibilityStatus::Supported { runner } => assert_eq!(runner, "qwen3"),
679            other => panic!("expected Supported, got {other:?}"),
680        }
681        assert!(r.status.is_runnable());
682        assert_eq!(r.gguf_fields.as_ref().unwrap().context_length, Some(8192));
683        std::fs::remove_file(&path).ok();
684    }
685
686    #[test]
687    fn missing_metadata_when_required_field_absent() {
688        // Qwen3 arch is supported, but no block_count → not runnable.
689        let path = write_test_gguf(
690            "qwen3",
691            &[
692                ("qwen3.context_length", MetaValueOwned::U64(8192)),
693                ("qwen3.embedding_length", MetaValueOwned::U64(4096)),
694                ("tokenizer.ggml.model", MetaValueOwned::Str("gpt2".into())),
695                (
696                    "tokenizer.ggml.tokens",
697                    MetaValueOwned::StringArray(vec!["a".into()]),
698                ),
699            ],
700        );
701        let r = check_path(&path).unwrap();
702        match &r.status {
703            CompatibilityStatus::MissingMetadata { missing } => {
704                assert!(missing.contains(&"<arch>.block_count"));
705            }
706            other => panic!("expected MissingMetadata, got {other:?}"),
707        }
708        assert!(!r.status.is_runnable());
709        std::fs::remove_file(&r.path).ok();
710    }
711
712    #[test]
713    fn known_unimplemented_when_arch_in_plan_but_not_implemented() {
714        let path = write_test_gguf(
715            "minimax-m2",
716            &[
717                ("minimax-m2.context_length", MetaValueOwned::U64(8192)),
718                ("minimax-m2.embedding_length", MetaValueOwned::U64(4096)),
719                ("minimax-m2.block_count", MetaValueOwned::U64(32)),
720                ("tokenizer.ggml.model", MetaValueOwned::Str("gpt2".into())),
721                (
722                    "tokenizer.ggml.tokens",
723                    MetaValueOwned::StringArray(vec!["a".into()]),
724                ),
725            ],
726        );
727        let r = check_path(&path).unwrap();
728        match &r.status {
729            CompatibilityStatus::KnownUnimplemented(u) => {
730                assert_eq!(u.milestone, "M5");
731                assert!(u.family.contains("MiniMax"));
732            }
733            other => panic!("expected KnownUnimplemented, got {other:?}"),
734        }
735        assert!(!r.status.is_runnable());
736        std::fs::remove_file(&r.path).ok();
737    }
738
739    #[test]
740    fn unknown_when_arch_not_recognized() {
741        let path = write_test_gguf("totally-fake-arch", &[]);
742        let r = check_path(&path).unwrap();
743        assert!(matches!(r.status, CompatibilityStatus::Unknown));
744        std::fs::remove_file(&r.path).ok();
745    }
746
747    #[test]
748    fn json_round_trip_emits_status_tag() {
749        let path = write_test_gguf("totally-fake-arch", &[]);
750        let r = check_path(&path).unwrap();
751        let j = r.to_json();
752        let v: serde_json::Value = serde_json::from_str(&j).unwrap();
753        assert_eq!(v["status"], "unknown");
754        assert_eq!(v["source"], "gguf");
755        assert_eq!(v["arch"], "totally-fake-arch");
756        std::fs::remove_file(&r.path).ok();
757    }
758
759    #[test]
760    fn looks_like_hf_repo_distinguishes_repos_from_paths() {
761        assert!(looks_like_hf_repo("unsloth/Qwen3-7B-GGUF"));
762        assert!(looks_like_hf_repo("bartowski/something"));
763        // Local paths should NOT be flagged as repos
764        assert!(!looks_like_hf_repo("/Users/me/model.gguf"));
765        assert!(!looks_like_hf_repo("./model.gguf"));
766        assert!(!looks_like_hf_repo("~/models/qwen3"));
767        assert!(!looks_like_hf_repo("model.gguf"));
768        // Multi-segment paths aren't repos
769        assert!(!looks_like_hf_repo("models/qwen3/file.gguf"));
770        // File-extension suffix → path even if exactly one slash
771        assert!(!looks_like_hf_repo("org/file.safetensors"));
772        assert!(!looks_like_hf_repo("org/file.gguf"));
773    }
774
775    #[cfg(feature = "compat-net")]
776    #[test]
777    fn hf_url_construction() {
778        use super::hf_fetch::{resolve_url, tree_api_url};
779        assert_eq!(
780            resolve_url("unsloth/Qwen3-7B-GGUF", "config.json"),
781            "https://huggingface.co/unsloth/Qwen3-7B-GGUF/resolve/main/config.json"
782        );
783        assert_eq!(
784            tree_api_url("unsloth/Qwen3-7B-GGUF"),
785            "https://huggingface.co/api/models/unsloth/Qwen3-7B-GGUF/tree/main"
786        );
787    }
788
789    #[test]
790    fn safetensors_uses_sidecar_model_type() {
791        let dir = std::env::temp_dir().join("rlx_compat_st_sidecar");
792        std::fs::create_dir_all(&dir).unwrap();
793        std::fs::write(dir.join("config.json"), br#"{"model_type":"llama"}"#).unwrap();
794        let st = dir.join("model.safetensors");
795        std::fs::write(&st, b"").unwrap();
796        let r = check_path(&st).unwrap();
797        match r.status {
798            CompatibilityStatus::Supported { runner } => assert_eq!(runner, "llama32"),
799            other => panic!("expected Supported, got {other:?}"),
800        }
801        std::fs::remove_dir_all(&dir).ok();
802    }
803}