use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::RegistryError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SourceKind {
HuggingFace,
Local,
}
impl fmt::Display for SourceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::HuggingFace => "huggingface",
Self::Local => "local",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModelId {
pub source: SourceKind,
pub repo: String,
pub path: Option<Vec<String>>,
}
impl ModelId {
pub fn parse(s: &str) -> Result<Self, RegistryError> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(RegistryError::Parse("empty id".into()));
}
if trimmed.starts_with('/') || trimmed.starts_with("./") || trimmed.starts_with("../") {
return Ok(Self {
source: SourceKind::Local,
repo: trimmed.to_string(),
path: None,
});
}
let parts: Vec<&str> = trimmed.split('/').collect();
match parts.len() {
2 => {
let org = parts[0];
let repo = parts[1];
if org.is_empty() || repo.is_empty() {
return Err(RegistryError::Parse(format!(
"HF id must have non-empty org and repo: {trimmed:?}"
)));
}
if org.contains(' ') || repo.contains(' ') {
return Err(RegistryError::Parse(format!(
"HF id segments must not contain spaces: {trimmed:?}"
)));
}
Ok(Self {
source: SourceKind::HuggingFace,
repo: format!("{org}/{repo}"),
path: None,
})
}
3 => {
let org = parts[0];
let repo = parts[1];
let file = parts[2];
if org.is_empty() || repo.is_empty() || file.is_empty() {
return Err(RegistryError::Parse(format!(
"HF single-file id must have three non-empty segments: {trimmed:?}"
)));
}
if file.contains(' ') {
return Err(RegistryError::Parse(format!(
"HF single-file file segment must not contain spaces: {trimmed:?}"
)));
}
if file == "."
|| file == ".."
|| file.contains('/')
|| file.contains('\\')
|| file.contains('\0')
{
return Err(RegistryError::Parse(format!(
"HF single-file file segment must be a bare filename \
(no traversal, no separators, no NULs): {trimmed:?}"
)));
}
Ok(Self {
source: SourceKind::HuggingFace,
repo: format!("{org}/{repo}"),
path: Some(vec![file.to_string()]),
})
}
n => Err(RegistryError::Parse(format!(
"HF id must have 2 or 3 segments, got {n}: {trimmed:?}"
))),
}
}
pub fn canonical(&self) -> String {
match (&self.source, &self.path) {
(SourceKind::HuggingFace, None) => self.repo.clone(),
(SourceKind::HuggingFace, Some(p)) if !p.is_empty() => {
format!("{}/{}", self.repo, p.join("/"))
}
(SourceKind::Local, _) => self.repo.clone(),
(SourceKind::HuggingFace, Some(p)) => {
debug_assert!(!p.is_empty(), "ModelId::path should not be empty");
self.repo.clone()
}
}
}
}
impl fmt::Display for ModelId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.canonical())
}
}
impl FromStr for ModelId {
type Err = RegistryError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_two_segment_hf() {
let id = ModelId::parse("Qwen/Qwen3-ASR-0.6B").unwrap();
assert_eq!(id.source, SourceKind::HuggingFace);
assert_eq!(id.repo, "Qwen/Qwen3-ASR-0.6B");
assert!(id.path.is_none());
}
#[test]
fn parse_three_segment_hf() {
let id = ModelId::parse("ggerganov/whisper.cpp/ggml-large-v3.bin").unwrap();
assert_eq!(id.source, SourceKind::HuggingFace);
assert_eq!(id.repo, "ggerganov/whisper.cpp");
assert_eq!(id.path, Some(vec!["ggml-large-v3.bin".to_string()]));
}
#[test]
fn parse_local_absolute() {
let id = ModelId::parse("/cache/models/qwen").unwrap();
assert_eq!(id.source, SourceKind::Local);
assert_eq!(id.repo, "/cache/models/qwen");
}
#[test]
fn parse_local_relative() {
let id = ModelId::parse("./local-model").unwrap();
assert_eq!(id.source, SourceKind::Local);
}
#[test]
fn parse_rejects_too_many_segments() {
assert!(ModelId::parse("a/b/c/d").is_err());
}
#[test]
fn parse_rejects_empty_segments() {
assert!(ModelId::parse("org//file").is_err());
}
#[test]
fn parse_rejects_empty_input() {
assert!(ModelId::parse("").is_err());
assert!(ModelId::parse(" ").is_err());
}
#[test]
fn canonical_round_trips() {
for s in [
"Qwen/Qwen3-ASR-0.6B",
"ggerganov/whisper.cpp/ggml-large-v3.bin",
"/cache/models/qwen",
"./local-model",
] {
let id = ModelId::parse(s).unwrap();
assert_eq!(id.canonical(), s, "round-trip for {s:?}");
}
}
#[test]
fn parse_rejects_traversal_segment() {
for bad in [
"foo/bar/..",
"foo/bar/.",
"foo/bar/foo\\bar",
"foo/bar/with\0null",
] {
let err = ModelId::parse(bad).expect_err(&format!(
"traversal/separator segment must be rejected: {bad:?}"
));
match err {
RegistryError::Parse(_) => {}
other => panic!("expected Parse, got {other:?}"),
}
}
}
#[test]
fn parse_accepts_dotted_filename() {
let id = ModelId::parse("foo/bar/.hidden").expect("dotfile accepted");
assert_eq!(id.repo, "foo/bar");
assert_eq!(id.path, Some(vec![".hidden".to_string()]));
}
}