1use std::path::{Path, PathBuf};
2
3use git2::{Repository, Signature, IndexAddOption};
4use sha2::{Sha256, Digest};
5use tracing::info;
6
7use smallvec::SmallVec;
8
9use crate::db::{ModdeDb, SaveEntry};
10use crate::error::{CoreError, Result};
11use crate::profile::EnabledMod;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct SaveFingerprint {
24 pub hash: String,
26 pub mod_ids: SmallVec<[String; 8]>,
29}
30
31const FINGERPRINT_TRAILER: &str = "Mod-Fingerprint";
33
34const MODS_TRAILER: &str = "Save-Breaking-Mods";
36
37impl SaveFingerprint {
38 pub fn compute(
45 mods: &[EnabledMod],
46 classify: impl Fn(&str) -> bool,
47 ) -> Self {
48 let mut breaking_ids: Vec<&str> = mods
49 .iter()
50 .filter(|m| m.enabled && classify(&m.mod_id))
51 .map(|m| m.mod_id.as_str())
52 .collect();
53 breaking_ids.sort_unstable();
54 breaking_ids.dedup();
55
56 let mut hasher = Sha256::new();
57 for id in &breaking_ids {
58 hasher.update(id.as_bytes());
59 hasher.update(b"\0");
60 }
61 let hash = format!("{:x}", hasher.finalize());
62
63 Self {
64 hash,
65 mod_ids: breaking_ids.into_iter().map(String::from).collect(),
66 }
67 }
68
69 pub fn short_hash(&self) -> &str {
71 &self.hash[..self.hash.len().min(12)]
72 }
73
74 pub fn empty() -> Self {
76 Self {
77 hash: "0".repeat(64),
78 mod_ids: SmallVec::new(),
79 }
80 }
81
82 pub fn is_empty(&self) -> bool {
83 self.mod_ids.is_empty()
84 }
85
86 fn to_trailers(&self) -> String {
88 let mut s = format!("{FINGERPRINT_TRAILER}: {}", self.short_hash());
89 if !self.mod_ids.is_empty() {
90 s.push_str(&format!(
91 "\n{MODS_TRAILER}: {}",
92 self.mod_ids.join(", ")
93 ));
94 }
95 s
96 }
97
98 fn from_commit_message(message: &str) -> Option<Self> {
100 let mut hash = None;
101 let mut mod_ids = SmallVec::new();
102
103 for line in message.lines() {
104 if let Some(value) = line.strip_prefix(&format!("{FINGERPRINT_TRAILER}: ")) {
105 hash = Some(value.trim().to_string());
106 } else if let Some(value) = line.strip_prefix(&format!("{MODS_TRAILER}: ")) {
107 mod_ids = value
108 .split(", ")
109 .map(|s| s.trim().to_string())
110 .filter(|s| !s.is_empty())
111 .collect();
112 }
113 }
114
115 hash.map(|h| Self { hash: h, mod_ids })
116 }
117}
118
119#[derive(Debug, Clone)]
121pub enum FingerprintCheck {
122 Compatible,
124 NoFingerprint,
126 Mismatch {
128 removed: SmallVec<[String; 4]>,
131 added: SmallVec<[String; 4]>,
133 },
134}
135
136impl FingerprintCheck {
137 pub fn is_compatible(&self) -> bool {
138 matches!(self, Self::Compatible | Self::NoFingerprint)
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct SaveSnapshot {
145 pub id: String,
147 pub message: String,
149 pub timestamp: i64,
151 pub file_count: usize,
153 pub fingerprint: Option<SaveFingerprint>,
155 pub profile_name: Option<String>,
157 pub character_name: Option<String>,
159 pub save_label: Option<String>,
161 pub category: Option<String>,
163}
164
165impl SaveSnapshot {
166 pub fn short_id(&self) -> &str {
169 &self.id[..self.id.len().min(8)]
170 }
171
172 pub fn display_title(&self) -> String {
174 if let (Some(char_name), Some(label)) = (&self.character_name, &self.save_label) {
175 format!("{char_name} — {label}")
176 } else if let Some(char_name) = &self.character_name {
177 char_name.clone()
178 } else if let Some(label) = &self.save_label {
179 label.clone()
180 } else {
181 self.message.lines().next().unwrap_or("").trim().to_string()
182 }
183 }
184
185 fn parse_metadata_from_message(&mut self) {
193 let first_line = self.message.lines().next().unwrap_or("").trim();
194
195 if let Some(rest) = first_line.strip_prefix("capture saves for profile '") {
197 if let Some(name) = rest.strip_suffix('\'') {
198 self.profile_name = Some(name.to_string());
199 }
200 return;
201 }
202
203 let body = if let Some(rest) = first_line.strip_prefix("capture: ") {
205 rest
206 } else if first_line.starts_with("capture (") {
207 if let Some(idx) = first_line.find("): ") {
209 &first_line[idx + 3..]
210 } else {
211 return;
212 }
213 } else {
214 return;
215 };
216
217 let (body, category) = if let Some(bracket_start) = body.rfind(" [") {
219 if body.ends_with(']') {
220 let cat = &body[bracket_start + 2..body.len() - 1];
221 self.category = Some(cat.to_string());
222 (&body[..bracket_start], Some(cat.to_string()))
223 } else {
224 (body, None)
225 }
226 } else {
227 (body, None)
228 };
229 let _ = category; if let Some((char_part, save_part)) = body.split_once(" — ") {
233 if char_part.ends_with("saves") && char_part.chars().next().map_or(false, |c| c.is_ascii_digit()) {
235 self.save_label = Some(first_line.to_string());
237 } else {
238 self.character_name = Some(char_part.to_string());
239 self.save_label = Some(save_part.to_string());
240 }
241 } else if body != "no new saves" {
242 self.save_label = Some(body.to_string());
244 }
245 }
246
247 pub fn check_compatibility(&self, current: &SaveFingerprint) -> FingerprintCheck {
249 let stored = match &self.fingerprint {
250 Some(fp) => fp,
251 None => return FingerprintCheck::NoFingerprint,
252 };
253
254 if stored.hash == current.hash || stored.short_hash() == current.short_hash() {
255 return FingerprintCheck::Compatible;
256 }
257
258 let stored_set: std::collections::HashSet<&str> =
259 stored.mod_ids.iter().map(|s| s.as_str()).collect();
260 let current_set: std::collections::HashSet<&str> =
261 current.mod_ids.iter().map(|s| s.as_str()).collect();
262
263 let removed: SmallVec<[String; 4]> = stored_set
264 .difference(¤t_set)
265 .map(|s| s.to_string())
266 .collect();
267 let added: SmallVec<[String; 4]> = current_set
268 .difference(&stored_set)
269 .map(|s| s.to_string())
270 .collect();
271
272 FingerprintCheck::Mismatch { removed, added }
273 }
274}
275
276pub struct SaveManager<'a> {
285 db: &'a ModdeDb,
286}
287
288impl<'a> SaveManager<'a> {
289 pub fn new(db: &'a ModdeDb) -> Self {
290 Self { db }
291 }
292
293 pub fn init_vault(game_id: &str) -> Result<Repository> {
297 let vault_path = crate::paths::save_vault_dir(game_id);
298 if vault_path.join(".git").exists() {
299 return Repository::open(&vault_path)
300 .map_err(|e| CoreError::SaveVaultError(format!("failed to open vault: {e}")));
301 }
302
303 std::fs::create_dir_all(&vault_path)?;
304 let repo = Repository::init(&vault_path)
305 .map_err(|e| CoreError::SaveVaultError(format!("failed to init vault: {e}")))?;
306
307 {
309 let sig = vault_signature();
310 let mut index = repo.index()
311 .map_err(|e| CoreError::SaveVaultError(format!("failed to get index: {e}")))?;
312 let tree_oid = index.write_tree()
313 .map_err(|e| CoreError::SaveVaultError(format!("failed to write tree: {e}")))?;
314 let tree = repo.find_tree(tree_oid)
315 .map_err(|e| CoreError::SaveVaultError(format!("failed to find tree: {e}")))?;
316 repo.commit(Some("HEAD"), &sig, &sig, "init save vault", &tree, &[])
317 .map_err(|e| CoreError::SaveVaultError(format!("failed to create initial commit: {e}")))?;
318 }
319
320 info!(game_id, path = %vault_path.display(), "initialized save vault");
321 Ok(repo)
322 }
323
324 pub fn vault_repo(game_id: &str) -> Result<Repository> {
326 let vault_path = crate::paths::save_vault_dir(game_id);
327 if vault_path.join(".git").exists() {
328 Repository::open(&vault_path)
329 .map_err(|e| CoreError::SaveVaultError(format!("failed to open vault: {e}")))
330 } else {
331 Self::init_vault(game_id)
332 }
333 }
334
335 pub fn ensure_branch(game_id: &str, profile_name: &str) -> Result<()> {
339 let repo = Self::vault_repo(game_id)?;
340 let branch_name = sanitize_branch_name(profile_name);
341
342 if repo.find_branch(&branch_name, git2::BranchType::Local).is_ok() {
343 return Ok(());
344 }
345
346 let head_commit = repo.head()
347 .and_then(|h| h.peel_to_commit())
348 .map_err(|e| CoreError::SaveVaultError(format!("failed to get HEAD: {e}")))?;
349
350 repo.branch(&branch_name, &head_commit, false)
351 .map_err(|e| CoreError::SaveVaultError(format!("failed to create branch '{branch_name}': {e}")))?;
352
353 info!(game_id, branch = %branch_name, "created save branch");
354 Ok(())
355 }
356
357 pub fn checkout_branch(game_id: &str, profile_name: &str) -> Result<()> {
359 let repo = Self::vault_repo(game_id)?;
360 let branch_name = sanitize_branch_name(profile_name);
361
362 Self::ensure_branch(game_id, profile_name)?;
363
364 let branch = repo.find_branch(&branch_name, git2::BranchType::Local)
365 .map_err(|e| CoreError::SaveVaultError(format!("branch '{branch_name}' not found: {e}")))?;
366
367 let refname = branch.get().name()
368 .ok_or_else(|| CoreError::SaveVaultError("invalid branch ref name".into()))?
369 .to_string();
370
371 let obj = repo.revparse_single(&refname)
372 .map_err(|e| CoreError::SaveVaultError(format!("failed to resolve branch: {e}")))?;
373
374 repo.checkout_tree(&obj, Some(git2::build::CheckoutBuilder::new().force()))
375 .map_err(|e| CoreError::SaveVaultError(format!("checkout failed: {e}")))?;
376
377 repo.set_head(&refname)
378 .map_err(|e| CoreError::SaveVaultError(format!("failed to set HEAD: {e}")))?;
379
380 info!(game_id, branch = %branch_name, "checked out save branch");
381 Ok(())
382 }
383
384 pub fn capture_with_fingerprint(
392 &self,
393 game_id: &str,
394 profile_name: &str,
395 game_save_dir: &Path,
396 fingerprint: Option<&SaveFingerprint>,
397 ) -> Result<usize> {
398 if !game_save_dir.exists() {
399 return Ok(0);
400 }
401
402 let repo = Self::vault_repo(game_id)?;
403 let vault_path = crate::paths::save_vault_dir(game_id);
404
405 Self::checkout_branch(game_id, profile_name)?;
406
407 let count = copy_dir_contents(game_save_dir, &vault_path)?;
408
409 if count == 0 {
410 return Ok(0);
411 }
412
413 let mut index = repo.index()
415 .map_err(|e| CoreError::SaveVaultError(format!("failed to get index: {e}")))?;
416
417 index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
418 .map_err(|e| CoreError::SaveVaultError(format!("failed to stage files: {e}")))?;
419
420 let mut to_remove = Vec::new();
422 for entry in index.iter() {
423 let path = String::from_utf8_lossy(&entry.path).to_string();
424 if !vault_path.join(&path).exists() {
425 to_remove.push(path);
426 }
427 }
428 for path in &to_remove {
429 index.remove_path(Path::new(path))
430 .map_err(|e| CoreError::SaveVaultError(format!("failed to remove from index: {e}")))?;
431 }
432
433 index.write()
434 .map_err(|e| CoreError::SaveVaultError(format!("failed to write index: {e}")))?;
435
436 let tree_oid = index.write_tree()
437 .map_err(|e| CoreError::SaveVaultError(format!("failed to write tree: {e}")))?;
438 let tree = repo.find_tree(tree_oid)
439 .map_err(|e| CoreError::SaveVaultError(format!("failed to find tree: {e}")))?;
440
441 let head_commit = repo.head()
442 .and_then(|h| h.peel_to_commit())
443 .map_err(|e| CoreError::SaveVaultError(format!("failed to get HEAD: {e}")))?;
444
445 if tree_oid == head_commit.tree_id() {
447 info!(game_id, profile = profile_name, "saves unchanged, skipping commit");
448 return Ok(0);
449 }
450
451 let sig = vault_signature();
452
453 let mut message = format!("capture saves for profile '{profile_name}'");
454 if let Some(fp) = fingerprint {
455 message.push_str("\n\n");
456 message.push_str(&fp.to_trailers());
457 }
458
459 repo.commit(
460 Some("HEAD"),
461 &sig,
462 &sig,
463 &message,
464 &tree,
465 &[&head_commit],
466 )
467 .map_err(|e| CoreError::SaveVaultError(format!("failed to commit: {e}")))?;
468
469 info!(game_id, profile = profile_name, count, "captured saves");
470 Ok(count)
471 }
472
473 pub fn capture(&self, game_id: &str, profile_name: &str, game_save_dir: &Path) -> Result<usize> {
475 self.capture_with_fingerprint(game_id, profile_name, game_save_dir, None)
476 }
477
478 pub fn deploy(&self, game_id: &str, profile_name: &str, game_save_dir: &Path) -> Result<usize> {
481 Self::checkout_branch(game_id, profile_name)?;
482
483 let vault_path = crate::paths::save_vault_dir(game_id);
484
485 clear_dir(game_save_dir)?;
487
488 std::fs::create_dir_all(game_save_dir)?;
489
490 let count = copy_dir_contents_filtered(&vault_path, game_save_dir, |name| {
492 name != ".git"
493 })?;
494
495 info!(game_id, profile = profile_name, count, "deployed saves");
496 Ok(count)
497 }
498
499 pub fn activate(
506 &self,
507 game_id: &str,
508 new_profile: &str,
509 current_profile: Option<&str>,
510 game_save_dir: &Path,
511 ) -> Result<()> {
512 self.activate_with_fingerprint(game_id, new_profile, current_profile, game_save_dir, None)
513 }
514
515 pub fn activate_with_fingerprint(
517 &self,
518 game_id: &str,
519 new_profile: &str,
520 current_profile: Option<&str>,
521 game_save_dir: &Path,
522 fingerprint: Option<&SaveFingerprint>,
523 ) -> Result<()> {
524 if let Some(current) = current_profile {
525 self.capture_with_fingerprint(game_id, current, game_save_dir, fingerprint)?;
526 }
527
528 Self::ensure_branch(game_id, new_profile)?;
529 self.deploy(game_id, new_profile, game_save_dir)?;
530
531 Ok(())
532 }
533
534 pub fn fork_saves(game_id: &str, source_profile: &str, target_profile: &str) -> Result<()> {
536 let repo = Self::vault_repo(game_id)?;
537 let source_branch = sanitize_branch_name(source_profile);
538 let target_branch = sanitize_branch_name(target_profile);
539
540 Self::ensure_branch(game_id, source_profile)?;
541
542 let branch = repo.find_branch(&source_branch, git2::BranchType::Local)
543 .map_err(|e| CoreError::SaveVaultError(format!("source branch not found: {e}")))?;
544
545 let commit = branch.get().peel_to_commit()
546 .map_err(|e| CoreError::SaveVaultError(format!("failed to get source commit: {e}")))?;
547
548 repo.branch(&target_branch, &commit, false)
549 .map_err(|e| CoreError::SaveVaultError(format!("failed to create fork branch: {e}")))?;
550
551 info!(game_id, source = source_profile, target = target_profile, "forked save branch");
552 Ok(())
553 }
554
555 pub fn history(game_id: &str, profile_name: &str, limit: usize) -> Result<Vec<SaveSnapshot>> {
559 let repo = Self::vault_repo(game_id)?;
560 let branch_name = sanitize_branch_name(profile_name);
561
562 let branch = repo.find_branch(&branch_name, git2::BranchType::Local)
563 .map_err(|e| CoreError::SaveVaultError(format!("branch '{branch_name}' not found: {e}")))?;
564
565 let commit_oid = branch.get().target()
566 .ok_or_else(|| CoreError::SaveVaultError("branch has no target".into()))?;
567
568 let mut revwalk = repo.revwalk()
569 .map_err(|e| CoreError::SaveVaultError(format!("revwalk failed: {e}")))?;
570 revwalk.push(commit_oid)
571 .map_err(|e| CoreError::SaveVaultError(format!("revwalk push failed: {e}")))?;
572
573 let mut snapshots = Vec::new();
574 for oid in revwalk.take(limit) {
575 let oid = oid.map_err(|e| CoreError::SaveVaultError(format!("revwalk iter: {e}")))?;
576 let commit = repo.find_commit(oid)
577 .map_err(|e| CoreError::SaveVaultError(format!("find commit: {e}")))?;
578
579 let message = commit.message().unwrap_or("").to_string();
580 let time = commit.time();
581 let secs = time.seconds();
582
583 let tree = commit.tree()
585 .map_err(|e| CoreError::SaveVaultError(format!("commit tree: {e}")))?;
586 let file_count = count_tree_entries(&repo, &tree);
587
588 let fingerprint = SaveFingerprint::from_commit_message(&message);
589
590 let mut snap = SaveSnapshot {
591 id: oid.to_string(),
592 message,
593 timestamp: secs,
594 file_count,
595 fingerprint,
596 profile_name: None,
597 character_name: None,
598 save_label: None,
599 category: None,
600 };
601 snap.parse_metadata_from_message();
602 snapshots.push(snap);
603 }
604
605 Ok(snapshots)
606 }
607
608 pub fn check_restore_compatibility(
613 game_id: &str,
614 _profile_name: &str,
615 commit_id: &str,
616 current_fingerprint: &SaveFingerprint,
617 ) -> Result<FingerprintCheck> {
618 let repo = Self::vault_repo(game_id)?;
619 let obj = repo.revparse_single(commit_id)
620 .map_err(|e| CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}")))?;
621 let commit = obj.peel_to_commit()
622 .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
623
624 let message = commit.message().unwrap_or("").to_string();
625 let snapshot_fp = SaveFingerprint::from_commit_message(&message);
626
627 match snapshot_fp {
628 Some(fp) => {
629 let snapshot = SaveSnapshot {
630 id: commit_id.to_string(),
631 message,
632 timestamp: 0,
633 file_count: 0,
634 fingerprint: Some(fp),
635 profile_name: None,
636 character_name: None,
637 save_label: None,
638 category: None,
639 };
640 Ok(snapshot.check_compatibility(current_fingerprint))
641 }
642 None => Ok(FingerprintCheck::NoFingerprint),
643 }
644 }
645
646 pub fn restore(game_id: &str, profile_name: &str, commit_id: &str, game_save_dir: &Path) -> Result<usize> {
648 let repo = Self::vault_repo(game_id)?;
649 let vault_path = crate::paths::save_vault_dir(game_id);
650
651 let obj = repo.revparse_single(commit_id)
653 .map_err(|e| CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}")))?;
654 let commit = obj.peel_to_commit()
655 .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
656
657 let branch_name = sanitize_branch_name(profile_name);
659 Self::checkout_branch(game_id, profile_name)?;
660
661 repo.checkout_tree(
662 commit.as_object(),
663 Some(git2::build::CheckoutBuilder::new().force()),
664 )
665 .map_err(|e| CoreError::SaveVaultError(format!("checkout failed: {e}")))?;
666
667 let refname = format!("refs/heads/{branch_name}");
669 repo.reference(&refname, commit.id(), true, &format!("restore to {commit_id}"))
670 .map_err(|e| CoreError::SaveVaultError(format!("failed to reset branch: {e}")))?;
671 repo.set_head(&refname)
672 .map_err(|e| CoreError::SaveVaultError(format!("failed to set HEAD: {e}")))?;
673
674 clear_dir(game_save_dir)?;
676 std::fs::create_dir_all(game_save_dir)?;
677
678 let count = copy_dir_contents_filtered(&vault_path, game_save_dir, |name| {
679 name != ".git"
680 })?;
681
682 info!(game_id, profile = profile_name, commit = commit_id, count, "restored saves from snapshot");
683 Ok(count)
684 }
685
686 pub fn snapshot_file_list(game_id: &str, commit_id: &str) -> Result<Vec<String>> {
688 let repo = Self::vault_repo(game_id)?;
689 let obj = repo.revparse_single(commit_id)
690 .map_err(|e| CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}")))?;
691 let commit = obj.peel_to_commit()
692 .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
693 let tree = commit.tree()
694 .map_err(|e| CoreError::SaveVaultError(format!("commit tree: {e}")))?;
695 Ok(collect_tree_paths(&repo, &tree, ""))
696 }
697
698 pub fn detect_unadopted(&self, game_id: &str, game_save_dir: &Path) -> Result<Option<usize>> {
703 if self.db.get_active_profile(game_id)?.is_some() {
704 return Ok(None);
705 }
706
707 if !game_save_dir.exists() {
708 return Ok(None);
709 }
710
711 let count = std::fs::read_dir(game_save_dir)?
712 .filter_map(|e| e.ok())
713 .count();
714
715 if count > 0 {
716 Ok(Some(count))
717 } else {
718 Ok(None)
719 }
720 }
721
722 pub fn adopt(&self, game_id: &str, profile_name: &str, game_save_dir: &Path) -> Result<usize> {
725 Self::ensure_branch(game_id, profile_name)?;
726 self.capture(game_id, profile_name, game_save_dir)
727 }
728
729 pub fn assign(&self, profile_id: i64, path: &Path, label: Option<&str>) -> Result<()> {
733 self.db.assign_save(profile_id, path, label)
734 }
735
736 pub fn unassign(&self, path: &Path) -> Result<()> {
738 self.db.unassign_save(path)
739 }
740
741 pub fn list(&self, profile_id: i64) -> Result<Vec<SaveEntry>> {
743 self.db.list_saves(profile_id)
744 }
745
746 pub fn list_unassigned(&self, game_save_dir: &Path) -> Result<Vec<PathBuf>> {
748 if !game_save_dir.exists() {
749 return Ok(Vec::new());
750 }
751
752 let mut unassigned = Vec::new();
753
754 for entry in std::fs::read_dir(game_save_dir)?.flatten() {
755 let path = entry.path();
756 if !self.db.is_save_assigned(&path)? {
757 unassigned.push(path);
758 }
759 }
760
761 Ok(unassigned)
762 }
763}
764
765fn vault_signature() -> Signature<'static> {
768 Signature::now("modde", "modde@localhost").expect("failed to create git signature")
769}
770
771fn sanitize_branch_name(name: &str) -> String {
773 name.chars()
774 .map(|c| match c {
775 ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' => '-',
776 c => c,
777 })
778 .collect()
779}
780
781pub fn format_timestamp(secs: i64) -> String {
787 use std::fmt::Write;
788 let dt = time_to_parts(secs);
789 let mut s = String::new();
790 let _ = write!(s, "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
791 dt.0, dt.1, dt.2, dt.3, dt.4, dt.5);
792 s
793}
794
795pub fn format_timestamp_short(secs: i64) -> String {
797 let (y, m, d, hour, minute, _) = time_to_parts(secs);
798 let month = match m {
799 1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr",
800 5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug",
801 9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec",
802 _ => "???",
803 };
804
805 let current_year = {
807 let now_days = (std::time::SystemTime::now()
808 .duration_since(std::time::UNIX_EPOCH)
809 .unwrap_or_default()
810 .as_secs() / 86400) as i32;
811 let z = now_days + 719468;
812 let era = if z >= 0 { z } else { z - 146096 } / 146097;
813 let doe = (z - era * 146097) as u32;
814 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
815 yoe as i32 + era * 400
816 };
817
818 if y != current_year {
819 format!("{month} {d} '{:02} {hour:02}:{minute:02}", y % 100)
820 } else {
821 format!("{month} {d} {hour:02}:{minute:02}")
822 }
823}
824
825pub fn time_to_parts(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
827 let days = (secs / 86400) as i32;
828 let time_of_day = (secs % 86400) as u32;
829 let hour = time_of_day / 3600;
830 let minute = (time_of_day % 3600) / 60;
831 let second = time_of_day % 60;
832
833 let z = days + 719468;
835 let era = if z >= 0 { z } else { z - 146096 } / 146097;
836 let doe = (z - era * 146097) as u32;
837 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
838 let y = yoe as i32 + era * 400;
839 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
840 let mp = (5 * doy + 2) / 153;
841 let d = doy - (153 * mp + 2) / 5 + 1;
842 let m = if mp < 10 { mp + 3 } else { mp - 9 };
843 let y = if m <= 2 { y + 1 } else { y };
844
845 (y, m, d, hour, minute, second)
846}
847
848fn count_tree_entries(repo: &Repository, tree: &git2::Tree) -> usize {
850 let mut count = 0;
851 for entry in tree.iter() {
852 match entry.kind() {
853 Some(git2::ObjectType::Blob) => count += 1,
854 Some(git2::ObjectType::Tree) => {
855 if let Ok(subtree) = repo.find_tree(entry.id()) {
856 count += count_tree_entries(repo, &subtree);
857 }
858 }
859 _ => {}
860 }
861 }
862 count
863}
864
865fn collect_tree_paths(repo: &Repository, tree: &git2::Tree, prefix: &str) -> Vec<String> {
867 let mut paths = Vec::new();
868 for entry in tree.iter() {
869 let name = entry.name().unwrap_or("");
870 let full = if prefix.is_empty() {
871 name.to_string()
872 } else {
873 format!("{prefix}/{name}")
874 };
875 match entry.kind() {
876 Some(git2::ObjectType::Blob) => paths.push(full),
877 Some(git2::ObjectType::Tree) => {
878 if let Ok(subtree) = repo.find_tree(entry.id()) {
879 paths.extend(collect_tree_paths(repo, &subtree, &full));
880 }
881 }
882 _ => {}
883 }
884 }
885 paths
886}
887
888fn clear_dir(dir: &Path) -> Result<()> {
890 if !dir.exists() {
891 return Ok(());
892 }
893 for entry in std::fs::read_dir(dir)? {
894 let entry = entry?;
895 let path = entry.path();
896 if path.is_dir() {
897 std::fs::remove_dir_all(&path)?;
898 } else {
899 std::fs::remove_file(&path)?;
900 }
901 }
902 Ok(())
903}
904
905fn copy_dir_contents(src: &Path, dst: &Path) -> Result<usize> {
907 copy_dir_contents_filtered(src, dst, |_| true)
908}
909
910fn copy_dir_contents_filtered(
912 src: &Path,
913 dst: &Path,
914 filter: impl Fn(&str) -> bool + Copy,
915) -> Result<usize> {
916 let mut count = 0usize;
917
918 if !src.exists() {
919 return Ok(0);
920 }
921
922 std::fs::create_dir_all(dst)?;
923
924 for entry in std::fs::read_dir(src)? {
925 let entry = entry?;
926 let name = entry.file_name();
927 let name_str = name.to_string_lossy();
928
929 if !filter(&name_str) {
930 continue;
931 }
932
933 let src_path = entry.path();
934 let dst_path = dst.join(&name);
935
936 if src_path.is_dir() {
937 count += copy_dir_contents_filtered(&src_path, &dst_path, filter)?;
938 } else {
939 std::fs::copy(&src_path, &dst_path)?;
940 count += 1;
941 }
942 }
943
944 Ok(count)
945}