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        hosts
50            .iter()
51            .any(|host| after_ascii_prefix(&text, host).is_some())
52    })
53}
54
55/// The `org/name` repo a Hugging Face reference points at, or `None` if `raw` is
56/// not a plausible HF repo. A multi-segment path is only accepted from an explicit
57/// `hf.co`/`huggingface.co` link whose third segment is empty or a known HF subpath.
58pub fn hugging_face_repo(raw: &str) -> Option<String> {
59    let text = cleaned(raw)?;
60    let text = stripped(&text, &HUGGING_FACE_HOSTS);
61    if text.contains("://") || text.contains(':') {
62        return None;
63    }
64    let components: Vec<&str> = text.split('/').collect();
65    if components.len() < 2 {
66        return None;
67    }
68    let org = components[0];
69    let name = components[1];
70    if org.is_empty()
71        || name.is_empty()
72        || HUGGING_FACE_RESERVED_ROOTS.contains(&org.to_lowercase().as_str())
73    {
74        return None;
75    }
76    if components.len() > 2 {
77        let raw_lower = raw.to_lowercase();
78        let from_hf_host = raw_lower.contains("hf.co") || raw_lower.contains("huggingface.co");
79        let next = components[2];
80        let next_ok =
81            next.is_empty() || HUGGING_FACE_SUBPATHS.contains(&next.to_lowercase().as_str());
82        if !(from_hf_host && next_ok) {
83            return None;
84        }
85    }
86    Some(format!("{org}/{name}"))
87}
88
89/// The Ollama tag a reference points at, requiring an explicit `:version` for a
90/// namespaced (`org/name`) reference.
91pub fn ollama_tag(raw: &str) -> Option<String> {
92    tag(raw, true)
93}
94
95/// The Ollama tag for an install, allowing a namespaced reference without an
96/// explicit version (it defaults to `:latest` downstream).
97pub fn ollama_install_tag(raw: &str) -> Option<String> {
98    tag(raw, false)
99}
100
101/// The Ollama tag for a search query — only when it isn't an HF repo and the input
102/// carries an explicit `:` or is an Ollama link (so a bare word doesn't resolve to
103/// Ollama by default).
104pub fn ollama_direct_tag(query: &str) -> Option<String> {
105    if hugging_face_repo(query).is_some() {
106        return None;
107    }
108    let tag = ollama_tag(query)?;
109    if query.contains(':') || is_ollama_link(query) {
110        Some(tag)
111    } else {
112        None
113    }
114}
115
116fn tag(raw: &str, require_explicit_tag_for_namespaced: bool) -> Option<String> {
117    let text = cleaned(raw)?;
118    let is_link = OLLAMA_HOSTS
119        .iter()
120        .any(|host| after_ascii_prefix(&text, host).is_some());
121    let mut text = stripped(&text, &OLLAMA_HOSTS);
122    if text.contains("://") {
123        return None;
124    }
125    if is_link {
126        let components: Vec<&str> = text.split('/').collect();
127        let selected: Vec<&str> = if components
128            .first()
129            .is_some_and(|first| first.eq_ignore_ascii_case("library"))
130        {
131            components.iter().skip(1).take(1).copied().collect()
132        } else {
133            components.iter().take(2).copied().collect()
134        };
135        if selected.is_empty() {
136            return None;
137        }
138        let joined = selected.join("/");
139        return shaped(&joined, false).then_some(joined);
140    }
141    if let Some(rest) = after_ascii_prefix(&text, "library/") {
142        text = rest.to_owned();
143    }
144    shaped(&text, require_explicit_tag_for_namespaced).then_some(text)
145}
146
147/// A tag with `:latest` added when no version is present, lowercased.
148pub fn normalized_tag(reference: &str) -> String {
149    let with_tag = if reference.contains(':') {
150        reference.to_owned()
151    } else {
152        format!("{reference}:latest")
153    };
154    with_tag.to_lowercase()
155}
156
157/// A reference normalized for `provider`: Ollama tags gain `:latest` and lowercase;
158/// other providers lowercase.
159pub fn normalized(provider: &InstallProviderId, reference: &str) -> String {
160    if provider.as_str() == "ollama" {
161        normalized_tag(reference)
162    } else {
163        reference.to_lowercase()
164    }
165}
166
167/// Whether `reference` is a well-formed Ollama tag (an explicit version is required
168/// for a namespaced reference). Part of the parsing API the gateway/cli use to
169/// validate a typed tag, alongside the other `reference` entry points.
170pub fn is_ollama_tag_shaped(reference: &str) -> bool {
171    shaped(reference, true)
172}
173
174fn shaped(reference: &str, require_explicit_tag_for_namespaced: bool) -> bool {
175    if reference.is_empty()
176        || reference.chars().any(char::is_whitespace)
177        || reference.contains("://")
178    {
179        return false;
180    }
181    let components: Vec<&str> = reference.split('/').collect();
182    if components.len() > 2 || components.iter().any(|component| component.is_empty()) {
183        return false;
184    }
185    let Some(name) = components.last() else {
186        return false;
187    };
188    let name_parts: Vec<&str> = name.split(':').collect();
189    if name_parts.len() > 2 || name_parts.iter().any(|part| part.is_empty()) {
190        return false;
191    }
192    if components.len() == 2 {
193        return !require_explicit_tag_for_namespaced || name.contains(':');
194    }
195    true
196}
197
198/// Trim, reject interior whitespace, drop an `http(s)://` scheme and any `?`/`#`
199/// query/fragment, and strip trailing slashes. `None` for an empty or whitespace-
200/// bearing input.
201fn cleaned(raw: &str) -> Option<String> {
202    let mut text = raw.trim().to_owned();
203    if text.is_empty() || text.chars().any(char::is_whitespace) {
204        return None;
205    }
206    // No `break`: the remaining text is re-tested against each scheme, so a
207    // stacked `https://http://…` prefix is fully stripped.
208    for scheme in ["https://", "http://"] {
209        if let Some(rest) = after_ascii_prefix(&text, scheme) {
210            text = rest.to_owned();
211        }
212    }
213    if let Some(stop) = text.find(['?', '#']) {
214        text.truncate(stop);
215    }
216    while text.ends_with('/') {
217        text.pop();
218    }
219    (!text.is_empty()).then_some(text)
220}
221
222/// Drop a leading known host prefix from `text` (case-insensitive), if present.
223fn stripped(text: &str, hosts: &[&str]) -> String {
224    hosts
225        .iter()
226        .find_map(|host| after_ascii_prefix(text, host))
227        .unwrap_or(text)
228        .to_owned()
229}
230
231/// `text` after `prefix`, matched without regard to ASCII case, or `None`
232/// when it does not begin so. Compared over the prefix's own bytes, so the
233/// cut is a character boundary whatever `text` holds.
234fn after_ascii_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
235    text.split_at_checked(prefix.len())
236        .filter(|(head, _)| head.eq_ignore_ascii_case(prefix))
237        .map(|(_, rest)| rest)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn a_prefix_is_matched_over_its_own_bytes() {
246        assert_eq!(after_ascii_prefix("HTTPS://a", "https://"), Some("a"));
247        assert_eq!(after_ascii_prefix("https://", "https://"), Some(""));
248        assert_eq!(after_ascii_prefix("http://a", "https://"), None);
249        // A Kelvin sign is three bytes that fold to one ASCII `k`; over its
250        // own bytes it is not a `k`, and the cut it would force is refused.
251        assert_eq!(after_ascii_prefix("\u{212A}x", "kx"), None);
252        assert_eq!(after_ascii_prefix("\u{212A}", "k"), None);
253    }
254
255    #[test]
256    fn tag_shape_requires_a_version_when_namespaced() {
257        assert!(is_ollama_tag_shaped("llama3"));
258        assert!(is_ollama_tag_shaped("llama3:8b"));
259        assert!(is_ollama_tag_shaped("org/model:tag"));
260        // Namespaced without a version → not shaped.
261        assert!(!is_ollama_tag_shaped("org/model"));
262        // Whitespace / too many segments / empty name-parts → not shaped.
263        assert!(!is_ollama_tag_shaped("a b"));
264        assert!(!is_ollama_tag_shaped("a/b/c"));
265        assert!(!is_ollama_tag_shaped("a:b:c"));
266        assert!(!is_ollama_tag_shaped("model:"));
267        assert!(!is_ollama_tag_shaped(""));
268    }
269
270    #[test]
271    fn cleaned_strips_stacked_schemes() {
272        // No `break` in the scheme loop: a stacked prefix is fully removed.
273        assert_eq!(cleaned("https://http://foo").as_deref(), Some("foo"));
274    }
275}