1use core::str::FromStr;
4use std::io;
5use std::process::{Command, Stdio};
6
7use camino::Utf8Path;
8use derive_more::Display;
9use serde_with::{DeserializeFromStr, SerializeDisplay};
10
11use crate::digest::is_binary;
12use crate::errors::{MergeError, Result};
13use crate::path::RelPath;
14
15const MARKERS: [&[u8]; 3] = [b"<<<<<<<", b"|||||||", b">>>>>>>"];
19
20#[derive(Clone, Debug, Default, PartialEq, Eq, Display, SerializeDisplay, DeserializeFromStr)]
23pub enum Driver {
24 #[default]
26 #[display("builtin")]
27 Builtin,
28 #[display("{line}")]
33 Command {
34 line: String,
36 args: Vec<String>,
38 },
39}
40
41#[derive(Debug)]
43pub(crate) struct Merged {
44 pub bytes: Vec<u8>,
46 pub conflict: Option<String>,
48}
49
50impl FromStr for Driver {
55 type Err = MergeError;
56
57 fn from_str(line: &str) -> Result<Self, MergeError> {
58 let invalid = |reason| MergeError::Driver { line: line.to_owned(), reason };
59 let args = shlex::split(line).ok_or_else(|| invalid("unbalanced quotes"))?;
60 if args.is_empty() {
61 return Err(invalid("empty"));
62 }
63 for arg in &args {
64 let mut chars = arg.chars();
65 while let Some(c) = chars.next() {
66 if c == '%' && !matches!(chars.next(), Some('O' | 'A' | 'B' | 'P' | '%')) {
67 return Err(invalid("placeholders are %O, %A, %B, %P and %%"));
68 }
69 }
70 }
71 if !args.iter().any(|arg| arg.contains("%A")) {
72 return Err(invalid("must name %A, where the merged result is left"));
73 }
74 Ok(Self::Command { line: line.to_owned(), args })
75 }
76}
77
78impl Driver {
79 pub(crate) fn merge(
81 &self, root: &Utf8Path, path: &RelPath, base: &[u8], ours: &[u8], theirs: &[u8],
82 ) -> Result<Merged> {
83 let Self::Command { line, args } = self else {
84 if [base, ours, theirs].into_iter().any(is_binary) {
85 let conflict = Some("binary: the sidecar holds the profile's version".to_owned());
86 return Ok(Merged { bytes: theirs.to_vec(), conflict });
87 }
88 return Ok(match diffy::merge_bytes(base, ours, theirs) {
89 Ok(bytes) => Merged { bytes, conflict: None },
90 Err(bytes) => Merged { bytes, conflict: Some("conflicting changes".to_owned()) },
91 });
92 };
93 let scratch = camino_tempfile::tempdir()?;
95 let [o, a, b] =
96 ["O", "A", "B"].map(|side| scratch.path().join(side).join(path.file_name()));
97 for (file, bytes) in [(&o, base), (&a, ours), (&b, theirs)] {
98 if let Some(dir) = file.parent() {
99 fs_err::create_dir_all(dir)?;
100 }
101 fs_err::write(file, bytes)?;
102 }
103 let expand =
104 |arg: &String| expand(arg, [o.as_str(), a.as_str(), b.as_str(), path.as_str()]);
105 let argv: Vec<String> = args.iter().map(expand).collect();
106 let (program, rest) = argv
107 .split_first()
108 .ok_or_else(|| MergeError::Driver { line: line.clone(), reason: "empty" })?;
109 let stopped = |reason| MergeError::Run { program: program.clone(), reason };
110 let output = Command::new(program)
111 .args(rest)
112 .current_dir(root)
113 .stdin(Stdio::null())
114 .stdout(Stdio::null())
115 .output()
116 .map_err(|e| {
117 if e.kind() == io::ErrorKind::NotFound {
118 MergeError::NoDriver { program: program.clone() }
119 } else {
120 stopped(e.to_string())
121 }
122 })?;
123 let bytes = fs_err::read(&a)?;
124 match output.status.code() {
125 Some(0) => Ok(Merged { bytes, conflict: None }),
126 Some(code) => {
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 let why = stderr.trim();
129 let conflict = if why.is_empty() {
130 format!("`{program}` exited {code}")
131 } else {
132 why.to_owned()
133 };
134 Ok(Merged { bytes, conflict: Some(conflict) })
135 },
136 None => Err(stopped("killed by a signal".to_owned()).into()),
137 }
138 }
139}
140
141fn expand(arg: &str, [o, a, b, p]: [&str; 4]) -> String {
143 let mut out = String::with_capacity(arg.len());
144 let mut chars = arg.chars();
145 while let Some(c) = chars.next() {
146 if c != '%' {
147 out.push(c);
148 continue;
149 }
150 match chars.next() {
151 Some('O') => out.push_str(o),
152 Some('A') => out.push_str(a),
153 Some('B') => out.push_str(b),
154 Some('P') => out.push_str(p),
155 Some(other) => out.push(other),
156 None => {},
157 }
158 }
159 out
160}
161
162pub(crate) fn has_markers(bytes: &[u8]) -> bool {
164 bytes.split(|&b| b == b'\n').any(|line| MARKERS.iter().any(|marker| line.starts_with(marker)))
165}
166
167#[cfg(test)]
168mod tests {
169 use camino::Utf8Path;
170
171 use super::{Driver, expand, has_markers};
172 use crate::path::RelPath;
173
174 #[test]
175 fn parses_driver_lines() {
176 let Ok(Driver::Command { args, .. }) = "mergiraf merge --git %O %A %B -p '%P'".parse()
177 else {
178 panic!("valid driver");
179 };
180 assert_eq!(
181 args,
182 ["mergiraf", "merge", "--git", "%O", "%A", "%B", "-p", "%P"],
183 "split without a shell"
184 );
185 for bad in ["", "tool 'open", "tool %O %B", "tool %A %Q"] {
186 assert!(bad.parse::<Driver>().is_err(), "{bad:?} should be rejected");
187 }
188 }
189
190 #[test]
191 fn expands_placeholders() {
192 assert_eq!(expand("--base=%O", ["o", "a", "b", "p"]), "--base=o", "inside an argument");
193 assert_eq!(expand("100%%", ["o", "a", "b", "p"]), "100%", "escaped percent");
194 }
195
196 #[test]
197 fn builtin_merges_and_conflicts() {
198 let merge = |base: &str, ours: &str, theirs: &str| {
199 let path = RelPath::new("f.txt").unwrap();
200 Driver::Builtin
201 .merge(
202 Utf8Path::new("."),
203 &path,
204 base.as_bytes(),
205 ours.as_bytes(),
206 theirs.as_bytes(),
207 )
208 .unwrap()
209 };
210 let clean = merge("a\nb\nc\n", "A\nb\nc\n", "a\nb\nC\n");
211 assert_eq!(
212 (clean.bytes.as_slice(), clean.conflict),
213 (&b"A\nb\nC\n"[..], None),
214 "disjoint edits merge"
215 );
216 let conflict = merge("a\n", "x\n", "y\n");
217 assert!(
218 conflict.conflict.is_some() && has_markers(&conflict.bytes),
219 "overlapping edits conflict"
220 );
221 }
222
223 #[test]
224 fn commands_follow_git_convention() {
225 let path = RelPath::new("f.txt").unwrap();
226 let run = |line: &str| {
227 line.parse::<Driver>()
228 .unwrap()
229 .merge(Utf8Path::new("."), &path, b"o\n", b"a\n", b"b\n")
230 .unwrap()
231 };
232 let theirs = run("cp %B %A");
233 assert_eq!(
234 (theirs.bytes.as_slice(), theirs.conflict),
235 (&b"b\n"[..], None),
236 "exit 0: result read from %A"
237 );
238 assert!(run("false %A").conflict.is_some(), "non-zero exit is a conflict");
239 }
240
241 #[test]
242 fn detects_markers() {
243 assert!(has_markers(b"a\n<<<<<<< ours\nb\n"), "opening marker");
244 assert!(!has_markers(b"Title\n=======\n"), "a Markdown underline is not a marker");
245 }
246}