use std::path::{Path, PathBuf};
pub fn hub_dir() -> PathBuf {
absolute(resolve(&env, home_dir().as_deref()))
}
pub fn hf_home() -> PathBuf {
absolute(resolve_home(&env, home_dir().as_deref()))
}
pub fn repo_dir(repo_id: &str) -> PathBuf {
repo_dir_in(&hub_dir(), repo_id)
}
pub fn repo_dir_in(hub: &Path, repo_id: &str) -> PathBuf {
hub.join(format!("models--{}", repo_id.replace('/', "--")))
}
pub fn checked_repo_dir_in(hub: &Path, repo_id: &str) -> Option<PathBuf> {
let part_ok = |p: &str| {
!p.is_empty()
&& p != "."
&& p != ".."
&& p.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
};
let mut parts = repo_id.split('/');
let ok = match (parts.next(), parts.next(), parts.next()) {
(Some(name), None, None) => part_ok(name),
(Some(owner), Some(name), None) => part_ok(owner) && part_ok(name),
_ => false,
};
ok.then(|| repo_dir_in(hub, repo_id))
}
pub fn api() -> hf_hub::api::tokio::ApiBuilder {
let mut builder = hf_hub::api::tokio::ApiBuilder::from_cache(hf_hub::Cache::new(hub_dir()))
.with_token(token());
if let Some(endpoint) = env("HF_ENDPOINT") {
builder = builder.with_endpoint(endpoint);
}
builder
}
pub fn apply_to(cmd: &mut tokio::process::Command) {
cmd.env("HF_HUB_CACHE", hub_dir());
}
pub fn apply_to_std(cmd: &mut std::process::Command) {
cmd.env("HF_HUB_CACHE", hub_dir());
}
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn token() -> Option<String> {
if let Some(token) = env("HF_TOKEN") {
return Some(token);
}
let path = match env("HF_TOKEN_PATH") {
Some(path) => absolute(expand(&path, &env, home_dir().as_deref())),
None => hf_home().join("token"),
};
std::fs::read_to_string(path)
.ok()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
}
fn home_dir() -> Option<PathBuf> {
let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
env(var)
.map(PathBuf::from)
.or_else(std::env::home_dir)
.filter(|home| home.is_absolute())
}
fn absolute(path: PathBuf) -> PathBuf {
if path.is_absolute() {
return path;
}
std::path::absolute(&path).unwrap_or(path)
}
fn resolve(env: &dyn Fn(&str) -> Option<String>, home: Option<&Path>) -> PathBuf {
for name in ["HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"] {
if let Some(value) = env(name) {
return expand(&value, env, home);
}
}
resolve_home(env, home).join("hub")
}
fn resolve_home(env: &dyn Fn(&str) -> Option<String>, home: Option<&Path>) -> PathBuf {
if let Some(value) = env("HF_HOME") {
return expand(&value, env, home);
}
let cache = match env("XDG_CACHE_HOME") {
Some(xdg) => PathBuf::from(xdg),
None => home.unwrap_or(Path::new(".")).join(".cache"),
};
expand(&cache.join("huggingface").to_string_lossy(), env, home)
}
fn expand(value: &str, env: &dyn Fn(&str) -> Option<String>, home: Option<&Path>) -> PathBuf {
let tilde = match (value.strip_prefix('~'), home) {
(Some(tail), Some(home))
if tail.is_empty()
|| tail.starts_with('/')
|| (cfg!(windows) && tail.starts_with('\\')) =>
{
home.join(tail.trim_start_matches(['/', '\\']))
.to_string_lossy()
.into_owned()
}
_ => value.to_string(),
};
let dollar = expand_dollar(&tilde, env);
PathBuf::from(if cfg!(windows) {
expand_percent(&dollar, env)
} else {
dollar
})
}
fn expand_dollar(value: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(i) = rest.find('$') {
out.push_str(&rest[..i]);
let after = &rest[i + 1..];
let (name, consumed) = match after.strip_prefix('{') {
Some(braced) => match braced.find('}') {
Some(end) => (&braced[..end], end + 2),
None => ("", 0),
},
None => {
let end = after
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(after.len());
(&after[..end], end)
}
};
match env(name).filter(|_| !name.is_empty()) {
Some(v) => out.push_str(&v),
None => out.push_str(&rest[i..i + 1 + consumed]),
}
rest = &rest[i + 1 + consumed..];
}
out.push_str(rest);
out
}
fn expand_percent(value: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(i) = rest.find('%') {
out.push_str(&rest[..i]);
let after = &rest[i + 1..];
match after.find('%') {
Some(end) if end > 0 => {
let name = &after[..end];
match env(name) {
Some(v) => out.push_str(&v),
None => out.push_str(&rest[i..i + end + 2]),
}
rest = &after[end + 1..];
}
_ => {
out.push('%');
rest = after;
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn with(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = vars
.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |name| map.get(name).cloned()
}
const HOME: &str = "/Users/someone";
fn hub(vars: &[(&str, &str)]) -> PathBuf {
resolve(&with(vars), Some(Path::new(HOME)))
}
fn home_of(vars: &[(&str, &str)]) -> PathBuf {
resolve_home(&with(vars), Some(Path::new(HOME)))
}
#[test]
fn resolves_the_hub_the_way_the_python_library_does() {
assert_eq!(
hub(&[]),
PathBuf::from("/Users/someone/.cache/huggingface/hub")
);
assert_eq!(
hub(&[("XDG_CACHE_HOME", "/xdg")]),
PathBuf::from("/xdg/huggingface/hub")
);
assert_eq!(
hub(&[("HF_HOME", "/hf"), ("XDG_CACHE_HOME", "/xdg")]),
PathBuf::from("/hf/hub")
);
assert_eq!(
hub(&[("HUGGINGFACE_HUB_CACHE", "/legacy"), ("HF_HOME", "/hf")]),
PathBuf::from("/legacy")
);
assert_eq!(
hub(&[
("HF_HUB_CACHE", "/hub"),
("HUGGINGFACE_HUB_CACHE", "/legacy"),
("HF_HOME", "/hf"),
]),
PathBuf::from("/hub")
);
assert_eq!(
home_of(&[("HF_HOME", "~/hf")]),
PathBuf::from("/Users/someone/hf")
);
assert_eq!(
home_of(&[("XDG_CACHE_HOME", "~/xdg")]),
PathBuf::from("/Users/someone/xdg/huggingface")
);
}
#[test]
fn empty_variables_count_as_unset() {
assert_eq!(
hub(&[("HF_HUB_CACHE", ""), ("HF_HOME", "/hf")]),
PathBuf::from("/hf/hub")
);
assert_eq!(
hub(&[("HF_HOME", "")]),
PathBuf::from("/Users/someone/.cache/huggingface/hub")
);
}
#[test]
fn expands_home_then_variables_as_python_does() {
assert_eq!(
hub(&[("HF_HOME", "~/hf")]),
PathBuf::from("/Users/someone/hf/hub")
);
assert_eq!(
hub(&[("HF_HUB_CACHE", "~")]),
PathBuf::from("/Users/someone")
);
assert_eq!(
hub(&[("HF_HUB_CACHE", "$ROOT/cache"), ("ROOT", "/data")]),
PathBuf::from("/data/cache")
);
assert_eq!(
hub(&[("HF_HUB_CACHE", "${ROOT}/cache"), ("ROOT", "/data")]),
PathBuf::from("/data/cache")
);
assert_eq!(
hub(&[("HF_HUB_CACHE", "$T/x"), ("T", "~")]),
PathBuf::from("~/x")
);
assert_eq!(
hub(&[("HF_HUB_CACHE", "$NOPE/c")]),
PathBuf::from("$NOPE/c")
);
assert_eq!(
hub(&[("HF_HUB_CACHE", "/a/${}/b")]),
PathBuf::from("/a/${}/b")
);
assert_eq!(hub(&[("HF_HUB_CACHE", "/a$")]), PathBuf::from("/a$"));
assert_eq!(
hub(&[("HF_HUB_CACHE", "/a/${OPEN")]),
PathBuf::from("/a/${OPEN")
);
assert_eq!(
hub(&[("HF_HUB_CACHE", "~root/x")]),
PathBuf::from("~root/x")
);
}
#[test]
fn percent_variables_expand_as_windows_does() {
let env = with(&[("LOCALAPPDATA", "C:\\Users\\me\\AppData\\Local")]);
assert_eq!(
expand_percent("%LOCALAPPDATA%\\hf", &env),
"C:\\Users\\me\\AppData\\Local\\hf"
);
assert_eq!(expand_percent("%NOPE%\\hf", &env), "%NOPE%\\hf");
assert_eq!(expand_percent("50%", &env), "50%");
}
#[test]
fn no_home_still_yields_an_absolute_hub() {
let relative = resolve(&with(&[]), None);
assert!(!relative.is_absolute(), "{relative:?}");
assert!(relative.ends_with(".cache/huggingface/hub"));
assert!(absolute(relative).is_absolute());
assert!(absolute(PathBuf::from("rel/hub")).is_absolute());
}
#[test]
fn a_repo_dir_is_the_hub_layout() {
assert_eq!(
repo_dir_in(Path::new("/h"), "mlx-community/Qwen3-4B-4bit"),
PathBuf::from("/h/models--mlx-community--Qwen3-4B-4bit")
);
}
#[test]
fn checked_repo_dirs_accept_only_hub_repo_ids() {
let hub = Path::new("/h");
assert_eq!(
checked_repo_dir_in(hub, "mlx-community/Qwen3-4B-4bit"),
Some(PathBuf::from("/h/models--mlx-community--Qwen3-4B-4bit"))
);
assert_eq!(
checked_repo_dir_in(hub, "gpt2"),
Some(PathBuf::from("/h/models--gpt2"))
);
for bad in [
"", "/", "a/", "/b", "a/b/c", "..", "a/..", ".", "a\\..\\x", "a b/c", "a/b:c",
] {
assert_eq!(checked_repo_dir_in(hub, bad), None, "{bad:?}");
}
}
}