use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result, bail};
use log::{debug, warn};
use pimalaya_config::command::{CommandConfig, shell};
use crate::conflict::Sides;
const PLACEHOLDERS: [&str; 4] = ["{base}", "{local}", "{remote}", "{output}"];
const UNWRITTEN: &[u8] = b"";
pub struct Merger<'a> {
pub command: &'a CommandConfig,
pub base: PathBuf,
pub local: PathBuf,
pub remote: PathBuf,
pub output: PathBuf,
}
impl<'a> Merger<'a> {
pub fn export(
command: &'a CommandConfig,
dir: &Path,
extension: &str,
sides: &Sides,
) -> Result<Self> {
let write = |name: &str, body: &Option<Vec<u8>>| -> Result<PathBuf> {
let Some(body) = body else {
bail!("The {name} side of this conflict is not in the store");
};
let path = dir.join(format!("{name}.{extension}"));
fs::write(&path, body)
.with_context(|| format!("Export the {name} side to {}", path.display()))?;
Ok(path)
};
Ok(Self {
command,
base: write("base", &sides.base)?,
local: write("local", &sides.local)?,
remote: write("remote", &sides.remote)?,
output: dir.join(format!("merged.{extension}")),
})
}
pub fn run(&self) -> Result<Option<Vec<u8>>> {
fs::write(&self.output, UNWRITTEN)
.with_context(|| format!("Seed the merger output {}", self.output.display()))?;
let mut command = self.command();
debug!("run the interactive merger: {command:?}");
let status = command.status().context("Run the interactive merger")?;
if !status.success() {
warn!("the merger exited with {status}, leaving the conflict as it was");
return Ok(None);
}
let body = fs::read(&self.output)
.with_context(|| format!("Read the merger output {}", self.output.display()))?;
if body == UNWRITTEN {
warn!("the merger wrote no body, leaving the conflict as it was");
return Ok(None);
}
Ok(Some(body))
}
fn command(&self) -> Command {
match self.command {
CommandConfig::Shell(line) => {
let paths = self.paths(quote);
match substitute(line, &paths) {
Some(line) => shell(&line),
None => shell(&format!("{line} {}", paths.join(" "))),
}
}
CommandConfig::Argv { program, args } => {
let paths = self.paths(|path| path.display().to_string());
let mut command = Command::new(program);
let mut substituted = false;
for arg in args {
match substitute(arg, &paths) {
Some(arg) => {
substituted = true;
command.arg(arg);
}
None => {
command.arg(arg);
}
}
}
if !substituted {
command.args(paths);
}
command
}
}
}
fn paths(&self, render: impl Fn(&Path) -> String) -> Vec<String> {
[&self.base, &self.local, &self.remote, &self.output]
.into_iter()
.map(|path| render(path))
.collect()
}
}
fn substitute(text: &str, paths: &[String]) -> Option<String> {
if !PLACEHOLDERS
.iter()
.any(|placeholder| text.contains(placeholder))
{
return None;
}
let mut substituted = text.to_string();
for (placeholder, path) in PLACEHOLDERS.iter().zip(paths) {
substituted = substituted.replace(placeholder, path);
}
Some(substituted)
}
#[cfg(unix)]
fn quote(path: &Path) -> String {
let path = path.display().to_string().replace('\'', r"'\''");
format!("'{path}'")
}
#[cfg(windows)]
fn quote(path: &Path) -> String {
let path = path.display();
format!("\"{path}\"")
}
#[cfg(test)]
mod tests {
use super::*;
fn sides() -> Sides {
Sides {
base: Some(b"base".to_vec()),
local: Some(b"local".to_vec()),
remote: Some(b"remote".to_vec()),
}
}
#[cfg(unix)]
#[test]
fn a_merger_that_aborts_or_writes_nothing_yields_no_body() {
let dir = tempfile::tempdir().unwrap();
for line in ["false", "true", "cat {base} > /dev/null"] {
let command = CommandConfig::Shell(String::from(line));
let merger = Merger::export(&command, dir.path(), "vcf", &sides()).unwrap();
assert_eq!(merger.run().unwrap(), None, "{line}");
}
}
#[cfg(unix)]
#[test]
fn a_positional_merger_is_handed_the_four_paths_in_order() {
let dir = tempfile::tempdir().unwrap();
let command = CommandConfig::Shell(String::from(r#"sh -c 'cat "$1" "$2" "$3" > "$4"' --"#));
let merger = Merger::export(&command, dir.path(), "vcf", &sides()).unwrap();
assert_eq!(merger.run().unwrap(), Some(b"baselocalremote".to_vec()));
}
#[cfg(unix)]
#[test]
fn a_merger_naming_its_placeholders_is_substituted_rather_than_appended() {
let dir = tempfile::tempdir().unwrap();
let command = CommandConfig::Argv {
program: String::from("cp"),
args: vec![String::from("{remote}"), String::from("{output}")],
};
let merger = Merger::export(&command, dir.path(), "vcf", &sides()).unwrap();
assert_eq!(merger.run().unwrap(), Some(b"remote".to_vec()));
}
}