mesh_client/models/
catalog.rs1use 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}