Skip to main content

kernel/install/
reference.rs

1//! Parsing a user-typed model reference — a bare id, a `hf.co/...` or
2//! `ollama.com/...` link — into a Hugging Face repo (`org/name`) or an Ollama tag
3//! (`name:version`). Pure string work; the ambiguity is resolved consistently so
4//! the CLI and gateway accept identical inputs.
5
6use crate::install::provider::InstallProviderId;
7
8const HUGGING_FACE_HOSTS: [&str; 3] = ["huggingface.co/", "www.huggingface.co/", "hf.co/"];
9const OLLAMA_HOSTS: [&str; 3] = ["ollama.com/", "www.ollama.com/", "registry.ollama.ai/"];
10const HUGGING_FACE_SUBPATHS: [&str; 8] = [
11    "tree",
12    "blob",
13    "resolve",
14    "commit",
15    "commits",
16    "discussions",
17    "blame",
18    "raw",
19];
20const HUGGING_FACE_RESERVED_ROOTS: [&str; 14] = [
21    "datasets",
22    "spaces",
23    "collections",
24    "models",
25    "blog",
26    "docs",
27    "papers",
28    "tasks",
29    "posts",
30    "pricing",
31    "settings",
32    "organizations",
33    "learn",
34    "chat",
35];
36
37/// Whether `raw` is a Hugging Face URL.
38pub fn is_hugging_face_link(raw: &str) -> bool {
39    matches_host(raw, &HUGGING_FACE_HOSTS)
40}
41
42/// Whether `raw` is an Ollama URL.
43pub fn is_ollama_link(raw: &str) -> bool {
44    matches_host(raw, &OLLAMA_HOSTS)
45}
46
47fn matches_host(raw: &str, hosts: &[&str]) -> bool {
48    cleaned(raw).is_some_and(|text| {
49        let lower = text.to_lowercase();
50        hosts.iter().any(|host| lower.starts_with(host))
51    })
52}
53
54/// The `org/name` repo a Hugging Face reference points at, or `None` if `raw` is
55/// not a plausible HF repo. A multi-segment path is only accepted from an explicit
56/// `hf.co`/`huggingface.co` link whose third segment is empty or a known HF subpath.
57pub fn hugging_face_repo(raw: &str) -> Option<String> {
58    let text = cleaned(raw)?;
59    let text = stripped(&text, &HUGGING_FACE_HOSTS);
60    if text.contains("://") || text.contains(':') {
61        return None;
62    }
63    let components: Vec<&str> = text.split('/').collect();
64    if components.len() < 2 {
65        return None;
66    }
67    let org = components[0];
68    let name = components[1];
69    if org.is_empty()
70        || name.is_empty()
71        || HUGGING_FACE_RESERVED_ROOTS.contains(&org.to_lowercase().as_str())
72    {
73        return None;
74    }
75    if components.len() > 2 {
76        let raw_lower = raw.to_lowercase();
77        let from_hf_host = raw_lower.contains("hf.co") || raw_lower.contains("huggingface.co");
78        let next = components[2];
79        let next_ok =
80            next.is_empty() || HUGGING_FACE_SUBPATHS.contains(&next.to_lowercase().as_str());
81        if !(from_hf_host && next_ok) {
82            return None;
83        }
84    }
85    Some(format!("{org}/{name}"))
86}
87
88/// The Ollama tag a reference points at, requiring an explicit `:version` for a
89/// namespaced (`org/name`) reference.
90pub fn ollama_tag(raw: &str) -> Option<String> {
91    tag(raw, true)
92}
93
94/// The Ollama tag for an install, allowing a namespaced reference without an
95/// explicit version (it defaults to `:latest` downstream).
96pub fn ollama_install_tag(raw: &str) -> Option<String> {
97    tag(raw, false)
98}
99
100/// The Ollama tag for a search query — only when it isn't an HF repo and the input
101/// carries an explicit `:` or is an Ollama link (so a bare word doesn't resolve to
102/// Ollama by default).
103pub fn ollama_direct_tag(query: &str) -> Option<String> {
104    if hugging_face_repo(query).is_some() {
105        return None;
106    }
107    let tag = ollama_tag(query)?;
108    if query.contains(':') || is_ollama_link(query) {
109        Some(tag)
110    } else {
111        None
112    }
113}
114
115fn tag(raw: &str, require_explicit_tag_for_namespaced: bool) -> Option<String> {
116    let text = cleaned(raw)?;
117    let lower = text.to_lowercase();
118    let is_link = OLLAMA_HOSTS.iter().any(|host| lower.starts_with(host));
119    let mut text = stripped(&text, &OLLAMA_HOSTS);
120    if text.contains("://") {
121        return None;
122    }
123    if is_link {
124        let components: Vec<&str> = text.split('/').collect();
125        let selected: Vec<&str> = if components
126            .first()
127            .is_some_and(|first| first.eq_ignore_ascii_case("library"))
128        {
129            components.iter().skip(1).take(1).copied().collect()
130        } else {
131            components.iter().take(2).copied().collect()
132        };
133        if selected.is_empty() {
134            return None;
135        }
136        let joined = selected.join("/");
137        return shaped(&joined, false).then_some(joined);
138    }
139    if lower.starts_with("library/") {
140        text = text["library/".len()..].to_string();
141    }
142    shaped(&text, require_explicit_tag_for_namespaced).then_some(text)
143}
144
145/// A tag with `:latest` added when no version is present, lowercased.
146pub fn normalized_tag(reference: &str) -> String {
147    let with_tag = if reference.contains(':') {
148        reference.to_owned()
149    } else {
150        format!("{reference}:latest")
151    };
152    with_tag.to_lowercase()
153}
154
155/// A reference normalized for `provider`: Ollama tags gain `:latest` and lowercase;
156/// other providers lowercase.
157pub fn normalized(provider: &InstallProviderId, reference: &str) -> String {
158    if provider.as_str() == "ollama" {
159        normalized_tag(reference)
160    } else {
161        reference.to_lowercase()
162    }
163}
164
165/// Whether `reference` is a well-formed Ollama tag (an explicit version is required
166/// for a namespaced reference). Part of the parsing API the gateway/cli use to
167/// validate a typed tag, alongside the other `reference` entry points.
168pub fn is_ollama_tag_shaped(reference: &str) -> bool {
169    shaped(reference, true)
170}
171
172fn shaped(reference: &str, require_explicit_tag_for_namespaced: bool) -> bool {
173    if reference.is_empty()
174        || reference.chars().any(char::is_whitespace)
175        || reference.contains("://")
176    {
177        return false;
178    }
179    let components: Vec<&str> = reference.split('/').collect();
180    if components.len() > 2 || components.iter().any(|component| component.is_empty()) {
181        return false;
182    }
183    let Some(name) = components.last() else {
184        return false;
185    };
186    let name_parts: Vec<&str> = name.split(':').collect();
187    if name_parts.len() > 2 || name_parts.iter().any(|part| part.is_empty()) {
188        return false;
189    }
190    if components.len() == 2 {
191        return !require_explicit_tag_for_namespaced || name.contains(':');
192    }
193    true
194}
195
196/// Trim, reject interior whitespace, drop an `http(s)://` scheme and any `?`/`#`
197/// query/fragment, and strip trailing slashes. `None` for an empty or whitespace-
198/// bearing input.
199fn cleaned(raw: &str) -> Option<String> {
200    let mut text = raw.trim().to_owned();
201    if text.is_empty() || text.chars().any(char::is_whitespace) {
202        return None;
203    }
204    // No `break`: the remaining text is re-tested against each scheme, so a
205    // stacked `https://http://…` prefix is fully stripped.
206    for scheme in ["https://", "http://"] {
207        if text.to_lowercase().starts_with(scheme) {
208            text = text[scheme.len()..].to_owned();
209        }
210    }
211    if let Some(stop) = text.find(['?', '#']) {
212        text.truncate(stop);
213    }
214    while text.ends_with('/') {
215        text.pop();
216    }
217    (!text.is_empty()).then_some(text)
218}
219
220/// Drop a leading known host prefix from `text` (case-insensitive), if present.
221fn stripped(text: &str, hosts: &[&str]) -> String {
222    let lower = text.to_lowercase();
223    for host in hosts {
224        if lower.starts_with(host) {
225            return text[host.len()..].to_owned();
226        }
227    }
228    text.to_owned()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn tag_shape_requires_a_version_when_namespaced() {
237        assert!(is_ollama_tag_shaped("llama3"));
238        assert!(is_ollama_tag_shaped("llama3:8b"));
239        assert!(is_ollama_tag_shaped("org/model:tag"));
240        // Namespaced without a version → not shaped.
241        assert!(!is_ollama_tag_shaped("org/model"));
242        // Whitespace / too many segments / empty name-parts → not shaped.
243        assert!(!is_ollama_tag_shaped("a b"));
244        assert!(!is_ollama_tag_shaped("a/b/c"));
245        assert!(!is_ollama_tag_shaped("a:b:c"));
246        assert!(!is_ollama_tag_shaped("model:"));
247        assert!(!is_ollama_tag_shaped(""));
248    }
249
250    #[test]
251    fn cleaned_strips_stacked_schemes() {
252        // No `break` in the scheme loop: a stacked prefix is fully removed.
253        assert_eq!(cleaned("https://http://foo").as_deref(), Some("foo"));
254    }
255}