1use crate::error::{FossilError, Result};
26use crate::fs::find::Permissions;
27use crate::repo::Repository;
28use std::collections::HashMap;
29
30#[derive(Debug, Clone)]
32pub enum Op {
33 Copy {
35 src: String,
37 dst: String,
39 },
40 Move {
42 src: String,
44 dst: String,
46 },
47 Delete {
49 path: String,
51 recursive: bool,
53 },
54 Chmod {
56 path: String,
58 permissions: Permissions,
60 recursive: bool,
62 },
63 Symlink {
65 link_path: String,
67 target: String,
69 },
70 Write {
72 path: String,
74 content: Vec<u8>,
76 },
77 MakeExecutable {
79 path: String,
81 },
82}
83
84#[derive(Debug)]
86pub struct Preview {
87 pub base_commit: String,
89 pub base_file_count: usize,
91 pub operations: Vec<Op>,
93}
94
95impl Preview {
96 pub fn describe(&self) -> Vec<String> {
98 self.operations
99 .iter()
100 .map(|op| match op {
101 Op::Copy { src, dst } => format!("COPY {} -> {}", src, dst),
102 Op::Move { src, dst } => format!("MOVE {} -> {}", src, dst),
103 Op::Delete { path, recursive } => {
104 if *recursive {
105 format!("DELETE {} (recursive)", path)
106 } else if path.starts_with("glob:") {
107 format!("DELETE matching {}", &path[5..])
108 } else {
109 format!("DELETE {}", path)
110 }
111 }
112 Op::Chmod {
113 path,
114 permissions,
115 recursive,
116 } => {
117 if *recursive {
118 format!("CHMOD {} {:o} (recursive)", path, permissions.to_octal())
119 } else {
120 format!("CHMOD {} {:o}", path, permissions.to_octal())
121 }
122 }
123 Op::Symlink { link_path, target } => {
124 format!("SYMLINK {} -> {}", link_path, target)
125 }
126 Op::Write { path, content } => {
127 format!("WRITE {} ({} bytes)", path, content.len())
128 }
129 Op::MakeExecutable { path } => format!("MAKE_EXECUTABLE {}", path),
130 })
131 .collect()
132 }
133}
134
135pub struct Modify<'a> {
224 repo: &'a Repository,
225 base_commit: Option<String>,
226 operations: Vec<Op>,
227 commit_message: Option<String>,
228 author: Option<String>,
229 branch: Option<String>,
230}
231
232impl<'a> Modify<'a> {
233 pub fn new(repo: &'a Repository) -> Self {
235 Self {
236 repo,
237 base_commit: None,
238 operations: Vec::new(),
239 commit_message: None,
240 author: None,
241 branch: None,
242 }
243 }
244
245 pub fn at_commit(mut self, hash: &str) -> Self {
247 self.base_commit = Some(hash.to_string());
248 self
249 }
250
251 pub fn on_trunk(self) -> Self {
253 self
254 }
255
256 pub fn on_branch(mut self, branch: &str) -> Result<Self> {
258 let tip = self.repo.branch_tip_internal(branch)?;
259 self.base_commit = Some(tip.hash);
260 self.branch = Some(branch.to_string());
261 Ok(self)
262 }
263
264 pub fn message(mut self, msg: &str) -> Self {
266 self.commit_message = Some(msg.to_string());
267 self
268 }
269
270 pub fn author(mut self, author: &str) -> Self {
272 self.author = Some(author.to_string());
273 self
274 }
275
276 pub fn copy_file(mut self, src: &str, dst: &str) -> Self {
282 self.operations.push(Op::Copy {
283 src: src.to_string(),
284 dst: dst.to_string(),
285 });
286 self
287 }
288
289 pub fn copy_dir(mut self, src: &str, dst: &str) -> Self {
291 self.operations.push(Op::Copy {
292 src: format!("{}/", src.trim_end_matches('/')),
293 dst: format!("{}/", dst.trim_end_matches('/')),
294 });
295 self
296 }
297
298 pub fn move_file(mut self, src: &str, dst: &str) -> Self {
304 self.operations.push(Op::Move {
305 src: src.to_string(),
306 dst: dst.to_string(),
307 });
308 self
309 }
310
311 pub fn move_dir(mut self, src: &str, dst: &str) -> Self {
313 self.operations.push(Op::Move {
314 src: format!("{}/", src.trim_end_matches('/')),
315 dst: format!("{}/", dst.trim_end_matches('/')),
316 });
317 self
318 }
319
320 pub fn rename(self, old_path: &str, new_path: &str) -> Self {
322 self.move_file(old_path, new_path)
323 }
324
325 pub fn delete_file(mut self, path: &str) -> Self {
331 self.operations.push(Op::Delete {
332 path: path.to_string(),
333 recursive: false,
334 });
335 self
336 }
337
338 pub fn delete_dir(mut self, path: &str) -> Self {
340 self.operations.push(Op::Delete {
341 path: format!("{}/", path.trim_end_matches('/')),
342 recursive: true,
343 });
344 self
345 }
346
347 pub fn delete_matching(mut self, pattern: &str) -> Self {
349 self.operations.push(Op::Delete {
350 path: format!("glob:{}", pattern),
351 recursive: false,
352 });
353 self
354 }
355
356 pub fn chmod(mut self, path: &str, mode: u32) -> Self {
362 self.operations.push(Op::Chmod {
363 path: path.to_string(),
364 permissions: Permissions::from_octal(mode),
365 recursive: false,
366 });
367 self
368 }
369
370 pub fn chmod_permissions(mut self, path: &str, perms: Permissions) -> Self {
372 self.operations.push(Op::Chmod {
373 path: path.to_string(),
374 permissions: perms,
375 recursive: false,
376 });
377 self
378 }
379
380 pub fn chmod_dir(mut self, path: &str, mode: u32) -> Self {
382 self.operations.push(Op::Chmod {
383 path: format!("{}/", path.trim_end_matches('/')),
384 permissions: Permissions::from_octal(mode),
385 recursive: true,
386 });
387 self
388 }
389
390 pub fn make_executable(mut self, path: &str) -> Self {
392 self.operations.push(Op::MakeExecutable {
393 path: path.to_string(),
394 });
395 self
396 }
397
398 pub fn symlink(mut self, link_path: &str, target: &str) -> Self {
404 self.operations.push(Op::Symlink {
405 link_path: link_path.to_string(),
406 target: target.to_string(),
407 });
408 self
409 }
410
411 pub fn symlink_file(self, link_path: &str, target_file: &str) -> Self {
413 self.symlink(link_path, target_file)
414 }
415
416 pub fn symlink_dir(self, link_path: &str, target_dir: &str) -> Self {
418 self.symlink(link_path, target_dir)
419 }
420
421 pub fn write(mut self, path: &str, content: &[u8]) -> Self {
427 self.operations.push(Op::Write {
428 path: path.to_string(),
429 content: content.to_vec(),
430 });
431 self
432 }
433
434 pub fn write_str(self, path: &str, content: &str) -> Self {
436 self.write(path, content.as_bytes())
437 }
438
439 pub fn touch(self, path: &str) -> Self {
441 self.write(path, &[])
442 }
443
444 pub fn execute(self) -> Result<String> {
450 let message = self.commit_message.ok_or_else(|| {
451 FossilError::InvalidArtifact("commit message required for fs operations".to_string())
452 })?;
453 let author = self.author.ok_or_else(|| {
454 FossilError::InvalidArtifact("author required for fs operations".to_string())
455 })?;
456
457 let base_hash = if let Some(hash) = self.base_commit {
458 hash
459 } else {
460 let tip = self.repo.branch_tip_internal("trunk")?;
461 tip.hash
462 };
463
464 let base_files = self.repo.list_files_internal(&base_hash)?;
465 let mut file_contents: HashMap<String, Vec<u8>> = HashMap::new();
466 let mut file_permissions: HashMap<String, String> = HashMap::new();
467 let mut _symlinks: HashMap<String, String> = HashMap::new();
468
469 for file in &base_files {
470 let content = self.repo.read_file_internal(&base_hash, &file.name)?;
471 file_contents.insert(file.name.clone(), content);
472 if let Some(ref perms) = file.permissions {
473 file_permissions.insert(file.name.clone(), perms.clone());
474 }
475 }
476
477 for op in &self.operations {
478 match op {
479 Op::Copy { src, dst } => {
480 if src.ends_with('/') {
481 let src_prefix = src.trim_end_matches('/');
482 let dst_prefix = dst.trim_end_matches('/');
483 let to_copy: Vec<_> = file_contents
484 .keys()
485 .filter(|k| k.starts_with(src_prefix))
486 .cloned()
487 .collect();
488 for path in to_copy {
489 let new_path = path.replacen(src_prefix, dst_prefix, 1);
490 if let Some(content) = file_contents.get(&path).cloned() {
491 file_contents.insert(new_path.clone(), content);
492 }
493 if let Some(perms) = file_permissions.get(&path).cloned() {
494 file_permissions.insert(new_path, perms);
495 }
496 }
497 } else {
498 if let Some(content) = file_contents.get(src).cloned() {
499 file_contents.insert(dst.clone(), content);
500 }
501 if let Some(perms) = file_permissions.get(src).cloned() {
502 file_permissions.insert(dst.clone(), perms);
503 }
504 }
505 }
506
507 Op::Move { src, dst } => {
508 if src.ends_with('/') {
509 let src_prefix = src.trim_end_matches('/');
510 let dst_prefix = dst.trim_end_matches('/');
511 let to_move: Vec<_> = file_contents
512 .keys()
513 .filter(|k| k.starts_with(src_prefix))
514 .cloned()
515 .collect();
516 for path in to_move {
517 let new_path = path.replacen(src_prefix, dst_prefix, 1);
518 if let Some(content) = file_contents.remove(&path) {
519 file_contents.insert(new_path.clone(), content);
520 }
521 if let Some(perms) = file_permissions.remove(&path) {
522 file_permissions.insert(new_path, perms);
523 }
524 }
525 } else {
526 if let Some(content) = file_contents.remove(src) {
527 file_contents.insert(dst.clone(), content);
528 }
529 if let Some(perms) = file_permissions.remove(src) {
530 file_permissions.insert(dst.clone(), perms);
531 }
532 }
533 }
534
535 Op::Delete { path, recursive } => {
536 if path.starts_with("glob:") {
537 let pattern = &path[5..];
538 if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
539 let to_delete: Vec<_> = file_contents
540 .keys()
541 .filter(|k| glob_pattern.matches(k))
542 .cloned()
543 .collect();
544 for p in to_delete {
545 file_contents.remove(&p);
546 file_permissions.remove(&p);
547 }
548 }
549 } else if *recursive || path.ends_with('/') {
550 let prefix = path.trim_end_matches('/');
551 let to_delete: Vec<_> = file_contents
552 .keys()
553 .filter(|k| k.starts_with(prefix) || *k == prefix)
554 .cloned()
555 .collect();
556 for p in to_delete {
557 file_contents.remove(&p);
558 file_permissions.remove(&p);
559 }
560 } else {
561 file_contents.remove(path);
562 file_permissions.remove(path);
563 }
564 }
565
566 Op::Chmod {
567 path,
568 permissions,
569 recursive,
570 } => {
571 let perm_str = format!("{:o}", permissions.to_octal());
572 if *recursive || path.ends_with('/') {
573 let prefix = path.trim_end_matches('/');
574 let to_chmod: Vec<_> = file_contents
575 .keys()
576 .filter(|k| k.starts_with(prefix))
577 .cloned()
578 .collect();
579 for p in to_chmod {
580 file_permissions.insert(p, perm_str.clone());
581 }
582 } else if file_contents.contains_key(path) {
583 file_permissions.insert(path.clone(), perm_str);
584 }
585 }
586
587 Op::MakeExecutable { path } => {
588 if file_contents.contains_key(path) {
589 file_permissions.insert(path.clone(), "755".to_string());
590 }
591 }
592
593 Op::Symlink { link_path, target } => {
594 let symlink_content = format!("link {}", target);
595 file_contents.insert(link_path.clone(), symlink_content.into_bytes());
596 _symlinks.insert(link_path.clone(), target.clone());
597 }
598
599 Op::Write { path, content } => {
600 file_contents.insert(path.clone(), content.clone());
601 }
602 }
603 }
604
605 let files: Vec<(&str, &[u8])> = file_contents
606 .iter()
607 .map(|(k, v)| (k.as_str(), v.as_slice()))
608 .collect();
609
610 self.repo.commit_internal(
611 &files,
612 &message,
613 &author,
614 Some(&base_hash),
615 self.branch.as_deref(),
616 )
617 }
618
619 pub fn preview(&self) -> Result<Preview> {
621 let base_hash = if let Some(ref hash) = self.base_commit {
622 hash.clone()
623 } else {
624 let tip = self.repo.branch_tip_internal("trunk")?;
625 tip.hash
626 };
627
628 let base_files = self.repo.list_files_internal(&base_hash)?;
629
630 Ok(Preview {
631 base_commit: base_hash,
632 base_file_count: base_files.len(),
633 operations: self.operations.clone(),
634 })
635 }
636}
637
638use std::path::Path;
643
644pub fn upload<P: AsRef<Path>>(
673 repo: &Repository,
674 os_path: P,
675 repo_path: &str,
676 author: &str,
677 message: Option<&str>,
678) -> Result<String> {
679 let os_path = os_path.as_ref();
680
681 let content = std::fs::read(os_path).map_err(|e| {
683 FossilError::Io(std::io::Error::new(
684 e.kind(),
685 format!("Failed to read file '{}': {}", os_path.display(), e),
686 ))
687 })?;
688
689 let msg = message.unwrap_or_else(|| "Upload file");
690 let full_message = format!("{}: {}", msg, repo_path);
691
692 Modify::new(repo)
693 .message(&full_message)
694 .author(author)
695 .write(repo_path, &content)
696 .execute()
697}
698
699pub fn upload_dir<P: AsRef<Path>>(
728 repo: &Repository,
729 os_path: P,
730 repo_path: &str,
731 author: &str,
732 message: Option<&str>,
733) -> Result<String> {
734 let os_path = os_path.as_ref();
735
736 if !os_path.is_dir() {
737 return Err(FossilError::Io(std::io::Error::new(
738 std::io::ErrorKind::NotADirectory,
739 format!("'{}' is not a directory", os_path.display()),
740 )));
741 }
742
743 let mut modify = Modify::new(repo);
744 let msg = message.unwrap_or("Upload directory");
745 modify = modify
746 .message(&format!("{}: {}", msg, repo_path))
747 .author(author);
748
749 fn collect_files<'a, P: AsRef<Path>>(
751 dir: P,
752 base: &Path,
753 repo_base: &str,
754 modify: Modify<'a>,
755 ) -> Result<Modify<'a>> {
756 let mut m = modify;
757 let entries = std::fs::read_dir(dir.as_ref()).map_err(|e| {
758 FossilError::Io(std::io::Error::new(
759 e.kind(),
760 format!(
761 "Failed to read directory '{}': {}",
762 dir.as_ref().display(),
763 e
764 ),
765 ))
766 })?;
767
768 for entry in entries {
769 let entry = entry.map_err(|e| FossilError::Io(e))?;
770 let path = entry.path();
771
772 if path.is_file() {
773 let relative = path.strip_prefix(base).unwrap_or(&path);
774 let repo_file_path = if repo_base.is_empty() {
775 relative.to_string_lossy().to_string()
776 } else {
777 format!(
778 "{}/{}",
779 repo_base.trim_end_matches('/'),
780 relative.to_string_lossy()
781 )
782 };
783
784 let content = std::fs::read(&path).map_err(|e| {
785 FossilError::Io(std::io::Error::new(
786 e.kind(),
787 format!("Failed to read file '{}': {}", path.display(), e),
788 ))
789 })?;
790
791 m = m.write(&repo_file_path, &content);
792 } else if path.is_dir() {
793 m = collect_files(&path, base, repo_base, m)?;
794 }
795 }
796
797 Ok(m)
798 }
799
800 let modify = collect_files(os_path, os_path, repo_path, modify)?;
801 modify.execute()
802}
803
804pub fn download<P: AsRef<Path>>(repo: &Repository, repo_path: &str, os_path: P) -> Result<()> {
830 let os_path = os_path.as_ref();
831
832 let tip = repo.branch_tip_internal("trunk")?;
834 let content = repo.read_file_internal(&tip.hash, repo_path)?;
835
836 if let Some(parent) = os_path.parent() {
838 if !parent.as_os_str().is_empty() && !parent.exists() {
839 std::fs::create_dir_all(parent).map_err(|e| {
840 FossilError::Io(std::io::Error::new(
841 e.kind(),
842 format!("Failed to create directory '{}': {}", parent.display(), e),
843 ))
844 })?;
845 }
846 }
847
848 std::fs::write(os_path, &content).map_err(|e| {
850 FossilError::Io(std::io::Error::new(
851 e.kind(),
852 format!("Failed to write file '{}': {}", os_path.display(), e),
853 ))
854 })?;
855
856 Ok(())
857}
858
859pub fn download_from_branch<P: AsRef<Path>>(
872 repo: &Repository,
873 branch: &str,
874 repo_path: &str,
875 os_path: P,
876) -> Result<()> {
877 let os_path = os_path.as_ref();
878
879 let tip = repo.branch_tip_internal(branch)?;
880 let content = repo.read_file_internal(&tip.hash, repo_path)?;
881
882 if let Some(parent) = os_path.parent() {
883 if !parent.as_os_str().is_empty() && !parent.exists() {
884 std::fs::create_dir_all(parent).map_err(|e| {
885 FossilError::Io(std::io::Error::new(
886 e.kind(),
887 format!("Failed to create directory '{}': {}", parent.display(), e),
888 ))
889 })?;
890 }
891 }
892
893 std::fs::write(os_path, &content).map_err(|e| {
894 FossilError::Io(std::io::Error::new(
895 e.kind(),
896 format!("Failed to write file '{}': {}", os_path.display(), e),
897 ))
898 })?;
899
900 Ok(())
901}
902
903pub fn download_dir<P: AsRef<Path>>(
929 repo: &Repository,
930 repo_path: &str,
931 os_path: P,
932) -> Result<usize> {
933 let os_path = os_path.as_ref();
934 let tip = repo.branch_tip_internal("trunk")?;
935 let all_files = repo.list_files_internal(&tip.hash)?;
936
937 let prefix = if repo_path.is_empty() {
938 String::new()
939 } else {
940 format!("{}/", repo_path.trim_end_matches('/'))
941 };
942
943 let mut count = 0;
944
945 for file in all_files {
946 let relative_path = if prefix.is_empty() {
948 Some(file.name.as_str())
949 } else if file.name.starts_with(&prefix) {
950 Some(&file.name[prefix.len()..])
951 } else {
952 None
953 };
954
955 if let Some(rel_path) = relative_path {
956 let dest_path = os_path.join(rel_path);
957
958 if let Some(parent) = dest_path.parent() {
960 if !parent.exists() {
961 std::fs::create_dir_all(parent).map_err(|e| {
962 FossilError::Io(std::io::Error::new(
963 e.kind(),
964 format!("Failed to create directory '{}': {}", parent.display(), e),
965 ))
966 })?;
967 }
968 }
969
970 let content = repo.read_file_internal(&tip.hash, &file.name)?;
972 std::fs::write(&dest_path, &content).map_err(|e| {
973 FossilError::Io(std::io::Error::new(
974 e.kind(),
975 format!("Failed to write file '{}': {}", dest_path.display(), e),
976 ))
977 })?;
978
979 count += 1;
980 }
981 }
982
983 Ok(count)
984}
985
986pub fn download_matching<P: AsRef<Path>>(
1002 repo: &Repository,
1003 pattern: &str,
1004 os_path: P,
1005) -> Result<usize> {
1006 let os_path = os_path.as_ref();
1007 let tip = repo.branch_tip_internal("trunk")?;
1008 let matched_files = repo.find_files_internal(&tip.hash, pattern)?;
1009
1010 let mut count = 0;
1011
1012 for file in matched_files {
1013 let dest_path = os_path.join(&file.name);
1014
1015 if let Some(parent) = dest_path.parent() {
1016 if !parent.exists() {
1017 std::fs::create_dir_all(parent).map_err(|e| {
1018 FossilError::Io(std::io::Error::new(
1019 e.kind(),
1020 format!("Failed to create directory '{}': {}", parent.display(), e),
1021 ))
1022 })?;
1023 }
1024 }
1025
1026 let content = repo.read_file_internal(&tip.hash, &file.name)?;
1027 std::fs::write(&dest_path, &content).map_err(|e| {
1028 FossilError::Io(std::io::Error::new(
1029 e.kind(),
1030 format!("Failed to write file '{}': {}", dest_path.display(), e),
1031 ))
1032 })?;
1033
1034 count += 1;
1035 }
1036
1037 Ok(count)
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043
1044 #[test]
1045 fn test_preview_describe() {
1046 let preview = Preview {
1047 base_commit: "abc123".to_string(),
1048 base_file_count: 10,
1049 operations: vec![
1050 Op::Copy {
1051 src: "a.txt".to_string(),
1052 dst: "b.txt".to_string(),
1053 },
1054 Op::Move {
1055 src: "old/".to_string(),
1056 dst: "new/".to_string(),
1057 },
1058 Op::Delete {
1059 path: "temp.log".to_string(),
1060 recursive: false,
1061 },
1062 Op::Delete {
1063 path: "cache/".to_string(),
1064 recursive: true,
1065 },
1066 Op::Chmod {
1067 path: "script.sh".to_string(),
1068 permissions: Permissions::executable(),
1069 recursive: false,
1070 },
1071 Op::Symlink {
1072 link_path: "link".to_string(),
1073 target: "target".to_string(),
1074 },
1075 Op::Write {
1076 path: "file.txt".to_string(),
1077 content: b"hello".to_vec(),
1078 },
1079 Op::MakeExecutable {
1080 path: "run.sh".to_string(),
1081 },
1082 ],
1083 };
1084
1085 let descriptions = preview.describe();
1086 assert_eq!(descriptions.len(), 8);
1087 assert_eq!(descriptions[0], "COPY a.txt -> b.txt");
1088 assert_eq!(descriptions[1], "MOVE old/ -> new/");
1089 assert_eq!(descriptions[2], "DELETE temp.log");
1090 assert_eq!(descriptions[3], "DELETE cache/ (recursive)");
1091 assert_eq!(descriptions[4], "CHMOD script.sh 755");
1092 assert_eq!(descriptions[5], "SYMLINK link -> target");
1093 assert_eq!(descriptions[6], "WRITE file.txt (5 bytes)");
1094 assert_eq!(descriptions[7], "MAKE_EXECUTABLE run.sh");
1095 }
1096
1097 #[test]
1098 fn test_preview_describe_glob_delete() {
1099 let preview = Preview {
1100 base_commit: "hash".to_string(),
1101 base_file_count: 5,
1102 operations: vec![Op::Delete {
1103 path: "glob:**/*.bak".to_string(),
1104 recursive: false,
1105 }],
1106 };
1107 let descriptions = preview.describe();
1108 assert_eq!(descriptions[0], "DELETE matching **/*.bak");
1109 }
1110
1111 #[test]
1112 fn test_preview_describe_recursive_chmod() {
1113 let preview = Preview {
1114 base_commit: "hash".to_string(),
1115 base_file_count: 5,
1116 operations: vec![Op::Chmod {
1117 path: "bin/".to_string(),
1118 permissions: Permissions::from_octal(0o755),
1119 recursive: true,
1120 }],
1121 };
1122 let descriptions = preview.describe();
1123 assert_eq!(descriptions[0], "CHMOD bin/ 755 (recursive)");
1124 }
1125
1126 #[test]
1127 fn test_preview_empty_operations() {
1128 let preview = Preview {
1129 base_commit: "abc123".to_string(),
1130 base_file_count: 0,
1131 operations: vec![],
1132 };
1133 assert!(preview.describe().is_empty());
1134 assert_eq!(preview.base_file_count, 0);
1135 }
1136
1137 #[test]
1138 fn test_op_copy_clone() {
1139 let op = Op::Copy {
1140 src: "src.txt".to_string(),
1141 dst: "dst.txt".to_string(),
1142 };
1143 let cloned = op.clone();
1144 if let Op::Copy { src, dst } = cloned {
1145 assert_eq!(src, "src.txt");
1146 assert_eq!(dst, "dst.txt");
1147 } else {
1148 panic!("Expected Copy operation");
1149 }
1150 }
1151
1152 #[test]
1153 fn test_op_move_clone() {
1154 let op = Op::Move {
1155 src: "old.txt".to_string(),
1156 dst: "new.txt".to_string(),
1157 };
1158 let cloned = op.clone();
1159 if let Op::Move { src, dst } = cloned {
1160 assert_eq!(src, "old.txt");
1161 assert_eq!(dst, "new.txt");
1162 } else {
1163 panic!("Expected Move operation");
1164 }
1165 }
1166
1167 #[test]
1168 fn test_op_delete_clone() {
1169 let op = Op::Delete {
1170 path: "file.txt".to_string(),
1171 recursive: true,
1172 };
1173 let cloned = op.clone();
1174 if let Op::Delete { path, recursive } = cloned {
1175 assert_eq!(path, "file.txt");
1176 assert!(recursive);
1177 } else {
1178 panic!("Expected Delete operation");
1179 }
1180 }
1181
1182 #[test]
1183 fn test_op_write_clone() {
1184 let op = Op::Write {
1185 path: "file.txt".to_string(),
1186 content: b"content".to_vec(),
1187 };
1188 let cloned = op.clone();
1189 if let Op::Write { path, content } = cloned {
1190 assert_eq!(path, "file.txt");
1191 assert_eq!(content, b"content");
1192 } else {
1193 panic!("Expected Write operation");
1194 }
1195 }
1196
1197 #[test]
1198 fn test_op_symlink_clone() {
1199 let op = Op::Symlink {
1200 link_path: "link".to_string(),
1201 target: "target".to_string(),
1202 };
1203 let cloned = op.clone();
1204 if let Op::Symlink { link_path, target } = cloned {
1205 assert_eq!(link_path, "link");
1206 assert_eq!(target, "target");
1207 } else {
1208 panic!("Expected Symlink operation");
1209 }
1210 }
1211
1212 #[test]
1213 fn test_op_chmod_clone() {
1214 let op = Op::Chmod {
1215 path: "file.sh".to_string(),
1216 permissions: Permissions::executable(),
1217 recursive: false,
1218 };
1219 let cloned = op.clone();
1220 if let Op::Chmod {
1221 path,
1222 permissions,
1223 recursive,
1224 } = cloned
1225 {
1226 assert_eq!(path, "file.sh");
1227 assert_eq!(permissions.to_octal(), 0o755);
1228 assert!(!recursive);
1229 } else {
1230 panic!("Expected Chmod operation");
1231 }
1232 }
1233
1234 #[test]
1235 fn test_op_make_executable_clone() {
1236 let op = Op::MakeExecutable {
1237 path: "script.sh".to_string(),
1238 };
1239 let cloned = op.clone();
1240 if let Op::MakeExecutable { path } = cloned {
1241 assert_eq!(path, "script.sh");
1242 } else {
1243 panic!("Expected MakeExecutable operation");
1244 }
1245 }
1246
1247 #[test]
1248 fn test_op_debug_format() {
1249 let op = Op::Copy {
1250 src: "a".to_string(),
1251 dst: "b".to_string(),
1252 };
1253 let debug_str = format!("{:?}", op);
1254 assert!(debug_str.contains("Copy"));
1255 assert!(debug_str.contains("a"));
1256 assert!(debug_str.contains("b"));
1257 }
1258
1259 #[test]
1260 fn test_preview_debug_format() {
1261 let preview = Preview {
1262 base_commit: "abc".to_string(),
1263 base_file_count: 5,
1264 operations: vec![],
1265 };
1266 let debug_str = format!("{:?}", preview);
1267 assert!(debug_str.contains("Preview"));
1268 assert!(debug_str.contains("abc"));
1269 }
1270
1271 use std::fs as std_fs;
1276 use tempfile::TempDir;
1277
1278 fn create_test_repo() -> (TempDir, crate::repo::Repository) {
1279 let tmp = TempDir::new().unwrap();
1280 let repo_path = tmp.path().join("test.forge");
1281 let repo = crate::repo::Repository::init(&repo_path).unwrap();
1282
1283 repo.commit_internal(
1285 &[
1286 ("README.md", b"# Test Project\n"),
1287 ("src/main.rs", b"fn main() {}\n"),
1288 ("src/lib.rs", b"pub fn hello() {}\n"),
1289 ("config.json", b"{\"key\": \"value\"}\n"),
1290 ],
1291 "Initial commit",
1292 "test_author",
1293 None,
1294 None,
1295 )
1296 .unwrap();
1297
1298 (tmp, repo)
1299 }
1300
1301 #[test]
1302 fn test_upload_single_file() {
1303 let (tmp, repo) = create_test_repo();
1304
1305 let os_file = tmp.path().join("upload_test.txt");
1307 std_fs::write(&os_file, b"uploaded content").unwrap();
1308
1309 let hash = upload(&repo, &os_file, "uploaded.txt", "uploader", None).unwrap();
1311 assert!(!hash.is_empty());
1312
1313 let tip = repo.branch_tip_internal("trunk").unwrap();
1315 let content = repo.read_file_internal(&tip.hash, "uploaded.txt").unwrap();
1316 assert_eq!(content, b"uploaded content");
1317 }
1318
1319 #[test]
1320 fn test_upload_with_custom_message() {
1321 let (tmp, repo) = create_test_repo();
1322
1323 let os_file = tmp.path().join("custom_msg.txt");
1324 std_fs::write(&os_file, b"content").unwrap();
1325
1326 let hash = upload(
1327 &repo,
1328 &os_file,
1329 "custom.txt",
1330 "author",
1331 Some("Custom upload message"),
1332 )
1333 .unwrap();
1334 assert!(!hash.is_empty());
1335
1336 let tip = repo.branch_tip_internal("trunk").unwrap();
1338 let content = repo.read_file_internal(&tip.hash, "custom.txt").unwrap();
1339 assert_eq!(content, b"content");
1340 }
1341
1342 #[test]
1343 fn test_upload_nonexistent_file() {
1344 let (tmp, repo) = create_test_repo();
1345
1346 let result = upload(
1347 &repo,
1348 tmp.path().join("nonexistent.txt"),
1349 "dest.txt",
1350 "author",
1351 None,
1352 );
1353 assert!(result.is_err());
1354 }
1355
1356 #[test]
1357 fn test_upload_dir() {
1358 let (tmp, repo) = create_test_repo();
1359
1360 let upload_dir_path = tmp.path().join("to_upload");
1362 std_fs::create_dir_all(upload_dir_path.join("subdir")).unwrap();
1363 std_fs::write(upload_dir_path.join("file1.txt"), b"content1").unwrap();
1364 std_fs::write(upload_dir_path.join("file2.txt"), b"content2").unwrap();
1365 std_fs::write(upload_dir_path.join("subdir/nested.txt"), b"nested").unwrap();
1366
1367 let hash = upload_dir(&repo, &upload_dir_path, "imported", "author", None).unwrap();
1369 assert!(!hash.is_empty());
1370
1371 let tip = repo.branch_tip_internal("trunk").unwrap();
1373 let content1 = repo
1374 .read_file_internal(&tip.hash, "imported/file1.txt")
1375 .unwrap();
1376 assert_eq!(content1, b"content1");
1377
1378 let content2 = repo
1379 .read_file_internal(&tip.hash, "imported/file2.txt")
1380 .unwrap();
1381 assert_eq!(content2, b"content2");
1382
1383 let nested = repo
1384 .read_file_internal(&tip.hash, "imported/subdir/nested.txt")
1385 .unwrap();
1386 assert_eq!(nested, b"nested");
1387 }
1388
1389 #[test]
1390 fn test_upload_dir_not_a_directory() {
1391 let (tmp, repo) = create_test_repo();
1392
1393 let file_path = tmp.path().join("regular_file.txt");
1394 std_fs::write(&file_path, b"content").unwrap();
1395
1396 let result = upload_dir(&repo, &file_path, "dest", "author", None);
1397 assert!(result.is_err());
1398 }
1399
1400 #[test]
1401 fn test_download_single_file() {
1402 let (tmp, repo) = create_test_repo();
1403
1404 let dest_path = tmp.path().join("downloaded.md");
1405 download(&repo, "README.md", &dest_path).unwrap();
1406
1407 let content = std_fs::read_to_string(&dest_path).unwrap();
1408 assert_eq!(content, "# Test Project\n");
1409 }
1410
1411 #[test]
1412 fn test_download_creates_parent_dirs() {
1413 let (tmp, repo) = create_test_repo();
1414
1415 let dest_path = tmp.path().join("deep/nested/path/config.json");
1416 download(&repo, "config.json", &dest_path).unwrap();
1417
1418 assert!(dest_path.exists());
1419 let content = std_fs::read_to_string(&dest_path).unwrap();
1420 assert_eq!(content, "{\"key\": \"value\"}\n");
1421 }
1422
1423 #[test]
1424 fn test_download_nonexistent_file() {
1425 let (tmp, repo) = create_test_repo();
1426
1427 let result = download(&repo, "nonexistent.txt", tmp.path().join("out.txt"));
1428 assert!(result.is_err());
1429 }
1430
1431 #[test]
1432 fn test_download_dir() {
1433 let (tmp, repo) = create_test_repo();
1434
1435 let dest_dir = tmp.path().join("exported_src");
1436 let count = download_dir(&repo, "src", &dest_dir).unwrap();
1437
1438 assert_eq!(count, 2); let main_content = std_fs::read_to_string(dest_dir.join("main.rs")).unwrap();
1441 assert_eq!(main_content, "fn main() {}\n");
1442
1443 let lib_content = std_fs::read_to_string(dest_dir.join("lib.rs")).unwrap();
1444 assert_eq!(lib_content, "pub fn hello() {}\n");
1445 }
1446
1447 #[test]
1448 fn test_download_dir_entire_repo() {
1449 let (tmp, repo) = create_test_repo();
1450
1451 let dest_dir = tmp.path().join("full_export");
1452 let count = download_dir(&repo, "", &dest_dir).unwrap();
1453
1454 assert_eq!(count, 4); assert!(dest_dir.join("README.md").exists());
1457 assert!(dest_dir.join("config.json").exists());
1458 assert!(dest_dir.join("src/main.rs").exists());
1459 assert!(dest_dir.join("src/lib.rs").exists());
1460 }
1461
1462 #[test]
1463 fn test_download_matching() {
1464 let (tmp, repo) = create_test_repo();
1465
1466 let dest_dir = tmp.path().join("rust_files");
1467 let count = download_matching(&repo, "**/*.rs", &dest_dir).unwrap();
1468
1469 assert_eq!(count, 2);
1470 assert!(dest_dir.join("src/main.rs").exists());
1471 assert!(dest_dir.join("src/lib.rs").exists());
1472 }
1473
1474 #[test]
1475 fn test_download_matching_no_matches() {
1476 let (tmp, repo) = create_test_repo();
1477
1478 let dest_dir = tmp.path().join("no_matches");
1479 let count = download_matching(&repo, "**/*.py", &dest_dir).unwrap();
1480
1481 assert_eq!(count, 0);
1482 }
1483
1484 #[test]
1485 fn test_upload_then_download_roundtrip() {
1486 let (tmp, repo) = create_test_repo();
1487
1488 let original_content = b"This is test content for roundtrip!";
1490 let original_path = tmp.path().join("original.txt");
1491 std_fs::write(&original_path, original_content).unwrap();
1492
1493 upload(&repo, &original_path, "roundtrip.txt", "author", None).unwrap();
1495
1496 let downloaded_path = tmp.path().join("downloaded.txt");
1498 download(&repo, "roundtrip.txt", &downloaded_path).unwrap();
1499
1500 let downloaded_content = std_fs::read(&downloaded_path).unwrap();
1502 assert_eq!(downloaded_content, original_content);
1503 }
1504
1505 #[test]
1506 fn test_upload_overwrites_existing() {
1507 let (tmp, repo) = create_test_repo();
1508
1509 let os_file = tmp.path().join("new_readme.md");
1511 std_fs::write(&os_file, b"# Updated README\n").unwrap();
1512
1513 upload(&repo, &os_file, "README.md", "author", None).unwrap();
1515
1516 let tip = repo.branch_tip_internal("trunk").unwrap();
1518 let content = repo.read_file_internal(&tip.hash, "README.md").unwrap();
1519 assert_eq!(content, b"# Updated README\n");
1520 }
1521
1522 #[test]
1523 fn test_upload_creates_new_commit() {
1524 let (tmp, repo) = create_test_repo();
1525
1526 let initial_tip = repo.branch_tip_internal("trunk").unwrap();
1528
1529 let os_file = tmp.path().join("new_file.txt");
1531 std_fs::write(&os_file, b"new content").unwrap();
1532 upload(&repo, &os_file, "new.txt", "author", None).unwrap();
1533
1534 let new_tip = repo.branch_tip_internal("trunk").unwrap();
1536 assert_ne!(initial_tip.hash, new_tip.hash);
1537 }
1538
1539 #[test]
1540 fn test_upload_dir_empty_repo_path() {
1541 let (tmp, repo) = create_test_repo();
1542
1543 let upload_path = tmp.path().join("root_upload");
1545 std_fs::create_dir_all(&upload_path).unwrap();
1546 std_fs::write(upload_path.join("root_file.txt"), b"at root").unwrap();
1547
1548 upload_dir(&repo, &upload_path, "", "author", None).unwrap();
1550
1551 let tip = repo.branch_tip_internal("trunk").unwrap();
1553 let content = repo.read_file_internal(&tip.hash, "root_file.txt").unwrap();
1554 assert_eq!(content, b"at root");
1555 }
1556}