Skip to main content

trustformers/hub_upload/
mod.rs

1//! Upload models and datasets to the HuggingFace Hub.
2//!
3//! `HubUploader` speaks the real Hub upload protocol (repo existence check,
4//! repo creation, and the NDJSON commit API) via `reqwest`, behind the
5//! `hub` feature — see `api` for the wire-level detail. Without a token,
6//! every operation fails fast with [`HubError::MissingCredentials`] /
7//! [`TrustformersError::Hub`] instead of proceeding. Without the `hub`
8//! feature (no networking compiled in), every operation fails with
9//! [`HubError::FeatureUnavailable`] instead of a fabricated success.
10//!
11//! An earlier revision of this module never contacted the Hub at all: every
12//! upload/create/delete method validated its inputs, logged
13//! `"(simulated)"`, and returned a synthetic [`UploadResult`] with a
14//! commit URL built from the all-zeros SHA `"0000...0000"` — indistinguishable
15//! from a real success to a caller that didn't read the log line. If a dry
16//! run — validate everything, touch no network — is what's wanted, set
17//! [`UploadConfig::dry_run`] explicitly; [`UploadResult::dry_run`] on the
18//! returned value says which one happened.
19
20mod api;
21
22use crate::error::{Result, TrustformersError};
23use api::{CommitFile, LFS_INLINE_THRESHOLD_BYTES};
24use std::path::{Path, PathBuf};
25use tracing::{debug, info, warn};
26
27const HF_HUB_URL: &str = "https://huggingface.co";
28
29/// Repository type on HuggingFace Hub
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum RepoType {
32    /// A model repository (default)
33    #[default]
34    Model,
35    /// A dataset repository
36    Dataset,
37    /// A Spaces application
38    Space,
39}
40
41impl RepoType {
42    /// Returns the string representation used in API calls
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            RepoType::Model => "model",
46            RepoType::Dataset => "dataset",
47            RepoType::Space => "space",
48        }
49    }
50}
51
52/// Configuration for uploading to HuggingFace Hub
53#[derive(Debug, Clone)]
54pub struct UploadConfig {
55    /// HuggingFace API token (required for upload)
56    pub token: String,
57    /// Repository ID in the format "username/model-name"
58    pub repo_id: String,
59    /// Repository type: Model, Dataset, or Space
60    pub repo_type: RepoType,
61    /// Branch/revision to upload to
62    pub revision: String,
63    /// Commit message for the upload
64    pub commit_message: String,
65    /// Whether to create the repository if it doesn't exist
66    pub create_if_missing: bool,
67    /// Whether the repository should be private
68    pub private: bool,
69    /// Base API endpoint. Defaults to the real Hugging Face Hub
70    /// (`https://huggingface.co`); override to point at a local mock server
71    /// in tests.
72    pub base_url: String,
73    /// When `true`, validate the token/repo id/files and report what *would*
74    /// be uploaded without making any network request.
75    /// [`UploadResult::dry_run`] is `true` on the result this produces.
76    pub dry_run: bool,
77}
78
79impl Default for UploadConfig {
80    fn default() -> Self {
81        Self {
82            token: String::new(),
83            repo_id: String::new(),
84            repo_type: RepoType::Model,
85            revision: "main".to_string(),
86            commit_message: "Upload via TrustformeRS".to_string(),
87            create_if_missing: true,
88            private: false,
89            base_url: HF_HUB_URL.to_string(),
90            dry_run: false,
91        }
92    }
93}
94
95/// Represents a single file to be uploaded
96#[derive(Debug, Clone)]
97pub struct UploadFile {
98    /// Local path to the file on disk
99    pub local_path: PathBuf,
100    /// Destination path within the repository (relative to repo root)
101    pub repo_path: String,
102}
103
104impl UploadFile {
105    /// Create a new UploadFile
106    pub fn new(local_path: impl Into<PathBuf>, repo_path: impl Into<String>) -> Self {
107        Self {
108            local_path: local_path.into(),
109            repo_path: repo_path.into(),
110        }
111    }
112}
113
114/// Result of an upload operation.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct UploadResult {
117    /// Repository ID where files were (or, in a dry run, would be) uploaded
118    pub repo_id: String,
119    /// Revision/branch that was (or would be) updated
120    pub revision: String,
121    /// URL to the commit on the Hub. `None` in a dry run, or if the server's
122    /// response didn't include one.
123    pub commit_url: Option<String>,
124    /// Commit SHA, when the server returned one.
125    pub commit_oid: Option<String>,
126    /// List of repo paths that were (or, in a dry run, would be) uploaded
127    pub files_uploaded: Vec<String>,
128    /// `true` if this result came from [`UploadConfig::dry_run`] rather than
129    /// an actual upload.
130    pub dry_run: bool,
131}
132
133/// Upload client for HuggingFace Hub
134pub struct HubUploader {
135    config: UploadConfig,
136}
137
138impl HubUploader {
139    /// Create a new uploader from config
140    pub fn new(config: UploadConfig) -> Self {
141        Self { config }
142    }
143
144    /// Validate the upload configuration.
145    ///
146    /// An empty token is a [`TrustformersError::Hub`] "missing credentials"
147    /// error, not a generic `InvalidInput` — callers can distinguish "you
148    /// never gave me a token" from "the server rejected your token" (the
149    /// latter surfaces as a `Hub` error from the network call itself).
150    pub fn validate(&self) -> Result<()> {
151        if self.config.token.is_empty() {
152            return Err(missing_credentials_error(&self.config.repo_id));
153        }
154        if self.config.repo_id.is_empty() {
155            return Err(TrustformersError::InvalidInput {
156                message: "Repository ID cannot be empty".to_string(),
157                parameter: Some("repo_id".to_string()),
158                expected: None,
159                received: None,
160                suggestion: None,
161            });
162        }
163        if !self.config.repo_id.contains('/') {
164            return Err(TrustformersError::InvalidInput {
165                message: "Repository ID must be in format 'username/repo-name'".to_string(),
166                parameter: Some("repo_id".to_string()),
167                expected: Some("username/repo-name".to_string()),
168                received: Some(self.config.repo_id.clone()),
169                suggestion: None,
170            });
171        }
172        if self.config.revision.is_empty() {
173            return Err(TrustformersError::InvalidInput {
174                message: "Revision/branch name cannot be empty".to_string(),
175                parameter: Some("revision".to_string()),
176                expected: None,
177                received: None,
178                suggestion: None,
179            });
180        }
181        Ok(())
182    }
183
184    /// Check whether the repository exists on the Hub
185    /// (`GET /api/{repo_type}s/{repo_id}`).
186    pub fn repo_exists(&self) -> Result<bool> {
187        self.validate()?;
188        if self.config.dry_run {
189            debug!(repo_id = %self.config.repo_id, "repo_exists: dry run, skipping network call");
190            return Ok(false);
191        }
192        api::run_blocking(api::repo_exists(
193            &self.config.base_url,
194            self.config.repo_type,
195            &self.config.repo_id,
196            &self.config.token,
197        ))
198        .map_err(|e| hub_error_to_trustformers(e, &self.config.repo_id))
199    }
200
201    /// Create a repository on the Hub (`POST /api/repos/create`).
202    ///
203    /// Returns the repository URL.
204    pub fn create_repo(&self) -> Result<String> {
205        self.validate()?;
206        if self.config.dry_run {
207            let url = format!("{}/{}", self.config.base_url, self.config.repo_id);
208            info!(repo_id = %self.config.repo_id, "create_repo: dry run, not contacting the Hub");
209            return Ok(url);
210        }
211        api::run_blocking(api::create_repo(
212            &self.config.base_url,
213            self.config.repo_type,
214            &self.config.repo_id,
215            self.config.private,
216            &self.config.token,
217        ))
218        .map_err(|e| hub_error_to_trustformers(e, &self.config.repo_id))
219    }
220
221    /// Upload a single file to the Hub as a one-file commit.
222    pub fn upload_file(&self, file: &UploadFile) -> Result<UploadResult> {
223        self.upload_files(std::slice::from_ref(file))
224    }
225
226    /// Upload multiple files in a single commit.
227    ///
228    /// Creates the repository first if [`UploadConfig::create_if_missing`]
229    /// is set and it doesn't already exist. Any file at or above
230    /// `api::LFS_INLINE_THRESHOLD_BYTES` is refused *before* any network
231    /// request — this module doesn't implement the real Git-LFS object
232    /// upload (a separate preupload + batch-upload exchange), so silently
233    /// either truncating it, inlining a huge base64 blob, or fabricating an
234    /// `lfsFile` pointer to bytes that were never actually sent anywhere are
235    /// all worse than a clear, immediate error.
236    pub fn upload_files(&self, files: &[UploadFile]) -> Result<UploadResult> {
237        self.validate()?;
238
239        if files.is_empty() {
240            return Err(TrustformersError::InvalidInput {
241                message: "File list cannot be empty".to_string(),
242                parameter: Some("files".to_string()),
243                expected: None,
244                received: None,
245                suggestion: None,
246            });
247        }
248
249        let mut repo_paths = Vec::with_capacity(files.len());
250        let mut commit_files = Vec::with_capacity(files.len());
251
252        for file in files {
253            if !file.local_path.exists() {
254                return Err(TrustformersError::Io {
255                    message: format!("File not found: {}", file.local_path.display()),
256                    path: Some(file.local_path.display().to_string()),
257                    suggestion: Some("Ensure all files exist before uploading".to_string()),
258                });
259            }
260            if file.repo_path.is_empty() {
261                return Err(TrustformersError::InvalidInput {
262                    message: "Repository path cannot be empty for one of the files".to_string(),
263                    parameter: Some("repo_path".to_string()),
264                    expected: None,
265                    received: None,
266                    suggestion: None,
267                });
268            }
269
270            let content = std::fs::read(&file.local_path).map_err(|e| TrustformersError::Io {
271                message: format!("Cannot read file: {e}"),
272                path: Some(file.local_path.display().to_string()),
273                suggestion: None,
274            })?;
275            if content.len() as u64 >= LFS_INLINE_THRESHOLD_BYTES {
276                return Err(hub_error_to_trustformers(
277                    HubError::LfsRequired {
278                        path: file.repo_path.clone(),
279                        size: content.len() as u64,
280                    },
281                    &self.config.repo_id,
282                ));
283            }
284
285            repo_paths.push(file.repo_path.clone());
286            commit_files.push(CommitFile {
287                repo_path: file.repo_path.clone(),
288                content,
289            });
290        }
291
292        if self.config.dry_run {
293            info!(
294                file_count = files.len(),
295                repo_id = %self.config.repo_id,
296                "upload_files: dry run, not contacting the Hub"
297            );
298            return Ok(UploadResult {
299                repo_id: self.config.repo_id.clone(),
300                revision: self.config.revision.clone(),
301                commit_url: None,
302                commit_oid: None,
303                files_uploaded: repo_paths,
304                dry_run: true,
305            });
306        }
307
308        if self.config.create_if_missing && !self.repo_exists()? {
309            info!(repo_id = %self.config.repo_id, "Repository does not exist yet; creating it");
310            self.create_repo()?;
311        }
312
313        let outcome = api::run_blocking(api::commit(
314            &self.config.base_url,
315            self.config.repo_type,
316            &self.config.repo_id,
317            &self.config.revision,
318            &self.config.commit_message,
319            &commit_files,
320            &[],
321            &self.config.token,
322        ))
323        .map_err(|e| hub_error_to_trustformers(e, &self.config.repo_id))?;
324
325        info!(
326            file_count = files.len(),
327            repo_id = %self.config.repo_id,
328            commit_url = ?outcome.commit_url,
329            "Uploaded files to Hub"
330        );
331
332        Ok(UploadResult {
333            repo_id: self.config.repo_id.clone(),
334            revision: self.config.revision.clone(),
335            commit_url: outcome.commit_url,
336            commit_oid: outcome.commit_oid,
337            files_uploaded: repo_paths,
338            dry_run: false,
339        })
340    }
341
342    /// Upload an entire directory to the Hub.
343    ///
344    /// All files under `local_dir` are recursively collected and uploaded.
345    /// `repo_prefix` is prepended to each file's relative path in the repository.
346    pub fn upload_directory(&self, local_dir: &Path, repo_prefix: &str) -> Result<UploadResult> {
347        self.validate()?;
348
349        if !local_dir.is_dir() {
350            return Err(TrustformersError::Io {
351                message: format!("Not a directory: {}", local_dir.display()),
352                path: Some(local_dir.display().to_string()),
353                suggestion: Some("Provide a path to an existing directory".to_string()),
354            });
355        }
356
357        let files = collect_files_recursive(local_dir, local_dir, repo_prefix)?;
358
359        if files.is_empty() {
360            warn!(
361                dir = %local_dir.display(),
362                "Directory is empty; nothing to upload"
363            );
364            return Ok(UploadResult {
365                repo_id: self.config.repo_id.clone(),
366                revision: self.config.revision.clone(),
367                commit_url: None,
368                commit_oid: None,
369                files_uploaded: vec![],
370                dry_run: self.config.dry_run,
371            });
372        }
373
374        self.upload_files(&files)
375    }
376
377    /// Delete a file from the repository (a commit with one `deletedFile` op).
378    pub fn delete_file(&self, repo_path: &str) -> Result<()> {
379        self.validate()?;
380
381        if repo_path.is_empty() {
382            return Err(TrustformersError::InvalidInput {
383                message: "Repository path cannot be empty".to_string(),
384                parameter: Some("repo_path".to_string()),
385                expected: None,
386                received: None,
387                suggestion: None,
388            });
389        }
390
391        if self.config.dry_run {
392            info!(repo_path = %repo_path, repo_id = %self.config.repo_id, "delete_file: dry run, not contacting the Hub");
393            return Ok(());
394        }
395
396        api::run_blocking(api::commit(
397            &self.config.base_url,
398            self.config.repo_type,
399            &self.config.repo_id,
400            &self.config.revision,
401            &format!("Delete {repo_path}"),
402            &[],
403            std::slice::from_ref(&repo_path.to_string()),
404            &self.config.token,
405        ))
406        .map_err(|e| hub_error_to_trustformers(e, &self.config.repo_id))?;
407
408        info!(repo_path = %repo_path, repo_id = %self.config.repo_id, "Deleted file from Hub");
409        Ok(())
410    }
411}
412
413/// Recursively collect all files under `base_dir`, building UploadFile entries.
414fn collect_files_recursive(
415    base_dir: &Path,
416    current_dir: &Path,
417    repo_prefix: &str,
418) -> Result<Vec<UploadFile>> {
419    let mut files = Vec::new();
420
421    let entries = std::fs::read_dir(current_dir).map_err(|e| TrustformersError::Io {
422        message: format!("Cannot read directory: {e}"),
423        path: Some(current_dir.display().to_string()),
424        suggestion: None,
425    })?;
426
427    for entry_result in entries {
428        let entry = entry_result.map_err(|e| TrustformersError::Io {
429            message: format!("Cannot read directory entry: {e}"),
430            path: Some(current_dir.display().to_string()),
431            suggestion: None,
432        })?;
433
434        let path = entry.path();
435
436        if path.is_dir() {
437            let mut sub_files = collect_files_recursive(base_dir, &path, repo_prefix)?;
438            files.append(&mut sub_files);
439        } else {
440            let relative = path.strip_prefix(base_dir).map_err(|e| TrustformersError::Io {
441                message: format!("Path prefix stripping failed: {e}"),
442                path: Some(path.display().to_string()),
443                suggestion: None,
444            })?;
445
446            let repo_path = if repo_prefix.is_empty() {
447                relative.display().to_string()
448            } else {
449                format!("{}/{}", repo_prefix, relative.display())
450            };
451
452            // Normalise OS-specific path separators to forward slashes
453            let repo_path = repo_path.replace('\\', "/");
454
455            files.push(UploadFile {
456                local_path: path.clone(),
457                repo_path,
458            });
459        }
460    }
461
462    Ok(files)
463}
464
465/// Builder pattern for constructing a `HubUploader`
466pub struct HubUploaderBuilder {
467    config: UploadConfig,
468}
469
470impl HubUploaderBuilder {
471    /// Start building with required fields: token and repo_id
472    pub fn new(token: impl Into<String>, repo_id: impl Into<String>) -> Self {
473        let config = UploadConfig {
474            token: token.into(),
475            repo_id: repo_id.into(),
476            ..Default::default()
477        };
478        Self { config }
479    }
480
481    /// Set the repository type
482    pub fn repo_type(mut self, repo_type: RepoType) -> Self {
483        self.config.repo_type = repo_type;
484        self
485    }
486
487    /// Set the branch/revision to upload to
488    pub fn revision(mut self, revision: impl Into<String>) -> Self {
489        self.config.revision = revision.into();
490        self
491    }
492
493    /// Set the commit message
494    pub fn commit_message(mut self, msg: impl Into<String>) -> Self {
495        self.config.commit_message = msg.into();
496        self
497    }
498
499    /// Set whether the repository should be private
500    pub fn private(mut self, private: bool) -> Self {
501        self.config.private = private;
502        self
503    }
504
505    /// Set whether to create the repository if it doesn't exist
506    pub fn create_if_missing(mut self, create: bool) -> Self {
507        self.config.create_if_missing = create;
508        self
509    }
510
511    /// Override the base API endpoint (for pointing at a local mock server).
512    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
513        self.config.base_url = base_url.into();
514        self
515    }
516
517    /// Set dry-run mode: validate everything, touch no network.
518    pub fn dry_run(mut self, dry_run: bool) -> Self {
519        self.config.dry_run = dry_run;
520        self
521    }
522
523    /// Build the `HubUploader`, validating the configuration first
524    pub fn build(self) -> Result<HubUploader> {
525        let uploader = HubUploader::new(self.config);
526        uploader.validate()?;
527        Ok(uploader)
528    }
529}
530
531// ─── HubError ─────────────────────────────────────────────────────────────────
532
533/// Dedicated error type for Hub upload/download operations.
534#[derive(Debug, Clone, PartialEq, Eq)]
535pub enum HubError {
536    /// The Hub rejected the credentials that were sent (HTTP 401/403).
537    Unauthorized { message: String },
538    /// No API token was supplied at all — distinct from `Unauthorized`
539    /// (which means a token *was* sent and the server rejected it).
540    MissingCredentials { message: String },
541    /// A requested resource was not found on the Hub.
542    NotFound {
543        repo_id: String,
544        path: Option<String>,
545    },
546    /// The request was rejected by the Hub (e.g., quota exceeded).
547    RequestFailed { status_code: u16, message: String },
548    /// A local file system operation failed.
549    Io {
550        message: String,
551        path: Option<String>,
552    },
553    /// Input validation failed.
554    InvalidInput { message: String },
555    /// Network connectivity issue.
556    Network { message: String },
557    /// The `hub` feature (networking) is not compiled in.
558    FeatureUnavailable { message: String },
559    /// A file is too large to inline as base64 in a commit and would need
560    /// real Git-LFS object storage, which this module does not implement.
561    LfsRequired { path: String, size: u64 },
562}
563
564impl std::fmt::Display for HubError {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        match self {
567            HubError::Unauthorized { message } => write!(f, "Unauthorized: {message}"),
568            HubError::MissingCredentials { message } => write!(f, "Missing credentials: {message}"),
569            HubError::NotFound { repo_id, path } => {
570                if let Some(p) = path {
571                    write!(f, "Not found: {repo_id}/{p}")
572                } else {
573                    write!(f, "Not found: {repo_id}")
574                }
575            },
576            HubError::RequestFailed {
577                status_code,
578                message,
579            } => {
580                write!(f, "Request failed (HTTP {status_code}): {message}")
581            },
582            HubError::Io { message, path } => {
583                if let Some(p) = path {
584                    write!(f, "IO error at {p}: {message}")
585                } else {
586                    write!(f, "IO error: {message}")
587                }
588            },
589            HubError::InvalidInput { message } => write!(f, "Invalid input: {message}"),
590            HubError::Network { message } => write!(f, "Network error: {message}"),
591            HubError::FeatureUnavailable { message } => write!(f, "Feature unavailable: {message}"),
592            HubError::LfsRequired { path, size } => {
593                write!(
594                    f,
595                    "'{path}' is {size} bytes, at or above the {}-byte inline-upload threshold, \
596                     and would require real Git-LFS object storage, which is not implemented",
597                    LFS_INLINE_THRESHOLD_BYTES
598                )
599            },
600        }
601    }
602}
603
604impl std::error::Error for HubError {}
605
606impl From<TrustformersError> for HubError {
607    fn from(e: TrustformersError) -> Self {
608        HubError::RequestFailed {
609            status_code: 0,
610            message: e.to_string(),
611        }
612    }
613}
614
615/// Map a [`HubError`] to the crate-wide [`TrustformersError`], carrying
616/// `repo_id`/`model_id` context along for the `Hub` variants.
617fn hub_error_to_trustformers(error: HubError, repo_id: &str) -> TrustformersError {
618    match error {
619        HubError::MissingCredentials { .. } => missing_credentials_error(repo_id),
620        HubError::Unauthorized { message } => TrustformersError::Hub {
621            message,
622            model_id: repo_id.to_string(),
623            endpoint: None,
624            suggestion: Some("Check that the API token is valid and has write access".to_string()),
625            recovery_actions: vec![],
626        },
627        HubError::FeatureUnavailable { message } => TrustformersError::Hub {
628            message,
629            model_id: repo_id.to_string(),
630            endpoint: None,
631            suggestion: Some("Rebuild with `--features hub`".to_string()),
632            recovery_actions: vec![],
633        },
634        HubError::LfsRequired { path, size } => TrustformersError::Hub {
635            message: format!(
636                "'{path}' is {size} bytes and would require real Git-LFS object storage, which \
637                 is not implemented"
638            ),
639            model_id: repo_id.to_string(),
640            endpoint: None,
641            suggestion: Some("Upload large files through the Hub web UI or the CLI's `huggingface-cli upload-large-folder` until LFS object upload is implemented here".to_string()),
642            recovery_actions: vec![],
643        },
644        HubError::NotFound { repo_id, path } => TrustformersError::Hub {
645            message: format!("Not found: {repo_id}{}", path.map(|p| format!("/{p}")).unwrap_or_default()),
646            model_id: repo_id,
647            endpoint: None,
648            suggestion: None,
649            recovery_actions: vec![],
650        },
651        HubError::RequestFailed { status_code, message } => TrustformersError::Hub {
652            message: format!("HTTP {status_code}: {message}"),
653            model_id: repo_id.to_string(),
654            endpoint: None,
655            suggestion: None,
656            recovery_actions: vec![],
657        },
658        HubError::Network { message } => TrustformersError::Hub {
659            message,
660            model_id: repo_id.to_string(),
661            endpoint: None,
662            suggestion: Some("Check network connectivity".to_string()),
663            recovery_actions: vec![],
664        },
665        HubError::Io { message, path } => TrustformersError::Io {
666            message,
667            path,
668            suggestion: None,
669        },
670        HubError::InvalidInput { message } => TrustformersError::InvalidInput {
671            message,
672            parameter: None,
673            expected: None,
674            received: None,
675            suggestion: None,
676        },
677    }
678}
679
680fn missing_credentials_error(repo_id: &str) -> TrustformersError {
681    TrustformersError::Hub {
682        message: "Missing credentials: a Hub API token is required to upload".to_string(),
683        model_id: repo_id.to_string(),
684        endpoint: None,
685        suggestion: Some(
686            "Set `UploadConfig::token` (e.g. from the `HF_TOKEN` environment variable)".to_string(),
687        ),
688        recovery_actions: vec![],
689    }
690}
691
692// ─── HubUploadConfig ──────────────────────────────────────────────────────────
693
694/// Simplified upload configuration with named fields that mirror the HF Hub API.
695#[derive(Debug, Clone)]
696pub struct HubUploadConfig {
697    /// Repository ID in "username/repo-name" format.
698    pub repo_id: String,
699    /// HuggingFace API token.
700    pub token: String,
701    /// Commit message to use when uploading.
702    pub commit_message: String,
703    /// Whether the repository is private.
704    pub private: bool,
705    /// Branch/revision to upload to. `None` defaults to "main".
706    pub revision: Option<String>,
707    /// Base API endpoint override (for tests).
708    pub base_url: Option<String>,
709    /// Dry-run mode (see [`UploadConfig::dry_run`]).
710    pub dry_run: bool,
711}
712
713impl HubUploadConfig {
714    /// Create a new config with required fields.
715    pub fn new(
716        repo_id: impl Into<String>,
717        token: impl Into<String>,
718        commit_message: impl Into<String>,
719    ) -> Self {
720        Self {
721            repo_id: repo_id.into(),
722            token: token.into(),
723            commit_message: commit_message.into(),
724            private: false,
725            revision: None,
726            base_url: None,
727            dry_run: false,
728        }
729    }
730
731    /// Set the private flag.
732    pub fn with_private(mut self, private: bool) -> Self {
733        self.private = private;
734        self
735    }
736
737    /// Set the target revision/branch.
738    pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
739        self.revision = Some(revision.into());
740        self
741    }
742
743    /// Override the base API endpoint (for pointing at a local mock server).
744    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
745        self.base_url = Some(base_url.into());
746        self
747    }
748
749    /// Enable dry-run mode.
750    pub fn with_dry_run(mut self, dry_run: bool) -> Self {
751        self.dry_run = dry_run;
752        self
753    }
754
755    /// Effective revision (defaults to "main").
756    pub fn effective_revision(&self) -> &str {
757        self.revision.as_deref().unwrap_or("main")
758    }
759
760    fn validate(&self) -> std::result::Result<(), HubError> {
761        if self.token.is_empty() {
762            return Err(HubError::MissingCredentials {
763                message: "API token cannot be empty".to_string(),
764            });
765        }
766        if self.repo_id.is_empty() {
767            return Err(HubError::InvalidInput {
768                message: "repo_id cannot be empty".to_string(),
769            });
770        }
771        if !self.repo_id.contains('/') {
772            return Err(HubError::InvalidInput {
773                message: format!(
774                    "repo_id must be in 'username/repo-name' format, got '{}'",
775                    self.repo_id
776                ),
777            });
778        }
779        Ok(())
780    }
781}
782
783// ─── HubUploadProgress ────────────────────────────────────────────────────────
784
785/// Tracks progress of a multi-file upload operation.
786#[derive(Debug, Clone, Default)]
787pub struct HubUploadProgress {
788    /// Total number of files to upload.
789    pub total_files: usize,
790    /// Number of files that have been uploaded so far.
791    pub uploaded_files: usize,
792    /// Total bytes across all files.
793    pub total_bytes: u64,
794    /// Bytes uploaded so far.
795    pub uploaded_bytes: u64,
796}
797
798impl HubUploadProgress {
799    /// Create a new progress tracker.
800    pub fn new(total_files: usize, total_bytes: u64) -> Self {
801        Self {
802            total_files,
803            uploaded_files: 0,
804            total_bytes,
805            uploaded_bytes: 0,
806        }
807    }
808
809    /// Mark a file as uploaded.
810    pub fn record_file(&mut self, bytes: u64) {
811        self.uploaded_files += 1;
812        self.uploaded_bytes += bytes;
813    }
814
815    /// Returns upload completion as a value in `[0.0, 1.0]`.
816    pub fn fraction(&self) -> f64 {
817        if self.total_bytes == 0 {
818            if self.total_files == 0 {
819                1.0
820            } else {
821                self.uploaded_files as f64 / self.total_files as f64
822            }
823        } else {
824            self.uploaded_bytes as f64 / self.total_bytes as f64
825        }
826    }
827
828    /// Returns `true` when all files are uploaded.
829    pub fn is_complete(&self) -> bool {
830        self.uploaded_files >= self.total_files
831    }
832}
833
834// ─── SHA-256 ──────────────────────────────────────────────────────────────────
835
836/// Compute the real SHA-256 digest of `data`, returned as 64 lowercase hex chars.
837pub fn sha256(data: &[u8]) -> String {
838    use sha2::{Digest, Sha256};
839    let mut hasher = Sha256::new();
840    hasher.update(data);
841    hex::encode(hasher.finalize())
842}
843
844/// Compute the SHA-256 hash of a file on disk.
845pub fn sha256_file(path: &Path) -> std::result::Result<String, HubError> {
846    let data = std::fs::read(path).map_err(|e| HubError::Io {
847        message: format!("Cannot read file for hashing: {e}"),
848        path: Some(path.display().to_string()),
849    })?;
850    Ok(sha256(&data))
851}
852
853// ─── SingleFileUploadResult ───────────────────────────────────────────────────
854
855/// Result of uploading a single file to the Hub.
856#[derive(Debug, Clone)]
857pub struct SingleFileUploadResult {
858    /// Remote URL where the file can be accessed.
859    pub remote_url: String,
860    /// Commit URL on the Hub, when the server returned one.
861    pub commit_url: Option<String>,
862    /// Commit SHA, when the server returned one.
863    pub commit_oid: Option<String>,
864    /// Size of the uploaded file in bytes.
865    pub file_size: u64,
866    /// SHA-256 hash of the file content.
867    pub sha256: String,
868}
869
870// ─── Extensions on HubUploader ────────────────────────────────────────────────
871
872impl HubUploader {
873    /// Create a `HubUploader` from a `HubUploadConfig`.
874    pub fn from_hub_config(cfg: HubUploadConfig) -> std::result::Result<Self, HubError> {
875        cfg.validate()?;
876        let revision = cfg.effective_revision().to_string();
877        let upload_config = UploadConfig {
878            token: cfg.token,
879            repo_id: cfg.repo_id,
880            repo_type: RepoType::Model,
881            revision,
882            commit_message: cfg.commit_message,
883            create_if_missing: true,
884            private: cfg.private,
885            base_url: cfg.base_url.unwrap_or_else(|| HF_HUB_URL.to_string()),
886            dry_run: cfg.dry_run,
887        };
888        Ok(Self::new(upload_config))
889    }
890
891    /// Upload a single local file by path, returning a rich `SingleFileUploadResult`.
892    pub fn upload_file_path(
893        &self,
894        local_path: &str,
895        remote_path: &str,
896    ) -> std::result::Result<SingleFileUploadResult, HubError> {
897        let path = Path::new(local_path);
898        if !path.exists() {
899            return Err(HubError::Io {
900                message: format!("File not found: {local_path}"),
901                path: Some(local_path.to_string()),
902            });
903        }
904        if remote_path.is_empty() {
905            return Err(HubError::InvalidInput {
906                message: "remote_path cannot be empty".to_string(),
907            });
908        }
909
910        let metadata = path.metadata().map_err(|e| HubError::Io {
911            message: format!("Cannot read file metadata: {e}"),
912            path: Some(local_path.to_string()),
913        })?;
914        let file_size = metadata.len();
915        let sha256 = sha256_file(path)?;
916
917        let result =
918            self.upload_file(&UploadFile::new(path, remote_path)).map_err(HubError::from)?;
919
920        let remote_url = format!(
921            "{}/{}/blob/{}/{}",
922            self.config.base_url, self.config.repo_id, self.config.revision, remote_path
923        );
924
925        Ok(SingleFileUploadResult {
926            remote_url,
927            commit_url: result.commit_url,
928            commit_oid: result.commit_oid,
929            file_size,
930            sha256,
931        })
932    }
933
934    /// Upload all files in a model directory (config.json, *.safetensors, tokenizer files, etc.).
935    ///
936    /// Returns one `SingleFileUploadResult` per file found.
937    pub fn upload_model(
938        &self,
939        model_dir: &str,
940    ) -> std::result::Result<Vec<SingleFileUploadResult>, HubError> {
941        let base = Path::new(model_dir);
942        if !base.is_dir() {
943            return Err(HubError::Io {
944                message: format!("Not a directory: {model_dir}"),
945                path: Some(model_dir.to_string()),
946            });
947        }
948        self.upload_dir_filtered(base, |name| {
949            // Upload model-relevant files: config, weights, generation config, etc.
950            name.ends_with(".json")
951                || name.ends_with(".safetensors")
952                || name.ends_with(".bin")
953                || name.ends_with(".pt")
954                || name.ends_with(".ckpt")
955                || name.ends_with(".msgpack")
956                || name.ends_with(".model")
957                || name == "README.md"
958        })
959    }
960
961    /// Upload tokenizer files from a directory (tokenizer.json, vocab.txt, merges.txt, etc.).
962    ///
963    /// Returns one `SingleFileUploadResult` per file found.
964    pub fn upload_tokenizer(
965        &self,
966        tokenizer_dir: &str,
967    ) -> std::result::Result<Vec<SingleFileUploadResult>, HubError> {
968        let base = Path::new(tokenizer_dir);
969        if !base.is_dir() {
970            return Err(HubError::Io {
971                message: format!("Not a directory: {tokenizer_dir}"),
972                path: Some(tokenizer_dir.to_string()),
973            });
974        }
975        self.upload_dir_filtered(base, |name| {
976            name.ends_with("tokenizer.json")
977                || name.ends_with("tokenizer_config.json")
978                || name.ends_with("vocab.json")
979                || name.ends_with("vocab.txt")
980                || name.ends_with("merges.txt")
981                || name.ends_with("special_tokens_map.json")
982                || name.ends_with("added_tokens.json")
983                || name.ends_with(".model")
984                || name.ends_with("spiece.model")
985        })
986    }
987
988    /// Create a repository on the Hub for the given `repo_type`.
989    ///
990    /// Returns the new repository URL.
991    pub fn create_repo_typed(&self, repo_type: RepoType) -> std::result::Result<String, HubError> {
992        let mut cfg = self.config.clone();
993        cfg.repo_type = repo_type;
994        let tmp = HubUploader::new(cfg);
995        tmp.create_repo().map_err(HubError::from)
996    }
997
998    /// Delete a file from the Hub repository.
999    pub fn delete_remote_file(&self, remote_path: &str) -> std::result::Result<(), HubError> {
1000        self.delete_file(remote_path).map_err(HubError::from)
1001    }
1002
1003    // ── Internal helpers ──────────────────────────────────────────────────────
1004
1005    fn upload_dir_filtered<F>(
1006        &self,
1007        base: &Path,
1008        filter: F,
1009    ) -> std::result::Result<Vec<SingleFileUploadResult>, HubError>
1010    where
1011        F: Fn(&str) -> bool,
1012    {
1013        let entries = collect_files_recursive_hub(base, base)?;
1014        let mut results = Vec::new();
1015        for (local_path, repo_path) in entries {
1016            let file_name = local_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1017            if !filter(file_name) {
1018                continue;
1019            }
1020            let local_str = local_path.display().to_string();
1021            let result = self.upload_file_path(&local_str, &repo_path)?;
1022            results.push(result);
1023        }
1024        Ok(results)
1025    }
1026}
1027
1028/// Recursively collect all files under `base`, returning (local_path, repo_relative_path) pairs.
1029fn collect_files_recursive_hub(
1030    base: &Path,
1031    current: &Path,
1032) -> std::result::Result<Vec<(PathBuf, String)>, HubError> {
1033    let mut files = Vec::new();
1034    let entries = std::fs::read_dir(current).map_err(|e| HubError::Io {
1035        message: format!("Cannot read directory: {e}"),
1036        path: Some(current.display().to_string()),
1037    })?;
1038    for entry_result in entries {
1039        let entry = entry_result.map_err(|e| HubError::Io {
1040            message: format!("Cannot read directory entry: {e}"),
1041            path: Some(current.display().to_string()),
1042        })?;
1043        let path = entry.path();
1044        if path.is_dir() {
1045            let mut sub = collect_files_recursive_hub(base, &path)?;
1046            files.append(&mut sub);
1047        } else {
1048            let relative = path.strip_prefix(base).map_err(|e| HubError::Io {
1049                message: format!("Path strip prefix failed: {e}"),
1050                path: Some(path.display().to_string()),
1051            })?;
1052            let repo_path = relative.display().to_string().replace('\\', "/");
1053            files.push((path, repo_path));
1054        }
1055    }
1056    Ok(files)
1057}
1058
1059// ─── Tests ────────────────────────────────────────────────────────────────────
1060
1061#[cfg(test)]
1062mod tests;