1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5use base64::Engine as _;
6use futures_util::StreamExt;
7use indicatif::{ProgressBar, ProgressStyle};
8use sha2::{Digest, Sha256};
9use tokio_util::io::ReaderStream;
10
11const HF_BASE: &str = "https://huggingface.co";
12const PREUPLOAD_SAMPLE: usize = 512;
13
14const SKIP_DIRS: &[&str] = &["target", ".git", "__pycache__", ".venv", "models"];
15
16const KEEP_ALWAYS: &[&str] = &[".gitattributes", ".gitignore"];
18
19struct RegularFile {
20 remote: String,
21 content_b64: String,
22}
23
24struct LfsFile {
25 local: PathBuf,
26 remote: String,
27 size: u64,
28 oid: String, }
30
31pub async fn run(repo_id: &str, token: &str, root: &Path) -> anyhow::Result<()> {
32 let client = build_client(token)?;
33
34 ensure_repo(&client, repo_id).await?;
35
36 let existing_files = list_repo_files(&client, repo_id).await?;
38 if !existing_files.is_empty() {
39 println!(" {} file(s) currently in repo", existing_files.len());
40 }
41
42 let files = gather_files(root)?;
43 println!("Gathered {} file(s) to upload …", files.len());
44
45 let (regular, lfs) = classify_files(&client, repo_id, &files).await?;
46 println!(" {} regular, {} LFS", regular.len(), lfs.len());
47
48 if !lfs.is_empty() {
49 upload_lfs(&client, repo_id, &lfs).await?;
50 }
51
52 let upload_paths: std::collections::HashSet<&str> = regular
54 .iter()
55 .map(|f| f.remote.as_str())
56 .chain(lfs.iter().map(|f| f.remote.as_str()))
57 .collect();
58 let to_delete: Vec<String> = existing_files
59 .into_iter()
60 .filter(|p| !upload_paths.contains(p.as_str()) && !KEEP_ALWAYS.contains(&p.as_str()))
61 .collect();
62 if !to_delete.is_empty() {
63 println!(" Deleting {} stale file(s)", to_delete.len());
64 }
65
66 make_commit(&client, repo_id, ®ular, &lfs, &to_delete).await?;
67
68 println!("Uploaded → https://huggingface.co/{repo_id}");
69 Ok(())
70}
71
72fn build_client(token: &str) -> anyhow::Result<reqwest::Client> {
73 let mut headers = reqwest::header::HeaderMap::new();
74 headers.insert(
75 reqwest::header::AUTHORIZATION,
76 format!("Bearer {token}").parse().context("invalid HF token")?,
77 );
78 headers.insert(
79 reqwest::header::USER_AGENT,
80 "zsfm-hub/0.1".parse().unwrap(),
81 );
82 Ok(reqwest::Client::builder().default_headers(headers).build()?)
83}
84
85async fn ensure_repo(client: &reqwest::Client, repo_id: &str) -> anyhow::Result<()> {
86 let name = repo_id.split('/').nth(1).context("repo_id must be owner/name")?;
87 let resp: reqwest::Response = client
88 .post(format!("{HF_BASE}/api/repos/create"))
89 .json(&serde_json::json!({ "name": name, "type": "model", "private": false }))
90 .send()
91 .await
92 .context("create repo")?;
93
94 let status = resp.status();
95 if status.is_success() {
96 println!("Created repo {repo_id}");
97 } else if status.as_u16() == 409 {
98 } else {
100 let body: String = resp.text().await.unwrap_or_default();
101 anyhow::bail!("create repo: HTTP {status}: {body}");
102 }
103 Ok(())
104}
105
106async fn list_repo_files(client: &reqwest::Client, repo_id: &str) -> anyhow::Result<Vec<String>> {
108 let mut files = Vec::new();
109 let mut url = format!("{HF_BASE}/api/models/{repo_id}/tree/main?recursive=true&limit=1000");
110
111 loop {
112 let resp: reqwest::Response = client.get(&url).send().await.context("list repo tree")?;
113 let status = resp.status();
114 if status.as_u16() == 404 {
115 return Ok(files); }
117 if !status.is_success() {
118 let body = resp.text().await.unwrap_or_default();
119 anyhow::bail!("list repo tree: HTTP {status}: {body}");
120 }
121
122 let next_url = resp
124 .headers()
125 .get("link")
126 .and_then(|v| v.to_str().ok())
127 .and_then(parse_next_link);
128
129 #[derive(serde::Deserialize)]
130 struct TreeEntry { r#type: String, path: String }
131 let entries: Vec<TreeEntry> = resp.json().await.context("parse tree response")?;
132 for e in entries {
133 if e.r#type == "file" {
134 files.push(e.path);
135 }
136 }
137
138 match next_url {
139 Some(next) => url = next,
140 None => break,
141 }
142 }
143
144 Ok(files)
145}
146
147fn parse_next_link(header: &str) -> Option<String> {
149 for part in header.split(',') {
150 let part = part.trim();
151 if part.contains(r#"rel="next""#) {
152 if let Some(url_part) = part.split(';').next() {
153 let url = url_part.trim().trim_start_matches('<').trim_end_matches('>');
154 return Some(url.to_string());
155 }
156 }
157 }
158 None
159}
160
161async fn classify_files(
163 client: &reqwest::Client,
164 repo_id: &str,
165 files: &[(PathBuf, String)],
166) -> anyhow::Result<(Vec<RegularFile>, Vec<LfsFile>)> {
167 let mut preupload_entries: Vec<serde_json::Value> = Vec::new();
168 for (local, remote) in files {
169 let size = std::fs::metadata(local)
170 .with_context(|| format!("stat {}", local.display()))?
171 .len();
172 let sample = {
173 let bytes = std::fs::read(local)
174 .with_context(|| format!("read {}", local.display()))?;
175 let n = bytes.len().min(PREUPLOAD_SAMPLE);
176 base64::engine::general_purpose::STANDARD.encode(&bytes[..n])
177 };
178 preupload_entries.push(serde_json::json!({
179 "path": remote,
180 "size": size,
181 "sample": sample,
182 }));
183 }
184
185 let url = format!("{HF_BASE}/api/models/{repo_id}/preupload/main");
186 let resp: reqwest::Response = client
187 .post(&url)
188 .json(&serde_json::json!({ "files": preupload_entries }))
189 .send()
190 .await
191 .context("preupload request")?;
192
193 let status = resp.status();
194 if !status.is_success() {
195 let body: String = resp.text().await.unwrap_or_default();
196 anyhow::bail!("preupload: HTTP {status}: {body}");
197 }
198
199 #[derive(serde::Deserialize)]
200 struct PreuploadFile {
201 path: String,
202 #[serde(rename = "uploadMode")]
203 upload_mode: String,
204 #[serde(rename = "shouldIgnore", default)]
205 _should_ignore: bool,
206 }
207 #[derive(serde::Deserialize)]
208 struct PreuploadResp { files: Vec<PreuploadFile> }
209
210 let preupload: PreuploadResp = resp.json().await.context("parse preupload response")?;
211 let modes: std::collections::HashMap<String, String> = preupload.files
212 .into_iter()
213 .map(|f| (f.path, f.upload_mode))
214 .collect();
215
216 let mut regular: Vec<RegularFile> = Vec::new();
217 let mut lfs: Vec<LfsFile> = Vec::new();
218
219 for (local, remote) in files {
220 let mode = modes.get(remote).map(|s| s.as_str()).unwrap_or("regular");
221 if mode == "lfs" {
222 let size = std::fs::metadata(local)?.len();
223 let oid = sha256_file(local)?;
224 lfs.push(LfsFile { local: local.clone(), remote: remote.clone(), size, oid });
225 } else {
226 let bytes = std::fs::read(local)
227 .with_context(|| format!("read {}", local.display()))?;
228 let content_b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
229 regular.push(RegularFile { remote: remote.clone(), content_b64 });
230 }
231 }
232
233 Ok((regular, lfs))
234}
235
236fn gather_files(root: &Path) -> anyhow::Result<Vec<(PathBuf, String)>> {
237 let mut out = Vec::new();
238 walk_dir(root, "", &mut out)?;
239
240 let has_root_readme = out.iter().any(|(_, r)| r == "README.md");
243 if !has_root_readme {
244 for (_, remote) in &mut out {
245 let parts: Vec<&str> = remote.splitn(3, '/').collect();
246 if parts.len() == 2 && parts[1] == "README.md" {
247 *remote = "README.md".to_string();
248 break;
249 }
250 }
251 }
252
253 Ok(out)
254}
255
256fn walk_dir(dir: &Path, prefix: &str, out: &mut Vec<(PathBuf, String)>) -> anyhow::Result<()> {
257 let mut entries: Vec<_> = std::fs::read_dir(dir)
258 .with_context(|| format!("read_dir {}", dir.display()))?
259 .collect::<Result<_, _>>()?;
260 entries.sort_by_key(|e| e.file_name());
261 for entry in entries {
262 let path = entry.path();
263 let name = entry
264 .file_name()
265 .into_string()
266 .map_err(|_| anyhow::anyhow!("non-UTF-8 filename"))?;
267 if SKIP_DIRS.contains(&name.as_str()) {
268 continue;
269 }
270 let remote = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };
271 if path.is_file() {
272 out.push((path, remote));
273 } else if path.is_dir() {
274 walk_dir(&path, &remote, out)?;
275 }
276 }
277 Ok(())
278}
279
280fn sha256_file(path: &Path) -> anyhow::Result<String> {
281 let mut f =
282 std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
283 let mut hasher = Sha256::new();
284 let mut buf = vec![0u8; 8 * 1024 * 1024];
285 loop {
286 let n = f.read(&mut buf)?;
287 if n == 0 { break; }
288 hasher.update(&buf[..n]);
289 }
290 Ok(format!("{:x}", hasher.finalize()))
291}
292
293async fn upload_lfs(
294 client: &reqwest::Client,
295 repo_id: &str,
296 files: &[LfsFile],
297) -> anyhow::Result<()> {
298 let objects: Vec<_> = files
299 .iter()
300 .map(|f| serde_json::json!({ "oid": f.oid, "size": f.size }))
301 .collect();
302
303 let url = format!("{HF_BASE}/{repo_id}.git/info/lfs/objects/batch");
305 let lfs_body = serde_json::to_string(&serde_json::json!({
306 "operation": "upload",
307 "transfers": ["multipart", "basic"],
308 "objects": objects,
309 }))?;
310 let resp: reqwest::Response = client
311 .post(&url)
312 .header("Content-Type", "application/vnd.git-lfs+json")
313 .header("Accept", "application/vnd.git-lfs+json")
314 .body(lfs_body)
315 .send()
316 .await
317 .context("LFS batch request")?;
318
319 let status = resp.status();
320 if !status.is_success() {
321 let body: String = resp.text().await.unwrap_or_default();
322 anyhow::bail!("LFS batch: HTTP {status}: {body}");
323 }
324
325 #[derive(serde::Deserialize)]
326 struct BatchResp { objects: Vec<serde_json::Value> }
327 let batch: BatchResp = resp.json().await.context("parse LFS batch response")?;
328
329 for (file, obj) in files.iter().zip(batch.objects.iter()) {
330 let Some(upload_href) =
331 obj.pointer("/actions/upload/href").and_then(|v: &serde_json::Value| v.as_str())
332 else {
333 println!(" (already on LFS) {}", file.remote);
334 continue;
335 };
336
337 let is_multipart = obj.pointer("/actions/upload/header/chunk_size").is_some();
339
340 if is_multipart {
341 upload_lfs_object_multipart(file, upload_href, obj).await
342 .with_context(|| format!("upload (multipart) {}", file.remote))?;
343 } else {
344 upload_lfs_object(file, upload_href, obj).await
345 .with_context(|| format!("upload {}", file.remote))?;
346 }
347
348 if let Some(verify_href) =
350 obj.pointer("/actions/verify/href").and_then(|v: &serde_json::Value| v.as_str())
351 {
352 let verify_headers = obj
353 .pointer("/actions/verify/header")
354 .and_then(|v: &serde_json::Value| v.as_object())
355 .cloned()
356 .unwrap_or_default();
357
358 let mut vreq: reqwest::RequestBuilder = reqwest::Client::new()
359 .post(verify_href)
360 .header("Content-Type", "application/vnd.git-lfs+json")
361 .json(&serde_json::json!({ "oid": file.oid, "size": file.size }));
362 for (k, v) in &verify_headers {
363 if let Some(val) = v.as_str() {
364 vreq = vreq.header(k.as_str(), val);
365 }
366 }
367 let vresp: reqwest::Response = vreq.send().await.context("LFS verify")?;
368 if !vresp.status().is_success() {
369 eprintln!(" Warning: LFS verify returned {}", vresp.status());
370 }
371 }
372 }
373
374 Ok(())
375}
376
377async fn upload_lfs_object(
378 file: &LfsFile,
379 href: &str,
380 obj: &serde_json::Value,
381) -> anyhow::Result<()> {
382 let pb = ProgressBar::new(file.size);
383 pb.set_style(
384 ProgressStyle::with_template(
385 " {msg} [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})",
386 )
387 .unwrap()
388 .progress_chars("=>-"),
389 );
390 pb.set_message(file.remote.clone());
391
392 let f = tokio::fs::File::open(&file.local)
393 .await
394 .with_context(|| format!("open {}", file.local.display()))?;
395
396 let pb2 = pb.clone();
397 let stream = ReaderStream::new(f).map(move |chunk| {
398 if let Ok(ref b) = chunk {
399 pb2.inc(b.len() as u64);
400 }
401 chunk
402 });
403
404 let mut req = reqwest::Client::new()
406 .put(href)
407 .header("Content-Length", file.size.to_string());
408
409 if let Some(extra) = obj.pointer("/actions/upload/header").and_then(|v| v.as_object()) {
410 for (k, v) in extra {
411 if let Some(val) = v.as_str() {
412 req = req.header(k.as_str(), val);
413 }
414 }
415 }
416
417 let resp = req
418 .body(reqwest::Body::wrap_stream(stream))
419 .send()
420 .await
421 .context("PUT LFS object")?;
422
423 pb.finish_and_clear();
424
425 let status = resp.status();
426 if !status.is_success() {
427 let body = resp.text().await.unwrap_or_default();
428 anyhow::bail!("LFS PUT HTTP {status}: {body}");
429 }
430
431 Ok(())
432}
433
434async fn upload_lfs_object_multipart(
435 file: &LfsFile,
436 complete_href: &str,
437 obj: &serde_json::Value,
438) -> anyhow::Result<()> {
439 use tokio::io::AsyncReadExt;
440
441 let header = obj
442 .pointer("/actions/upload/header")
443 .and_then(|v| v.as_object())
444 .ok_or_else(|| anyhow::anyhow!("no header in multipart LFS response"))?;
445
446 let chunk_size: usize = header
447 .get("chunk_size")
448 .and_then(|v| v.as_str())
449 .and_then(|s| s.parse().ok())
450 .ok_or_else(|| anyhow::anyhow!("missing chunk_size in multipart header"))?;
451
452 let mut parts: Vec<(u32, String)> = header
454 .iter()
455 .filter_map(|(k, v)| {
456 let n: u32 = k.parse().ok()?;
457 Some((n, v.as_str()?.to_string()))
458 })
459 .collect();
460 parts.sort_by_key(|(n, _)| *n);
461
462 let pb = ProgressBar::new(file.size);
463 pb.set_style(
464 ProgressStyle::with_template(
465 " {msg} [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})",
466 )
467 .unwrap()
468 .progress_chars("=>-"),
469 );
470 pb.set_message(file.remote.clone());
471
472 let mut f = tokio::fs::File::open(&file.local)
473 .await
474 .with_context(|| format!("open {}", file.local.display()))?;
475
476 let s3 = reqwest::Client::new(); let mut etags: Vec<(u32, String)> = Vec::with_capacity(parts.len());
478
479 for (part_num, url) in &parts {
480 let mut buf = vec![0u8; chunk_size];
482 let mut pos = 0;
483 while pos < chunk_size {
484 let n = f.read(&mut buf[pos..]).await?;
485 if n == 0 { break; }
486 pos += n;
487 }
488 if pos == 0 { break; }
489 buf.truncate(pos);
490 let len = buf.len();
491
492 const MAX_RETRIES: usize = 3;
494 let mut last_err: Option<anyhow::Error> = None;
495 let mut etag_opt: Option<String> = None;
496 for attempt in 0..MAX_RETRIES {
497 if attempt > 0 {
498 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
499 eprintln!(" Retrying part {part_num} (attempt {})…", attempt + 1);
500 }
501 match s3
502 .put(url.as_str())
503 .header("Content-Length", len.to_string())
504 .body(buf.clone())
505 .send()
506 .await
507 {
508 Err(e) => {
509 last_err = Some(anyhow::anyhow!("PUT part {part_num}: {e}"));
510 }
511 Ok(resp) => {
512 let status = resp.status();
513 let tag = resp.headers().get("etag")
514 .and_then(|v| v.to_str().ok())
515 .map(|s| s.to_string());
516 if status.is_success() {
517 if let Some(t) = tag {
518 etag_opt = Some(t);
519 last_err = None;
520 break;
521 } else {
522 last_err = Some(anyhow::anyhow!("PUT part {part_num}: no ETag in response"));
523 }
524 } else {
525 let body = resp.text().await.unwrap_or_default();
526 last_err = Some(anyhow::anyhow!("PUT part {part_num}: HTTP {status}: {body}"));
527 }
528 }
529 }
530 }
531 let etag = etag_opt.ok_or_else(|| {
532 last_err.unwrap_or_else(|| anyhow::anyhow!("PUT part {part_num}: exhausted retries"))
533 })?;
534
535 pb.inc(len as u64);
536 etags.push((*part_num, etag));
537 }
538
539 pb.finish_and_clear();
540
541 let parts_json: Vec<serde_json::Value> = etags
543 .iter()
544 .map(|(n, e)| serde_json::json!({ "partNumber": n, "etag": e }))
545 .collect();
546
547 let resp = s3
548 .post(complete_href)
549 .json(&serde_json::json!({ "oid": file.oid, "parts": parts_json }))
550 .send()
551 .await
552 .context("complete multipart")?;
553
554 let status = resp.status();
555 if !status.is_success() {
556 let body = resp.text().await.unwrap_or_default();
557 anyhow::bail!("complete multipart: HTTP {status}: {body}");
558 }
559
560 Ok(())
561}
562
563async fn make_commit(
564 client: &reqwest::Client,
565 repo_id: &str,
566 regular: &[RegularFile],
567 lfs: &[LfsFile],
568 to_delete: &[String],
569) -> anyhow::Result<()> {
570 let mut lines: Vec<String> = Vec::new();
571
572 lines.push(serde_json::to_string(&serde_json::json!({
573 "key": "header",
574 "value": { "summary": "Upload model files", "description": "" },
575 }))?);
576
577 for rf in regular {
578 lines.push(serde_json::to_string(&serde_json::json!({
579 "key": "file",
580 "value": {
581 "path": rf.remote,
582 "encoding": "base64",
583 "content": rf.content_b64,
584 },
585 }))?);
586 }
587
588 for lf in lfs {
589 lines.push(serde_json::to_string(&serde_json::json!({
590 "key": "lfsFile",
591 "value": {
592 "path": lf.remote,
593 "algo": "sha256",
594 "oid": lf.oid,
595 "size": lf.size,
596 },
597 }))?);
598 }
599
600 for path in to_delete {
601 lines.push(serde_json::to_string(&serde_json::json!({
602 "key": "deletedEntry",
603 "value": { "path": path },
604 }))?);
605 }
606
607 let body = lines.join("\n");
608
609 let url = format!("{HF_BASE}/api/models/{repo_id}/commit/main");
610 let resp = client
611 .post(&url)
612 .header("Content-Type", "application/x-ndjson")
613 .body(body)
614 .send()
615 .await
616 .context("POST commit")?;
617
618 let status = resp.status();
619 if !status.is_success() {
620 let body = resp.text().await.unwrap_or_default();
621 anyhow::bail!("commit: HTTP {status}: {body}");
622 }
623
624 Ok(())
625}