use std::io::{BufRead, Write};
use crate::error::{Error, Result};
use crate::etree::{TextNode, TextTree};
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ResolveMode {
Ours,
Theirs,
Both,
Skip,
Interactive,
}
impl ResolveMode {
pub fn from_cli_flag(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"ours" => Ok(ResolveMode::Ours),
"theirs" => Ok(ResolveMode::Theirs),
"both" => Ok(ResolveMode::Both),
"skip" => Ok(ResolveMode::Skip),
"interactive" | "i" => Ok(ResolveMode::Interactive),
other => Err(Error::msg(format!(
"unknown resolve mode '{}' (expected: ours, theirs, both, skip, interactive)",
other
))),
}
}
}
pub fn resolve_tree(tree: &TextTree, mode: ResolveMode) -> Result<(TextTree, usize)> {
resolve_tree_with_overrides(tree, mode, &WordOverride::default())
}
#[derive(Default, Clone, Debug)]
pub struct WordOverride {
overrides: std::collections::HashMap<String, ResolveMode>,
}
impl WordOverride {
pub fn from_cli_flags(flags: &[String]) -> Result<Self> {
let mut overrides = std::collections::HashMap::new();
for f in flags {
let (word, mode_str) = f
.split_once(':')
.ok_or_else(|| Error::msg(format!("--word value must be WORD:MODE, got '{f}'")))?;
if word.is_empty() {
return Err(Error::msg(format!("--word value '{f}' has empty WORD")));
}
let mode = ResolveMode::from_cli_flag(mode_str)?;
if matches!(mode, ResolveMode::Interactive) {
return Err(Error::msg(format!(
"--word {word}:interactive not supported (interactive prompts only via --mode)"
)));
}
overrides.insert(word.to_string(), mode);
}
Ok(WordOverride { overrides })
}
pub fn get(&self, word: &str) -> Option<ResolveMode> {
self.overrides.get(word).copied()
}
}
pub fn resolve_tree_with_overrides(
tree: &TextTree,
mode: ResolveMode,
overrides: &WordOverride,
) -> Result<(TextTree, usize)> {
let mut count = 0;
let mut out = Vec::with_capacity(tree.len());
for node in tree {
match node {
TextNode::Conflict { keyw, ours, theirs } => {
let pick = if let Some(ov) = overrides.get(keyw) {
ov
} else {
match mode {
ResolveMode::Interactive => prompt_one(keyw, ours, theirs)?,
other => other,
}
};
count += 1;
emit_resolution(&mut out, keyw, ours, theirs, pick);
}
other => out.push(other.clone()),
}
}
Ok((out, count))
}
fn emit_resolution(
out: &mut TextTree,
keyw: &str,
ours: &TextTree,
theirs: &TextTree,
pick: ResolveMode,
) {
match pick {
ResolveMode::Ours => out.extend_from_slice(ours),
ResolveMode::Theirs => out.extend_from_slice(theirs),
ResolveMode::Both => {
out.extend_from_slice(ours);
out.extend_from_slice(theirs);
}
ResolveMode::Skip | ResolveMode::Interactive => {
let _ = (keyw, ours, theirs);
}
}
}
fn prompt_one(keyw: &str, ours: &TextTree, theirs: &TextTree) -> Result<ResolveMode> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
writeln!(
stdout,
"CONFLICT on WORD {} — choose resolution [o/ours, t/theirs, b/both, s/skip]:",
keyw
)?;
writeln!(stdout, " -- ours --")?;
print_tree(&mut stdout, ours, " ")?;
writeln!(stdout, " -- theirs --")?;
print_tree(&mut stdout, theirs, " ")?;
write!(stdout, "> ")?;
stdout.flush()?;
let mut buf = String::new();
stdin.lock().read_line(&mut buf)?;
let trimmed = buf.trim().to_ascii_lowercase();
match trimmed.as_str() {
"o" | "ours" => Ok(ResolveMode::Ours),
"t" | "theirs" => Ok(ResolveMode::Theirs),
"b" | "both" => Ok(ResolveMode::Both),
"s" | "skip" | "" => Ok(ResolveMode::Skip),
other => Err(Error::msg(format!(
"unknown response '{}' (expected: o, t, b, s)",
other
))),
}
}
fn print_tree<W: Write>(out: &mut W, tree: &TextTree, indent: &str) -> Result<()> {
for node in tree {
match node {
TextNode::Plain(s) => writeln!(out, "{}{}", indent, s)?,
TextNode::Data(d) => writeln!(out, "{}<{} data bytes>", indent, d.len())?,
TextNode::Stored { keyw, cas } => writeln!(
out,
"{}STORED {} {}",
indent,
keyw,
&cas[..cas.len().min(16)]
)?,
TextNode::Encrypted { keyw, .. } => writeln!(out, "{}ENCRYPTED {}", indent, keyw)?,
TextNode::BeginEnd { keyw, .. } => writeln!(out, "{}BEGIN/END {}", indent, keyw)?,
TextNode::Chain { .. } => writeln!(out, "{}CHAIN", indent)?,
TextNode::Include { hash } => {
writeln!(out, "{}INCLUDE {}", indent, &hash[..hash.len().min(16)])?
}
TextNode::Conflict { keyw, .. } => writeln!(out, "{}CONFLICT {}", indent, keyw)?,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::etree::ParseOps;
use std::io::Cursor;
fn parse_str(s: &str) -> TextTree {
let mut paops = ParseOps::new(crate::crypto::default_policy()).unwrap();
crate::etree::parse(Cursor::new(s.as_bytes()), &mut paops).unwrap()
}
fn conflict_tree() -> TextTree {
parse_str(
"// <( CONFLICT X )>\n// <( OURS )>\n// <( BEGIN X )>\nhi-our\n// <( END X )>\n// <( THEIRS )>\n// <( BEGIN X )>\nhi-their\n// <( END X )>\n// <( END X )>\n",
)
}
#[test]
fn resolve_ours_drops_theirs_and_markers() {
let t = conflict_tree();
let (resolved, n) = resolve_tree(&t, ResolveMode::Ours).unwrap();
assert_eq!(n, 1);
assert!(
!resolved
.iter()
.any(|n| matches!(n, TextNode::Conflict { .. }))
);
assert!(
resolved
.iter()
.any(|n| matches!(n, TextNode::BeginEnd { keyw, .. } if keyw == "X"))
);
}
#[test]
fn resolve_theirs_drops_ours() {
let t = conflict_tree();
let (resolved, _) = resolve_tree(&t, ResolveMode::Theirs).unwrap();
assert!(
!resolved
.iter()
.any(|n| matches!(n, TextNode::Conflict { .. }))
);
}
#[test]
fn resolve_both_keeps_both_sides() {
let t = conflict_tree();
let (resolved, _) = resolve_tree(&t, ResolveMode::Both).unwrap();
let x_count = resolved
.iter()
.filter(|n| matches!(n, TextNode::BeginEnd { keyw, .. } if keyw == "X"))
.count();
assert_eq!(x_count, 2);
}
#[test]
fn resolve_skip_drops_everything() {
let t = conflict_tree();
let (resolved, _) = resolve_tree(&t, ResolveMode::Skip).unwrap();
assert!(
!resolved
.iter()
.any(|n| matches!(n, TextNode::BeginEnd { keyw, .. } if keyw == "X"))
);
}
#[test]
fn resolve_on_clean_tree_is_noop() {
let t = parse_str("// <( BEGIN X )>\nbody\n// <( END X )>\n");
let (resolved, n) = resolve_tree(&t, ResolveMode::Ours).unwrap();
assert_eq!(n, 0);
assert_eq!(resolved, t);
}
#[test]
fn mode_from_cli_flag_round_trips() {
assert_eq!(
ResolveMode::from_cli_flag("ours").unwrap(),
ResolveMode::Ours
);
assert_eq!(
ResolveMode::from_cli_flag("THEIRS").unwrap(),
ResolveMode::Theirs
);
assert!(ResolveMode::from_cli_flag("garbage").is_err());
}
#[test]
fn word_override_parses_valid_flags() {
let ov = WordOverride::from_cli_flags(&[
"Agent_007:ours".into(),
"GEHEIM:theirs".into(),
"PUBLIC:both".into(),
])
.unwrap();
assert_eq!(ov.get("Agent_007"), Some(ResolveMode::Ours));
assert_eq!(ov.get("GEHEIM"), Some(ResolveMode::Theirs));
assert_eq!(ov.get("PUBLIC"), Some(ResolveMode::Both));
assert_eq!(ov.get("UNLISTED"), None);
}
#[test]
fn word_override_rejects_malformed_flags() {
assert!(WordOverride::from_cli_flags(&["no-colon".into()]).is_err());
assert!(WordOverride::from_cli_flags(&[":ours".into()]).is_err());
assert!(WordOverride::from_cli_flags(&["X:bogus".into()]).is_err());
assert!(WordOverride::from_cli_flags(&["X:interactive".into()]).is_err());
}
#[test]
fn resolve_per_word_override_wins_over_global_mode() {
let t = parse_str(
"// <( CONFLICT X )>\n// <( OURS )>\n// <( BEGIN X )>\nhi-our\n// <( END X )>\n// <( THEIRS )>\n// <( BEGIN X )>\nhi-their\n// <( END X )>\n// <( END X )>\n// <( CONFLICT Y )>\n// <( OURS )>\n// <( BEGIN Y )>\nyo-our\n// <( END Y )>\n// <( THEIRS )>\n// <( BEGIN Y )>\nyo-their\n// <( END Y )>\n// <( END Y )>\n",
);
let ov = WordOverride::from_cli_flags(&["X:ours".into()]).unwrap();
let (resolved, _) = resolve_tree_with_overrides(&t, ResolveMode::Theirs, &ov).unwrap();
let body = serialize(&resolved);
assert!(body.contains("hi-our") && !body.contains("hi-their"));
assert!(body.contains("yo-their") && !body.contains("yo-our"));
}
fn serialize(tree: &TextTree) -> String {
let mut buf: Vec<u8> = Vec::new();
let mut paops = ParseOps::new(crate::crypto::default_policy()).unwrap();
crate::etree::tree_write(&mut buf, tree, &mut paops).unwrap();
String::from_utf8(buf).unwrap()
}
}