#[cfg(not(target_family = "wasm"))]
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use bytes::Bytes;
use futures::stream::Stream;
use serde::{Deserialize, Serialize};
#[allow(unused_imports)] use super::HFRepository;
use crate::constants;
use crate::error::HFResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobLfsInfo {
pub size: Option<u64>,
pub sha256: Option<String>,
pub pointer_size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastCommitInfo {
pub id: Option<String>,
pub title: Option<String>,
pub date: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlobSecurityInfo {
pub status: String,
#[serde(default)]
pub av_scan: Option<serde_json::Value>,
#[serde(default)]
pub pickle_import_scan: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadataInfo {
pub filename: String,
pub etag: String,
pub commit_hash: String,
pub xet_hash: Option<String>,
pub file_size: u64,
pub location: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum RepoTreeEntry {
File {
oid: String,
size: u64,
path: String,
lfs: Option<BlobLfsInfo>,
#[serde(default, rename = "lastCommit")]
last_commit: Option<LastCommitInfo>,
#[serde(default, rename = "xetHash")]
xet_hash: Option<String>,
#[serde(default, rename = "securityFileStatus")]
security: Option<BlobSecurityInfo>,
},
Directory {
oid: String,
path: String,
#[serde(default, rename = "lastCommit")]
last_commit: Option<LastCommitInfo>,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitInfo {
#[serde(default)]
pub commit_url: Option<String>,
#[serde(default)]
pub commit_message: Option<String>,
#[serde(default)]
pub commit_description: Option<String>,
#[serde(default)]
pub commit_oid: Option<String>,
#[serde(default)]
pub pr_url: Option<String>,
#[serde(default)]
pub pr_num: Option<u64>,
}
#[derive(Debug, Clone)]
pub enum CommitOperation {
Add {
path_in_repo: String,
source: AddSource,
},
Delete {
path_in_repo: String,
},
}
impl CommitOperation {
#[cfg(not(target_family = "wasm"))]
pub fn add_file(path_in_repo: impl Into<String>, source: impl Into<PathBuf>) -> Self {
CommitOperation::Add {
path_in_repo: path_in_repo.into(),
source: AddSource::file(source),
}
}
pub fn add_bytes(path_in_repo: impl Into<String>, source: impl Into<Bytes>) -> Self {
CommitOperation::Add {
path_in_repo: path_in_repo.into(),
source: AddSource::bytes(source),
}
}
pub fn delete(path_in_repo: impl Into<String>) -> Self {
CommitOperation::Delete {
path_in_repo: path_in_repo.into(),
}
}
}
#[cfg(not(target_family = "wasm"))]
pub type SourceByteStream = Pin<Box<dyn Stream<Item = HFResult<Bytes>> + Send>>;
#[cfg(target_family = "wasm")]
pub type SourceByteStream = Pin<Box<dyn Stream<Item = HFResult<Bytes>>>>;
pub trait StreamFactory: Send + Sync {
fn open(&self) -> SourceByteStream;
}
#[derive(Clone)]
pub struct StreamSource {
factory: Arc<dyn StreamFactory>,
size: u64,
}
impl StreamSource {
pub fn new(factory: Arc<dyn StreamFactory>, size: u64) -> Self {
Self { factory, size }
}
pub fn size(&self) -> u64 {
self.size
}
pub fn open(&self) -> SourceByteStream {
self.factory.open()
}
}
impl std::fmt::Debug for StreamSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StreamSource").field("size", &self.size).finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub enum AddSource {
#[cfg(not(target_family = "wasm"))]
File(PathBuf),
Bytes(Bytes),
Stream(StreamSource),
}
impl AddSource {
#[cfg(not(target_family = "wasm"))]
pub fn file(path: impl Into<PathBuf>) -> Self {
AddSource::File(path.into())
}
pub fn bytes(bytes: impl Into<Bytes>) -> Self {
AddSource::Bytes(bytes.into())
}
pub fn stream(source: StreamSource) -> Self {
AddSource::Stream(source)
}
}
pub(super) fn extract_etag(response: &reqwest::Response) -> Option<String> {
let headers = response.headers();
let raw = headers
.get(constants::HEADER_X_LINKED_ETAG)
.or_else(|| headers.get(reqwest::header::ETAG))
.and_then(|v| v.to_str().ok())?;
let normalized = raw.strip_prefix("W/").unwrap_or(raw);
Some(normalized.trim_matches('"').to_string())
}
pub(super) fn extract_commit_hash(response: &reqwest::Response) -> Option<String> {
response
.headers()
.get(constants::HEADER_X_REPO_COMMIT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
pub(crate) fn extract_file_size(response: &reqwest::Response) -> Option<u64> {
let headers = response.headers();
headers
.get(constants::HEADER_X_LINKED_SIZE)
.or_else(|| headers.get(reqwest::header::CONTENT_LENGTH))
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
}
pub(crate) fn extract_xet_hash(response: &reqwest::Response) -> Option<String> {
response
.headers()
.get(constants::HEADER_X_XET_HASH)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
pub(super) fn matches_any_glob(patterns: &[String], path: &str) -> bool {
patterns.iter().any(|p| {
globset::Glob::new(p)
.ok()
.map(|g| g.compile_matcher().is_match(path))
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::{BlobSecurityInfo, CommitInfo, FileMetadataInfo, RepoTreeEntry};
#[test]
fn test_repo_tree_entry_deserialize_file() {
let json = r#"{"type":"file","oid":"abc123","size":100,"path":"test.txt"}"#;
let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
match entry {
RepoTreeEntry::File {
path,
size,
xet_hash,
security,
..
} => {
assert_eq!(path, "test.txt");
assert_eq!(size, 100);
assert!(xet_hash.is_none());
assert!(security.is_none());
},
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_repo_tree_entry_file_expanded() {
let json = r#"{
"type":"file","oid":"abc123","size":100,"path":"weights.safetensors",
"xetHash":"xet-deadbeef",
"securityFileStatus":{"status":"safe","avScan":{"virusFound":false},"pickleImportScan":null},
"lastCommit":{"id":"sha","title":"t","date":"2025-01-01T00:00:00Z"}
}"#;
let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
match entry {
RepoTreeEntry::File {
xet_hash,
security,
last_commit,
..
} => {
assert_eq!(xet_hash.as_deref(), Some("xet-deadbeef"));
let security = security.unwrap();
assert_eq!(security.status, "safe");
assert!(security.av_scan.is_some());
assert!(security.pickle_import_scan.is_none());
assert!(last_commit.is_some());
},
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_repo_tree_entry_directory_with_last_commit() {
let json = r#"{"type":"directory","oid":"def456","path":"src","lastCommit":{"id":"sha","title":"t","date":"2025-01-01T00:00:00Z"}}"#;
let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
match entry {
RepoTreeEntry::Directory { path, last_commit, .. } => {
assert_eq!(path, "src");
assert!(last_commit.is_some());
},
_ => panic!("Expected Directory variant"),
}
}
#[test]
fn test_repo_tree_entry_deserialize_directory() {
let json = r#"{"type":"directory","oid":"def456","path":"src"}"#;
let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
match entry {
RepoTreeEntry::Directory { path, last_commit, .. } => {
assert_eq!(path, "src");
assert!(last_commit.is_none());
},
_ => panic!("Expected Directory variant"),
}
}
#[test]
fn test_blob_security_info_unsafe_status() {
let json = r#"{"status":"suspicious","avScan":null,"pickleImportScan":{"matches":[]}}"#;
let info: BlobSecurityInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.status, "suspicious");
assert!(info.av_scan.is_none());
assert!(info.pickle_import_scan.is_some());
}
#[test]
fn test_commit_info_with_pr() {
let json = r#"{
"commitUrl":"https://huggingface.co/owner/repo/commit/abc123",
"commitOid":"abc123",
"prUrl":"https://huggingface.co/owner/repo/discussions/7",
"prNum":7
}"#;
let info: CommitInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.commit_oid.as_deref(), Some("abc123"));
assert_eq!(info.pr_url.as_deref(), Some("https://huggingface.co/owner/repo/discussions/7"));
assert_eq!(info.pr_num, Some(7));
}
#[test]
fn test_commit_info_no_pr() {
let json = r#"{"commitUrl":"https://huggingface.co/owner/repo/commit/abc","commitOid":"abc"}"#;
let info: CommitInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.commit_url.as_deref(), Some("https://huggingface.co/owner/repo/commit/abc"));
assert!(info.pr_url.is_none());
assert!(info.pr_num.is_none());
}
#[test]
fn test_file_metadata_info_location_round_trip() {
let original = FileMetadataInfo {
filename: "config.json".into(),
etag: "abc".into(),
commit_hash: "deadbeef".into(),
xet_hash: None,
file_size: 100,
location: Some("https://cdn-lfs.huggingface.co/repos/.../config.json".into()),
};
let json = serde_json::to_string(&original).unwrap();
assert!(json.contains("location"));
let round_tripped: FileMetadataInfo = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped.location, original.location);
}
#[test]
fn test_file_metadata_info_location_optional() {
let json = r#"{"filename":"f","etag":"e","commit_hash":"c","xet_hash":null,"file_size":0}"#;
let info: FileMetadataInfo = serde_json::from_str(json).unwrap();
assert!(info.location.is_none());
}
}