Skip to main content

model_hf/
lib.rs

1mod cache_paths;
2pub mod store;
3
4pub use cache_paths::{
5    DownloadDirectoryFallback, DownloadDirectoryKind, PreparedDownloadDirectories,
6    download_cache_diagnostic, download_cache_diagnostic_for, huggingface_hub_cache_dir,
7    huggingface_xet_cache_dir, mesh_llm_cache_dir, prepare_download_directories,
8};
9
10use std::{
11    ffi::OsStr,
12    path::{Path, PathBuf},
13    time::Duration,
14};
15
16use anyhow::{Context, Result};
17use async_trait::async_trait;
18use hf_hub::{
19    HFClient, HFClientBuilder, RepoType, RepoTypeModel,
20    cache::{CachedRepoInfo, HFCacheInfo},
21    repository::ModelInfo,
22};
23use model_artifact::{ModelArtifactFile, ModelIdentity, ModelRepository, ResolvedModelArtifact};
24use model_ref::{
25    format_canonical_ref, format_model_ref, normalize_gguf_distribution_id,
26    quant_selector_from_gguf_file,
27};
28use serde::{Deserialize, Serialize};
29
30const HF_RETRY_MAX_ATTEMPTS_ENV: &str = "MESH_HF_RETRY_MAX_ATTEMPTS";
31const HF_RETRY_BASE_DELAY_MS_ENV: &str = "MESH_HF_RETRY_BASE_DELAY_MS";
32
33#[derive(Clone)]
34pub struct HfModelRepository {
35    api: HFClient,
36    cache_dir: PathBuf,
37}
38
39impl HfModelRepository {
40    pub fn from_env() -> Result<Self> {
41        Self::builder().build()
42    }
43
44    pub fn builder() -> HfModelRepositoryBuilder {
45        HfModelRepositoryBuilder::default()
46    }
47
48    pub fn cache_dir(&self) -> &Path {
49        &self.cache_dir
50    }
51
52    pub async fn download_file(&self, repo: &str, revision: &str, file: &str) -> Result<PathBuf> {
53        let (owner, name) = repo_parts(repo);
54        self.api
55            .model(owner, name)
56            .download_file()
57            .filename(file.to_string())
58            .revision(revision.to_string())
59            .send()
60            .await
61            .with_context(|| {
62                format!(
63                    "download Hugging Face model file {repo}@{revision}/{file}. {}",
64                    download_cache_diagnostic_for(&self.cache_dir)
65                )
66            })
67    }
68
69    pub async fn download_artifact_files(
70        &self,
71        artifact: &ResolvedModelArtifact,
72    ) -> Result<Vec<PathBuf>> {
73        let mut paths = Vec::with_capacity(artifact.files.len());
74        for file in &artifact.files {
75            paths.push(
76                self.download_file(&artifact.source_repo, &artifact.source_revision, &file.path)
77                    .await?,
78            );
79        }
80        Ok(paths)
81    }
82
83    pub fn identity_for_path(&self, path: &Path) -> Option<HfModelIdentity> {
84        huggingface_identity_for_path_in_cache(path, &self.cache_dir)
85    }
86}
87
88#[derive(Default)]
89pub struct HfModelRepositoryBuilder {
90    cache_dir: Option<PathBuf>,
91    endpoint: Option<String>,
92    token: Option<String>,
93    retry_max_attempts: Option<usize>,
94    retry_base_delay: Option<Duration>,
95}
96
97impl HfModelRepositoryBuilder {
98    pub fn cache_dir(mut self, cache_dir: impl Into<PathBuf>) -> Self {
99        self.cache_dir = Some(cache_dir.into());
100        self
101    }
102
103    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
104        self.endpoint = Some(endpoint.into());
105        self
106    }
107
108    pub fn token(mut self, token: impl Into<String>) -> Self {
109        self.token = Some(token.into());
110        self
111    }
112
113    pub fn retry_max_attempts(mut self, max_attempts: usize) -> Self {
114        self.retry_max_attempts = Some(max_attempts);
115        self
116    }
117
118    pub fn retry_base_delay(mut self, delay: Duration) -> Self {
119        self.retry_base_delay = Some(delay);
120        self
121    }
122
123    pub fn build(self) -> Result<HfModelRepository> {
124        let cache_dir = self.cache_dir.unwrap_or_else(huggingface_hub_cache_dir);
125        let mut builder = HFClientBuilder::new()
126            .cache_dir(cache_dir.clone())
127            .retry_max_attempts(6)
128            .retry_base_delay(Duration::from_millis(500));
129
130        let endpoint = self
131            .endpoint
132            .or_else(|| std::env::var("HF_ENDPOINT").ok())
133            .map(|endpoint| endpoint.trim().to_string())
134            .filter(|endpoint| !endpoint.is_empty());
135        if let Some(endpoint) = endpoint {
136            builder = builder.endpoint(endpoint);
137        }
138
139        let token = self.token.or_else(hf_token_override);
140        if let Some(token) = token {
141            builder = builder.token(token);
142        }
143
144        let retry_max_attempts = self
145            .retry_max_attempts
146            .or_else(|| env_usize(HF_RETRY_MAX_ATTEMPTS_ENV));
147        if let Some(max_attempts) = retry_max_attempts {
148            builder = builder.retry_max_attempts(max_attempts);
149        }
150
151        let retry_base_delay = self
152            .retry_base_delay
153            .or_else(|| env_duration_millis(HF_RETRY_BASE_DELAY_MS_ENV));
154        if let Some(delay) = retry_base_delay {
155            builder = builder.retry_base_delay(delay);
156        }
157
158        let api = builder.build().context("build Hugging Face API client")?;
159        Ok(HfModelRepository { api, cache_dir })
160    }
161}
162
163#[async_trait]
164impl ModelRepository for HfModelRepository {
165    async fn resolve_revision(&self, repo: &str, revision: Option<&str>) -> Result<String> {
166        let revision = revision.unwrap_or("main");
167        self.repo_info(repo, revision)
168            .await?
169            .sha
170            .with_context(|| format!("Hugging Face repo {repo}@{revision} did not return a sha"))
171    }
172
173    async fn list_files(&self, repo: &str, revision: &str) -> Result<Vec<ModelArtifactFile>> {
174        let info = self.repo_info(repo, revision).await?;
175        Ok(info
176            .siblings
177            .unwrap_or_default()
178            .into_iter()
179            .map(|sibling| ModelArtifactFile {
180                path: sibling.rfilename,
181                size_bytes: sibling.size,
182                sha256: None,
183            })
184            .collect())
185    }
186}
187
188impl HfModelRepository {
189    async fn repo_info(&self, repo: &str, revision: &str) -> Result<ModelInfo> {
190        let (owner, name) = repo_parts(repo);
191        self.api
192            .model(owner, name)
193            .info()
194            .revision(revision.to_string())
195            .send()
196            .await
197            .with_context(|| format!("fetch Hugging Face model repo {repo}@{revision}"))
198    }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct HfModelIdentity {
203    pub model_id: String,
204    pub repo_id: String,
205    pub revision: String,
206    pub file: String,
207    pub canonical_ref: String,
208    pub distribution_id: Option<String>,
209    pub selector: Option<String>,
210}
211
212impl HfModelIdentity {
213    pub fn to_model_identity(&self) -> ModelIdentity {
214        ModelIdentity {
215            model_id: self.model_id.clone(),
216            source_repo: Some(self.repo_id.clone()),
217            source_revision: Some(self.revision.clone()),
218            source_file: Some(self.file.clone()),
219            canonical_ref: Some(self.canonical_ref.clone()),
220            distribution_id: self.distribution_id.clone(),
221            selector: self.selector.clone(),
222        }
223    }
224
225    pub fn distribution_ref(&self) -> Option<String> {
226        self.distribution_id.as_ref().map(|distribution_id| {
227            format!("{}@{}/{}", self.repo_id, self.revision, distribution_id)
228        })
229    }
230}
231
232pub fn hf_token_override() -> Option<String> {
233    for key in ["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"] {
234        if let Ok(token) = std::env::var(key) {
235            let token = token.trim();
236            if !token.is_empty() {
237                return Some(token.to_string());
238            }
239        }
240    }
241    None
242}
243
244pub fn huggingface_repo_folder_name(repo_id: &str, repo_type: impl RepoType) -> String {
245    let type_plural = repo_type.plural();
246    std::iter::once(type_plural)
247        .chain(repo_id.split('/'))
248        .collect::<Vec<_>>()
249        .join("--")
250}
251
252pub fn huggingface_snapshot_path(
253    repo_id: &str,
254    repo_type: impl RepoType,
255    revision: &str,
256) -> PathBuf {
257    huggingface_hub_cache_dir()
258        .join(huggingface_repo_folder_name(repo_id, repo_type))
259        .join("snapshots")
260        .join(revision)
261}
262
263pub fn huggingface_identity_for_path_in_cache(
264    path: &Path,
265    cache_root: &Path,
266) -> Option<HfModelIdentity> {
267    if let Some(identity) = identity_from_cache_snapshot_path(path, cache_root) {
268        return Some(identity);
269    }
270    let resolved_cache_root = cache_root
271        .canonicalize()
272        .unwrap_or_else(|_| cache_root.to_path_buf());
273    if resolved_cache_root != cache_root
274        && let Some(identity) = identity_from_cache_snapshot_path(path, &resolved_cache_root)
275    {
276        return Some(identity);
277    }
278    let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
279    if resolved != path {
280        if let Some(identity) = identity_from_cache_snapshot_path(&resolved, cache_root) {
281            return Some(identity);
282        }
283        if resolved_cache_root != cache_root
284            && let Some(identity) =
285                identity_from_cache_snapshot_path(&resolved, &resolved_cache_root)
286        {
287            return Some(identity);
288        }
289    }
290    if let Some(identity) = identity_from_snapshot_layout_ancestors(path) {
291        return Some(identity);
292    }
293    if resolved != path
294        && let Some(identity) = identity_from_snapshot_layout_ancestors(&resolved)
295    {
296        return Some(identity);
297    }
298    scan_hf_cache_identity_for_path(path, cache_root)
299}
300
301fn identity_from_cache_snapshot_path(path: &Path, cache_root: &Path) -> Option<HfModelIdentity> {
302    let relative = path.strip_prefix(cache_root).ok()?;
303    let mut components = relative.components();
304    let repo_folder = components.next()?.as_os_str().to_str()?;
305    let repo_id = parse_model_repo_folder_name(repo_folder)?;
306    if components.next()?.as_os_str() != OsStr::new("snapshots") {
307        return None;
308    }
309    let revision = components.next()?.as_os_str().to_str()?.to_string();
310    let file = components
311        .map(|component| component.as_os_str().to_str())
312        .collect::<Option<Vec<_>>>()?
313        .join("/");
314    if file.is_empty() {
315        return None;
316    }
317    Some(identity_from_parts(repo_id, revision, file))
318}
319
320fn identity_from_snapshot_layout_ancestors(path: &Path) -> Option<HfModelIdentity> {
321    for revision_dir in path.ancestors() {
322        let Some(snapshots_dir) = revision_dir.parent() else {
323            continue;
324        };
325        if snapshots_dir.file_name()? != OsStr::new("snapshots") {
326            continue;
327        }
328        let repo_dir = snapshots_dir.parent()?;
329        let repo_folder = repo_dir.file_name()?.to_str()?;
330        let repo_id = parse_model_repo_folder_name(repo_folder)?;
331        let revision = revision_dir.file_name()?.to_str()?.to_string();
332        let file = path
333            .strip_prefix(revision_dir)
334            .ok()?
335            .components()
336            .map(|component| component.as_os_str().to_str())
337            .collect::<Option<Vec<_>>>()?
338            .join("/");
339        if file.is_empty() {
340            continue;
341        }
342        return Some(identity_from_parts(repo_id, revision, file));
343    }
344    None
345}
346
347fn scan_hf_cache_identity_for_path(path: &Path, cache_root: &Path) -> Option<HfModelIdentity> {
348    let cache_info = scan_hf_cache_info(cache_root)?;
349    let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
350
351    for repo in &cache_info.repos {
352        let Some(repo_id) = cache_repo_id(repo) else {
353            continue;
354        };
355        for revision in &repo.revisions {
356            for file in &revision.files {
357                let candidate = file
358                    .file_path
359                    .canonicalize()
360                    .unwrap_or_else(|_| file.file_path.clone());
361                if file.file_path != path && candidate != resolved {
362                    continue;
363                }
364
365                let relative_path = file
366                    .file_path
367                    .strip_prefix(&revision.snapshot_path)
368                    .ok()?
369                    .to_string_lossy()
370                    .replace('\\', "/");
371                if relative_path.is_empty() {
372                    return None;
373                }
374
375                return Some(identity_from_parts(
376                    repo_id.to_string(),
377                    revision.commit_hash.clone(),
378                    relative_path,
379                ));
380            }
381        }
382    }
383    None
384}
385
386fn scan_hf_cache_info(cache_root: &Path) -> Option<HFCacheInfo> {
387    let cache_root = cache_root.to_path_buf();
388    let scan = move || {
389        let runtime = tokio::runtime::Builder::new_current_thread()
390            .enable_all()
391            .build()
392            .ok()?;
393        runtime
394            .block_on(
395                HFClientBuilder::new()
396                    .cache_dir(cache_root)
397                    .build()
398                    .ok()?
399                    .scan_cache()
400                    .send(),
401            )
402            .ok()
403    };
404
405    if tokio::runtime::Handle::try_current().is_ok() {
406        std::thread::spawn(scan).join().ok().flatten()
407    } else {
408        scan()
409    }
410}
411
412fn identity_from_parts(repo_id: String, revision: String, file: String) -> HfModelIdentity {
413    let selector = quant_selector_from_gguf_file(&file);
414    let model_id = format_model_ref(&repo_id, None, selector.as_deref());
415    let distribution_id = normalize_gguf_distribution_id(&file);
416    let canonical_ref = format_canonical_ref(&repo_id, &revision, &file);
417    HfModelIdentity {
418        model_id,
419        repo_id,
420        revision,
421        file,
422        canonical_ref,
423        distribution_id,
424        selector,
425    }
426}
427
428fn cache_repo_id(repo: &CachedRepoInfo) -> Option<&str> {
429    (repo.repo_type == RepoTypeModel.singular()).then_some(repo.repo_id.as_str())
430}
431
432fn parse_model_repo_folder_name(folder: &str) -> Option<String> {
433    folder
434        .strip_prefix("models--")
435        .map(|value| value.replace("--", "/"))
436}
437
438fn repo_parts(repo: &str) -> (&str, &str) {
439    repo.split_once('/').unwrap_or(("", repo))
440}
441
442fn env_usize(key: &str) -> Option<usize> {
443    let value = std::env::var(key).ok()?;
444    value.trim().parse().ok()
445}
446
447fn env_duration_millis(key: &str) -> Option<Duration> {
448    env_usize(key).map(|millis| Duration::from_millis(millis as u64))
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use std::path::Path;
455    use std::sync::{
456        Arc, Mutex,
457        atomic::{AtomicUsize, Ordering},
458    };
459    use std::time::Duration;
460
461    #[test]
462    fn cache_path_identity_matches_mesh_snapshot_layout() {
463        let cache_root = PathBuf::from("/cache/hub");
464        let path = cache_root
465            .join("models--org--repo")
466            .join("snapshots")
467            .join("abc123")
468            .join("Qwen3-8B-Q4_K_M.gguf");
469
470        let identity = huggingface_identity_for_path_in_cache(&path, &cache_root).unwrap();
471        assert_eq!(identity.model_id, "org/repo:Q4_K_M");
472        assert_eq!(identity.repo_id, "org/repo");
473        assert_eq!(identity.revision, "abc123");
474        assert_eq!(identity.file, "Qwen3-8B-Q4_K_M.gguf");
475        assert_eq!(
476            identity.canonical_ref,
477            "org/repo@abc123/Qwen3-8B-Q4_K_M.gguf"
478        );
479        assert_eq!(identity.distribution_id.as_deref(), Some("Qwen3-8B-Q4_K_M"));
480        assert_eq!(
481            identity.distribution_ref().as_deref(),
482            Some("org/repo@abc123/Qwen3-8B-Q4_K_M")
483        );
484    }
485
486    #[test]
487    fn cache_path_identity_collapses_split_gguf_distribution() {
488        let cache_root = PathBuf::from("/cache/hub");
489        let path = cache_root
490            .join("models--org--repo")
491            .join("snapshots")
492            .join("abc123")
493            .join("UD-IQ2_M")
494            .join("GLM-5.1-UD-IQ2_M-00001-of-00006.gguf");
495
496        let identity = huggingface_identity_for_path_in_cache(&path, &cache_root).unwrap();
497        assert_eq!(identity.model_id, "org/repo:UD-IQ2_M");
498        assert_eq!(identity.selector.as_deref(), Some("UD-IQ2_M"));
499        assert_eq!(
500            identity.distribution_id.as_deref(),
501            Some("GLM-5.1-UD-IQ2_M")
502        );
503    }
504
505    #[test]
506    fn cache_path_identity_falls_back_to_snapshot_layout_ancestors() {
507        let path = PathBuf::from("/alternate/root")
508            .join("models--org--repo")
509            .join("snapshots")
510            .join("abc123")
511            .join("nested")
512            .join("Qwen3-8B-Q4_K_M.gguf");
513
514        let identity =
515            huggingface_identity_for_path_in_cache(&path, Path::new("/unrelated/cache")).unwrap();
516
517        assert_eq!(identity.model_id, "org/repo:Q4_K_M");
518        assert_eq!(identity.repo_id, "org/repo");
519        assert_eq!(identity.revision, "abc123");
520        assert_eq!(identity.file, "nested/Qwen3-8B-Q4_K_M.gguf");
521    }
522
523    #[test]
524    fn repo_folder_name_matches_huggingface_cache_layout() {
525        assert_eq!(
526            huggingface_repo_folder_name("org/repo", RepoTypeModel),
527            "models--org--repo"
528        );
529    }
530
531    #[tokio::test]
532    async fn download_file_resumes_existing_incomplete_cache_blob() {
533        let body = Arc::new(b"abcdefghij".to_vec());
534        let ranges = Arc::new(Mutex::new(Vec::new()));
535        let endpoint = start_http_resume_server(Arc::clone(&body), Arc::clone(&ranges));
536
537        let cache_dir = tempfile::tempdir().unwrap();
538        let incomplete = cache_dir
539            .path()
540            .join("models--owner--repo")
541            .join("blobs")
542            .join(format!("{TEST_ETAG}.incomplete"));
543        std::fs::create_dir_all(incomplete.parent().unwrap()).unwrap();
544        std::fs::write(&incomplete, b"abcd").unwrap();
545
546        let repo = HfModelRepository::builder()
547            .endpoint(endpoint)
548            .cache_dir(cache_dir.path())
549            .build()
550            .unwrap();
551
552        let path = repo
553            .download_file("owner/repo", "main", "model.bin")
554            .await
555            .unwrap();
556
557        assert_eq!(std::fs::read(path).unwrap(), body.as_slice());
558        assert!(
559            ranges
560                .lock()
561                .unwrap()
562                .iter()
563                .any(|range| range == "bytes=4-")
564        );
565    }
566
567    #[tokio::test]
568    async fn retry_config_recovers_rate_limited_repo_info() {
569        let endpoint = start_rate_limited_repo_info_server(1);
570        let cache_dir = tempfile::tempdir().unwrap();
571        let repo = HfModelRepository::builder()
572            .endpoint(endpoint)
573            .cache_dir(cache_dir.path())
574            .retry_max_attempts(1)
575            .retry_base_delay(Duration::from_millis(1))
576            .build()
577            .unwrap();
578
579        let revision = repo
580            .resolve_revision("owner/repo", Some("main"))
581            .await
582            .unwrap();
583
584        assert_eq!(revision, TEST_COMMIT);
585    }
586
587    #[tokio::test]
588    async fn retry_config_can_disable_rate_limited_retry() {
589        let endpoint = start_rate_limited_repo_info_server(1);
590        let cache_dir = tempfile::tempdir().unwrap();
591        let repo = HfModelRepository::builder()
592            .endpoint(endpoint)
593            .cache_dir(cache_dir.path())
594            .retry_max_attempts(0)
595            .retry_base_delay(Duration::from_millis(1))
596            .build()
597            .unwrap();
598
599        let error = repo
600            .resolve_revision("owner/repo", Some("main"))
601            .await
602            .unwrap_err();
603
604        assert!(
605            error.to_string().contains("fetch Hugging Face model repo"),
606            "unexpected error: {error:?}"
607        );
608        assert!(
609            format!("{error:?}").contains("Rate limited"),
610            "unexpected error chain: {error:?}"
611        );
612    }
613
614    const TEST_COMMIT: &str = "0123456789012345678901234567890123456789";
615    const TEST_ETAG: &str = "etag-http";
616
617    fn start_http_resume_server(body: Arc<Vec<u8>>, ranges: Arc<Mutex<Vec<String>>>) -> String {
618        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
619        let addr = listener.local_addr().unwrap();
620        std::thread::spawn(move || {
621            for connection in listener.incoming() {
622                let Ok(mut stream) = connection else {
623                    return;
624                };
625                let body = Arc::clone(&body);
626                let ranges = Arc::clone(&ranges);
627                std::thread::spawn(move || handle_resume_request(&mut stream, &body, &ranges));
628            }
629        });
630        format!("http://{addr}")
631    }
632
633    fn start_rate_limited_repo_info_server(rate_limited_attempts: usize) -> String {
634        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
635        let addr = listener.local_addr().unwrap();
636        let attempts = Arc::new(AtomicUsize::new(0));
637        std::thread::spawn(move || {
638            for connection in listener.incoming() {
639                let Ok(mut stream) = connection else {
640                    return;
641                };
642                let attempts = Arc::clone(&attempts);
643                std::thread::spawn(move || {
644                    handle_rate_limited_repo_info_request(
645                        &mut stream,
646                        &attempts,
647                        rate_limited_attempts,
648                    )
649                });
650            }
651        });
652        format!("http://{addr}")
653    }
654
655    fn handle_resume_request(
656        stream: &mut std::net::TcpStream,
657        body: &[u8],
658        ranges: &Mutex<Vec<String>>,
659    ) {
660        use std::io::{Read, Write};
661
662        let mut request = vec![0; 4096];
663        let Ok(read) = stream.read(&mut request) else {
664            return;
665        };
666        let request = String::from_utf8_lossy(&request[..read]);
667        let is_head = request.starts_with("HEAD ");
668        let range = request.lines().find_map(range_header_value);
669        if !is_head && let Some(range) = range {
670            ranges.lock().unwrap().push(range.to_string());
671        }
672        let response = http_resume_response(body, is_head, range);
673        let _ = stream.write_all(&response);
674    }
675
676    fn handle_rate_limited_repo_info_request(
677        stream: &mut std::net::TcpStream,
678        attempts: &AtomicUsize,
679        rate_limited_attempts: usize,
680    ) {
681        use std::io::{Read, Write};
682
683        let mut request = vec![0; 4096];
684        let Ok(read) = stream.read(&mut request) else {
685            return;
686        };
687        let request = String::from_utf8_lossy(&request[..read]);
688        let path = request
689            .lines()
690            .next()
691            .and_then(|line| line.split_whitespace().nth(1))
692            .unwrap_or("/");
693        let attempt = attempts.fetch_add(1, Ordering::SeqCst);
694        let response =
695            if path == "/api/models/owner/repo/revision/main" && attempt < rate_limited_attempts {
696                response_bytes_with_headers(
697                    "429 Too Many Requests",
698                    &[("Retry-After", "0")],
699                    br#"{"error":"rate limited"}"#,
700                )
701            } else if path == "/api/models/owner/repo/revision/main" {
702                response_bytes_with_headers(
703                    "200 OK",
704                    &[("Content-Type", "application/json")],
705                    format!(r#"{{"id":"owner/repo","sha":"{TEST_COMMIT}"}}"#).as_bytes(),
706                )
707            } else {
708                response_bytes_with_headers("404 Not Found", &[], br#"{"error":"not found"}"#)
709            };
710        let _ = stream.write_all(&response);
711    }
712
713    fn range_header_value(line: &str) -> Option<&str> {
714        let (name, value) = line.split_once(':')?;
715        name.eq_ignore_ascii_case("range").then(|| value.trim())
716    }
717
718    fn http_resume_response(body: &[u8], is_head: bool, range: Option<&str>) -> Vec<u8> {
719        if is_head {
720            return response_bytes("200 OK", body.len(), None, &[]);
721        }
722        if range == Some("bytes=4-") {
723            return response_bytes(
724                "206 Partial Content",
725                body.len() - 4,
726                Some("bytes 4-9/10"),
727                &body[4..],
728            );
729        }
730        response_bytes("200 OK", body.len(), None, body)
731    }
732
733    fn response_bytes(
734        status: &str,
735        content_length: usize,
736        content_range: Option<&str>,
737        body: &[u8],
738    ) -> Vec<u8> {
739        let content_range = content_range
740            .map(|value| format!("Content-Range: {value}\r\n"))
741            .unwrap_or_default();
742        format!(
743            "HTTP/1.1 {status}\r\n\
744             ETag: \"{TEST_ETAG}\"\r\n\
745             X-Repo-Commit: {TEST_COMMIT}\r\n\
746             Content-Length: {content_length}\r\n\
747             {content_range}\
748             Connection: close\r\n\
749             \r\n"
750        )
751        .into_bytes()
752        .into_iter()
753        .chain(body.iter().copied())
754        .collect()
755    }
756
757    fn response_bytes_with_headers(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> {
758        let extra_headers = headers
759            .iter()
760            .map(|(name, value)| format!("{name}: {value}\r\n"))
761            .collect::<String>();
762        format!(
763            "HTTP/1.1 {status}\r\n\
764             Content-Length: {}\r\n\
765             {extra_headers}\
766             Connection: close\r\n\
767             \r\n",
768            body.len()
769        )
770        .into_bytes()
771        .into_iter()
772        .chain(body.iter().copied())
773        .collect()
774    }
775}