clankerdiff_core/
patch.rs1use similar::TextDiff;
2use thiserror::Error;
3
4#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
5pub enum PatchError {
6 #[error("patch path must be nonempty, contain no NUL bytes, and not be /dev/null")]
7 InvalidPath,
8 #[error("text patches cannot contain NUL bytes")]
9 BinaryText,
10}
11
12pub fn git_patch_from_texts(
13 path: &str,
14 old: Option<&str>,
15 new: Option<&str>,
16) -> Result<Option<String>, PatchError> {
17 if path.is_empty() || path.contains('\0') || path == "/dev/null" {
18 return Err(PatchError::InvalidPath);
19 }
20 if old.into_iter().chain(new).any(|text| text.contains('\0')) {
21 return Err(PatchError::BinaryText);
22 }
23 if old == new {
24 return Ok(None);
25 }
26
27 let quoted_path = quote_path(path);
28 let mut output = format!("diff --git {quoted_path} {quoted_path}\n");
29 if old.is_none() {
30 output.push_str("new file mode 100644\n");
31 } else if new.is_none() {
32 output.push_str("deleted file mode 100644\n");
33 }
34
35 let old_path = if old.is_some() {
36 "ed_path
37 } else {
38 "/dev/null"
39 };
40 let new_path = if new.is_some() {
41 "ed_path
42 } else {
43 "/dev/null"
44 };
45 output.extend(["--- ", old_path, "\n+++ ", new_path, "\n"]);
46 let diff = TextDiff::from_lines(old.unwrap_or_default(), new.unwrap_or_default());
47 output.push_str(&diff.unified_diff().context_radius(3).to_string());
48 Ok(Some(output))
49}
50
51fn quote_path(path: &str) -> String {
52 let mut quoted = String::from("\"");
53 for byte in path.bytes() {
54 match byte {
55 b'"' => quoted.push_str("\\\""),
56 b'\\' => quoted.push_str("\\\\"),
57 0x20..=0x7e => quoted.push(char::from(byte)),
58 _ => {
59 quoted.push('\\');
60 quoted.push(char::from(b'0' + (byte >> 6)));
61 quoted.push(char::from(b'0' + ((byte >> 3) & 7)));
62 quoted.push(char::from(b'0' + (byte & 7)));
63 }
64 }
65 }
66 quoted.push('"');
67 quoted
68}