use crate::error::LlamaError;
const MAX_REPO_ID_LEN: usize = 128;
const FORBIDDEN_FIRST_SEGMENT: &[&str] = &["models", "model"];
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HfRepo(String);
impl HfRepo {
pub fn new(s: &str) -> Result<Self, LlamaError> {
Self::validate(s)?;
Ok(Self(s.to_owned()))
}
#[must_use]
pub fn looks_like_repo_id(s: &str) -> bool {
Self::validate(s).is_ok()
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn repo_id(&self) -> &str {
&self.0
}
fn validate(s: &str) -> Result<(), LlamaError> {
if s.is_empty() {
return Err(invalid("repo id is empty"));
}
if s.len() > MAX_REPO_ID_LEN {
return Err(invalid("repo id exceeds 128 characters"));
}
let mut iter = s.split('/');
let first = iter.next().unwrap_or("");
if first.is_empty() {
return Err(invalid("repo id has a leading or empty segment"));
}
if !is_valid_segment(first) {
return Err(invalid("repo id segment contains invalid characters"));
}
if is_forbidden_first_segment(first) {
return Err(invalid(
"repo id first segment is a reserved local-path word",
));
}
match iter.next() {
None => Ok(()),
Some(second) => {
if second.is_empty() {
return Err(invalid("repo id has a trailing or double slash"));
}
if !is_valid_segment(second) {
return Err(invalid("repo id segment contains invalid characters"));
}
if iter.next().is_some() {
return Err(invalid("repo id has more than two segments"));
}
Ok(())
}
}
}
}
fn invalid(msg: &str) -> LlamaError {
LlamaError::ModelLoad(format!("invalid HF repo id: {msg}"))
}
fn is_valid_segment(seg: &str) -> bool {
seg.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}
fn is_forbidden_first_segment(seg: &str) -> bool {
FORBIDDEN_FIRST_SEGMENT
.iter()
.any(|bad| seg.eq_ignore_ascii_case(bad))
}
#[cfg(test)]
mod tests {
use super::HfRepo;
#[test]
fn accepts_two_segment() {
assert!(HfRepo::new("TheBloke/Llama-2-7B-Chat-GGUF").is_ok());
}
#[test]
fn accepts_single_segment() {
assert!(HfRepo::new("gpt2").is_ok());
}
#[test]
fn accepts_dots_and_underscores_and_dashes() {
assert!(HfRepo::new("user.name_with-dots/and-dashes_etc").is_ok());
}
#[test]
fn rejects_empty() {
assert!(HfRepo::new("").is_err());
}
#[test]
fn rejects_trailing_slash() {
assert!(HfRepo::new("TheBloke/").is_err());
}
#[test]
fn rejects_leading_slash() {
assert!(HfRepo::new("/TheBloke/Llama").is_err());
}
#[test]
fn rejects_relative_prefix() {
assert!(HfRepo::new("./TheBloke/Llama").is_err());
}
#[test]
fn rejects_tilde() {
assert!(HfRepo::new("~/models/foo").is_err());
}
#[test]
fn rejects_drive_letter() {
assert!(HfRepo::new("C:/foo/bar").is_err());
}
#[test]
fn rejects_url() {
assert!(HfRepo::new("https://huggingface.co/TheBloke/Llama").is_err());
}
#[test]
fn rejects_no_slash() {
assert!(HfRepo::new("models/foo").is_err());
}
#[test]
fn rejects_double_slash() {
assert!(HfRepo::new("TheBloke//Llama").is_err());
}
#[test]
fn rejects_three_segments() {
assert!(HfRepo::new("TheBloke/Llama/file.gguf").is_err());
}
#[test]
fn looks_like_repo_id_matches_new() {
for ok in [
"gpt2",
"TheBloke/Llama-2-7B-Chat-GGUF",
"user.name/repo_1-2",
] {
assert_eq!(
HfRepo::looks_like_repo_id(ok),
HfRepo::new(ok).is_ok(),
"input: {ok}"
);
}
for bad in [
"",
"/x",
"x/",
"a//b",
"a/b/c",
"https://huggingface.co/x/y",
] {
assert!(!HfRepo::looks_like_repo_id(bad), "input: {bad}");
}
}
#[test]
fn returns_inner_string_unchanged() {
let repo = HfRepo::new("TheBloke/Foo").expect("valid");
assert_eq!(repo.repo_id(), "TheBloke/Foo");
}
}