Skip to main content

mesh_client/models/
catalog.rs

1use serde::Deserialize;
2use std::sync::LazyLock;
3
4#[derive(Clone, Debug, Deserialize)]
5pub struct CatalogAsset {
6    pub file: String,
7    pub url: String,
8}
9
10#[derive(Clone, Debug)]
11pub struct CatalogModel {
12    pub name: String,
13    pub file: String,
14    pub url: String,
15    pub size: String,
16    pub description: String,
17    pub draft: Option<String>,
18    pub extra_files: Vec<CatalogAsset>,
19    pub mmproj: Option<CatalogAsset>,
20}
21
22impl CatalogModel {
23    pub fn source_repo(&self) -> Option<&str> {
24        parse_hf_resolve_url_parts(&self.url).map(|(repo, _, _)| repo)
25    }
26
27    pub fn source_revision(&self) -> Option<&str> {
28        parse_hf_resolve_url_parts(&self.url).and_then(|(_, revision, _)| revision)
29    }
30
31    pub fn source_file(&self) -> Option<&str> {
32        parse_hf_resolve_url_parts(&self.url).map(|(_, _, file)| file)
33    }
34}
35
36#[derive(Debug, Deserialize)]
37struct CatalogModelJson {
38    name: String,
39    file: String,
40    url: String,
41    size: String,
42    description: String,
43    draft: Option<String>,
44    #[serde(default)]
45    extra_files: Vec<CatalogAsset>,
46    mmproj: Option<CatalogAsset>,
47}
48
49pub static MODEL_CATALOG: LazyLock<Vec<CatalogModel>> = LazyLock::new(load_catalog);
50
51fn load_catalog() -> Vec<CatalogModel> {
52    let raw: Vec<CatalogModelJson> =
53        serde_json::from_str(include_str!("catalog.json")).expect("parse bundled catalog.json");
54    raw.into_iter().map(CatalogModel::from_json).collect()
55}
56
57impl CatalogModel {
58    fn from_json(raw: CatalogModelJson) -> Self {
59        Self {
60            name: raw.name,
61            file: raw.file,
62            url: raw.url,
63            size: raw.size,
64            description: raw.description,
65            draft: raw.draft,
66            extra_files: raw.extra_files,
67            mmproj: raw.mmproj,
68        }
69    }
70}
71
72pub fn parse_size_gb(s: &str) -> f64 {
73    let s = s.trim();
74    if let Some(gb) = s.strip_suffix("GB") {
75        gb.trim().parse().unwrap_or(0.0)
76    } else if let Some(mb) = s.strip_suffix("MB") {
77        mb.trim().parse::<f64>().unwrap_or(0.0) / 1000.0
78    } else {
79        0.0
80    }
81}
82
83pub fn find_model(query: &str) -> Option<&'static CatalogModel> {
84    let q = query.to_lowercase();
85    MODEL_CATALOG
86        .iter()
87        .find(|m| m.name.to_lowercase() == q)
88        .or_else(|| {
89            MODEL_CATALOG
90                .iter()
91                .find(|m| m.name.to_lowercase().contains(&q))
92        })
93}
94
95pub fn parse_hf_resolve_url_parts(url: &str) -> Option<(&str, Option<&str>, &str)> {
96    let tail = url
97        .strip_prefix("https://huggingface.co/")
98        .or_else(|| url.strip_prefix("http://huggingface.co/"))?;
99    let (repo, rest) = tail.split_once("/resolve/")?;
100    if !repo.contains('/') {
101        return None;
102    }
103    let (revision, file) = rest.split_once('/')?;
104    if file.is_empty() {
105        return None;
106    }
107    Some((repo, Some(revision), file))
108}
109
110pub fn huggingface_repo_url(url: &str) -> Option<String> {
111    let (repo, _, _) = parse_hf_resolve_url_parts(url)?;
112    Some(format!("https://huggingface.co/{repo}"))
113}
114
115pub fn list_models() {
116    eprintln!("Available models:");
117    eprintln!();
118    for m in MODEL_CATALOG.iter() {
119        let draft_info = if let Some(d) = m.draft.as_deref() {
120            format!(" (draft: {})", d)
121        } else {
122            String::new()
123        };
124        eprintln!(
125            "  {:40} {:>6}  {}{}",
126            m.name, m.size, m.description, draft_info
127        );
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn source_identity_is_exposed_for_hf_catalog_entries() {
137        let model = find_model("Qwen3-8B-Q4_K_M").unwrap();
138        assert_eq!(model.source_repo(), Some("unsloth/Qwen3-8B-GGUF"));
139        assert_eq!(model.source_revision(), Some("main"));
140        assert_eq!(model.source_file(), Some("Qwen3-8B-Q4_K_M.gguf"));
141        assert!(model.source_repo().is_some());
142    }
143
144    #[test]
145    fn source_identity_is_absent_for_direct_url_entries() {
146        let model = find_model("Qwen3.5-27B-Q4_K_M").unwrap();
147        assert_eq!(model.source_repo(), None);
148        assert_eq!(model.source_revision(), None);
149        assert_eq!(model.source_file(), None);
150        assert!(model.source_repo().is_none());
151    }
152
153    #[test]
154    fn test_split_url_generation() {
155        let filename = "Model-Q4_K_M-00001-of-00003.gguf";
156        let url = "https://huggingface.co/org/repo/resolve/main/Model-Q4_K_M-00001-of-00003.gguf";
157
158        let mut files = Vec::new();
159        for i in 1..=3u32 {
160            let part_filename = filename.replace("-00001-of-", &format!("-{i:05}-of-"));
161            let part_url = url.replace("-00001-of-", &format!("-{i:05}-of-"));
162            files.push((part_filename, part_url));
163        }
164
165        assert_eq!(files.len(), 3);
166        assert_eq!(files[0].0, "Model-Q4_K_M-00001-of-00003.gguf");
167        assert_eq!(files[1].0, "Model-Q4_K_M-00002-of-00003.gguf");
168        assert_eq!(files[2].0, "Model-Q4_K_M-00003-of-00003.gguf");
169        assert!(files[0].1.contains("-00001-of-"));
170        assert!(files[1].1.contains("-00002-of-"));
171        assert!(files[2].1.contains("-00003-of-"));
172    }
173}