scm_diff_editor/
lib.rs

1//! An interactive difftool for use in VCS programs like
2//! [Jujutsu](https://github.com/martinvonz/jj) or Git.
3#![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/// Render a partial commit selector for use as a difftool or mergetool.
32///
33/// This can be used to interactively select changes to include as part of a
34/// commit, to resolve merge conflicts, or to simply display a diff in a
35/// readable way.
36#[derive(Debug, Parser)]
37pub struct Opts {
38    /// Instead of comparing two files, compare two directories recursively.
39    #[clap(short = 'd', long = "dir-diff")]
40    pub dir_diff: bool,
41
42    /// The left-hand file to compare (or directory if `--dir-diff` is passed).
43    pub left: PathBuf,
44
45    /// The right-hand file to compare (or directory if `--dir-diff` is passed).
46    pub right: PathBuf,
47
48    /// Disable all editing controls and do not write the selected commit
49    /// contents to disk.
50    #[clap(long = "read-only")]
51    pub read_only: bool,
52
53    /// Show what would have been written to disk as part of the commit
54    /// selection, but do not actually write it.
55    #[clap(short = 'N', long = "dry-run")]
56    pub dry_run: bool,
57
58    /// Render the interface as a mergetool instead of a difftool and use this
59    /// file as the base of a three-way diff as part of resolving merge
60    /// conflicts.
61    #[clap(
62        short = 'b',
63        long = "base",
64        requires("output"),
65        conflicts_with("dir_diff")
66    )]
67    pub base: Option<PathBuf>,
68
69    /// Write the resolved merge conflicts to this file.
70    #[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
122/// Result type alias.
123pub type Result<T> = std::result::Result<T, Error>;
124
125/// Information about a file that was read from disk. Note that the file may not have existed, in
126/// which case its contents will be marked as absent.
127#[derive(Clone, Debug)]
128pub struct FileInfo {
129    /// The file mode (see [`scm_record::FileMode`]).
130    pub file_mode: FileMode,
131
132    /// The file contents.
133    pub contents: FileContents,
134}
135
136/// Representation of a file's contents.
137#[derive(Clone, Debug)]
138pub enum FileContents {
139    /// There is no file. (This is different from the file being present but empty.)
140    Absent,
141
142    /// The file is a text file with the given contents.
143    Text {
144        /// The contents of the file.
145        contents: String,
146
147        /// The hash of `contents`.
148        hash: String,
149
150        /// The size of `contents`, in bytes.
151        num_bytes: u64,
152    },
153
154    /// The file is a binary file (not able to be displayed directly in the UI).
155    Binary {
156        /// The hash of the file's contents.
157        hash: String,
158
159        /// The size of the file's contents, in bytes.
160        num_bytes: u64,
161    },
162}
163
164/// Abstraction over the filesystem.
165pub trait Filesystem {
166    /// Find the set of files that appear in either `left` or `right`.
167    fn read_dir_diff_paths(&self, left: &Path, right: &Path) -> Result<BTreeSet<PathBuf>>;
168
169    /// Read the [`FileInfo`] for the provided `path`.
170    fn read_file_info(&self, path: &Path) -> Result<FileInfo>;
171
172    /// Write new file contents to `path`.
173    fn write_file(&mut self, path: &Path, contents: &str) -> Result<()>;
174
175    /// Copy the file at `old_path` to `new_path`. (This can be more efficient
176    /// than reading and writing the entire contents, particularly for large
177    /// binary files.)
178    fn copy_file(&mut self, old_path: &Path, new_path: &Path) -> Result<()>;
179
180    /// Delete the file at `path`.
181    fn remove_file(&mut self, path: &Path) -> Result<()>;
182
183    /// Create the directory `path` and any parent directories as necessary.
184    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                // TODO: no support for gitlinks (submodules).
224                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/// Information about the files to display/diff in the UI.
322#[derive(Debug)]
323pub struct DiffContext {
324    /// The files to diff.
325    /// - When diffing a single file, this will have only one entry.
326    /// - When diffing a directory, this may have many entries (one for each pair of files).
327    pub files: Vec<File<'static>>,
328
329    /// When writing results to the filesystem, this path should be prepended to
330    /// each `File`'s path. It may be empty (indicating to overwrite the file
331    /// in-place).
332    pub write_root: PathBuf,
333}
334
335/// Process the command-line options to find the files to diff.
336pub 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                // Printing that the file is unchanged is incorrect (and that the contents
473                // is unchanged is just noisy) if we've already printed that the mode changed.
474                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
496/// After the user has selected changes in the provided [`RecordState`], write
497/// the results to the provided [`Filesystem`].
498pub 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                // Do nothing.
527            }
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                // TODO: Respect executable bit
545                filesystem.write_file(&file_path, &contents)?;
546            }
547        }
548    }
549    Ok(())
550}
551
552/// Select changes interactively and apply them to disk.
553pub 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        // Select no changes from new file.
1421        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 changes from new file.
1440        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        // Select only some changes from new file.
1471        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}