1use std::error::Error;
25use std::fmt;
26
27pub const AGENT_GUIDE: &str = include_str!("../AGENT_GUIDE.md");
29
30pub type Result<T> = std::result::Result<T, ModelForgeError>;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ModelProvider {
36 Hf,
38 Ollama,
40 Civitai,
42}
43
44impl ModelProvider {
45 pub const ALL: [Self; 3] = [Self::Hf, Self::Ollama, Self::Civitai];
47
48 pub fn all() -> &'static [Self] {
50 &Self::ALL
51 }
52
53 pub fn label(self) -> &'static str {
55 match self {
56 Self::Hf => "Hugging Face",
57 Self::Ollama => "Ollama",
58 Self::Civitai => "CivitAI",
59 }
60 }
61
62 pub fn short_label(self) -> &'static str {
64 match self {
65 Self::Hf => "HF",
66 Self::Ollama => "OLM",
67 Self::Civitai => "CIV",
68 }
69 }
70
71 pub fn slug(self) -> &'static str {
73 match self {
74 Self::Hf => "hf",
75 Self::Ollama => "ollama",
76 Self::Civitai => "civitai",
77 }
78 }
79
80 pub fn from_slug(value: &str) -> Option<Self> {
82 let normalized = normalized_provider_slug(value);
83 match normalized.as_str() {
84 "hf" | "huggingface" => Some(Self::Hf),
85 "ollama" | "olm" => Some(Self::Ollama),
86 "civitai" | "civit" | "civ" => Some(Self::Civitai),
87 _ => None,
88 }
89 }
90}
91
92fn normalized_provider_slug(value: &str) -> String {
93 value
94 .trim()
95 .chars()
96 .filter(|c| !matches!(c, '-' | '_' | ' ' | '\t' | '\n' | '\r'))
97 .flat_map(char::to_lowercase)
98 .collect()
99}
100
101impl fmt::Display for ModelProvider {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 match self {
104 Self::Hf | Self::Ollama | Self::Civitai => write!(f, "{}", self.slug()),
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum ModelTarget {
112 Org(String),
114 Model(String),
116 Top,
118 Latest,
120}
121
122impl ModelTarget {
123 pub fn is_org(&self) -> bool {
125 matches!(self, Self::Org(_))
126 }
127
128 pub fn is_model(&self) -> bool {
130 matches!(self, Self::Model(_))
131 }
132
133 pub fn is_preset(&self) -> bool {
135 matches!(self, Self::Top | Self::Latest)
136 }
137
138 pub fn org(&self) -> Option<&str> {
140 match self {
141 Self::Org(value) => Some(value),
142 Self::Model(_) | Self::Top | Self::Latest => None,
143 }
144 }
145
146 pub fn model(&self) -> Option<&str> {
148 match self {
149 Self::Model(value) => Some(value),
150 Self::Org(_) | Self::Top | Self::Latest => None,
151 }
152 }
153
154 pub fn kind(&self) -> &'static str {
156 match self {
157 Self::Org(_) => "org",
158 Self::Model(_) => "model",
159 Self::Top => "top",
160 Self::Latest => "latest",
161 }
162 }
163
164 pub fn value(&self) -> Option<&str> {
166 match self {
167 Self::Org(value) | Self::Model(value) => Some(value),
168 Self::Top | Self::Latest => None,
169 }
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum ModelForgeError {
176 InvalidTarget(String),
178}
179
180impl ModelForgeError {
181 pub fn target(&self) -> &str {
183 match self {
184 Self::InvalidTarget(target) => target,
185 }
186 }
187}
188
189impl fmt::Display for ModelForgeError {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 match self {
192 Self::InvalidTarget(target) => write!(f, "invalid model target '{target}'"),
193 }
194 }
195}
196
197impl Error for ModelForgeError {}
198
199pub fn parse_model_target(input: &str) -> Result<ModelTarget> {
204 let cleaned = input.trim();
205 if cleaned.is_empty() {
206 return Err(ModelForgeError::InvalidTarget(
207 "empty model target".to_string(),
208 ));
209 }
210
211 if let Some(path) = strip_any_prefix_ignore_ascii_case(
212 cleaned,
213 &[
214 "https://huggingface.co/",
215 "http://huggingface.co/",
216 "https://www.huggingface.co/",
217 "http://www.huggingface.co/",
218 "https://hf.co/",
219 "http://hf.co/",
220 "https://www.hf.co/",
221 "http://www.hf.co/",
222 ],
223 ) {
224 return parse_model_path(path);
225 }
226
227 let parts = cleaned
228 .split('/')
229 .filter(|part| !part.is_empty())
230 .collect::<Vec<_>>();
231 match parts.len() {
232 0 => Err(ModelForgeError::InvalidTarget(cleaned.to_string())),
233 1 => Ok(ModelTarget::Org(parts[0].to_string())),
234 _ => Ok(ModelTarget::Model(format!("{}/{}", parts[0], parts[1]))),
235 }
236}
237
238pub fn parse_model_target_for_provider(
246 input: &str,
247 provider: ModelProvider,
248) -> Result<ModelTarget> {
249 let normalized = input.trim();
250 let canonical = normalized.to_lowercase();
251
252 match canonical.as_str() {
253 "top" | "trending" | "popular" => return Ok(ModelTarget::Top),
254 "latest" | "newest" | "new" => return Ok(ModelTarget::Latest),
255 _ => {}
256 }
257
258 if normalized.is_empty() {
259 return Err(ModelForgeError::InvalidTarget(
260 "empty model target".to_string(),
261 ));
262 }
263
264 if let ModelProvider::Civitai = provider {
265 if let Some(path) = strip_any_prefix_ignore_ascii_case(
266 normalized,
267 &[
268 "https://civitai.com/models/",
269 "http://civitai.com/models/",
270 "https://www.civitai.com/models/",
271 "http://www.civitai.com/models/",
272 ],
273 ) {
274 let id = path.split(['/', '?', '#']).next().unwrap_or("").trim();
275 if !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) {
276 return Ok(ModelTarget::Model(id.to_string()));
277 }
278 }
279
280 if normalized.chars().all(|c| c.is_ascii_digit()) {
281 return Ok(ModelTarget::Model(normalized.to_string()));
282 }
283 }
284
285 if let ModelProvider::Ollama = provider {
286 if let Some(path) = strip_any_prefix_ignore_ascii_case(
287 normalized,
288 &[
289 "https://ollama.com/library/",
290 "http://ollama.com/library/",
291 "https://www.ollama.com/library/",
292 "http://www.ollama.com/library/",
293 ],
294 ) {
295 let id = first_clean_path_segment(path);
296 if !id.is_empty() {
297 return Ok(ModelTarget::Model(id.to_string()));
298 }
299 }
300 }
301
302 let model_target = parse_model_target(normalized)?;
303 match provider {
304 ModelProvider::Civitai => match model_target {
305 ModelTarget::Model(model) if model.chars().all(|c| c.is_ascii_digit()) => {
306 Ok(ModelTarget::Model(model))
307 }
308 other => Ok(other),
309 },
310 _ => Ok(model_target),
311 }
312}
313
314fn parse_model_path(path: &str) -> Result<ModelTarget> {
315 let cleaned = path.split(['?', '#']).next().unwrap_or(path);
316 let parts = cleaned
317 .trim_matches('/')
318 .split('/')
319 .filter(|part| !part.is_empty())
320 .collect::<Vec<_>>();
321 match parts.len() {
322 0 => Err(ModelForgeError::InvalidTarget(path.to_string())),
323 1 => Ok(ModelTarget::Org(parts[0].to_string())),
324 _ => Ok(ModelTarget::Model(format!("{}/{}", parts[0], parts[1]))),
325 }
326}
327
328fn first_clean_path_segment(path: &str) -> &str {
329 path.split(['/', '?', '#']).next().unwrap_or("").trim()
330}
331
332fn strip_any_prefix_ignore_ascii_case<'a>(value: &'a str, prefixes: &[&str]) -> Option<&'a str> {
333 prefixes
334 .iter()
335 .find_map(|prefix| strip_prefix_ignore_ascii_case(value, prefix))
336}
337
338fn strip_prefix_ignore_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
339 let candidate = value.as_bytes().get(..prefix.len())?;
340 if candidate.eq_ignore_ascii_case(prefix.as_bytes()) {
341 value.get(prefix.len()..)
342 } else {
343 None
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn parses_hugging_face_targets() {
353 assert_eq!(
354 parse_model_target_for_provider("meta-llama/Llama-2-7b", ModelProvider::Hf),
355 Ok(ModelTarget::Model("meta-llama/Llama-2-7b".to_string()))
356 );
357 assert_eq!(
358 parse_model_target_for_provider("top", ModelProvider::Hf),
359 Ok(ModelTarget::Top)
360 );
361 assert_eq!(
362 parse_model_target_for_provider(
363 "https://huggingface.co/openai-community/gpt2",
364 ModelProvider::Hf,
365 ),
366 Ok(ModelTarget::Model("openai-community/gpt2".to_string()))
367 );
368 assert_eq!(
369 parse_model_target_for_provider(
370 "https://hf.co/openai-community/gpt2",
371 ModelProvider::Hf
372 ),
373 Ok(ModelTarget::Model("openai-community/gpt2".to_string()))
374 );
375 assert_eq!(
376 parse_model_target_for_provider(
377 "HTTPS://WWW.HUGGINGFACE.CO/openai-community/gpt2",
378 ModelProvider::Hf,
379 ),
380 Ok(ModelTarget::Model("openai-community/gpt2".to_string()))
381 );
382 }
383
384 #[test]
385 fn parses_civitai_targets() {
386 assert_eq!(
387 parse_model_target_for_provider("827184", ModelProvider::Civitai),
388 Ok(ModelTarget::Model("827184".to_string()))
389 );
390 assert_eq!(
391 parse_model_target_for_provider("newest", ModelProvider::Civitai),
392 Ok(ModelTarget::Latest)
393 );
394 assert_eq!(
395 parse_model_target_for_provider(
396 "https://www.civitai.com/models/827184",
397 ModelProvider::Civitai,
398 ),
399 Ok(ModelTarget::Model("827184".to_string()))
400 );
401 assert_eq!(
402 parse_model_target_for_provider("realistic-vision", ModelProvider::Civitai),
403 Ok(ModelTarget::Org("realistic-vision".to_string()))
404 );
405 }
406
407 #[test]
408 fn parses_ollama_targets() {
409 assert_eq!(
410 parse_model_target_for_provider("llama3", ModelProvider::Ollama),
411 Ok(ModelTarget::Org("llama3".to_string()))
412 );
413 assert_eq!(
414 parse_model_target_for_provider(
415 "HTTPS://WWW.OLLAMA.COM/library/llama3.2/tags",
416 ModelProvider::Ollama,
417 ),
418 Ok(ModelTarget::Model("llama3.2".to_string()))
419 );
420 assert_eq!(
421 parse_model_target_for_provider("trending", ModelProvider::Ollama),
422 Ok(ModelTarget::Top)
423 );
424 }
425
426 #[test]
427 fn strips_url_query_and_fragment_noise() {
428 assert_eq!(
429 parse_model_target_for_provider(
430 "https://huggingface.co/openai-community/gpt2?library=transformers#files",
431 ModelProvider::Hf,
432 ),
433 Ok(ModelTarget::Model("openai-community/gpt2".to_string()))
434 );
435 assert_eq!(
436 parse_model_target_for_provider(
437 "https://ollama.com/library/deepseek-r1?sort=popular",
438 ModelProvider::Ollama,
439 ),
440 Ok(ModelTarget::Model("deepseek-r1".to_string()))
441 );
442 }
443
444 #[test]
445 fn provider_slugs_round_trip() {
446 assert_eq!(ModelProvider::from_slug("hf"), Some(ModelProvider::Hf));
447 assert_eq!(
448 ModelProvider::from_slug("hugging-face"),
449 Some(ModelProvider::Hf)
450 );
451 assert_eq!(
452 ModelProvider::from_slug("hugging face"),
453 Some(ModelProvider::Hf)
454 );
455 assert_eq!(
456 ModelProvider::from_slug("civit_ai"),
457 Some(ModelProvider::Civitai)
458 );
459 assert_eq!(ModelProvider::from_slug("olm"), Some(ModelProvider::Ollama));
460 assert_eq!(
461 ModelProvider::from_slug("civ"),
462 Some(ModelProvider::Civitai)
463 );
464 assert_eq!(ModelProvider::from_slug("unknown"), None);
465 assert_eq!(ModelProvider::Civitai.to_string(), "civitai");
466 }
467
468 #[test]
469 fn target_helpers_describe_targets() {
470 let model = ModelTarget::Model("owner/model".to_string());
471 assert!(model.is_model());
472 assert!(!model.is_org());
473 assert!(!model.is_preset());
474 assert_eq!(model.kind(), "model");
475 assert_eq!(model.model(), Some("owner/model"));
476 assert_eq!(model.org(), None);
477 assert_eq!(model.value(), Some("owner/model"));
478
479 let org = ModelTarget::Org("owner".to_string());
480 assert!(org.is_org());
481 assert!(!org.is_model());
482 assert_eq!(org.org(), Some("owner"));
483 assert_eq!(org.model(), None);
484
485 let latest = ModelTarget::Latest;
486 assert!(latest.is_preset());
487 assert_eq!(latest.kind(), "latest");
488 assert_eq!(latest.value(), None);
489 }
490
491 #[test]
492 fn providers_iterate_in_stable_display_order() {
493 assert_eq!(ModelProvider::all(), &ModelProvider::ALL);
494 assert_eq!(ModelProvider::all()[0], ModelProvider::Hf);
495 assert_eq!(ModelProvider::all()[1], ModelProvider::Ollama);
496 assert_eq!(ModelProvider::all()[2], ModelProvider::Civitai);
497 }
498}