1#![warn(missing_docs)]
4#![warn(
5 clippy::all,
6 clippy::as_conversions,
7 clippy::clone_on_ref_ptr,
8 clippy::dbg_macro
9)]
10#![allow(clippy::too_many_arguments)]
11
12mod render;
13pub mod testing;
14
15use std::borrow::Cow;
16use std::collections::BTreeSet;
17use std::fs;
18use std::io;
19use std::path::{Path, PathBuf, StripPrefixError};
20
21use clap::Parser;
22use sha1::Digest;
23use thiserror::Error;
24use walkdir::WalkDir;
25
26use scm_record::helpers::CrosstermInput;
27use scm_record::{
28 File, FileMode, RecordError, RecordState, Recorder, SelectedChanges, SelectedContents,
29};
30
31#[derive(Debug, Parser)]
37pub struct Opts {
38 #[clap(short = 'd', long = "dir-diff")]
40 pub dir_diff: bool,
41
42 pub left: PathBuf,
44
45 pub right: PathBuf,
47
48 #[clap(long = "read-only")]
51 pub read_only: bool,
52
53 #[clap(short = 'N', long = "dry-run")]
56 pub dry_run: bool,
57
58 #[clap(
62 short = 'b',
63 long = "base",
64 requires("output"),
65 conflicts_with("dir_diff")
66 )]
67 pub base: Option<PathBuf>,
68
69 #[clap(short = 'o', long = "output", conflicts_with("dir_diff"))]
71 pub output: Option<PathBuf>,
72}
73
74#[derive(Debug, Error)]
75#[allow(missing_docs)]
76pub enum Error {
77 #[error("aborted by user")]
78 Cancelled,
79
80 #[error("dry run, not writing any files")]
81 DryRun,
82
83 #[error("walking directory: {source}")]
84 WalkDir { source: walkdir::Error },
85
86 #[error("stripping directory prefix {root} from {path}: {source}")]
87 StripPrefix {
88 root: PathBuf,
89 path: PathBuf,
90 source: StripPrefixError,
91 },
92
93 #[error("reading file {path}: {source}")]
94 ReadFile { path: PathBuf, source: io::Error },
95
96 #[error("removing file {path}: {source}")]
97 RemoveFile { path: PathBuf, source: io::Error },
98
99 #[error("copying file {old_path} to {new_path}: {source}")]
100 CopyFile {
101 old_path: PathBuf,
102 new_path: PathBuf,
103 source: io::Error,
104 },
105
106 #[error("creating directory {path}: {source}")]
107 CreateDirAll { path: PathBuf, source: io::Error },
108
109 #[error("writing file {path}: {source}")]
110 WriteFile { path: PathBuf, source: io::Error },
111
112 #[error("file did not exist: {path}")]
113 MissingMergeFile { path: PathBuf },
114
115 #[error("file was not text: {path}")]
116 BinaryMergeFile { path: PathBuf },
117
118 #[error("recording changes: {source}")]
119 Record { source: RecordError },
120}
121
122pub type Result<T> = std::result::Result<T, Error>;
124
125#[derive(Clone, Debug)]
128pub struct FileInfo {
129 pub file_mode: FileMode,
131
132 pub contents: FileContents,
134}
135
136#[derive(Clone, Debug)]
138pub enum FileContents {
139 Absent,
141
142 Text {
144 contents: String,
146
147 hash: String,
149
150 num_bytes: u64,
152 },
153
154 Binary {
156 hash: String,
158
159 num_bytes: u64,
161 },
162}
163
164pub trait Filesystem {
166 fn read_dir_diff_paths(&self, left: &Path, right: &Path) -> Result<BTreeSet<PathBuf>>;
168
169 fn read_file_info(&self, path: &Path) -> Result<FileInfo>;
171
172 fn write_file(&mut self, path: &Path, contents: &str) -> Result<()>;
174
175 fn copy_file(&mut self, old_path: &Path, new_path: &Path) -> Result<()>;
179
180 fn remove_file(&mut self, path: &Path) -> Result<()>;
182
183 fn create_dir_all(&mut self, path: &Path) -> Result<()>;
185}
186
187struct RealFilesystem;
188
189impl Filesystem for RealFilesystem {
190 fn read_dir_diff_paths(&self, left: &Path, right: &Path) -> Result<BTreeSet<PathBuf>> {
191 fn walk_dir(dir: &Path) -> Result<BTreeSet<PathBuf>> {
192 let mut files = BTreeSet::new();
193 for entry in WalkDir::new(dir) {
194 let entry = entry.map_err(|err| Error::WalkDir { source: err })?;
195 if entry.file_type().is_file() || entry.file_type().is_symlink() {
196 let relative_path = match entry.path().strip_prefix(dir) {
197 Ok(path) => path.to_owned(),
198 Err(err) => {
199 return Err(Error::StripPrefix {
200 root: dir.to_owned(),
201 path: entry.path().to_owned(),
202 source: err,
203 })
204 }
205 };
206 files.insert(relative_path);
207 }
208 }
209 Ok(files)
210 }
211 let left_files = walk_dir(left)?;
212 let right_files = walk_dir(right)?;
213 let paths = left_files
214 .into_iter()
215 .chain(right_files)
216 .collect::<BTreeSet<_>>();
217 Ok(paths)
218 }
219
220 fn read_file_info(&self, path: &Path) -> Result<FileInfo> {
221 let file_mode = match fs::metadata(path) {
222 Ok(metadata) => {
223 if metadata.is_symlink() {
225 FileMode::Unix(0o120000)
226 } else {
227 let permissions = metadata.permissions();
228 #[cfg(unix)]
229 let executable = {
230 use std::os::unix::fs::PermissionsExt;
231 permissions.mode() & 0o001 == 0o001
232 };
233 #[cfg(not(unix))]
234 let executable = false;
235 if executable {
236 FileMode::Unix(0o100755)
237 } else {
238 FileMode::Unix(0o100644)
239 }
240 }
241 }
242 Err(err) if err.kind() == io::ErrorKind::NotFound => FileMode::Absent,
243 Err(err) => {
244 return Err(Error::ReadFile {
245 path: path.to_owned(),
246 source: err,
247 })
248 }
249 };
250 let contents = match fs::read(path) {
251 Ok(contents) => {
252 let hash = {
253 let mut hasher = sha1::Sha1::new();
254 hasher.update(&contents);
255 format!("{:x}", hasher.finalize())
256 };
257 let num_bytes: u64 = contents.len().try_into().unwrap();
258 if contents.contains(&0) {
259 FileContents::Binary { hash, num_bytes }
260 } else {
261 match String::from_utf8(contents) {
262 Ok(contents) => FileContents::Text {
263 contents,
264 hash,
265 num_bytes,
266 },
267 Err(_) => FileContents::Binary { hash, num_bytes },
268 }
269 }
270 }
271 Err(err) if err.kind() == io::ErrorKind::NotFound => FileContents::Absent,
272 Err(err) => {
273 return Err(Error::ReadFile {
274 path: path.to_owned(),
275 source: err,
276 })
277 }
278 };
279 Ok(FileInfo {
280 file_mode,
281 contents,
282 })
283 }
284
285 fn write_file(&mut self, path: &Path, contents: &str) -> Result<()> {
286 fs::write(path, contents).map_err(|err| Error::WriteFile {
287 path: path.to_owned(),
288 source: err,
289 })
290 }
291
292 fn copy_file(&mut self, old_path: &Path, new_path: &Path) -> Result<()> {
293 fs::copy(old_path, new_path).map_err(|err| Error::CopyFile {
294 old_path: old_path.to_owned(),
295 new_path: new_path.to_owned(),
296 source: err,
297 })?;
298 Ok(())
299 }
300
301 fn remove_file(&mut self, path: &Path) -> Result<()> {
302 match fs::remove_file(path) {
303 Ok(()) => Ok(()),
304 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
305 Err(err) => Err(Error::RemoveFile {
306 path: path.to_owned(),
307 source: err,
308 }),
309 }
310 }
311
312 fn create_dir_all(&mut self, path: &Path) -> Result<()> {
313 fs::create_dir_all(path).map_err(|err| Error::CreateDirAll {
314 path: path.to_owned(),
315 source: err,
316 })?;
317 Ok(())
318 }
319}
320
321#[derive(Debug)]
323pub struct DiffContext {
324 pub files: Vec<File<'static>>,
328
329 pub write_root: PathBuf,
333}
334
335pub fn process_opts(filesystem: &dyn Filesystem, opts: &Opts) -> Result<DiffContext> {
337 let result = match opts {
338 Opts {
339 dir_diff: false,
340 left,
341 right,
342 base: None,
343 output: _,
344 read_only: _,
345 dry_run: _,
346 } => {
347 let files = vec![render::create_file(
348 filesystem,
349 left.clone(),
350 left.clone(),
351 right.clone(),
352 right.clone(),
353 )?];
354 DiffContext {
355 files,
356 write_root: PathBuf::new(),
357 }
358 }
359
360 Opts {
361 dir_diff: true,
362 left,
363 right,
364 base: None,
365 output: _,
366 read_only: _,
367 dry_run: _,
368 } => {
369 let display_paths = filesystem.read_dir_diff_paths(left, right)?;
370 let mut files = Vec::new();
371 for display_path in display_paths {
372 files.push(render::create_file(
373 filesystem,
374 left.join(&display_path),
375 display_path.clone(),
376 right.join(&display_path),
377 display_path.clone(),
378 )?);
379 }
380 DiffContext {
381 files,
382 write_root: right.clone(),
383 }
384 }
385
386 Opts {
387 dir_diff: false,
388 left,
389 right,
390 base: Some(base),
391 output: Some(output),
392 read_only: _,
393 dry_run: _,
394 } => {
395 let files = vec![render::create_merge_file(
396 filesystem,
397 base.clone(),
398 left.clone(),
399 right.clone(),
400 output.clone(),
401 )?];
402 DiffContext {
403 files,
404 write_root: PathBuf::new(),
405 }
406 }
407
408 Opts {
409 dir_diff: false,
410 left: _,
411 right: _,
412 base: Some(_),
413 output: None,
414 read_only: _,
415 dry_run: _,
416 } => {
417 unreachable!("--output is required when --base is provided");
418 }
419
420 Opts {
421 dir_diff: true,
422 left: _,
423 right: _,
424 base: Some(_),
425 output: _,
426 read_only: _,
427 dry_run: _,
428 } => {
429 unimplemented!("--base cannot be used with --dir-diff");
430 }
431 };
432 Ok(result)
433}
434
435fn print_dry_run(write_root: &Path, state: RecordState) {
436 let RecordState {
437 is_read_only: _,
438 commits: _,
439 files,
440 } = state;
441 for file in files {
442 let file_path = write_root.join(file.path.clone());
443 let (selected_contents, _unselected_contents) = file.get_selected_contents();
444
445 let File {
446 file_mode: old_file_mode,
447 ..
448 } = file;
449
450 let SelectedChanges {
451 contents,
452 file_mode,
453 } = selected_contents;
454
455 if file_mode == FileMode::Absent {
456 println!("Would delete file: {}", file_path.display());
457 continue;
458 }
459
460 let print_file_mode_change = old_file_mode != file_mode;
461 if print_file_mode_change {
462 println!(
463 "Would change file mode from {} to {}: {}",
464 old_file_mode,
465 file_mode,
466 file_path.display()
467 );
468 }
469
470 match contents {
471 SelectedContents::Unchanged => {
472 if !print_file_mode_change {
475 println!("Would leave file unchanged: {}", file_path.display())
476 }
477 }
478 SelectedContents::Binary {
479 old_description,
480 new_description,
481 } => {
482 println!("Would update binary file: {}", file_path.display());
483 println!(" Old: {:?}", old_description);
484 println!(" New: {:?}", new_description);
485 }
486 SelectedContents::Text { contents } => {
487 println!("Would update text file: {}", file_path.display());
488 for line in contents.lines() {
489 println!(" {line}");
490 }
491 }
492 }
493 }
494}
495
496pub fn apply_changes(
499 filesystem: &mut dyn Filesystem,
500 write_root: &Path,
501 state: RecordState,
502) -> Result<()> {
503 let RecordState {
504 is_read_only,
505 commits: _,
506 files,
507 } = state;
508 if is_read_only {
509 return Ok(());
510 }
511 for file in files {
512 let file_path = write_root.join(file.path.clone());
513 let (selected_changes, _unselected_changes) = file.get_selected_contents();
514
515 let SelectedChanges {
516 contents,
517 file_mode,
518 } = selected_changes;
519
520 if file_mode == FileMode::Absent {
521 filesystem.remove_file(&file_path)?;
522 }
523
524 match contents {
525 SelectedContents::Unchanged => {
526 }
528 SelectedContents::Binary {
529 old_description: _,
530 new_description: _,
531 } => {
532 let new_path = file_path;
533 let old_path = match &file.old_path {
534 Some(old_path) => old_path.clone(),
535 None => Cow::Borrowed(new_path.as_path()),
536 };
537 filesystem.copy_file(&old_path, &new_path)?;
538 }
539 SelectedContents::Text { contents } => {
540 if let Some(parent_dir) = file_path.parent() {
541 filesystem.create_dir_all(parent_dir)?;
542 }
543
544 filesystem.write_file(&file_path, &contents)?;
546 }
547 }
548 }
549 Ok(())
550}
551
552pub fn run(opts: Opts) -> Result<()> {
554 let filesystem = RealFilesystem;
555 let DiffContext { files, write_root } = process_opts(&filesystem, &opts)?;
556 let state = RecordState {
557 is_read_only: opts.read_only,
558 commits: Default::default(),
559 files,
560 };
561 let mut input = CrosstermInput;
562 let recorder = Recorder::new(state, &mut input);
563 match recorder.run() {
564 Ok(state) => {
565 if opts.dry_run {
566 print_dry_run(&write_root, state);
567 Err(Error::DryRun)
568 } else {
569 let mut filesystem = filesystem;
570 apply_changes(&mut filesystem, &write_root, state)?;
571 Ok(())
572 }
573 }
574 Err(RecordError::Cancelled) => Err(Error::Cancelled),
575 Err(err) => Err(Error::Record { source: err }),
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use insta::assert_debug_snapshot;
582 use maplit::btreemap;
583 use std::collections::BTreeMap;
584
585 use scm_record::Section;
586
587 use super::*;
588
589 #[derive(Debug)]
590 struct TestFilesystem {
591 files: BTreeMap<PathBuf, FileInfo>,
592 dirs: BTreeSet<PathBuf>,
593 }
594
595 impl TestFilesystem {
596 pub fn new(files: BTreeMap<PathBuf, FileInfo>) -> Self {
597 let dirs = files
598 .keys()
599 .flat_map(|path| path.ancestors().skip(1))
600 .map(|path| path.to_owned())
601 .collect();
602 Self { files, dirs }
603 }
604
605 fn assert_parent_dir_exists(&self, path: &Path) {
606 if let Some(parent_dir) = path.parent() {
607 assert!(
608 self.dirs.contains(parent_dir),
609 "parent dir for {path:?} does not exist"
610 );
611 }
612 }
613 }
614
615 impl Filesystem for TestFilesystem {
616 fn read_dir_diff_paths(&self, left: &Path, right: &Path) -> Result<BTreeSet<PathBuf>> {
617 let left_files = self
618 .files
619 .keys()
620 .filter_map(|path| path.strip_prefix(left).ok());
621 let right_files = self
622 .files
623 .keys()
624 .filter_map(|path| path.strip_prefix(right).ok());
625 Ok(left_files
626 .chain(right_files)
627 .map(|path| path.to_path_buf())
628 .collect())
629 }
630
631 fn read_file_info(&self, path: &Path) -> Result<FileInfo> {
632 match self.files.get(path) {
633 Some(file_info) => Ok(file_info.clone()),
634 None => match self.dirs.get(path) {
635 Some(_path) => Err(Error::ReadFile {
636 path: path.to_owned(),
637 source: io::Error::new(io::ErrorKind::Other, "is a directory"),
638 }),
639 None => Ok(FileInfo {
640 file_mode: FileMode::Absent,
641 contents: FileContents::Absent,
642 }),
643 },
644 }
645 }
646
647 fn write_file(&mut self, path: &Path, contents: &str) -> Result<()> {
648 self.assert_parent_dir_exists(path);
649 self.files.insert(path.to_owned(), file_info(contents));
650 Ok(())
651 }
652
653 fn copy_file(&mut self, old_path: &Path, new_path: &Path) -> Result<()> {
654 self.assert_parent_dir_exists(new_path);
655 let file_info = self.read_file_info(old_path)?;
656 self.files.insert(new_path.to_owned(), file_info);
657 Ok(())
658 }
659
660 fn remove_file(&mut self, path: &Path) -> Result<()> {
661 self.files.remove(path);
662 Ok(())
663 }
664
665 fn create_dir_all(&mut self, path: &Path) -> Result<()> {
666 self.dirs.insert(path.to_owned());
667 Ok(())
668 }
669 }
670
671 fn file_info(contents: impl Into<String>) -> FileInfo {
672 let contents = contents.into();
673 let num_bytes = contents.len().try_into().unwrap();
674 FileInfo {
675 file_mode: FileMode::Unix(0o100644),
676 contents: FileContents::Text {
677 contents,
678 hash: "abc123".to_string(),
679 num_bytes,
680 },
681 }
682 }
683
684 fn select_all(files: &mut [File]) {
685 for file in files {
686 file.set_checked(true);
687 }
688 }
689
690 #[test]
691 fn test_diff() -> Result<()> {
692 let mut filesystem = TestFilesystem::new(btreemap! {
693 PathBuf::from("left") => file_info("\
694foo
695common1
696common2
697bar
698"),
699 PathBuf::from("right") => file_info("\
700qux1
701common1
702common2
703qux2
704"),
705 });
706 let DiffContext {
707 mut files,
708 write_root,
709 } = process_opts(
710 &filesystem,
711 &Opts {
712 dir_diff: false,
713 left: PathBuf::from("left"),
714 right: PathBuf::from("right"),
715 base: None,
716 output: None,
717 read_only: false,
718 dry_run: false,
719 },
720 )?;
721 assert_debug_snapshot!(files, @r###"
722 [
723 File {
724 old_path: Some(
725 "left",
726 ),
727 path: "right",
728 file_mode: Unix(
729 33188,
730 ),
731 sections: [
732 Changed {
733 lines: [
734 SectionChangedLine {
735 is_checked: false,
736 change_type: Removed,
737 line: "foo\n",
738 },
739 SectionChangedLine {
740 is_checked: false,
741 change_type: Added,
742 line: "qux1\n",
743 },
744 ],
745 },
746 Unchanged {
747 lines: [
748 "common1\n",
749 "common2\n",
750 ],
751 },
752 Changed {
753 lines: [
754 SectionChangedLine {
755 is_checked: false,
756 change_type: Removed,
757 line: "bar\n",
758 },
759 SectionChangedLine {
760 is_checked: false,
761 change_type: Added,
762 line: "qux2\n",
763 },
764 ],
765 },
766 ],
767 },
768 ]
769 "###);
770
771 select_all(&mut files);
772 apply_changes(
773 &mut filesystem,
774 &write_root,
775 RecordState {
776 is_read_only: false,
777 commits: Default::default(),
778 files,
779 },
780 )?;
781 insta::assert_debug_snapshot!(filesystem, @r###"
782 TestFilesystem {
783 files: {
784 "left": FileInfo {
785 file_mode: Unix(
786 33188,
787 ),
788 contents: Text {
789 contents: "foo\ncommon1\ncommon2\nbar\n",
790 hash: "abc123",
791 num_bytes: 24,
792 },
793 },
794 "right": FileInfo {
795 file_mode: Unix(
796 33188,
797 ),
798 contents: Text {
799 contents: "qux1\ncommon1\ncommon2\nqux2\n",
800 hash: "abc123",
801 num_bytes: 26,
802 },
803 },
804 },
805 dirs: {
806 "",
807 },
808 }
809 "###);
810
811 Ok(())
812 }
813
814 #[test]
815 fn test_diff_no_changes() -> Result<()> {
816 let mut filesystem = TestFilesystem::new(btreemap! {
817 PathBuf::from("left") => file_info("\
818foo
819common1
820common2
821bar
822"),
823 PathBuf::from("right") => file_info("\
824qux1
825common1
826common2
827qux2
828"),
829 });
830 let DiffContext { files, write_root } = process_opts(
831 &filesystem,
832 &Opts {
833 dir_diff: false,
834 left: PathBuf::from("left"),
835 right: PathBuf::from("right"),
836 base: None,
837 output: None,
838 read_only: false,
839 dry_run: false,
840 },
841 )?;
842
843 apply_changes(
844 &mut filesystem,
845 &write_root,
846 RecordState {
847 is_read_only: false,
848 commits: Default::default(),
849 files,
850 },
851 )?;
852 insta::assert_debug_snapshot!(filesystem, @r###"
853 TestFilesystem {
854 files: {
855 "left": FileInfo {
856 file_mode: Unix(
857 33188,
858 ),
859 contents: Text {
860 contents: "foo\ncommon1\ncommon2\nbar\n",
861 hash: "abc123",
862 num_bytes: 24,
863 },
864 },
865 "right": FileInfo {
866 file_mode: Unix(
867 33188,
868 ),
869 contents: Text {
870 contents: "foo\ncommon1\ncommon2\nbar\n",
871 hash: "abc123",
872 num_bytes: 24,
873 },
874 },
875 },
876 dirs: {
877 "",
878 },
879 }
880 "###);
881
882 Ok(())
883 }
884
885 #[test]
886 fn test_diff_absent_left() -> Result<()> {
887 let mut filesystem = TestFilesystem::new(btreemap! {
888 PathBuf::from("right") => file_info("right\n"),
889 });
890 let DiffContext {
891 mut files,
892 write_root,
893 } = process_opts(
894 &filesystem,
895 &Opts {
896 dir_diff: false,
897 left: PathBuf::from("left"),
898 right: PathBuf::from("right"),
899 base: None,
900 output: None,
901 read_only: false,
902 dry_run: false,
903 },
904 )?;
905 assert_debug_snapshot!(files, @r###"
906 [
907 File {
908 old_path: Some(
909 "left",
910 ),
911 path: "right",
912 file_mode: Absent,
913 sections: [
914 FileMode {
915 is_checked: false,
916 mode: Unix(
917 33188,
918 ),
919 },
920 Changed {
921 lines: [
922 SectionChangedLine {
923 is_checked: false,
924 change_type: Added,
925 line: "right\n",
926 },
927 ],
928 },
929 ],
930 },
931 ]
932 "###);
933
934 select_all(&mut files);
935 apply_changes(
936 &mut filesystem,
937 &write_root,
938 RecordState {
939 is_read_only: false,
940 commits: Default::default(),
941 files,
942 },
943 )?;
944 insta::assert_debug_snapshot!(filesystem, @r###"
945 TestFilesystem {
946 files: {
947 "right": FileInfo {
948 file_mode: Unix(
949 33188,
950 ),
951 contents: Text {
952 contents: "right\n",
953 hash: "abc123",
954 num_bytes: 6,
955 },
956 },
957 },
958 dirs: {
959 "",
960 },
961 }
962 "###);
963
964 Ok(())
965 }
966
967 #[test]
968 fn test_diff_absent_right() -> Result<()> {
969 let mut filesystem = TestFilesystem::new(btreemap! {
970 PathBuf::from("left") => file_info("left\n"),
971 });
972 let DiffContext {
973 mut files,
974 write_root,
975 } = process_opts(
976 &filesystem,
977 &Opts {
978 dir_diff: false,
979 left: PathBuf::from("left"),
980 right: PathBuf::from("right"),
981 base: None,
982 output: None,
983 read_only: false,
984 dry_run: false,
985 },
986 )?;
987 assert_debug_snapshot!(files, @r###"
988 [
989 File {
990 old_path: Some(
991 "left",
992 ),
993 path: "right",
994 file_mode: Unix(
995 33188,
996 ),
997 sections: [
998 FileMode {
999 is_checked: false,
1000 mode: Absent,
1001 },
1002 Changed {
1003 lines: [
1004 SectionChangedLine {
1005 is_checked: false,
1006 change_type: Removed,
1007 line: "left\n",
1008 },
1009 ],
1010 },
1011 ],
1012 },
1013 ]
1014 "###);
1015
1016 select_all(&mut files);
1017 apply_changes(
1018 &mut filesystem,
1019 &write_root,
1020 RecordState {
1021 is_read_only: false,
1022 commits: Default::default(),
1023 files,
1024 },
1025 )?;
1026 insta::assert_debug_snapshot!(filesystem, @r###"
1027 TestFilesystem {
1028 files: {
1029 "left": FileInfo {
1030 file_mode: Unix(
1031 33188,
1032 ),
1033 contents: Text {
1034 contents: "left\n",
1035 hash: "abc123",
1036 num_bytes: 5,
1037 },
1038 },
1039 },
1040 dirs: {
1041 "",
1042 },
1043 }
1044 "###);
1045
1046 Ok(())
1047 }
1048
1049 #[test]
1050 fn test_reject_diff_non_files() -> Result<()> {
1051 let filesystem = TestFilesystem::new(btreemap! {
1052 PathBuf::from("left/foo") => file_info("left\n"),
1053 PathBuf::from("right/foo") => file_info("right\n"),
1054 });
1055 let result = process_opts(
1056 &filesystem,
1057 &Opts {
1058 dir_diff: false,
1059 left: PathBuf::from("left"),
1060 right: PathBuf::from("right"),
1061 base: None,
1062 output: None,
1063 read_only: false,
1064 dry_run: false,
1065 },
1066 );
1067 insta::assert_debug_snapshot!(result, @r###"
1068 Err(
1069 ReadFile {
1070 path: "left",
1071 source: Custom {
1072 kind: Other,
1073 error: "is a directory",
1074 },
1075 },
1076 )
1077 "###);
1078
1079 Ok(())
1080 }
1081
1082 #[test]
1083 fn test_diff_files_in_subdirectories() -> Result<()> {
1084 let mut filesystem = TestFilesystem::new(btreemap! {
1085 PathBuf::from("left/foo") => file_info("left contents\n"),
1086 PathBuf::from("right/foo") => file_info("right contents\n"),
1087 });
1088
1089 let DiffContext { files, write_root } = process_opts(
1090 &filesystem,
1091 &Opts {
1092 dir_diff: false,
1093 left: PathBuf::from("left/foo"),
1094 right: PathBuf::from("right/foo"),
1095 base: None,
1096 output: None,
1097 read_only: false,
1098 dry_run: false,
1099 },
1100 )?;
1101
1102 apply_changes(
1103 &mut filesystem,
1104 &write_root,
1105 RecordState {
1106 is_read_only: false,
1107 commits: Default::default(),
1108 files,
1109 },
1110 )?;
1111 assert_debug_snapshot!(filesystem, @r###"
1112 TestFilesystem {
1113 files: {
1114 "left/foo": FileInfo {
1115 file_mode: Unix(
1116 33188,
1117 ),
1118 contents: Text {
1119 contents: "left contents\n",
1120 hash: "abc123",
1121 num_bytes: 14,
1122 },
1123 },
1124 "right/foo": FileInfo {
1125 file_mode: Unix(
1126 33188,
1127 ),
1128 contents: Text {
1129 contents: "left contents\n",
1130 hash: "abc123",
1131 num_bytes: 14,
1132 },
1133 },
1134 },
1135 dirs: {
1136 "",
1137 "left",
1138 "right",
1139 },
1140 }
1141 "###);
1142
1143 Ok(())
1144 }
1145
1146 #[test]
1147 fn test_dir_diff_no_changes() -> Result<()> {
1148 let mut filesystem = TestFilesystem::new(btreemap! {
1149 PathBuf::from("left/foo") => file_info("left contents\n"),
1150 PathBuf::from("right/foo") => file_info("right contents\n"),
1151 });
1152
1153 let DiffContext { files, write_root } = process_opts(
1154 &filesystem,
1155 &Opts {
1156 dir_diff: false,
1157 left: PathBuf::from("left/foo"),
1158 right: PathBuf::from("right/foo"),
1159 base: None,
1160 output: None,
1161 read_only: false,
1162 dry_run: false,
1163 },
1164 )?;
1165
1166 apply_changes(
1167 &mut filesystem,
1168 &write_root,
1169 RecordState {
1170 is_read_only: false,
1171 commits: Default::default(),
1172 files,
1173 },
1174 )?;
1175 assert_debug_snapshot!(filesystem, @r###"
1176 TestFilesystem {
1177 files: {
1178 "left/foo": FileInfo {
1179 file_mode: Unix(
1180 33188,
1181 ),
1182 contents: Text {
1183 contents: "left contents\n",
1184 hash: "abc123",
1185 num_bytes: 14,
1186 },
1187 },
1188 "right/foo": FileInfo {
1189 file_mode: Unix(
1190 33188,
1191 ),
1192 contents: Text {
1193 contents: "left contents\n",
1194 hash: "abc123",
1195 num_bytes: 14,
1196 },
1197 },
1198 },
1199 dirs: {
1200 "",
1201 "left",
1202 "right",
1203 },
1204 }
1205 "###);
1206
1207 Ok(())
1208 }
1209
1210 #[test]
1211 fn test_create_merge() -> Result<()> {
1212 let base_contents = "\
1213Hello world 1
1214Hello world 2
1215Hello world 3
1216Hello world 4
1217";
1218 let left_contents = "\
1219Hello world 1
1220Hello world 2
1221Hello world L
1222Hello world 4
1223";
1224 let right_contents = "\
1225Hello world 1
1226Hello world 2
1227Hello world R
1228Hello world 4
1229";
1230 let mut filesystem = TestFilesystem::new(btreemap! {
1231 PathBuf::from("base") => file_info(base_contents),
1232 PathBuf::from("left") => file_info(left_contents),
1233 PathBuf::from("right") => file_info(right_contents),
1234 });
1235
1236 let DiffContext {
1237 mut files,
1238 write_root,
1239 } = process_opts(
1240 &filesystem,
1241 &Opts {
1242 dir_diff: false,
1243 left: "left".into(),
1244 right: "right".into(),
1245 read_only: false,
1246 dry_run: false,
1247 base: Some("base".into()),
1248 output: Some("output".into()),
1249 },
1250 )?;
1251 insta::assert_debug_snapshot!(files, @r###"
1252 [
1253 File {
1254 old_path: Some(
1255 "base",
1256 ),
1257 path: "output",
1258 file_mode: Unix(
1259 33188,
1260 ),
1261 sections: [
1262 Unchanged {
1263 lines: [
1264 "Hello world 1\n",
1265 "Hello world 2\n",
1266 ],
1267 },
1268 Changed {
1269 lines: [
1270 SectionChangedLine {
1271 is_checked: false,
1272 change_type: Added,
1273 line: "Hello world L\n",
1274 },
1275 SectionChangedLine {
1276 is_checked: false,
1277 change_type: Removed,
1278 line: "Hello world 3\n",
1279 },
1280 SectionChangedLine {
1281 is_checked: false,
1282 change_type: Added,
1283 line: "Hello world R\n",
1284 },
1285 ],
1286 },
1287 Unchanged {
1288 lines: [
1289 "Hello world 4\n",
1290 ],
1291 },
1292 ],
1293 },
1294 ]
1295 "###);
1296
1297 select_all(&mut files);
1298 apply_changes(
1299 &mut filesystem,
1300 &write_root,
1301 RecordState {
1302 is_read_only: false,
1303 commits: Default::default(),
1304 files,
1305 },
1306 )?;
1307
1308 assert_debug_snapshot!(filesystem, @r###"
1309 TestFilesystem {
1310 files: {
1311 "base": FileInfo {
1312 file_mode: Unix(
1313 33188,
1314 ),
1315 contents: Text {
1316 contents: "Hello world 1\nHello world 2\nHello world 3\nHello world 4\n",
1317 hash: "abc123",
1318 num_bytes: 56,
1319 },
1320 },
1321 "left": FileInfo {
1322 file_mode: Unix(
1323 33188,
1324 ),
1325 contents: Text {
1326 contents: "Hello world 1\nHello world 2\nHello world L\nHello world 4\n",
1327 hash: "abc123",
1328 num_bytes: 56,
1329 },
1330 },
1331 "output": FileInfo {
1332 file_mode: Unix(
1333 33188,
1334 ),
1335 contents: Text {
1336 contents: "Hello world 1\nHello world 2\nHello world L\nHello world R\nHello world 4\n",
1337 hash: "abc123",
1338 num_bytes: 70,
1339 },
1340 },
1341 "right": FileInfo {
1342 file_mode: Unix(
1343 33188,
1344 ),
1345 contents: Text {
1346 contents: "Hello world 1\nHello world 2\nHello world R\nHello world 4\n",
1347 hash: "abc123",
1348 num_bytes: 56,
1349 },
1350 },
1351 },
1352 dirs: {
1353 "",
1354 },
1355 }
1356 "###);
1357
1358 Ok(())
1359 }
1360
1361 #[test]
1362 fn test_new_file() -> Result<()> {
1363 let new_file_contents = "\
1364Hello world 1
1365Hello world 2
1366";
1367 let mut filesystem = TestFilesystem::new(btreemap! {
1368 PathBuf::from("right") => file_info(new_file_contents),
1369 });
1370
1371 let DiffContext {
1372 mut files,
1373 write_root,
1374 } = process_opts(
1375 &filesystem,
1376 &Opts {
1377 dir_diff: false,
1378 left: "left".into(),
1379 right: "right".into(),
1380 read_only: false,
1381 dry_run: false,
1382 base: None,
1383 output: None,
1384 },
1385 )?;
1386 insta::assert_debug_snapshot!(files, @r###"
1387 [
1388 File {
1389 old_path: Some(
1390 "left",
1391 ),
1392 path: "right",
1393 file_mode: Absent,
1394 sections: [
1395 FileMode {
1396 is_checked: false,
1397 mode: Unix(
1398 33188,
1399 ),
1400 },
1401 Changed {
1402 lines: [
1403 SectionChangedLine {
1404 is_checked: false,
1405 change_type: Added,
1406 line: "Hello world 1\n",
1407 },
1408 SectionChangedLine {
1409 is_checked: false,
1410 change_type: Added,
1411 line: "Hello world 2\n",
1412 },
1413 ],
1414 },
1415 ],
1416 },
1417 ]
1418 "###);
1419
1420 apply_changes(
1422 &mut filesystem,
1423 &write_root,
1424 RecordState {
1425 is_read_only: false,
1426 commits: Default::default(),
1427 files: files.clone(),
1428 },
1429 )?;
1430 insta::assert_debug_snapshot!(filesystem, @r###"
1431 TestFilesystem {
1432 files: {},
1433 dirs: {
1434 "",
1435 },
1436 }
1437 "###);
1438
1439 select_all(&mut files);
1441 apply_changes(
1442 &mut filesystem,
1443 &write_root,
1444 RecordState {
1445 is_read_only: false,
1446 commits: Default::default(),
1447 files: files.clone(),
1448 },
1449 )?;
1450 insta::assert_debug_snapshot!(filesystem, @r###"
1451 TestFilesystem {
1452 files: {
1453 "right": FileInfo {
1454 file_mode: Unix(
1455 33188,
1456 ),
1457 contents: Text {
1458 contents: "Hello world 1\nHello world 2\n",
1459 hash: "abc123",
1460 num_bytes: 28,
1461 },
1462 },
1463 },
1464 dirs: {
1465 "",
1466 },
1467 }
1468 "###);
1469
1470 match files[0].sections.get_mut(1).unwrap() {
1472 Section::Changed { ref mut lines } => lines[0].is_checked = false,
1473 _ => panic!("Expected changed section"),
1474 }
1475 apply_changes(
1476 &mut filesystem,
1477 &write_root,
1478 RecordState {
1479 is_read_only: false,
1480 commits: Default::default(),
1481 files: files.clone(),
1482 },
1483 )?;
1484 insta::assert_debug_snapshot!(filesystem, @r###"
1485 TestFilesystem {
1486 files: {
1487 "right": FileInfo {
1488 file_mode: Unix(
1489 33188,
1490 ),
1491 contents: Text {
1492 contents: "Hello world 2\n",
1493 hash: "abc123",
1494 num_bytes: 14,
1495 },
1496 },
1497 },
1498 dirs: {
1499 "",
1500 },
1501 }
1502 "###);
1503
1504 Ok(())
1505 }
1506}