Skip to main content

cherenkov/model/index/
reference.rs

1//! Catalog lookup accepts aliases and source URIs without dropping source identity.
2
3use super::{
4    Catalog, ModelEntry, Source, disk,
5    selector::{self, Selector},
6};
7use anyhow::{Context, Result, ensure};
8use std::path::Path;
9
10/// Resolve exactly one catalog entry; never fall back from an ambiguous source.
11pub(super) fn lookup<'a>(catalog: &'a Catalog, reference: &str) -> Result<&'a ModelEntry> {
12    let selector = selector::parse(Path::new(reference))?;
13
14    find(catalog, &selector, reference)?
15        .with_context(|| format!("model {reference:?} is not registered"))
16}
17
18/// Lookup may report absence so the resolver can register explicit sources.
19/// Store-qualified references inspect catalog metadata, not live store contents.
20pub(super) fn find<'a>(
21    catalog: &'a Catalog,
22    selector: &Selector,
23    reference: &str,
24) -> Result<Option<&'a ModelEntry>> {
25    if let Selector::Registered(key) = selector {
26        return Ok(catalog.models.get(key).or_else(|| {
27            catalog
28                .models
29                .values()
30                .find(|model| model.name.as_deref() == Some(key.as_str()))
31        }));
32    }
33
34    if let Selector::Disk { store, .. } = selector {
35        disk::registered(catalog, store)?;
36    }
37
38    let mut matches = catalog
39        .models
40        .values()
41        .filter(|model| matches_selector(catalog, &model.source, selector));
42    let first = matches.next();
43
44    ensure!(
45        matches.next().is_none(),
46        "model reference {reference:?} is ambiguous; append @revision or use an alias or model ID"
47    );
48
49    Ok(first)
50}
51
52/// Prefer an alias or unqualified source URI; add revision detail only when needed.
53pub(super) fn preferred(catalog: &Catalog, model: &ModelEntry) -> String {
54    if let Some(name) = &model.name {
55        return name.clone();
56    }
57
58    let (reference, revision) = display_source(catalog, &model.source);
59
60    if selects(catalog, &reference, &model.id) {
61        return reference;
62    }
63
64    if let Some(revision) = revision
65        && is_revision(&revision)
66    {
67        for length in 8..=revision.len() {
68            let qualified = format!("{reference}@{}", &revision[..length]);
69
70            if selects(catalog, &qualified, &model.id) {
71                return qualified;
72            }
73        }
74    }
75
76    model.id.clone()
77}
78
79/// Choose a concise source locator, keeping revision detail for disambiguation.
80fn display_source(catalog: &Catalog, source: &Source) -> (String, Option<String>) {
81    match source {
82        Source::HuggingFace { repo, revision, .. } => {
83            (format!("hf://{repo}"), Some(revision.clone()))
84        }
85        Source::Local { path, fingerprint } => {
86            for store in catalog.stores.values().filter(|store| store.enabled) {
87                if let Some(Selector::Disk {
88                    store,
89                    repo,
90                    revision,
91                }) = store.selector(path)
92                {
93                    return (
94                        format!("disk://{store}/{repo}"),
95                        Some(revision.unwrap_or_else(|| fingerprint.clone())),
96                    );
97                }
98            }
99
100            (path.to_string_lossy().into_owned(), None)
101        }
102    }
103}
104
105/// Confirm that a display selector returns this entry, including alias precedence.
106fn selects(catalog: &Catalog, reference: &str, id: &str) -> bool {
107    lookup(catalog, reference).is_ok_and(|model| model.id == id)
108}
109
110/// Match an exact source namespace and optional immutable revision prefix.
111fn matches_selector(catalog: &Catalog, source: &Source, selector: &Selector) -> bool {
112    match (source, selector) {
113        (
114            Source::HuggingFace { repo, revision, .. },
115            Selector::Hub {
116                repo: wanted,
117                revision: rev,
118            },
119        ) => {
120            repo == wanted
121                && rev
122                    .as_deref()
123                    .is_none_or(|r| is_revision(r) && revision.starts_with(r))
124        }
125        (Source::Local { path, .. }, Selector::Path(wanted)) => {
126            let absolute = crate::config::absolute(wanted).ok();
127
128            absolute.is_some_and(|p| path == &p.canonicalize().unwrap_or(p))
129        }
130        (
131            Source::Local { path, fingerprint },
132            Selector::Disk {
133                store,
134                repo,
135                revision,
136            },
137        ) => {
138            let Some(store) = catalog.stores.get(store) else {
139                return false;
140            };
141            let Some(Selector::Disk {
142                repo: found,
143                revision: commit,
144                ..
145            }) = store.selector(path)
146            else {
147                return false;
148            };
149
150            found == *repo
151                && revision.as_deref().is_none_or(|r| {
152                    is_revision(r) && commit.as_deref().unwrap_or(fingerprint).starts_with(r)
153                })
154        }
155        (
156            Source::Local { path, fingerprint },
157            Selector::LocalRevision {
158                path: wanted,
159                revision,
160            },
161        ) => path == wanted && fingerprint.starts_with(revision),
162        _ => false,
163    }
164}
165
166/// Immutable revision prefixes use at least eight hexadecimal digits.
167pub(super) fn is_revision(value: &str) -> bool {
168    value.len() >= 8 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
169}