use crate::install::provider::InstallProviderId;
const HUGGING_FACE_HOSTS: [&str; 3] = ["huggingface.co/", "www.huggingface.co/", "hf.co/"];
const OLLAMA_HOSTS: [&str; 3] = ["ollama.com/", "www.ollama.com/", "registry.ollama.ai/"];
const HUGGING_FACE_SUBPATHS: [&str; 8] = [
"tree",
"blob",
"resolve",
"commit",
"commits",
"discussions",
"blame",
"raw",
];
const HUGGING_FACE_RESERVED_ROOTS: [&str; 14] = [
"datasets",
"spaces",
"collections",
"models",
"blog",
"docs",
"papers",
"tasks",
"posts",
"pricing",
"settings",
"organizations",
"learn",
"chat",
];
pub fn is_hugging_face_link(raw: &str) -> bool {
matches_host(raw, &HUGGING_FACE_HOSTS)
}
pub fn is_ollama_link(raw: &str) -> bool {
matches_host(raw, &OLLAMA_HOSTS)
}
fn matches_host(raw: &str, hosts: &[&str]) -> bool {
cleaned(raw).is_some_and(|text| {
let lower = text.to_lowercase();
hosts.iter().any(|host| lower.starts_with(host))
})
}
pub fn hugging_face_repo(raw: &str) -> Option<String> {
let text = cleaned(raw)?;
let text = stripped(&text, &HUGGING_FACE_HOSTS);
if text.contains("://") || text.contains(':') {
return None;
}
let components: Vec<&str> = text.split('/').collect();
if components.len() < 2 {
return None;
}
let org = components[0];
let name = components[1];
if org.is_empty()
|| name.is_empty()
|| HUGGING_FACE_RESERVED_ROOTS.contains(&org.to_lowercase().as_str())
{
return None;
}
if components.len() > 2 {
let raw_lower = raw.to_lowercase();
let from_hf_host = raw_lower.contains("hf.co") || raw_lower.contains("huggingface.co");
let next = components[2];
let next_ok =
next.is_empty() || HUGGING_FACE_SUBPATHS.contains(&next.to_lowercase().as_str());
if !(from_hf_host && next_ok) {
return None;
}
}
Some(format!("{org}/{name}"))
}
pub fn ollama_tag(raw: &str) -> Option<String> {
tag(raw, true)
}
pub fn ollama_install_tag(raw: &str) -> Option<String> {
tag(raw, false)
}
pub fn ollama_direct_tag(query: &str) -> Option<String> {
if hugging_face_repo(query).is_some() {
return None;
}
let tag = ollama_tag(query)?;
if query.contains(':') || is_ollama_link(query) {
Some(tag)
} else {
None
}
}
fn tag(raw: &str, require_explicit_tag_for_namespaced: bool) -> Option<String> {
let text = cleaned(raw)?;
let lower = text.to_lowercase();
let is_link = OLLAMA_HOSTS.iter().any(|host| lower.starts_with(host));
let mut text = stripped(&text, &OLLAMA_HOSTS);
if text.contains("://") {
return None;
}
if is_link {
let components: Vec<&str> = text.split('/').collect();
let selected: Vec<&str> = if components
.first()
.is_some_and(|first| first.eq_ignore_ascii_case("library"))
{
components.iter().skip(1).take(1).copied().collect()
} else {
components.iter().take(2).copied().collect()
};
if selected.is_empty() {
return None;
}
let joined = selected.join("/");
return shaped(&joined, false).then_some(joined);
}
if lower.starts_with("library/") {
text = text["library/".len()..].to_string();
}
shaped(&text, require_explicit_tag_for_namespaced).then_some(text)
}
pub fn normalized_tag(reference: &str) -> String {
let with_tag = if reference.contains(':') {
reference.to_owned()
} else {
format!("{reference}:latest")
};
with_tag.to_lowercase()
}
pub fn normalized(provider: &InstallProviderId, reference: &str) -> String {
if provider.as_str() == "ollama" {
normalized_tag(reference)
} else {
reference.to_lowercase()
}
}
pub fn is_ollama_tag_shaped(reference: &str) -> bool {
shaped(reference, true)
}
fn shaped(reference: &str, require_explicit_tag_for_namespaced: bool) -> bool {
if reference.is_empty()
|| reference.chars().any(char::is_whitespace)
|| reference.contains("://")
{
return false;
}
let components: Vec<&str> = reference.split('/').collect();
if components.len() > 2 || components.iter().any(|component| component.is_empty()) {
return false;
}
let Some(name) = components.last() else {
return false;
};
let name_parts: Vec<&str> = name.split(':').collect();
if name_parts.len() > 2 || name_parts.iter().any(|part| part.is_empty()) {
return false;
}
if components.len() == 2 {
return !require_explicit_tag_for_namespaced || name.contains(':');
}
true
}
fn cleaned(raw: &str) -> Option<String> {
let mut text = raw.trim().to_owned();
if text.is_empty() || text.chars().any(char::is_whitespace) {
return None;
}
for scheme in ["https://", "http://"] {
if text.to_lowercase().starts_with(scheme) {
text = text[scheme.len()..].to_owned();
}
}
if let Some(stop) = text.find(['?', '#']) {
text.truncate(stop);
}
while text.ends_with('/') {
text.pop();
}
(!text.is_empty()).then_some(text)
}
fn stripped(text: &str, hosts: &[&str]) -> String {
let lower = text.to_lowercase();
for host in hosts {
if lower.starts_with(host) {
return text[host.len()..].to_owned();
}
}
text.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_shape_requires_a_version_when_namespaced() {
assert!(is_ollama_tag_shaped("llama3"));
assert!(is_ollama_tag_shaped("llama3:8b"));
assert!(is_ollama_tag_shaped("org/model:tag"));
assert!(!is_ollama_tag_shaped("org/model"));
assert!(!is_ollama_tag_shaped("a b"));
assert!(!is_ollama_tag_shaped("a/b/c"));
assert!(!is_ollama_tag_shaped("a:b:c"));
assert!(!is_ollama_tag_shaped("model:"));
assert!(!is_ollama_tag_shaped(""));
}
#[test]
fn cleaned_strips_stacked_schemes() {
assert_eq!(cleaned("https://http://foo").as_deref(), Some("foo"));
}
}