Skip to main content

hf_hub/buckets/
mod.rs

1//! Bucket handles and file operations for Hugging Face Hub buckets.
2//!
3//! Buckets are flat file stores that live under a namespace such as
4//! `"user/my-bucket"`. Start by creating an [`HFBucket`] handle with
5//! [`HFClient::bucket`], then use the handle to inspect metadata, browse the
6//! tree, upload or download files, or delete entries.
7//!
8//! This module exposes both high-level helpers and the lower-level batch API:
9//!
10//! - Use [`HFBucket::info`], [`HFBucket::list_tree`], and [`HFBucket::get_paths_info`] to inspect bucket contents.
11//! - Use [`HFBucket::upload_files`] and [`HFBucket::download_files`] for common transfer workflows.
12//! - Use [`HFBucket::batch_operations`] when you already have xet hashes and want direct control over add, delete, or
13//!   copy operations.
14//! - Use [`sync`] for one-way directory mirroring between a local folder and a bucket prefix.
15
16#[cfg(not(target_family = "wasm"))]
17pub mod sync;
18
19#[cfg(not(target_family = "wasm"))]
20use std::path::PathBuf;
21
22use bon::bon;
23use futures::{Stream, StreamExt};
24use serde::{Deserialize, Serialize};
25use url::Url;
26
27use crate::client::HFClient;
28use crate::error::{HFError, HFResult, NotFoundContext};
29use crate::progress::{DownloadEvent, EmitEvent, Progress, UploadEvent};
30use crate::repository::download::{HFByteStream, wrap_stream_with_progress};
31use crate::retry;
32
33const BUCKET_BATCH_CHUNK_SIZE: usize = 1000;
34const BUCKET_PATHS_INFO_BATCH_SIZE: usize = 1000;
35
36/// A handle for a single bucket on the Hugging Face Hub.
37///
38/// `HFBucket` is created via [`HFClient::bucket`] and binds together the client,
39/// owner (namespace), and bucket name. All bucket-scoped API operations are methods
40/// on this type.
41///
42/// Cheap to clone — the inner [`HFClient`] is `Arc`-backed.
43///
44/// # Example
45///
46/// ```rust,no_run
47/// # use futures::StreamExt;
48/// # use hf_hub::HFClient;
49/// # #[tokio::main] async fn main() -> hf_hub::HFResult<()> {
50/// let client = HFClient::builder().build()?;
51/// let bucket = client.bucket("my-org", "my-bucket");
52///
53/// let info = bucket.info().send().await?;
54/// println!("bucket {} ({} files)", info.id, info.total_files);
55///
56/// let stream = bucket.list_tree().send()?;
57/// futures::pin_mut!(stream);
58/// while let Some(entry) = stream.next().await {
59///     println!("{:?}", entry?);
60/// }
61/// # Ok(()) }
62/// ```
63#[derive(Clone)]
64pub struct HFBucket {
65    pub(crate) hf_client: HFClient,
66    pub(super) owner: String,
67    pub(super) name: String,
68}
69
70impl std::fmt::Debug for HFBucket {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("HFBucket")
73            .field("owner", &self.owner)
74            .field("name", &self.name)
75            .finish()
76    }
77}
78
79impl HFBucket {
80    /// Construct a new bucket handle. Prefer [`HFClient::bucket`] in most cases.
81    pub fn new(client: HFClient, owner: impl Into<String>, name: impl Into<String>) -> Self {
82        Self {
83            hf_client: client,
84            owner: owner.into(),
85            name: name.into(),
86        }
87    }
88
89    /// Return a reference to the underlying [`HFClient`].
90    pub fn client(&self) -> &HFClient {
91        &self.hf_client
92    }
93
94    /// The bucket owner (user or organization namespace).
95    pub fn owner(&self) -> &str {
96        &self.owner
97    }
98
99    /// The bucket name (without owner prefix).
100    pub fn name(&self) -> &str {
101        &self.name
102    }
103
104    /// The full `"owner/name"` bucket identifier used in Hub API calls.
105    pub fn bucket_id(&self) -> String {
106        format!("{}/{}", self.owner, self.name)
107    }
108}
109
110#[bon]
111impl HFBucket {
112    /// Get metadata about this bucket.
113    ///
114    /// Endpoint: `GET /api/buckets/{bucket_id}`.
115    #[builder(finish_fn = send, derive(Debug, Clone))]
116    pub async fn info(&self) -> HFResult<BucketInfo> {
117        let bucket_id = self.bucket_id();
118        let url = self.hf_client.bucket_api_url(&bucket_id);
119
120        let headers = self.hf_client.auth_headers();
121        let response = retry::retry(self.hf_client.retry_config(), || {
122            self.hf_client.http_client().get(&url).headers(headers.clone()).send()
123        })
124        .await?;
125
126        let response = self
127            .hf_client
128            .check_response(response, Some(&bucket_id), NotFoundContext::Bucket)
129            .await?;
130        Ok(response.json().await?)
131    }
132
133    /// List files and directories in this bucket.
134    ///
135    /// This is the main API for browsing bucket contents. For targeted lookups of a known set of
136    /// paths, prefer [`HFBucket::get_paths_info`].
137    ///
138    /// Endpoint: `GET /api/buckets/{bucket_id}/tree[/{prefix}]` (paginated).
139    ///
140    /// # Parameters
141    ///
142    /// - `prefix`: filter results to entries under this prefix.
143    /// - `recursive` (default `false`): traverse subdirectories.
144    #[builder(finish_fn = send, derive(Debug, Clone))]
145    pub fn list_tree(
146        &self,
147        /// Filter results to entries under this prefix.
148        #[builder(into)]
149        prefix: Option<String>,
150        /// Traverse subdirectories.
151        #[builder(default)]
152        recursive: bool,
153    ) -> HFResult<impl Stream<Item = HFResult<BucketTreeEntry>> + '_> {
154        let bucket_id = self.bucket_id();
155        let url_str = format!("{}/api/buckets/{}/tree", self.hf_client.endpoint(), bucket_id);
156        let mut url = Url::parse(&url_str)?;
157        if let Some(ref prefix) = prefix {
158            crate::client::append_path_segments(&mut url, prefix)?;
159        }
160
161        let mut query = vec![];
162        if recursive {
163            query.push(("recursive".to_string(), "true".to_string()));
164        }
165
166        Ok(self.hf_client.paginate(url, query, None))
167    }
168
169    /// Get info about specific paths in this bucket.
170    ///
171    /// This is useful when you already know which paths you care about and do not want to stream
172    /// the full tree. Requests are automatically chunked in batches of 1000 paths.
173    ///
174    /// Endpoint: `POST /api/buckets/{bucket_id}/paths-info`.
175    ///
176    /// # Parameters
177    ///
178    /// - `paths` (required): paths to inspect.
179    #[builder(finish_fn = send, derive(Debug, Clone))]
180    pub async fn get_paths_info(
181        &self,
182        /// Paths to inspect.
183        paths: Vec<String>,
184    ) -> HFResult<Vec<BucketTreeEntry>> {
185        let bucket_id = self.bucket_id();
186        let url = format!("{}/api/buckets/{}/paths-info", self.hf_client.endpoint(), bucket_id);
187
188        let headers = self.hf_client.auth_headers();
189        let mut all_entries = Vec::new();
190        for chunk in paths.chunks(BUCKET_PATHS_INFO_BATCH_SIZE) {
191            let body = serde_json::json!({ "paths": chunk });
192
193            let response = retry::retry(self.hf_client.retry_config(), || {
194                self.hf_client
195                    .http_client()
196                    .post(&url)
197                    .headers(headers.clone())
198                    .json(&body)
199                    .send()
200            })
201            .await?;
202
203            let response = self
204                .hf_client
205                .check_response(response, Some(&bucket_id), NotFoundContext::Bucket)
206                .await?;
207            let entries: Vec<BucketTreeEntry> = response.json().await?;
208            all_entries.extend(entries);
209        }
210
211        Ok(all_entries)
212    }
213
214    /// Stream the bytes of a single file in this bucket without writing to disk.
215    ///
216    /// Parallel of
217    /// [`HFRepository::download_file_stream`](crate::repository::HFRepository::download_file_stream) for buckets.
218    /// Returns `(content_length, stream)` — `content_length` reflects the file size reported by `paths-info`.
219    /// Xet-backed files dispatch through xet streaming; non-xet files fall back to a direct GET on the bucket's
220    /// `resolve` URL.
221    ///
222    /// Endpoint (non-xet branch): `GET {endpoint}/buckets/{bucket_id}/resolve/{remote_path}`.
223    ///
224    /// # Parameters
225    ///
226    /// - `remote_path` (required): file path within the bucket.
227    /// - `progress`: optional progress handler. `Start` is emitted before the stream is returned; `Progress` is emitted
228    ///   as the caller polls each chunk; `Complete` is emitted when the stream is exhausted.
229    #[builder(finish_fn = send, derive(Debug, Clone))]
230    pub async fn download_file_stream(
231        &self,
232        /// File path within the bucket.
233        #[builder(into)]
234        remote_path: String,
235        /// Progress handler. `Start` is emitted before the stream is returned; `Progress` is
236        /// emitted as the caller polls each chunk; `Complete` is emitted when the stream is
237        /// exhausted.
238        #[builder(into)]
239        progress: Option<Progress>,
240    ) -> HFResult<(Option<u64>, HFByteStream)> {
241        Box::pin(self.download_file_stream_impl(remote_path, progress)).await
242    }
243
244    /// Get metadata for a single file in this bucket via a HEAD request.
245    ///
246    /// Endpoint: `HEAD /buckets/{bucket_id}/resolve/{path}`.
247    ///
248    /// # Parameters
249    ///
250    /// - `remote_path` (required): file path within the bucket.
251    #[cfg(not(target_family = "wasm"))]
252    #[builder(finish_fn = send, derive(Debug, Clone))]
253    pub async fn get_file_metadata(
254        &self,
255        /// File path within the bucket.
256        #[builder(into)]
257        remote_path: String,
258    ) -> HFResult<BucketFileMetadata> {
259        let bucket_id = self.bucket_id();
260        let mut url = Url::parse(&format!("{}/buckets/{}/resolve", self.hf_client.endpoint(), bucket_id))?;
261        crate::client::append_path_segments(&mut url, &remote_path)?;
262
263        let headers = self.hf_client.auth_headers();
264        let response = retry::retry(self.hf_client.retry_config(), || {
265            self.hf_client
266                .no_redirect_client()
267                .head(url.clone())
268                .headers(headers.clone())
269                .send()
270        })
271        .await?;
272
273        let response = self
274            .hf_client
275            .check_response(response, Some(&bucket_id), NotFoundContext::Entry { path: remote_path })
276            .await?;
277
278        let size = response
279            .headers()
280            .get(reqwest::header::CONTENT_LENGTH)
281            .and_then(|v| v.to_str().ok())
282            .and_then(|v| v.parse::<u64>().ok())
283            .unwrap_or(0);
284
285        let xet_hash = response
286            .headers()
287            .get("x-xet-hash")
288            .and_then(|v| v.to_str().ok())
289            .unwrap_or("")
290            .to_string();
291
292        Ok(BucketFileMetadata { size, xet_hash })
293    }
294
295    /// Execute batch file operations (add, delete, copy) on this bucket.
296    ///
297    /// This is the low-level file mutation API. `add` operations only register metadata on the
298    /// bucket side; the file contents must already have been uploaded to xet so each entry has a
299    /// valid [`BucketAddFile::xet_hash`].
300    ///
301    /// For simpler upload and download flows, prefer [`HFBucket::upload_files`] and
302    /// [`HFBucket::download_files`].
303    ///
304    /// Endpoint: `POST /api/buckets/{bucket_id}/batch` (NDJSON). Operations are chunked at 1000
305    /// entries per request.
306    ///
307    /// All paths in `add`, `delete`, and `copy` are **bucket-relative**, slash-separated paths
308    /// (e.g., `"data/train/0001.bin"`) — no leading slash, forward slashes regardless of platform,
309    /// no `bucket_id` prefix.
310    ///
311    /// # Parameters
312    ///
313    /// - `add_files`: files to register in the bucket. Each [`BucketAddFile`] requires:
314    ///   - `path` (`String`): bucket-relative destination path.
315    ///   - `xet_hash` (`String`): xet content hash from a prior xet upload — the bytes must already be in xet storage;
316    ///     this call only registers metadata.
317    ///   - `size` (`u64`): file size in bytes.
318    ///   - `mtime` (`Option<u64>`): last modification time as a Unix timestamp in seconds.
319    ///   - `content_type` (`Option<String>`): MIME type (e.g., `"text/plain"`).
320    /// - `delete`: bucket-relative paths to remove from the bucket.
321    /// - `copy`: server-side copies into this bucket. Each [`BucketCopyFile`] requires:
322    ///   - `path` (`String`): bucket-relative destination path.
323    ///   - `xet_hash` (`String`): xet content hash of the source bytes (already present in xet storage from another
324    ///     repo or bucket — copies are by-hash, no data is transferred).
325    ///   - `source_repo_type` ([`BucketCopySourceType`]): repo type of the source — one of `Bucket`, `Model`,
326    ///     `Dataset`, or `Space`. Serializes to the lowercase wire string the Hub expects.
327    ///   - `source_repo_id` (`String`): full source identifier in `"namespace/name"` form (e.g., `"user/my-bucket"`).
328    #[builder(finish_fn = send, derive(Debug, Clone))]
329    pub async fn batch_operations(
330        &self,
331        /// Files to register in the bucket.
332        #[builder(default)]
333        add_files: Vec<BucketAddFile>,
334        /// Bucket-relative paths to remove from the bucket.
335        #[builder(default)]
336        delete: Vec<String>,
337        /// Server-side copies into this bucket.
338        #[builder(default)]
339        copy: Vec<BucketCopyFile>,
340    ) -> HFResult<()> {
341        let bucket_id = self.bucket_id();
342        let url = format!("{}/api/buckets/{}/batch", self.hf_client.endpoint(), bucket_id);
343
344        let mut lines: Vec<String> = Vec::new();
345
346        for add in &add_files {
347            let mut obj = serde_json::json!({
348                "type": "addFile",
349                "path": add.path,
350                "xetHash": add.xet_hash,
351                "size": add.size,
352            });
353            if let Some(mtime) = add.mtime {
354                obj["mtime"] = serde_json::Value::Number(mtime.into());
355            }
356            if let Some(ref ct) = add.content_type {
357                obj["contentType"] = serde_json::Value::String(ct.clone());
358            }
359            lines.push(serde_json::to_string(&obj)?);
360        }
361
362        for path in &delete {
363            let obj = serde_json::json!({
364                "type": "deleteFile",
365                "path": path,
366            });
367            lines.push(serde_json::to_string(&obj)?);
368        }
369
370        for copy in &copy {
371            let mut obj = serde_json::json!({
372                "type": "copyFile",
373                "path": copy.path,
374                "xetHash": copy.xet_hash,
375                "sourceRepoType": copy.source_repo_type.as_str(),
376                "sourceRepoId": copy.source_repo_id,
377            });
378            if let Some(size) = copy.size {
379                obj["size"] = serde_json::Value::Number(size.into());
380            }
381            if let Some(mtime) = copy.mtime {
382                obj["mtime"] = serde_json::Value::Number(mtime.into());
383            }
384            if let Some(ref ct) = copy.content_type {
385                obj["contentType"] = serde_json::Value::String(ct.clone());
386            }
387            lines.push(serde_json::to_string(&obj)?);
388        }
389
390        let headers = self.hf_client.auth_headers();
391        for chunk in lines.chunks(BUCKET_BATCH_CHUNK_SIZE) {
392            let body = chunk.join("\n") + "\n";
393
394            let response = retry::retry(self.hf_client.retry_config(), || {
395                self.hf_client
396                    .http_client()
397                    .post(&url)
398                    .headers(headers.clone())
399                    .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
400                    .body(body.clone())
401                    .send()
402            })
403            .await?;
404
405            self.hf_client
406                .check_response(response, Some(&bucket_id), NotFoundContext::Bucket)
407                .await?;
408        }
409
410        Ok(())
411    }
412
413    /// Delete files from this bucket by path.
414    ///
415    /// Convenience wrapper around [`HFBucket::batch_operations`] that sends only `deleteFile` operations.
416    ///
417    /// # Parameters
418    ///
419    /// - `paths` (required): paths to delete from the bucket.
420    #[builder(finish_fn = send, derive(Debug, Clone))]
421    pub async fn delete_files(
422        &self,
423        /// Paths to delete from the bucket.
424        paths: Vec<String>,
425    ) -> HFResult<()> {
426        self.batch_operations().delete(paths).send().await
427    }
428
429    /// Upload an arbitrary set of [`AddSource`](crate::repository::AddSource)-backed files to the bucket.
430    ///
431    /// More general analog of [`HFBucket::upload_files`] (which only reads from local
432    /// paths): each pair can use any [`AddSource`](crate::repository::AddSource)
433    /// variant — `Bytes` for in-memory content, `File` for a local path, or `Stream`
434    /// for sources too large to materialize at once. For the common in-memory case,
435    /// build entries with
436    /// [`AddSource::bytes`](crate::repository::AddSource::bytes):
437    ///
438    /// ```rust,no_run
439    /// # use hf_hub::repository::AddSource;
440    /// # let _: Vec<(String, AddSource)> =
441    /// vec![("logs/run-1.txt".into(), AddSource::bytes(b"hello".as_slice()))]
442    /// # ;
443    /// ```
444    ///
445    /// Goes through the same preupload + xet pipeline as [`HFBucket::upload_files`].
446    ///
447    /// # Parameters
448    ///
449    /// - `files` (required): list of `(remote_path, source)` pairs.
450    /// - `progress`: optional progress handler.
451    #[builder(finish_fn = send, derive(Debug, Clone))]
452    pub async fn upload_source_files(
453        &self,
454        /// List of `(remote_path, source)` pairs.
455        files: Vec<(String, crate::repository::AddSource)>,
456        /// Progress handler.
457        #[builder(into)]
458        progress: Option<Progress>,
459    ) -> HFResult<()> {
460        Box::pin(self.upload_sources_impl(files, progress)).await
461    }
462
463    /// Upload local files to the bucket.
464    ///
465    /// File contents are uploaded to xet first, then registered in the bucket via the batch
466    /// endpoint. Each entry pairs a local source with a bucket-relative destination via the
467    /// [`BucketUpload`] struct (use [`BucketUpload::new`] to construct one).
468    ///
469    /// # Parameters
470    ///
471    /// - `files` (required): list of [`BucketUpload`] entries describing each `local` → `remote` mapping.
472    /// - `progress`: optional progress handler.
473    #[cfg(not(target_family = "wasm"))]
474    #[builder(finish_fn = send, derive(Debug, Clone))]
475    pub async fn upload_files(
476        &self,
477        /// List of [`BucketUpload`] entries describing each `local` → `remote` mapping.
478        files: Vec<BucketUpload>,
479        /// Progress handler.
480        #[builder(into)]
481        progress: Option<Progress>,
482    ) -> HFResult<()> {
483        Box::pin(self.upload_files_impl(files, progress)).await
484    }
485
486    /// Download files from the bucket to local paths.
487    ///
488    /// The method first resolves xet metadata for each `remote` path, then downloads the file
489    /// contents through xet. Directory entries are rejected. Each entry pairs a bucket-relative
490    /// source with a local destination via the [`BucketDownload`] struct (use
491    /// [`BucketDownload::new`] to construct one).
492    ///
493    /// # Parameters
494    ///
495    /// - `files` (required): list of [`BucketDownload`] entries describing each `remote` → `local` mapping.
496    /// - `progress`: optional progress handler.
497    #[cfg(not(target_family = "wasm"))]
498    #[builder(finish_fn = send, derive(Debug, Clone))]
499    pub async fn download_files(
500        &self,
501        /// List of [`BucketDownload`] entries describing each `remote` → `local` mapping.
502        files: Vec<BucketDownload>,
503        /// Progress handler.
504        #[builder(into)]
505        progress: Option<Progress>,
506    ) -> HFResult<()> {
507        Box::pin(self.download_files_impl(files, progress)).await
508    }
509}
510
511impl HFBucket {
512    /// Body of [`HFBucket::download_file_stream`], extracted so the builder `send()` future only
513    /// holds a boxed pointer to this state machine rather than embedding it inline.
514    async fn download_file_stream_impl(
515        &self,
516        remote_path: String,
517        progress: Option<Progress>,
518    ) -> HFResult<(Option<u64>, HFByteStream)> {
519        let bucket_id = self.bucket_id();
520
521        let entries = self.get_paths_info().paths(vec![remote_path.clone()]).send().await?;
522        let (xet_hash, file_size) = entries
523            .into_iter()
524            .find_map(|e| match e {
525                BucketTreeEntry::File {
526                    path, size, xet_hash, ..
527                } if path == remote_path => Some((xet_hash, size)),
528                _ => None,
529            })
530            .ok_or_else(|| HFError::EntryNotFound {
531                path: remote_path.clone(),
532                repo_id: bucket_id.clone(),
533                context: None,
534            })?;
535
536        if !xet_hash.is_empty() {
537            let stream = self.xet_download_stream(&xet_hash, file_size).await?;
538            progress.emit(DownloadEvent::Start {
539                total_files: 1,
540                total_bytes: file_size,
541            });
542            let boxed: HFByteStream = Box::new(Box::pin(stream));
543            let wrapped = wrap_stream_with_progress(boxed, progress, remote_path, file_size);
544            #[cfg(target_family = "wasm")]
545            let wrapped = crate::repository::download::buffer_wasm_stream(wrapped);
546            return Ok((Some(file_size), wrapped));
547        }
548
549        let mut url = Url::parse(&format!("{}/buckets/{}/resolve", self.hf_client.endpoint(), bucket_id))?;
550        crate::client::append_path_segments(&mut url, &remote_path)?;
551        let headers = self.hf_client.auth_headers();
552        let response = retry::retry(self.hf_client.retry_config(), || {
553            self.hf_client.http_client().get(url.clone()).headers(headers.clone()).send()
554        })
555        .await?;
556        let response = self
557            .hf_client
558            .check_response(
559                response,
560                Some(&bucket_id),
561                NotFoundContext::Entry {
562                    path: remote_path.clone(),
563                },
564            )
565            .await?;
566
567        let content_length = response.content_length().or(Some(file_size));
568        let total_bytes = content_length.unwrap_or(file_size);
569        let stream = response.bytes_stream().map(|r| r.map_err(HFError::from));
570        progress.emit(DownloadEvent::Start {
571            total_files: 1,
572            total_bytes,
573        });
574        let boxed: HFByteStream = Box::new(Box::pin(stream));
575        let wrapped = wrap_stream_with_progress(boxed, progress, remote_path, total_bytes);
576        #[cfg(target_family = "wasm")]
577        let wrapped = crate::repository::download::buffer_wasm_stream(wrapped);
578        Ok((content_length, wrapped))
579    }
580
581    /// Body of [`HFBucket::upload_files`], extracted so the builder `send()` future only holds a
582    /// boxed pointer to this state machine rather than embedding it inline.
583    #[cfg(not(target_family = "wasm"))]
584    async fn upload_files_impl(&self, files: Vec<BucketUpload>, progress: Option<Progress>) -> HFResult<()> {
585        if files.is_empty() {
586            return Ok(());
587        }
588
589        let total_bytes: u64 = files
590            .iter()
591            .filter_map(|f| std::fs::metadata(&f.local).ok())
592            .map(|m| m.len())
593            .sum();
594        progress.emit(UploadEvent::Start {
595            total_files: files.len(),
596            total_bytes,
597        });
598
599        let xet_files: Vec<(String, crate::repository::AddSource)> = files
600            .iter()
601            .map(|f| (f.remote.clone(), crate::repository::AddSource::file(f.local.clone())))
602            .collect();
603
604        let xet_infos = self.xet_upload(&xet_files, &progress).await?;
605
606        let add_files: Vec<BucketAddFile> = files
607            .iter()
608            .zip(xet_infos.iter())
609            .map(|(f, xet_info)| {
610                let metadata = std::fs::metadata(&f.local).ok();
611                let size = metadata.as_ref().map(|m| m.len()).or(xet_info.file_size).unwrap_or(0);
612                let mtime = metadata
613                    .as_ref()
614                    .and_then(|m| m.modified().ok())
615                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
616                    .map(|d| d.as_secs());
617
618                BucketAddFile {
619                    path: f.remote.clone(),
620                    xet_hash: xet_info.hash.clone(),
621                    size,
622                    mtime,
623                    content_type: None,
624                }
625            })
626            .collect();
627
628        self.batch_operations().add_files(add_files).send().await?;
629
630        progress.emit(UploadEvent::Complete);
631        Ok(())
632    }
633
634    /// Body of [`HFBucket::download_files`], extracted so the builder `send()` future only holds a
635    /// boxed pointer to this state machine rather than embedding it inline.
636    #[cfg(not(target_family = "wasm"))]
637    async fn download_files_impl(&self, files: Vec<BucketDownload>, progress: Option<Progress>) -> HFResult<()> {
638        if files.is_empty() {
639            return Ok(());
640        }
641
642        let remote_paths: Vec<String> = files.iter().map(|f| f.remote.clone()).collect();
643        let entries = self.get_paths_info().paths(remote_paths).send().await?;
644
645        let entry_map: std::collections::HashMap<String, BucketTreeEntry> = entries
646            .into_iter()
647            .map(|e| {
648                let path = match &e {
649                    BucketTreeEntry::File { path, .. } => path.clone(),
650                    BucketTreeEntry::Directory { path, .. } => path.clone(),
651                };
652                (path, e)
653            })
654            .collect();
655
656        let mut xet_batch_files = Vec::new();
657        let mut total_bytes: u64 = 0;
658
659        for f in &files {
660            match entry_map.get(&f.remote) {
661                Some(BucketTreeEntry::File { xet_hash, size, .. }) => {
662                    total_bytes += size;
663                    xet_batch_files.push(crate::xet::XetBatchFile {
664                        hash: xet_hash.clone(),
665                        file_size: *size,
666                        path: f.local.clone(),
667                        filename: f.remote.clone(),
668                    });
669                },
670                Some(BucketTreeEntry::Directory { path, .. }) => {
671                    return Err(HFError::InvalidParameter(format!("Cannot download directory entry: {path}")));
672                },
673                None => {
674                    return Err(HFError::EntryNotFound {
675                        path: f.remote.clone(),
676                        repo_id: self.bucket_id(),
677                        context: None,
678                    });
679                },
680            }
681        }
682
683        progress.emit(DownloadEvent::Start {
684            total_files: xet_batch_files.len(),
685            total_bytes,
686        });
687
688        self.xet_download_batch(&xet_batch_files, &progress).await?;
689
690        progress.emit(DownloadEvent::Complete);
691        Ok(())
692    }
693
694    /// Shared implementation: upload pre-built `(remote_path, AddSource)` pairs through xet
695    /// and register them in the bucket. Used by [`HFBucket::upload_source_files`].
696    async fn upload_sources_impl(
697        &self,
698        uploads: Vec<(String, crate::repository::AddSource)>,
699        progress: Option<Progress>,
700    ) -> HFResult<()> {
701        if uploads.is_empty() {
702            return Ok(());
703        }
704
705        let total_bytes: u64 = uploads
706            .iter()
707            .map(|(_, source)| match source {
708                crate::repository::AddSource::Bytes(b) => b.len() as u64,
709                crate::repository::AddSource::Stream(s) => s.size(),
710                #[cfg(not(target_family = "wasm"))]
711                crate::repository::AddSource::File(p) => std::fs::metadata(p).map(|m| m.len()).unwrap_or(0),
712            })
713            .sum();
714        progress.emit(UploadEvent::Start {
715            total_files: uploads.len(),
716            total_bytes,
717        });
718
719        let xet_infos = self.xet_upload(&uploads, &progress).await?;
720
721        let add_files: Vec<BucketAddFile> = uploads
722            .iter()
723            .zip(xet_infos.iter())
724            .map(|((remote, _source), xet_info)| BucketAddFile {
725                path: remote.clone(),
726                xet_hash: xet_info.hash.clone(),
727                size: xet_info.file_size.unwrap_or(0),
728                mtime: None,
729                content_type: None,
730            })
731            .collect();
732
733        self.batch_operations().add_files(add_files).send().await?;
734
735        progress.emit(UploadEvent::Complete);
736        Ok(())
737    }
738}
739
740/// A file to register in a bucket via the batch endpoint.
741///
742/// Represents an `addFile` entry in the NDJSON batch payload.
743/// The file content must have already been uploaded to xet to obtain the `xet_hash`.
744/// Used as input to [`HFBucket::batch_operations`].
745#[derive(Debug, Clone)]
746pub struct BucketAddFile {
747    /// Destination path in the bucket.
748    pub path: String,
749    /// Xet content hash from a prior upload.
750    pub xet_hash: String,
751    /// File size in bytes.
752    pub size: u64,
753    /// Last modification time as a Unix timestamp (seconds).
754    pub mtime: Option<u64>,
755    /// MIME content type (e.g., `"text/plain"`, `"application/octet-stream"`).
756    pub content_type: Option<String>,
757}
758
759/// Source repo type for a [`BucketCopyFile`].
760///
761/// Tells the batch endpoint where the source bytes for a server-side copy live.
762/// Serializes to the lowercase string the Hub expects (`"bucket"`, `"model"`,
763/// `"dataset"`, `"space"`) so callers can't typo the field.
764#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765pub enum BucketCopySourceType {
766    /// Source bytes live in another bucket.
767    Bucket,
768    /// Source bytes live in a model repository.
769    Model,
770    /// Source bytes live in a dataset repository.
771    Dataset,
772    /// Source bytes live in a Space repository.
773    Space,
774}
775
776impl BucketCopySourceType {
777    /// Wire string sent to the Hub for this source type.
778    pub fn as_str(&self) -> &'static str {
779        match self {
780            BucketCopySourceType::Bucket => "bucket",
781            BucketCopySourceType::Model => "model",
782            BucketCopySourceType::Dataset => "dataset",
783            BucketCopySourceType::Space => "space",
784        }
785    }
786}
787
788/// A server-side copy operation for the batch endpoint.
789///
790/// Represents a `copyFile` entry in the NDJSON batch payload.
791/// Copies are performed by xet hash — no data transfer occurs.
792/// Used as input to [`HFBucket::batch_operations`].
793#[derive(Debug, Clone)]
794pub struct BucketCopyFile {
795    /// Destination path in the bucket.
796    pub path: String,
797    /// Xet content hash to copy.
798    pub xet_hash: String,
799    /// Source repo type. See [`BucketCopySourceType`].
800    pub source_repo_type: BucketCopySourceType,
801    /// Source repo or bucket ID (e.g., `"user/my-bucket"`).
802    pub source_repo_id: String,
803    /// Source file size in bytes, when known. Forwarded to the batch endpoint as `size`.
804    pub size: Option<u64>,
805    /// Modification time in milliseconds since the Unix epoch, when set. Forwarded as `mtime`.
806    /// Python's `_BucketCopyFile` defaults this to "now" at construction time; in Rust callers set
807    /// it explicitly when they want it sent.
808    pub mtime: Option<u64>,
809    /// MIME content type, when known. Forwarded as `contentType`.
810    pub content_type: Option<String>,
811}
812
813/// Metadata about a bucket on the Hugging Face Hub.
814///
815/// Returned by [`HFBucket::info`] and
816/// [`HFClient::list_buckets`].
817#[derive(Debug, Clone, Serialize, Deserialize)]
818pub struct BucketInfo {
819    /// Full bucket identifier, e.g., `"namespace/bucket_name"`.
820    pub id: String,
821    /// Whether the bucket is private.
822    pub private: bool,
823    /// ISO 8601 creation timestamp.
824    #[serde(rename = "createdAt")]
825    pub created_at: String,
826    /// Total size of all files in bytes.
827    pub size: u64,
828    /// Number of files in the bucket.
829    #[serde(rename = "totalFiles")]
830    pub total_files: u64,
831}
832
833/// URL returned after creating a bucket.
834///
835/// Returned by [`HFClient::create_bucket`].
836#[derive(Debug, Clone, Serialize, Deserialize)]
837pub struct BucketUrl {
838    /// Full URL to the bucket on the Hub.
839    pub url: String,
840}
841
842/// A file or directory entry in a bucket tree listing.
843///
844/// Returned by [`HFBucket::list_tree`] and
845/// [`HFBucket::get_paths_info`].
846#[derive(Debug, Clone, Serialize, Deserialize)]
847#[serde(tag = "type", rename_all = "lowercase")]
848pub enum BucketTreeEntry {
849    /// A file entry with content hash and size.
850    File {
851        /// Path within the bucket.
852        path: String,
853        /// File size in bytes.
854        size: u64,
855        /// Xet content-addressable hash.
856        #[serde(rename = "xetHash")]
857        xet_hash: String,
858        /// Last modification time (ISO 8601), if available.
859        mtime: Option<String>,
860        /// Upload timestamp (ISO 8601), if available.
861        uploaded_at: Option<String>,
862    },
863    /// A directory entry.
864    Directory {
865        /// Directory path within the bucket.
866        path: String,
867        /// Upload timestamp (ISO 8601), if available.
868        uploaded_at: Option<String>,
869    },
870}
871
872/// Metadata for a single file in a bucket, retrieved via HEAD request.
873///
874/// Returned by [`HFBucket::get_file_metadata`].
875#[derive(Debug, Clone)]
876pub struct BucketFileMetadata {
877    /// File size in bytes.
878    pub size: u64,
879    /// Xet content-addressable hash.
880    pub xet_hash: String,
881}
882
883/// One file to upload via [`HFBucket::upload_files`].
884///
885/// Pairs a local source path with the destination path inside the bucket.
886/// Using a named struct (rather than a `(local, remote)` tuple) prevents the
887/// two paths from being silently swapped at the call site, where a copy-paste
888/// from [`BucketDownload`] would otherwise compile.
889#[cfg(not(target_family = "wasm"))]
890#[derive(Debug, Clone)]
891pub struct BucketUpload {
892    /// Path on the local filesystem (absolute or relative to the current
893    /// working directory) of the file to upload.
894    pub local: PathBuf,
895    /// **Bucket-relative**, slash-separated destination path (e.g.,
896    /// `"data/train/0001.bin"`) — no leading slash, forward slashes regardless
897    /// of platform, and no `bucket_id` prefix.
898    pub remote: String,
899}
900
901#[cfg(not(target_family = "wasm"))]
902impl BucketUpload {
903    /// Construct a [`BucketUpload`] from a local source path and a bucket-relative
904    /// destination path.
905    pub fn new(local: impl Into<PathBuf>, remote: impl Into<String>) -> Self {
906        Self {
907            local: local.into(),
908            remote: remote.into(),
909        }
910    }
911}
912
913/// One file to download via [`HFBucket::download_files`].
914///
915/// Pairs a bucket-relative source path with the local destination path. Using a
916/// named struct (rather than a `(remote, local)` tuple) prevents the two paths
917/// from being silently swapped at the call site, where a copy-paste from
918/// [`BucketUpload`] would otherwise compile.
919#[cfg(not(target_family = "wasm"))]
920#[derive(Debug, Clone)]
921pub struct BucketDownload {
922    /// **Bucket-relative**, slash-separated source path (e.g.,
923    /// `"data/train/0001.bin"`) — no leading slash, forward slashes regardless
924    /// of platform, and no `bucket_id` prefix. Must resolve to a file entry;
925    /// directory entries return [`HFError::InvalidParameter`].
926    pub remote: String,
927    /// Destination path on the local filesystem. Parent directories are
928    /// created as needed.
929    pub local: PathBuf,
930}
931
932#[cfg(not(target_family = "wasm"))]
933impl BucketDownload {
934    /// Construct a [`BucketDownload`] from a bucket-relative source path and a
935    /// local destination path.
936    pub fn new(remote: impl Into<String>, local: impl Into<PathBuf>) -> Self {
937        Self {
938            remote: remote.into(),
939            local: local.into(),
940        }
941    }
942}
943
944#[bon]
945impl HFClient {
946    /// Create an [`HFBucket`] handle for a bucket.
947    ///
948    /// The returned handle is cheap to clone and can be reused across multiple
949    /// bucket-scoped operations.
950    pub fn bucket(&self, owner: impl Into<String>, name: impl Into<String>) -> HFBucket {
951        HFBucket::new(self.clone(), owner, name)
952    }
953
954    /// Create a new bucket on the Hub. Endpoint: `POST /api/buckets/{namespace}/{name}`.
955    ///
956    /// When `exist_ok` is `true`, an existing bucket is treated as success and a [`BucketUrl`] is
957    /// synthesized locally.
958    ///
959    /// # Parameters
960    ///
961    /// - `namespace` (required): namespace (user or organization) that owns the bucket.
962    /// - `name` (required): bucket name within the namespace.
963    /// - `private` (default `false`): whether the bucket should be private.
964    /// - `resource_group_id`: enterprise resource group ID.
965    /// - `exist_ok` (default `false`): if `true`, do not error when the bucket already exists.
966    #[builder(finish_fn = send, derive(Debug, Clone))]
967    pub async fn create_bucket(
968        &self,
969        /// Namespace (user or organization) that owns the bucket.
970        namespace: &str,
971        /// Bucket name within the namespace.
972        name: &str,
973        /// Whether the bucket should be private.
974        #[builder(default)]
975        private: bool,
976        /// Enterprise resource group ID.
977        #[builder(into)]
978        resource_group_id: Option<String>,
979        /// If `true`, do not error when the bucket already exists.
980        #[builder(default)]
981        exist_ok: bool,
982    ) -> HFResult<BucketUrl> {
983        let url = format!("{}/api/buckets/{}/{}", self.endpoint(), namespace, name);
984
985        let mut body = serde_json::json!({});
986        if private {
987            body["private"] = serde_json::Value::Bool(true);
988        }
989        if let Some(rg) = resource_group_id {
990            body["resourceGroupId"] = serde_json::Value::String(rg);
991        }
992
993        let headers = self.auth_headers();
994        let response = retry::retry(self.retry_config(), || {
995            self.http_client().post(&url).headers(headers.clone()).json(&body).send()
996        })
997        .await?;
998
999        let bucket_id = format!("{}/{}", namespace, name);
1000
1001        if response.status().as_u16() == 409 && exist_ok {
1002            return Ok(BucketUrl {
1003                url: format!("{}/buckets/{}", self.endpoint(), bucket_id),
1004            });
1005        }
1006
1007        let response = self
1008            .check_response(response, Some(&bucket_id), NotFoundContext::Generic)
1009            .await?;
1010        Ok(response.json().await?)
1011    }
1012
1013    /// Delete a bucket from the Hub. Endpoint: `DELETE /api/buckets/{bucket_id}`.
1014    ///
1015    /// # Parameters
1016    ///
1017    /// - `bucket_id` (required): bucket ID in `"owner/name"` format.
1018    /// - `missing_ok` (default `false`): if `true`, do not error when the bucket does not exist.
1019    #[builder(finish_fn = send, derive(Debug, Clone))]
1020    pub async fn delete_bucket(
1021        &self,
1022        /// Bucket ID in `"owner/name"` format.
1023        bucket_id: &str,
1024        /// If `true`, do not error when the bucket does not exist.
1025        #[builder(default)]
1026        missing_ok: bool,
1027    ) -> HFResult<()> {
1028        let url = self.bucket_api_url(bucket_id);
1029
1030        let headers = self.auth_headers();
1031        let response =
1032            retry::retry(self.retry_config(), || self.http_client().delete(&url).headers(headers.clone()).send())
1033                .await?;
1034
1035        if response.status().as_u16() == 404 && missing_ok {
1036            return Ok(());
1037        }
1038
1039        self.check_response(response, Some(bucket_id), NotFoundContext::Bucket).await?;
1040        Ok(())
1041    }
1042
1043    /// List buckets in a namespace.
1044    ///
1045    /// Streams bucket metadata for all buckets owned by the given user or organization.
1046    ///
1047    /// Endpoint: `GET /api/buckets/{namespace}` (paginated).
1048    ///
1049    /// # Parameters
1050    ///
1051    /// - `namespace` (required): user or organization namespace to list buckets for.
1052    #[builder(finish_fn = send, derive(Debug, Clone))]
1053    pub fn list_buckets(
1054        &self,
1055        /// User or organization namespace to list buckets for.
1056        #[builder(into)]
1057        namespace: String,
1058    ) -> HFResult<impl Stream<Item = HFResult<BucketInfo>> + '_> {
1059        let url = Url::parse(&format!("{}/api/buckets/{}", self.endpoint(), namespace))?;
1060        Ok(self.paginate(url, vec![], None))
1061    }
1062
1063    /// Move (rename) a bucket.
1064    ///
1065    /// Endpoint: `POST /api/repos/move` with `type: "bucket"`.
1066    ///
1067    /// # Parameters
1068    ///
1069    /// - `from_id` (required): current bucket ID in `"owner/name"` format.
1070    /// - `to_id` (required): new bucket ID in `"owner/name"` format.
1071    #[builder(finish_fn = send, derive(Debug, Clone))]
1072    pub async fn move_bucket(
1073        &self,
1074        /// Current bucket ID in `"owner/name"` format.
1075        from_id: &str,
1076        /// New bucket ID in `"owner/name"` format.
1077        to_id: &str,
1078    ) -> HFResult<()> {
1079        let url = format!("{}/api/repos/move", self.endpoint());
1080        let body = serde_json::json!({
1081            "fromRepo": from_id,
1082            "toRepo": to_id,
1083            "type": "bucket",
1084        });
1085
1086        let headers = self.auth_headers();
1087        let response = retry::retry(self.retry_config(), || {
1088            self.http_client().post(&url).headers(headers.clone()).json(&body).send()
1089        })
1090        .await?;
1091
1092        self.check_response(response, None, NotFoundContext::Generic).await?;
1093        Ok(())
1094    }
1095}
1096
1097#[cfg(feature = "blocking")]
1098#[bon]
1099impl crate::blocking::HFClientSync {
1100    /// Blocking counterpart of [`HFClient::create_bucket`]. See the async method for parameters
1101    /// and behavior.
1102    #[builder(finish_fn = send, derive(Debug, Clone))]
1103    pub fn create_bucket(
1104        &self,
1105        namespace: &str,
1106        name: &str,
1107        #[builder(default)] private: bool,
1108        #[builder(into)] resource_group_id: Option<String>,
1109        #[builder(default)] exist_ok: bool,
1110    ) -> HFResult<BucketUrl> {
1111        self.runtime.block_on(
1112            self.inner
1113                .create_bucket()
1114                .namespace(namespace)
1115                .name(name)
1116                .private(private)
1117                .maybe_resource_group_id(resource_group_id)
1118                .exist_ok(exist_ok)
1119                .send(),
1120        )
1121    }
1122
1123    /// Blocking counterpart of [`HFClient::delete_bucket`]. See the async method for parameters
1124    /// and behavior.
1125    #[builder(finish_fn = send, derive(Debug, Clone))]
1126    pub fn delete_bucket(&self, bucket_id: &str, #[builder(default)] missing_ok: bool) -> HFResult<()> {
1127        self.runtime
1128            .block_on(self.inner.delete_bucket().bucket_id(bucket_id).missing_ok(missing_ok).send())
1129    }
1130
1131    /// Blocking counterpart of [`HFClient::list_buckets`]. Collects the stream into a
1132    /// `Vec<BucketInfo>`. See the async method for parameters and behavior.
1133    #[builder(finish_fn = send, derive(Debug, Clone))]
1134    pub fn list_buckets(&self, #[builder(into)] namespace: String) -> HFResult<Vec<BucketInfo>> {
1135        self.runtime.block_on(async move {
1136            let stream = self.inner.list_buckets().namespace(namespace).send()?;
1137            futures::pin_mut!(stream);
1138            let mut items = Vec::new();
1139            while let Some(item) = stream.next().await {
1140                items.push(item?);
1141            }
1142            Ok(items)
1143        })
1144    }
1145
1146    /// Blocking counterpart of [`HFClient::move_bucket`]. See the async method for parameters
1147    /// and behavior.
1148    #[builder(finish_fn = send, derive(Debug, Clone))]
1149    pub fn move_bucket(&self, from_id: &str, to_id: &str) -> HFResult<()> {
1150        self.runtime
1151            .block_on(self.inner.move_bucket().from_id(from_id).to_id(to_id).send())
1152    }
1153}
1154
1155#[cfg(feature = "blocking")]
1156#[bon]
1157impl crate::blocking::HFBucketSync {
1158    /// Blocking counterpart of [`HFBucket::info`].
1159    #[builder(finish_fn = send, derive(Debug, Clone))]
1160    pub fn info(&self) -> HFResult<BucketInfo> {
1161        self.runtime.block_on(self.inner.info().send())
1162    }
1163
1164    /// Blocking counterpart of [`HFBucket::list_tree`]. Collects the stream into a
1165    /// `Vec<BucketTreeEntry>`. See the async method for parameters and behavior.
1166    #[builder(finish_fn = send, derive(Debug, Clone))]
1167    pub fn list_tree(
1168        &self,
1169        #[builder(into)] prefix: Option<String>,
1170        #[builder(default)] recursive: bool,
1171    ) -> HFResult<Vec<BucketTreeEntry>> {
1172        self.runtime.block_on(async move {
1173            let stream = self.inner.list_tree().maybe_prefix(prefix).recursive(recursive).send()?;
1174            futures::pin_mut!(stream);
1175            let mut items = Vec::new();
1176            while let Some(item) = stream.next().await {
1177                items.push(item?);
1178            }
1179            Ok(items)
1180        })
1181    }
1182
1183    /// Blocking counterpart of [`HFBucket::get_paths_info`]. See the async method for parameters
1184    /// and behavior.
1185    #[builder(finish_fn = send, derive(Debug, Clone))]
1186    pub fn get_paths_info(&self, paths: Vec<String>) -> HFResult<Vec<BucketTreeEntry>> {
1187        self.runtime.block_on(self.inner.get_paths_info().paths(paths).send())
1188    }
1189
1190    /// Blocking counterpart of [`HFBucket::get_file_metadata`]. See the async method for parameters
1191    /// and behavior.
1192    #[builder(finish_fn = send, derive(Debug, Clone))]
1193    pub fn get_file_metadata(&self, #[builder(into)] remote_path: String) -> HFResult<BucketFileMetadata> {
1194        self.runtime
1195            .block_on(self.inner.get_file_metadata().remote_path(remote_path).send())
1196    }
1197
1198    /// Blocking counterpart of [`HFBucket::batch_operations`]. See the async method for parameters and
1199    /// behavior.
1200    #[builder(finish_fn = send, derive(Debug, Clone))]
1201    pub fn batch_operations(
1202        &self,
1203        #[builder(default)] add_files: Vec<BucketAddFile>,
1204        #[builder(default)] delete: Vec<String>,
1205        #[builder(default)] copy: Vec<BucketCopyFile>,
1206    ) -> HFResult<()> {
1207        self.runtime.block_on(
1208            self.inner
1209                .batch_operations()
1210                .add_files(add_files)
1211                .delete(delete)
1212                .copy(copy)
1213                .send(),
1214        )
1215    }
1216
1217    /// Blocking counterpart of [`HFBucket::delete_files`]. See the async method for parameters
1218    /// and behavior.
1219    #[builder(finish_fn = send, derive(Debug, Clone))]
1220    pub fn delete_files(&self, paths: Vec<String>) -> HFResult<()> {
1221        self.runtime.block_on(self.inner.delete_files().paths(paths).send())
1222    }
1223
1224    /// Blocking counterpart of [`HFBucket::upload_source_files`]. See the async method for
1225    /// parameters and behavior.
1226    #[builder(finish_fn = send, derive(Debug, Clone))]
1227    pub fn upload_source_files(
1228        &self,
1229        files: Vec<(String, crate::repository::AddSource)>,
1230        #[builder(into)] progress: Option<Progress>,
1231    ) -> HFResult<()> {
1232        self.runtime
1233            .block_on(self.inner.upload_source_files().files(files).maybe_progress(progress).send())
1234    }
1235
1236    /// Blocking counterpart of [`HFBucket::upload_files`]. See the async method for parameters
1237    /// and behavior.
1238    #[builder(finish_fn = send, derive(Debug, Clone))]
1239    pub fn upload_files(&self, files: Vec<BucketUpload>, #[builder(into)] progress: Option<Progress>) -> HFResult<()> {
1240        self.runtime
1241            .block_on(self.inner.upload_files().files(files).maybe_progress(progress).send())
1242    }
1243
1244    /// Blocking counterpart of [`HFBucket::download_files`]. See the async method for parameters
1245    /// and behavior.
1246    #[builder(finish_fn = send, derive(Debug, Clone))]
1247    pub fn download_files(
1248        &self,
1249        files: Vec<BucketDownload>,
1250        #[builder(into)] progress: Option<Progress>,
1251    ) -> HFResult<()> {
1252        self.runtime
1253            .block_on(self.inner.download_files().files(files).maybe_progress(progress).send())
1254    }
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259    use super::{BucketCopyFile, BucketCopySourceType, HFBucket};
1260
1261    #[test]
1262    fn test_bucket_accessors() {
1263        let client = crate::HFClient::builder().build().unwrap();
1264        let bucket = HFBucket::new(client, "my-org", "my-bucket");
1265
1266        assert_eq!(bucket.owner(), "my-org");
1267        assert_eq!(bucket.name(), "my-bucket");
1268        assert_eq!(bucket.bucket_id(), "my-org/my-bucket");
1269    }
1270
1271    #[test]
1272    fn test_bucket_copy_source_type_wire_strings() {
1273        assert_eq!(BucketCopySourceType::Bucket.as_str(), "bucket");
1274        assert_eq!(BucketCopySourceType::Model.as_str(), "model");
1275        assert_eq!(BucketCopySourceType::Dataset.as_str(), "dataset");
1276        assert_eq!(BucketCopySourceType::Space.as_str(), "space");
1277    }
1278
1279    #[test]
1280    fn test_bucket_copy_file_optional_fields_construct() {
1281        let with_extras = BucketCopyFile {
1282            path: "p".into(),
1283            xet_hash: "x".into(),
1284            source_repo_type: BucketCopySourceType::Bucket,
1285            source_repo_id: "u/b".into(),
1286            size: Some(42),
1287            mtime: Some(1_700_000_000_000),
1288            content_type: Some("text/plain".into()),
1289        };
1290        assert_eq!(with_extras.size, Some(42));
1291        assert_eq!(with_extras.mtime, Some(1_700_000_000_000));
1292        assert_eq!(with_extras.content_type.as_deref(), Some("text/plain"));
1293
1294        let bare = BucketCopyFile {
1295            path: "p".into(),
1296            xet_hash: "x".into(),
1297            source_repo_type: BucketCopySourceType::Bucket,
1298            source_repo_id: "u/b".into(),
1299            size: None,
1300            mtime: None,
1301            content_type: None,
1302        };
1303        assert!(bare.size.is_none() && bare.mtime.is_none() && bare.content_type.is_none());
1304    }
1305}