use std::path::{Path, PathBuf};
use walkdir::{DirEntry, WalkDir};
use crate::{DataInterface, DataReadError, DataSaveError, EntriesToCompare, FakeData, FileEntry};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ThreeDirInput {
pub left: PathBuf,
pub right: PathBuf,
pub edit: PathBuf,
}
impl DataInterface for ThreeDirInput {
fn scan(&self) -> Result<EntriesToCompare, DataReadError> {
let Self { left, right, edit } = self;
scan_several([left, right, edit])
}
fn save_unchecked(
&mut self,
result: indexmap::IndexMap<String, String>,
) -> Result<(), DataSaveError> {
let Self { edit: outdir, .. } = self;
if let Some(only_file_contents) = result.get("") {
if result.len() == 1 {
return std::fs::write(outdir.clone(), only_file_contents)
.map_err(|io_err| DataSaveError::IOError(outdir.clone(), io_err));
}
}
for (relpath, contents) in result.into_iter() {
let relpath = PathBuf::from(relpath);
let path = outdir.join(relpath);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|io_err| DataSaveError::IOError(path.clone(), io_err))?;
}
std::fs::write(path.clone(), contents)
.map_err(|io_err| DataSaveError::IOError(path, io_err))?;
}
Ok(())
}
}
use clap::Parser;
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
dirs: Vec<PathBuf>,
#[arg(long, conflicts_with("dirs"))]
demo: bool,
}
impl Cli {
pub fn into_data_interface(self) -> Result<Box<dyn DataInterface>, String> {
if self.demo {
Ok(Box::new(FakeData))
} else {
match self.dirs.as_slice() {
[left, right, output] => Ok(Box::new(ThreeDirInput {
left: left.to_path_buf(),
right: right.to_path_buf(),
edit: output.to_path_buf(),
})),
[left, right] => Ok(Box::new(ThreeDirInput {
left: left.to_path_buf(),
right: right.to_path_buf(),
edit: right.to_path_buf(),
})),
_ => Err(format!(
"Must have 2 or 3 dirs to compare, got {} dirs instead",
self.dirs.len()
)),
}
}
}
}
const MAX_FILE_LENGTH: usize = 200_000;
fn scan(root: &Path) -> impl Iterator<Item = (DirEntry, FileEntry)> + use<> {
WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| -> (DirEntry, FileEntry) {
(
e.clone(),
match std::fs::read_to_string(e.path()) {
Err(io_error) => FileEntry::Unsupported(format!("IO Error: {io_error}")),
Ok(contents) if contents.len() > MAX_FILE_LENGTH => {
FileEntry::Unsupported(format!("File length exceeds {MAX_FILE_LENGTH}"))
}
Ok(contents) if contents.contains('\0') => {
FileEntry::Unsupported("A binary file (contains the NUL byte)".to_string())
}
Ok(contents) => FileEntry::Text(contents),
},
)
})
}
fn scan_several(roots: [&PathBuf; 3]) -> Result<EntriesToCompare, DataReadError> {
let mut result = EntriesToCompare::default();
for (i, root) in roots.iter().enumerate() {
for (file_entry, contents) in scan(root) {
let value = result
.0
.entry(PathBuf::from(
file_entry.path().strip_prefix(root).unwrap_or_else(|_| {
panic!(
"The path {:?} does not begin with {root:?}.",
file_entry.path(),
)
}),
))
.or_insert([FileEntry::Missing, FileEntry::Missing, FileEntry::Missing])
.as_mut();
value[i] = contents;
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use assert_matches::assert_matches;
use indexmap::IndexMap;
use indoc::indoc;
use serde::Serialize;
use tempfile::TempDir;
use super::*;
fn to_slash_string_lossy(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn showdir(path: &Path) -> impl Serialize + use<> {
BTreeMap::from_iter(scan(path).map(|(dir_path, file_type)| {
(
to_slash_string_lossy(dir_path.path().strip_prefix(path).unwrap()),
file_type,
)
}))
}
fn showscan(input: &ThreeDirInput) -> impl Serialize + use<> {
let entries = input.scan().unwrap();
BTreeMap::from_iter(
entries
.0
.into_iter()
.map(|(k, v)| (to_slash_string_lossy(&k), v)),
)
}
fn left_right_edit_threedirinput(base: &Path) -> ThreeDirInput {
ThreeDirInput {
left: base.join("left").to_owned(),
right: base.join("right").to_owned(),
edit: base.join("edit").to_owned(),
}
}
fn string_pair(first: &str, second: &str) -> (String, String) {
(first.to_string(), second.to_string())
}
fn tmpdir_from_txtar(textarchive: &str) -> TempDir {
let tmp_dir = TempDir::with_prefix("de3test").unwrap();
txtar::from_str(textarchive)
.materialize(tmp_dir.path())
.unwrap();
tmp_dir
}
#[test]
fn dirs_save() {
let tmp_dir = tmpdir_from_txtar(indoc! {"
-- left/subdir/txt --
Some text
-- right/subdir/txt --
Changed text
-- edit/subdir/txt --
Changed text for editing
"});
insta::assert_yaml_snapshot!(showdir(tmp_dir.path()), @r###"
---
edit/subdir/txt:
type: Text
value: "Changed text for editing\n"
left/subdir/txt:
type: Text
value: "Some text\n"
right/subdir/txt:
type: Text
value: "Changed text\n"
"###);
let mut input = left_right_edit_threedirinput(tmp_dir.path());
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
subdir/txt:
- type: Text
value: "Some text\n"
- type: Text
value: "Changed text\n"
- type: Text
value: "Changed text for editing\n"
"###);
let () = input
.save(IndexMap::from([string_pair("subdir/txt", "Edited text")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
subdir/txt:
- type: Text
value: "Some text\n"
- type: Text
value: "Changed text\n"
- type: Text
value: Edited text
"###);
let () = input
.save(IndexMap::from([string_pair("subdir/txt", "")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
subdir/txt:
- type: Text
value: "Some text\n"
- type: Text
value: "Changed text\n"
- type: Text
value: ""
"###);
let result = input.save(IndexMap::from([
string_pair("another_txt", ""),
string_pair("subdir/txt", "This text should not be written"),
]));
insta::assert_debug_snapshot!(result, @r###"
Err(
ValidationFailError(
"another_txt",
),
)
"###);
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
subdir/txt:
- type: Text
value: "Some text\n"
- type: Text
value: "Changed text\n"
- type: Text
value: ""
"###);
}
#[test]
fn single_file_save() {
let tmp_dir = tmpdir_from_txtar(indoc! {"
-- left --
Some text
-- right --
Changed text
-- edit --
Changed text for editing
"});
insta::assert_yaml_snapshot!(showdir(tmp_dir.path()), @r#"
---
edit:
type: Text
value: "Changed text for editing\n"
left:
type: Text
value: "Some text\n"
right:
type: Text
value: "Changed text\n"
"#);
let mut input = left_right_edit_threedirinput(tmp_dir.path());
insta::assert_yaml_snapshot!(showscan(&input), @r#"
---
"":
- type: Text
value: "Some text\n"
- type: Text
value: "Changed text\n"
- type: Text
value: "Changed text for editing\n"
"#);
let () = input
.save(IndexMap::from([string_pair("", "Edited text")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r#"
---
"":
- type: Text
value: "Some text\n"
- type: Text
value: "Changed text\n"
- type: Text
value: Edited text
"#);
let result = input.save(IndexMap::from([
string_pair("another_txt", ""),
string_pair("subdir/txt", "This text should not be written"),
]));
insta::assert_debug_snapshot!(result, @r###"
Err(
ValidationFailError(
"another_txt",
),
)
"###);
insta::assert_yaml_snapshot!(showscan(&input), @r#"
---
"":
- type: Text
value: "Some text\n"
- type: Text
value: "Changed text\n"
- type: Text
value: Edited text
"#);
}
#[test]
fn files_and_dirs_mix_save_error() {
let tmp_dir = tmpdir_from_txtar(indoc! {"
-- left --
Some text
-- right/file --
Text in a subdir
-- edit/file --
Changed text in a subdir for editing
"});
insta::assert_yaml_snapshot!(showdir(tmp_dir.path()), @r#"
---
edit/file:
type: Text
value: "Changed text in a subdir for editing\n"
left:
type: Text
value: "Some text\n"
right/file:
type: Text
value: "Text in a subdir\n"
"#);
let mut input = left_right_edit_threedirinput(tmp_dir.path());
insta::assert_yaml_snapshot!(showscan(&input), @r#"
---
"":
- type: Text
value: "Some text\n"
- type: Missing
- type: Missing
file:
- type: Missing
- type: Text
value: "Text in a subdir\n"
- type: Text
value: "Changed text in a subdir for editing\n"
"#);
let result = input.save(IndexMap::from([
string_pair("file", "Edited text for file"),
string_pair("", "Edited text for root"),
]));
assert_matches!(dbg!(result), Err(DataSaveError::IOError(_path, _)));
}
#[test]
fn deleted_lhs_file() {
let tmp_dir = tmpdir_from_txtar(indoc! {"
-- right/txt --
Some text
-- edit/txt --
Some text for editing
"});
insta::assert_yaml_snapshot!(showdir(tmp_dir.path()), @r###"
---
edit/txt:
type: Text
value: "Some text for editing\n"
right/txt:
type: Text
value: "Some text\n"
"###);
let mut input = left_right_edit_threedirinput(tmp_dir.path());
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
txt:
- type: Missing
- type: Text
value: "Some text\n"
- type: Text
value: "Some text for editing\n"
"###);
let () = input
.save(IndexMap::from([string_pair("txt", "somevalue")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
txt:
- type: Missing
- type: Text
value: "Some text\n"
- type: Text
value: somevalue
"###);
let () = input
.save(IndexMap::from([string_pair("txt", "")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
txt:
- type: Missing
- type: Text
value: "Some text\n"
- type: Text
value: ""
"###);
}
#[test]
fn deleted_rhs_file() {
let tmp_dir = tmpdir_from_txtar(indoc! {"
-- left/txt --
Some text
"});
insta::assert_yaml_snapshot!(showdir(tmp_dir.path()), @r###"
---
left/txt:
type: Text
value: "Some text\n"
"###);
let mut input = left_right_edit_threedirinput(tmp_dir.path());
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
txt:
- type: Text
value: "Some text\n"
- type: Missing
- type: Missing
"###);
let () = input
.save(IndexMap::from([string_pair("txt", "somevalue")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
txt:
- type: Text
value: "Some text\n"
- type: Missing
- type: Text
value: somevalue
"###);
let () = input
.save(IndexMap::from([string_pair("txt", "")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
txt:
- type: Text
value: "Some text\n"
- type: Missing
- type: Text
value: ""
"###);
}
#[test]
fn deleted_rhs_file_with_dir() {
let tmp_dir = tmpdir_from_txtar(indoc! {"
-- left/txt --
Some text
-- edit/randomfile --
This file exists solely to create the `edit/` dir
"});
insta::assert_yaml_snapshot!(showdir(tmp_dir.path()), @r###"
---
edit/randomfile:
type: Text
value: "This file exists solely to create the `edit/` dir\n"
left/txt:
type: Text
value: "Some text\n"
"###);
let mut input = left_right_edit_threedirinput(tmp_dir.path());
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
randomfile:
- type: Missing
- type: Missing
- type: Text
value: "This file exists solely to create the `edit/` dir\n"
txt:
- type: Text
value: "Some text\n"
- type: Missing
- type: Missing
"###);
let () = input
.save(IndexMap::from([string_pair("txt", "somevalue")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
randomfile:
- type: Missing
- type: Missing
- type: Text
value: "This file exists solely to create the `edit/` dir\n"
txt:
- type: Text
value: "Some text\n"
- type: Missing
- type: Text
value: somevalue
"###);
let () = input
.save(IndexMap::from([string_pair("txt", "")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
randomfile:
- type: Missing
- type: Missing
- type: Text
value: "This file exists solely to create the `edit/` dir\n"
txt:
- type: Text
value: "Some text\n"
- type: Missing
- type: Text
value: ""
"###);
}
#[test]
fn file_vs_dir() {
let tmp_dir = tmpdir_from_txtar(indoc! {"
-- left/test --
Some text
-- right/test/file --
Dir file
-- edit/test/file --
Dir file (edit)
"});
insta::assert_yaml_snapshot!(showdir(tmp_dir.path()), @r###"
---
edit/test/file:
type: Text
value: "Dir file (edit)\n"
left/test:
type: Text
value: "Some text\n"
right/test/file:
type: Text
value: "Dir file\n"
"###);
let mut input = left_right_edit_threedirinput(tmp_dir.path());
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
test:
- type: Text
value: "Some text\n"
- type: Missing
- type: Missing
test/file:
- type: Missing
- type: Text
value: "Dir file\n"
- type: Text
value: "Dir file (edit)\n"
"###);
let () = input
.save(IndexMap::from([string_pair("test/file", "New value\n")]))
.unwrap();
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
test:
- type: Text
value: "Some text\n"
- type: Missing
- type: Missing
test/file:
- type: Missing
- type: Text
value: "Dir file\n"
- type: Text
value: "New value\n"
"###);
let result = input.save(IndexMap::from([string_pair("test", "")]));
assert_matches!(dbg!(result),
Err(DataSaveError::IOError(path, _))
if path.ends_with("edit/test")
);
let result = input.save(IndexMap::from([string_pair("test", "")]));
assert_matches!(dbg!(result),
Err(DataSaveError::IOError(path, _))
if path.ends_with("edit/test")
);
insta::assert_yaml_snapshot!(showscan(&input), @r###"
---
test:
- type: Text
value: "Some text\n"
- type: Missing
- type: Missing
test/file:
- type: Missing
- type: Text
value: "Dir file\n"
- type: Text
value: "New value\n"
"###);
}
}