Skip to main content

cherenkov/model/index/
selector.rs

1//! Parse locator syntax once, then map its structured fields through the index.
2
3use anyhow::{Context, Result, ensure};
4use reqwest::Url;
5use std::path::{Path, PathBuf};
6
7/// Parsed input; each variant selects an index lookup or a source adapter.
8#[derive(Debug, PartialEq, Eq)]
9pub(super) enum Selector {
10    Registered(String),
11    Hub {
12        repo: String,
13        revision: Option<String>,
14    },
15    Disk {
16        store: String,
17        repo: String,
18        revision: Option<String>,
19    },
20    Path(PathBuf),
21    LocalRevision {
22        path: PathBuf,
23        revision: String,
24    },
25}
26
27/// Explicit paths take precedence; all URI forms go through the URL parser.
28pub(super) fn parse(input: &Path) -> Result<Selector> {
29    let Some(text) = input.to_str() else {
30        return Ok(Selector::Path(input.to_owned()));
31    };
32
33    ensure!(!text.is_empty(), "empty model reference");
34
35    if input.is_absolute()
36        || matches!(text, "." | "..")
37        || text.starts_with("./")
38        || text.starts_with("../")
39    {
40        return Ok(Selector::Path(input.to_owned()));
41    }
42
43    let mut components = input.components();
44
45    if components
46        .next()
47        .is_some_and(|part| part.as_os_str() == "~")
48    {
49        return Ok(Selector::Path(
50            dirs::home_dir()
51                .context("home directory unavailable")?
52                .join(components.as_path()),
53        ));
54    }
55
56    let uri = match Url::parse(text) {
57        Ok(uri) => uri,
58        Err(_) if !text.contains(':') => {
59            return Ok(if text.contains('/') {
60                Selector::Path(input.to_owned())
61            } else {
62                Selector::Registered(text.to_owned())
63            });
64        }
65        Err(error) => return Err(error).context("invalid model locator"),
66    };
67
68    ensure!(
69        uri.query().is_none() && uri.fragment().is_none(),
70        "model locators do not accept queries or fragments"
71    );
72
73    match uri.scheme() {
74        "model" => {
75            ensure!(
76                uri.cannot_be_a_base(),
77                "use model:alias for legacy references"
78            );
79
80            legacy(uri.path())
81        }
82        "hf" | "disk" => source(&uri, text),
83        _ => anyhow::bail!("unsupported model source scheme {:?}", uri.scheme()),
84    }
85}
86
87/// Map a parsed URI authority and repository path into a source selector.
88fn source(uri: &Url, original: &str) -> Result<Selector> {
89    ensure!(
90        uri.username().is_empty() && uri.password().is_none() && uri.port().is_none(),
91        "model source authority cannot contain credentials or a port"
92    );
93    ensure!(
94        !original.contains('%')
95            && !uri.as_str().contains('%')
96            && !original.split('/').any(|part| matches!(part, "." | "..")),
97        "model source paths must use literal names without dot segments"
98    );
99
100    let authority = uri
101        .host_str()
102        .context("model source requires an authority")?;
103
104    ensure!(
105        crate::storage::safe_component(authority),
106        "invalid source authority"
107    );
108
109    let segments: Vec<_> = uri
110        .path_segments()
111        .context("model source requires a repository path")?
112        .collect();
113    let location = segments.join("/");
114    let (repository, revision) = revision(&location)?;
115
116    match uri.scheme() {
117        "hf" => {
118            ensure!(
119                crate::storage::safe_component(repository),
120                "use hf://owner/repo[@revision]"
121            );
122
123            Ok(Selector::Hub {
124                repo: format!("{authority}/{repository}"),
125                revision,
126            })
127        }
128        "disk" => {
129            let (owner, name) = repository
130                .split_once('/')
131                .context("use disk://store/owner/repo[@revision]")?;
132
133            ensure!(
134                crate::storage::safe_component(owner) && crate::storage::safe_component(name),
135                "use disk://store/owner/repo[@revision]"
136            );
137
138            Ok(Selector::Disk {
139                store: authority.to_owned(),
140                repo: repository.to_owned(),
141                revision,
142            })
143        }
144        _ => unreachable!(),
145    }
146}
147
148/// Separate the optional revision delimiter from the repository path.
149fn revision(location: &str) -> Result<(&str, Option<String>)> {
150    let Some((repository, revision)) = location.split_once('@') else {
151        return Ok((location, None));
152    };
153
154    validate_revision(revision)?;
155
156    Ok((repository, Some(revision.to_owned())))
157}
158
159/// Apply the same literal revision rules to URI suffixes and option overrides.
160pub(super) fn validate_revision(revision: &str) -> Result<()> {
161    ensure!(
162        revision.split('/').all(crate::storage::safe_component),
163        "invalid model revision"
164    );
165
166    Ok(())
167}
168
169/// Map older opaque model: locators onto the same structured selectors.
170fn legacy(key: &str) -> Result<Selector> {
171    ensure!(!key.is_empty(), "empty model reference");
172
173    let Some((kind, location)) = key.split_once(':') else {
174        return Ok(Selector::Registered(key.to_owned()));
175    };
176    let (location, revision) = location
177        .rsplit_once(':')
178        .context("legacy source reference needs a revision")?;
179
180    ensure!(
181        super::reference::is_revision(revision),
182        "invalid legacy revision"
183    );
184
185    match kind {
186        "hf" => {
187            let (owner, repo) = location
188                .split_once('/')
189                .context("HF source requires owner/repo")?;
190
191            ensure!(
192                crate::storage::safe_component(owner) && crate::storage::safe_component(repo),
193                "invalid HF repository"
194            );
195
196            Ok(Selector::Hub {
197                repo: location.to_owned(),
198                revision: Some(revision.to_owned()),
199            })
200        }
201        "local" => {
202            ensure!(
203                Path::new(location).is_absolute(),
204                "legacy local reference requires an absolute path"
205            );
206
207            Ok(Selector::LocalRevision {
208                path: PathBuf::from(location),
209                revision: revision.to_owned(),
210            })
211        }
212        _ => anyhow::bail!("unknown legacy source kind"),
213    }
214}
215
216#[cfg(test)]
217#[path = "../../../tests/unit/model/index/selector.rs"]
218mod tests;