Skip to main content

opendal_service_hf/
backend.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::path::PathBuf;
19use std::sync::Arc;
20
21use log::debug;
22
23use super::HF_SCHEME;
24use super::config::HfConfig;
25use super::core::HfCore;
26use super::core::HfDownloadMode;
27use super::core::{HfRepo, HfRepoType};
28use super::deleter::HfDeleter;
29use super::lister::HfLister;
30use super::reader::*;
31use super::writer::HfLazyWriter;
32use opendal_core::raw::*;
33use opendal_core::*;
34
35/// [Hugging Face](https://huggingface.co/docs/huggingface_hub/package_reference/hf_api)'s API support.
36#[doc = include_str!("docs.md")]
37#[derive(Debug, Default)]
38pub struct HfBuilder {
39    pub(super) config: HfConfig,
40}
41
42impl HfBuilder {
43    /// Set repo type of this backend. Default is model.
44    ///
45    /// Available values:
46    /// - model
47    /// - dataset
48    /// - datasets (alias for dataset)
49    /// - space
50    /// - bucket
51    ///
52    /// [Reference](https://huggingface.co/docs/hub/repositories)
53    pub fn repo_type(mut self, repo_type: &str) -> Self {
54        if !repo_type.is_empty()
55            && let Ok(rt) = HfRepoType::parse(repo_type)
56        {
57            self.config.repo_type = Some(rt);
58        }
59        self
60    }
61
62    /// Set repo id of this backend. This is required.
63    ///
64    /// Repo id consists of the account name and the repository name.
65    ///
66    /// For example, model's repo id looks like:
67    /// - meta-llama/Llama-2-7b
68    ///
69    /// Dataset's repo id looks like:
70    /// - databricks/databricks-dolly-15k
71    pub fn repo_id(mut self, repo_id: &str) -> Self {
72        if !repo_id.is_empty() {
73            self.config.repo_id = Some(repo_id.to_string());
74        }
75        self
76    }
77
78    /// Set revision of this backend. Default is main.
79    ///
80    /// Revision can be a branch name or a commit hash.
81    ///
82    /// For example, revision can be:
83    /// - main
84    /// - 1d0c4eb
85    pub fn revision(mut self, revision: &str) -> Self {
86        if !revision.is_empty() {
87            self.config.revision = Some(revision.to_string());
88        }
89        self
90    }
91
92    /// Set root of this backend.
93    ///
94    /// All operations will happen under this root.
95    pub fn root(mut self, root: &str) -> Self {
96        self.config.root = if root.is_empty() {
97            None
98        } else {
99            Some(root.to_string())
100        };
101
102        self
103    }
104
105    /// Set the token of this backend.
106    ///
107    /// This is optional.
108    pub fn token(mut self, token: &str) -> Self {
109        if !token.is_empty() {
110            self.config.token = Some(token.to_string());
111        }
112        self
113    }
114
115    /// Set the download mode. Either `xet` (default) or `http`.
116    ///
117    /// - `xet`: uses the XET protocol for downloads (default).
118    /// - `http`: plain HTTP download, following the redirect from the server.
119    ///
120    /// When this is not set explicitly, the download mode is resolved from the
121    /// `HF_HUB_DISABLE_XET` environment variable (the same variable used by
122    /// `huggingface_hub`): if it is set to a non-empty value, the mode is forced
123    /// to `http`; otherwise it defaults to `xet`. An explicit value set here
124    /// always takes precedence over the environment variable.
125    ///
126    /// See <https://huggingface.co/docs/huggingface_hub/package_reference/environment_variables#hfhubdisablexet>.
127    pub fn download_mode(mut self, mode: &str) -> Self {
128        if !mode.is_empty()
129            && let Ok(m) = HfDownloadMode::parse(mode)
130        {
131            self.config.download_mode = Some(m);
132        }
133        self
134    }
135
136    /// Enable caching of resolved HTTP download addresses and XET file metadata.
137    ///
138    /// Defaults to `false`: each new reader resolves through the Hub. An XET-mode
139    /// reader retains the XET metadata returned by its first read for its lifetime,
140    /// even when this option is disabled. Its subsequent ranges use that file
141    /// version. Create a new reader to resolve the path again. HTTP reads resolve
142    /// each range.
143    ///
144    /// Set to `true` to share resolve results across readers on the same backend.
145    /// HTTP addresses refresh near expiry.
146    ///
147    /// Enable this only when previously written files are not modified. Changed
148    /// files can remain invisible while cached results are reused, including
149    /// changes from other clients or a floating repository revision. Issued
150    /// download URLs can remain usable until expiry after Hub permissions change.
151    /// Separate authorization identities must use separately constructed backends.
152    pub fn enable_resolve_cache(mut self, enabled: bool) -> Self {
153        self.config.enable_resolve_cache = enabled;
154        self
155    }
156
157    /// Set the Hub base URL.
158    ///
159    /// Configure this when your organization uses a
160    /// [Private Hub](https://huggingface.co/enterprise).
161    ///
162    /// The default is `https://huggingface.co`.
163    pub fn endpoint(mut self, endpoint: &str) -> Self {
164        if !endpoint.is_empty() {
165            self.config.endpoint = Some(endpoint.to_string());
166        }
167        self
168    }
169
170    /// Resolve the Hub base URL: an explicit config value wins, then
171    /// `HF_ENDPOINT`, then the public Hub. A trailing slash is trimmed
172    /// because every URL is built by appending `/api/...` to this, and HF
173    /// answers the resulting `//api/...` with a 404.
174    fn hf_endpoint(&self) -> String {
175        self.config
176            .endpoint
177            .clone()
178            .or_else(|| std::env::var("HF_ENDPOINT").ok())
179            .unwrap_or_else(|| "https://huggingface.co".to_string())
180            .trim_end_matches('/')
181            .to_string()
182    }
183
184    /// Resolve the download mode: an explicit config value wins; otherwise a set,
185    /// non-empty HF_HUB_DISABLE_XET (a huggingface_hub env var) forces http; default Xet.
186    fn hf_download_mode(&self) -> HfDownloadMode {
187        if let Some(mode) = self.config.download_mode {
188            return mode;
189        }
190        if let Ok(val) = std::env::var("HF_HUB_DISABLE_XET")
191            && !val.is_empty()
192        {
193            return HfDownloadMode::Http;
194        }
195        HfDownloadMode::default()
196    }
197
198    fn hf_home() -> Option<PathBuf> {
199        if let Ok(h) = std::env::var("HF_HOME") {
200            return Some(PathBuf::from(h));
201        }
202        if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
203            return Some(PathBuf::from(xdg).join("huggingface"));
204        }
205        let home = std::env::var("HOME").ok()?;
206        Some(PathBuf::from(home).join(".cache/huggingface"))
207    }
208
209    /// Resolve the authentication token using the same priority order as hf-hub:
210    /// explicit config → HF_HUB_DISABLE_IMPLICIT_TOKEN check → HF_TOKEN env → token file.
211    fn hf_token(&self) -> Option<String> {
212        if let Some(t) = self.config.token.clone() {
213            return Some(t);
214        }
215        if let Ok(val) = std::env::var("HF_HUB_DISABLE_IMPLICIT_TOKEN")
216            && !val.is_empty()
217        {
218            return None;
219        }
220        if let Ok(t) = std::env::var("HF_TOKEN")
221            && !t.is_empty()
222        {
223            return Some(t);
224        }
225        let token_path = if let Ok(p) = std::env::var("HF_TOKEN_PATH") {
226            Some(PathBuf::from(p))
227        } else {
228            Self::hf_home().map(|h| h.join("token"))
229        };
230        token_path
231            .and_then(|p| std::fs::read_to_string(p).ok())
232            .map(|s| s.trim().to_string())
233            .filter(|s| !s.is_empty())
234    }
235}
236
237impl Builder for HfBuilder {
238    type Config = HfConfig;
239
240    fn build(self) -> Result<impl Service> {
241        debug!("backend build started: {:?}", self);
242
243        let token = self.hf_token();
244        let endpoint = self.hf_endpoint();
245        let download_mode = self.hf_download_mode();
246
247        let repo_type = self.config.repo_type.ok_or_else(|| {
248            Error::new(ErrorKind::ConfigInvalid, "repo_type is required")
249                .with_operation("Builder::build")
250                .with_context("service", HF_SCHEME)
251        })?;
252        debug!("backend use repo_type: {:?}", repo_type);
253
254        let repo_id = self.config.repo_id.ok_or_else(|| {
255            Error::new(ErrorKind::ConfigInvalid, "repo_id is required")
256                .with_operation("Builder::build")
257                .with_context("service", HF_SCHEME)
258        })?;
259        debug!("backend use repo_id: {}", repo_id);
260
261        let revision = match &self.config.revision {
262            Some(revision) => revision.clone(),
263            None => "main".to_string(),
264        };
265        debug!("backend use revision: {}", revision);
266
267        let root = normalize_root(&self.config.root.unwrap_or_default());
268        debug!("backend use root: {}", root);
269        debug!("backend use token: {}", token.is_some());
270        debug!("backend use endpoint: {}", endpoint);
271        debug!("backend use download_mode: {:?}", download_mode);
272
273        let info = ServiceInfo::new(HF_SCHEME, "", "");
274        let capability = Capability {
275            stat: true,
276            read: true,
277            write: token.is_some(),
278            write_can_multi: token.is_some(),
279            delete: token.is_some(),
280            delete_max_size: Some(100),
281            list: true,
282            list_with_recursive: true,
283            shared: true,
284            ..Default::default()
285        };
286
287        let repo = HfRepo::new(repo_type, repo_id, Some(revision.clone()));
288        debug!("backend repo uri: {:?}", repo.uri(&root, ""));
289
290        let mut core = HfCore::build(info, capability, repo, root, token, endpoint, download_mode)?;
291        core.enable_resolve_cache = self.config.enable_resolve_cache;
292        Ok(HfBackend {
293            core: Arc::new(core),
294        })
295    }
296}
297
298/// Backend for Hugging Face service
299#[derive(Debug, Clone)]
300pub struct HfBackend {
301    pub(crate) core: Arc<HfCore>,
302}
303
304impl Service for HfBackend {
305    type Reader = oio::StreamReader<HfReader>;
306    type Writer = HfLazyWriter;
307    type Lister = oio::PageLister<HfLister>;
308    type Deleter = oio::BatchDeleter<HfDeleter>;
309    type Copier = ();
310    type Composer = ();
311
312    fn info(&self) -> ServiceInfo {
313        self.core.info.clone()
314    }
315
316    fn capability(&self) -> Capability {
317        self.core.capability
318    }
319
320    async fn create_dir(
321        &self,
322        _ctx: &OperationContext,
323        _path: &str,
324        _args: OpCreateDir,
325    ) -> Result<RpCreateDir> {
326        Err(Error::new(
327            ErrorKind::Unsupported,
328            "operation is not supported",
329        ))
330    }
331
332    async fn stat(&self, ctx: &OperationContext, path: &str, _: OpStat) -> Result<RpStat> {
333        // Stat root always returns a DIR.
334        if path == "/" {
335            return Ok(RpStat::new(MetadataBuilder::dir().build()));
336        }
337
338        // Buckets have no git directory entries; treat any trailing-slash path as a virtual dir.
339        if self.core.repo.is_bucket() && path.ends_with('/') {
340            return Ok(RpStat::new(MetadataBuilder::dir().build()));
341        }
342
343        let info = self.core.path_info(ctx, path).await?;
344        Ok(RpStat::new(info.metadata()?))
345    }
346    fn read(&self, ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
347        let output: oio::StreamReader<HfReader> = {
348            Ok(oio::StreamReader::new(HfReader::new(
349                self.clone(),
350                ctx.clone(),
351                path,
352                args,
353            )))
354        }?;
355
356        Ok(output)
357    }
358
359    fn list(&self, ctx: &OperationContext, path: &str, args: OpList) -> Result<Self::Lister> {
360        let output: oio::PageLister<HfLister> = {
361            let lister = HfLister::new(
362                self.core.clone(),
363                ctx.clone(),
364                path.to_string(),
365                args.recursive(),
366            );
367            Ok(oio::PageLister::new(lister))
368        }?;
369
370        Ok(output)
371    }
372
373    fn write(&self, ctx: &OperationContext, path: &str, _args: OpWrite) -> Result<Self::Writer> {
374        Ok(HfLazyWriter::new(
375            self.core.clone(),
376            ctx.clone(),
377            path.to_string(),
378        ))
379    }
380
381    fn delete(&self, ctx: &OperationContext) -> Result<Self::Deleter> {
382        let output: oio::BatchDeleter<HfDeleter> = {
383            let deleter = HfDeleter::new(self.core.clone(), ctx.clone());
384            let max_batch_size = self.core.capability.delete_max_size;
385            Ok(oio::BatchDeleter::new(deleter, max_batch_size))
386        }?;
387
388        Ok(output)
389    }
390
391    fn copy(
392        &self,
393        _ctx: &OperationContext,
394        _from: &str,
395        _to: &str,
396        _args: OpCopy,
397    ) -> Result<Self::Copier> {
398        Err(Error::new(
399            ErrorKind::Unsupported,
400            "operation is not supported",
401        ))
402    }
403
404    async fn rename(
405        &self,
406        _ctx: &OperationContext,
407        _from: &str,
408        _to: &str,
409        _args: OpRename,
410    ) -> Result<RpRename> {
411        Err(Error::new(
412            ErrorKind::Unsupported,
413            "operation is not supported",
414        ))
415    }
416
417    async fn presign(
418        &self,
419        _ctx: &OperationContext,
420        _path: &str,
421        _args: OpPresign,
422    ) -> Result<RpPresign> {
423        Err(Error::new(
424            ErrorKind::Unsupported,
425            "operation is not supported",
426        ))
427    }
428}
429
430#[cfg(test)]
431pub(super) mod test_utils {
432    use std::sync::Arc;
433
434    use super::super::core::{HfCore, HfDownloadMode};
435    use super::super::core::{HfRepo, HfRepoType};
436    use super::HfBuilder;
437    use opendal_core::Capability;
438    use opendal_core::HttpTransporter;
439    use opendal_core::OperationContext;
440    use opendal_core::Operator;
441    use opendal_core::raw::ServiceInfo;
442
443    fn finish_operator(op: Operator) -> Operator {
444        let transport =
445            HttpTransporter::new(opendal_http_transport_reqwest::ReqwestTransport::default());
446        op.with_context(OperationContext::new().with_http_transport(transport))
447    }
448
449    pub fn mbpp_operator() -> Operator {
450        let op = Operator::new(
451            HfBuilder::default()
452                .repo_type("dataset")
453                .repo_id("google-research-datasets/mbpp"),
454        )
455        .unwrap();
456        finish_operator(op)
457    }
458
459    /// Same public dataset as [`mbpp_operator`], but with the repo id cased
460    /// differently from the canonical `google-research-datasets/mbpp`. HF
461    /// answers every request for it with a `307` to the canonical URL.
462    pub fn miscased_mbpp_operator() -> Operator {
463        let op = Operator::new(
464            HfBuilder::default()
465                .repo_type("dataset")
466                .repo_id("Google-Research-Datasets/MBPP"),
467        )
468        .unwrap();
469        finish_operator(op)
470    }
471
472    pub fn testing_dataset_core() -> Arc<HfCore> {
473        let repo_id = std::env::var("HF_OPENDAL_DATASET").expect("HF_OPENDAL_DATASET must be set");
474        let token = std::env::var("HF_OPENDAL_TOKEN").expect("HF_OPENDAL_TOKEN must be set");
475
476        let info = ServiceInfo::new("hf", "", "");
477        let capability = Capability {
478            read: true,
479            write: true,
480            delete: true,
481            ..Default::default()
482        };
483
484        let repo = HfRepo::new(HfRepoType::Dataset, repo_id, Some("main".to_string()));
485
486        Arc::new(
487            HfCore::build(
488                info,
489                capability,
490                repo,
491                "/".to_string(),
492                Some(token),
493                "https://huggingface.co".to_string(),
494                HfDownloadMode::Xet,
495            )
496            .expect("failed to build HfCore"),
497        )
498    }
499
500    pub fn testing_bucket_operator() -> Operator {
501        let repo_id = std::env::var("HF_OPENDAL_BUCKET").expect("HF_OPENDAL_BUCKET must be set");
502        let token = std::env::var("HF_OPENDAL_TOKEN").expect("HF_OPENDAL_TOKEN must be set");
503        let op = Operator::new(
504            HfBuilder::default()
505                .repo_type("bucket")
506                .repo_id(&repo_id)
507                .token(&token),
508        )
509        .unwrap();
510        finish_operator(op)
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use std::sync::Mutex;
518
519    // Env vars are process-global; serialize all tests that mutate them.
520    static ENV_LOCK: Mutex<()> = Mutex::new(());
521
522    fn builder_with_token(token: &str) -> HfBuilder {
523        HfBuilder::default().token(token)
524    }
525
526    fn builder_no_token() -> HfBuilder {
527        HfBuilder::default()
528    }
529
530    #[test]
531    fn hf_token_config_takes_priority_over_env() {
532        let _guard = ENV_LOCK.lock().unwrap();
533        unsafe { std::env::set_var("HF_TOKEN", "env-token") };
534        let result = builder_with_token("config-token").hf_token();
535        unsafe { std::env::remove_var("HF_TOKEN") };
536        assert_eq!(result.as_deref(), Some("config-token"));
537    }
538
539    #[test]
540    fn hf_token_reads_hf_token_env_var() {
541        let _guard = ENV_LOCK.lock().unwrap();
542        unsafe { std::env::remove_var("HF_HUB_DISABLE_IMPLICIT_TOKEN") };
543        unsafe { std::env::remove_var("HF_TOKEN_PATH") };
544        unsafe { std::env::set_var("HF_TOKEN", "my-env-token") };
545        let result = builder_no_token().hf_token();
546        unsafe { std::env::remove_var("HF_TOKEN") };
547        assert_eq!(result.as_deref(), Some("my-env-token"));
548    }
549
550    #[test]
551    fn hf_token_disable_flag_suppresses_discovery() {
552        let _guard = ENV_LOCK.lock().unwrap();
553        unsafe { std::env::set_var("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1") };
554        unsafe { std::env::set_var("HF_TOKEN", "my-env-token") };
555        let result = builder_no_token().hf_token();
556        unsafe { std::env::remove_var("HF_HUB_DISABLE_IMPLICIT_TOKEN") };
557        unsafe { std::env::remove_var("HF_TOKEN") };
558        assert_eq!(result, None);
559    }
560
561    #[test]
562    fn hf_token_reads_from_file_via_hf_token_path() {
563        let _guard = ENV_LOCK.lock().unwrap();
564        let token_file = std::env::temp_dir().join("opendal-hf-token-test");
565        std::fs::write(&token_file, "file-token\n").unwrap();
566        unsafe { std::env::remove_var("HF_HUB_DISABLE_IMPLICIT_TOKEN") };
567        unsafe { std::env::remove_var("HF_TOKEN") };
568        unsafe { std::env::set_var("HF_TOKEN_PATH", &token_file) };
569        let result = builder_no_token().hf_token();
570        unsafe { std::env::remove_var("HF_TOKEN_PATH") };
571        std::fs::remove_file(&token_file).ok();
572        assert_eq!(result.as_deref(), Some("file-token"));
573    }
574
575    #[test]
576    fn hf_endpoint_trims_trailing_slash() {
577        let _guard = ENV_LOCK.lock().unwrap();
578        unsafe { std::env::remove_var("HF_ENDPOINT") };
579        assert_eq!(
580            HfBuilder::default()
581                .endpoint("https://hub.example.com/")
582                .hf_endpoint(),
583            "https://hub.example.com"
584        );
585    }
586
587    #[test]
588    fn hf_endpoint_returns_default() {
589        let _guard = ENV_LOCK.lock().unwrap();
590        unsafe { std::env::remove_var("HF_ENDPOINT") };
591        let result = HfBuilder::default().hf_endpoint();
592        assert_eq!(result, "https://huggingface.co");
593    }
594
595    #[test]
596    fn hf_endpoint_config_takes_priority_over_env() {
597        let _guard = ENV_LOCK.lock().unwrap();
598        unsafe { std::env::set_var("HF_ENDPOINT", "https://env.example.com") };
599        let result = HfBuilder::default()
600            .endpoint("https://config.example.com")
601            .hf_endpoint();
602        unsafe { std::env::remove_var("HF_ENDPOINT") };
603        assert_eq!(result, "https://config.example.com");
604    }
605
606    #[test]
607    fn hf_endpoint_reads_hf_endpoint_env_var() {
608        let _guard = ENV_LOCK.lock().unwrap();
609        unsafe { std::env::set_var("HF_ENDPOINT", "https://env.example.com") };
610        let result = HfBuilder::default().hf_endpoint();
611        unsafe { std::env::remove_var("HF_ENDPOINT") };
612        assert_eq!(result, "https://env.example.com");
613    }
614
615    #[test]
616    fn hf_download_mode_defaults_to_xet() {
617        let _guard = ENV_LOCK.lock().unwrap();
618        unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
619        assert_eq!(HfBuilder::default().hf_download_mode(), HfDownloadMode::Xet);
620    }
621
622    #[test]
623    fn hf_download_mode_disable_xet_env_forces_http() {
624        let _guard = ENV_LOCK.lock().unwrap();
625        unsafe { std::env::set_var("HF_HUB_DISABLE_XET", "1") };
626        let mode = HfBuilder::default().hf_download_mode();
627        unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
628        assert_eq!(mode, HfDownloadMode::Http);
629    }
630
631    #[test]
632    fn hf_download_mode_config_takes_priority_over_env() {
633        let _guard = ENV_LOCK.lock().unwrap();
634        unsafe { std::env::set_var("HF_HUB_DISABLE_XET", "1") };
635        let mode = HfBuilder::default().download_mode("xet").hf_download_mode();
636        unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
637        assert_eq!(mode, HfDownloadMode::Xet);
638    }
639
640    #[test]
641    fn hf_download_mode_empty_env_keeps_xet() {
642        let _guard = ENV_LOCK.lock().unwrap();
643        unsafe { std::env::set_var("HF_HUB_DISABLE_XET", "") };
644        let mode = HfBuilder::default().hf_download_mode();
645        unsafe { std::env::remove_var("HF_HUB_DISABLE_XET") };
646        assert_eq!(mode, HfDownloadMode::Xet);
647    }
648
649    #[tokio::test]
650    async fn build_resolve_cache_requires_opt_in() -> Result<()> {
651        use super::super::core::test_utils::MockHttpTransport;
652
653        for enabled in [None, Some(false), Some(true)] {
654            let mut builder = HfBuilder::default()
655                .repo_type("model")
656                .repo_id("org/repo")
657                .download_mode("xet");
658            if let Some(enabled) = enabled {
659                builder = builder.enable_resolve_cache(enabled);
660            }
661            let transport = MockHttpTransport::new();
662            let ctx = OperationContext::new()
663                .with_http_transport(HttpTransporter::new(transport.clone()));
664            let op = Operator::new(builder)?.with_context(ctx);
665            for reads in 1..=2 {
666                assert_eq!(op.read("plain.txt").await?.to_vec(), b"hello");
667                assert_eq!(
668                    transport.request_count(),
669                    reads + usize::from(enabled == Some(true))
670                );
671            }
672        }
673        Ok(())
674    }
675
676    #[test]
677    fn build_accepts_datasets_alias() {
678        HfBuilder::default()
679            .repo_id("org/repo")
680            .repo_type("datasets")
681            .build()
682            .expect("builder should accept datasets alias");
683    }
684
685    #[test]
686    fn build_accepts_space_repo_type() {
687        HfBuilder::default()
688            .repo_id("org/space")
689            .repo_type("space")
690            .build()
691            .expect("builder should accept space repo type");
692    }
693
694    #[test]
695    fn test_both_schemes_are_supported() {
696        use opendal_core::OperatorRegistry;
697
698        let registry = OperatorRegistry::get();
699        super::super::register_hf_service(registry);
700
701        // Test short scheme "hf"
702        let op = registry
703            .load("hf://user/repo")
704            .expect("short scheme should be registered and work");
705        assert_eq!(op.info().scheme(), "hf");
706
707        // Test long scheme "huggingface"
708        let op = registry
709            .load("huggingface://user/repo")
710            .expect("long scheme should be registered and work");
711        assert_eq!(op.info().scheme(), "hf");
712    }
713}