clankerdiff-core 0.1.11

Renderer-independent diff and code review domain model
Documentation
use clankerdiff_core::patch::{PatchError, git_patch_from_texts};
use std::{
    fs,
    io::{self, Write},
    process::{Command, Stdio},
};
use tempfile::{TempDir, tempdir};
use thiserror::Error;

#[test]
fn patches_apply_and_reverse_to_exact_snapshots() -> Result<(), TestError> {
    let cases = [
        (None, Some("new\n")),
        (None, Some("new")),
        (None, Some("")),
        (Some("old\n"), None),
        (Some("old"), None),
        (Some(""), None),
        (Some(""), Some("new\n")),
        (Some("old\n"), Some("")),
        (Some("old\n"), Some("new\n")),
        (Some("old"), Some("new")),
        (Some("old\n"), Some("new")),
        (Some("old"), Some("new\n")),
        (Some("a\r\nb\r\n"), Some("a\r\nc\r\n")),
        (Some("a\r\nb\r\n"), Some("a\nb\n")),
        (Some("a\nb\r\nc"), Some("a\r\nb\nc\n")),
        (Some("old\nunchanged"), Some("new\nunchanged")),
        (Some("old\n\nend\n"), Some("new\n\nend\n")),
        (Some("\nold\n"), Some("\nnew\n")),
    ];
    for absolute in [false, true] {
        for name in ["file.txt", "space name.txt", "quote\"back\\tab\t\n.txt"] {
            for (old, new) in cases {
                PatchFixture::new()?
                    .name(name)
                    .absolute(absolute)
                    .check(old, new)?;
            }
        }
    }
    Ok(())
}

#[test]
fn no_patch_for_identical_snapshots() -> Result<(), PatchError> {
    for text in [None, Some(""), Some("same\n"), Some("same")] {
        assert_eq!(git_patch_from_texts("file.txt", text, text)?, None);
    }
    Ok(())
}

#[test]
fn file_existence_controls_git_headers() -> Result<(), TestError> {
    for (old, new, header) in [
        (None, Some(""), "new file mode 100644\n"),
        (Some(""), None, "deleted file mode 100644\n"),
    ] {
        let patch = git_patch_from_texts("file.txt", old, new)?.ok_or(TestError::MissingPatch)?;
        assert!(patch.contains(header));
        assert!(!patch.contains("@@"));
    }
    for (old, new) in [("", "text\n"), ("text\n", "")] {
        let patch = git_patch_from_texts("file.txt", Some(old), Some(new))?
            .ok_or(TestError::MissingPatch)?;
        assert!(!patch.contains("file mode"));
        assert!(!patch.contains("/dev/null"));
    }
    Ok(())
}

#[test]
fn distant_edits_have_compact_contextual_hunks() -> Result<(), TestError> {
    let mut old = String::new();
    for line in 0..1000 {
        old.extend(["line ", &line.to_string(), "\n"]);
    }
    let new = old
        .replace("line 10\n", "changed 10\n")
        .replace("line 990\n", "changed 990\n");
    let patch =
        git_patch_from_texts("file.txt", Some(&old), Some(&new))?.ok_or(TestError::MissingPatch)?;
    assert_eq!(
        patch.lines().filter(|line| line.starts_with("@@")).count(),
        2
    );
    assert!(patch.contains(" line 9\n"));
    assert!(patch.contains("-line 10\n+changed 10\n"));
    assert!(!patch.contains("line 500\n"));
    assert!(patch.lines().count() < 30);
    PatchFixture::new()?.check(Some(&old), Some(&new))
}

#[test]
fn eof_outside_changed_hunks_is_preserved() -> Result<(), TestError> {
    let old = format!("old\n{}last", "context\n".repeat(20));
    let new = old.replacen("old", "new", 1);
    let patch =
        git_patch_from_texts("file.txt", Some(&old), Some(&new))?.ok_or(TestError::MissingPatch)?;
    assert!(!patch.contains("No newline"));
    PatchFixture::new()?.check(Some(&old), Some(&new))
}

#[test]
fn rejects_invalid_paths_and_binary_text() {
    for path in ["", "bad\0path", "/dev/null"] {
        assert!(matches!(
            git_patch_from_texts(path, None, Some("new")),
            Err(PatchError::InvalidPath)
        ));
    }
    for (old, new) in [(Some("old\0"), Some("new")), (None, Some("new\0"))] {
        assert!(matches!(
            git_patch_from_texts("file.txt", old, new),
            Err(PatchError::BinaryText)
        ));
    }
}

#[derive(Debug, Error)]
enum TestError {
    #[error(transparent)]
    Io(#[from] io::Error),
    #[error(transparent)]
    Patch(#[from] PatchError),
    #[error("expected a patch")]
    MissingPatch,
}

struct PatchFixture {
    directory: TempDir,
    name: String,
    absolute: bool,
}

impl PatchFixture {
    fn new() -> Result<Self, TestError> {
        let directory = tempdir()?;
        fs::write(directory.path().join(".keep"), "")?;
        Ok(Self {
            directory,
            name: "file.txt".into(),
            absolute: false,
        })
    }

    fn name(mut self, name: &str) -> Self {
        self.name = name.into();
        self
    }

    fn absolute(mut self, absolute: bool) -> Self {
        self.absolute = absolute;
        self
    }

    fn check(self, old: Option<&str>, new: Option<&str>) -> Result<(), TestError> {
        let file_path = self.directory.path().canonicalize()?.join(&self.name);
        if let Some(old) = old {
            fs::write(&file_path, old)?;
        }
        let patch_path = if self.absolute {
            file_path.to_string_lossy().into_owned()
        } else {
            self.name.clone()
        };
        let patch = git_patch_from_texts(&patch_path, old, new)?.ok_or(TestError::MissingPatch)?;
        for (reverse, expected) in [(false, new), (true, old)] {
            let mut command = Command::new("git");
            command.current_dir(self.directory.path()).args([
                "apply",
                "--unsafe-paths",
                "--whitespace=nowarn",
                "-p0",
            ]);
            if reverse {
                command.arg("--reverse");
            }
            let mut child = command
                .arg("-")
                .stdin(Stdio::piped())
                .stderr(Stdio::piped())
                .spawn()?;
            child
                .stdin
                .take()
                .ok_or_else(|| io::Error::other("missing stdin"))?
                .write_all(patch.as_bytes())?;
            let output = child.wait_with_output()?;
            assert!(
                output.status.success(),
                "reverse={reverse}: {}\n{patch}",
                String::from_utf8_lossy(&output.stderr)
            );
            match expected {
                Some(text) => {
                    assert!(
                        file_path.try_exists()?,
                        "missing file after reverse={reverse}: {patch}"
                    );
                    assert_eq!(fs::read(&file_path)?, text.as_bytes());
                }
                None => assert!(!file_path.try_exists()?),
            }
        }
        Ok(())
    }
}