choir_merge/lib.rs
1//! Merge-strategy pipeline seam (DECISIONS.md D4/D19).
2//!
3//! Ordered strategies, cheapest first; each maps (base, left, right) to
4//! Resolved or Conflict. LLM resolution and Mergiraf are just strategy slots,
5//! so widening or dropping them is configuration, not surgery. Mergiraf is
6//! GPLv3 (audited 2026-08-08): subprocess only, never linked (D4).
7//!
8//! # Examples
9//!
10//! ```
11//! use choir_merge::{MergeOutcome, Pipeline};
12//!
13//! let pipeline = Pipeline::default_v1();
14//! // Only the right side changed, so the merge resolves trivially.
15//! let result = pipeline.merge("a\n", "a\n", "a\nb\n");
16//! assert_eq!(result.strategy, "trivial");
17//! match result.outcome {
18//! MergeOutcome::Resolved(text) => assert_eq!(text, "a\nb\n"),
19//! _ => unreachable!(),
20//! }
21//! ```
22//!
23//! # Where this sits
24//!
25//! `docs/architecture.md` is the map of the whole workspace.
26//! This crate is the merge-strategy pipeline (D4/D19), cheapest strategy first.
27//!
28//! It depends on no other crate in this workspace.
29
30pub mod safety;
31pub mod silent_revert;
32
33/// Result of one strategy's attempt at a 3-way merge.
34pub enum MergeOutcome {
35 /// Strategy produced a clean merge.
36 Resolved(String),
37 /// Strategy ran but could not resolve; carry the conflict downstream
38 /// (jj-style first-class conflict is the pipeline's terminal fallback).
39 Conflict {
40 /// Merge text with conflict markers preserved.
41 annotated: String,
42 },
43 /// Strategy not applicable in this environment (e.g. binary missing).
44 Unavailable(String),
45}
46
47/// The strategy seam: one slot in the [`Pipeline`].
48pub trait MergeStrategy: Send + Sync {
49 /// Stable identifier, recorded in [`PipelineResult::strategy`] for
50 /// provenance and review escalation.
51 fn name(&self) -> &'static str;
52
53 /// Attempts a 3-way merge of `left` and `right` against `base`.
54 fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome;
55}
56
57/// Runs strategies in order; first Resolved wins. If none resolves, returns
58/// the last Conflict (first-class conflict, never a silent pick).
59pub struct Pipeline {
60 strategies: Vec<Box<dyn MergeStrategy>>,
61}
62
63/// A [`Pipeline`] verdict plus which strategy produced it.
64pub struct PipelineResult {
65 /// The merge outcome; never [`MergeOutcome::Unavailable`].
66 pub outcome: MergeOutcome,
67 /// Which strategy produced the outcome (provenance for review escalation).
68 pub strategy: &'static str,
69}
70
71impl Pipeline {
72 /// Builds a pipeline from an ordered list of strategies, cheapest first.
73 pub fn new(strategies: Vec<Box<dyn MergeStrategy>>) -> Self {
74 Self { strategies }
75 }
76
77 /// The v1 default: trivial, then line-based. Structured (Mergiraf) and
78 /// LLM slots are appended by the caller when enabled.
79 pub fn default_v1() -> Self {
80 Self::new(vec![Box::new(TrivialMerge), Box::new(LineMerge)])
81 }
82
83 /// Runs the strategies in order and returns the first clean resolution,
84 /// or the last conflict if nothing resolves.
85 ///
86 /// # Panics
87 ///
88 /// Panics if every strategy reports [`MergeOutcome::Unavailable`]; a
89 /// pipeline must always contain at least one applicable strategy.
90 pub fn merge(&self, base: &str, left: &str, right: &str) -> PipelineResult {
91 let mut last_conflict: Option<PipelineResult> = None;
92 for s in &self.strategies {
93 match s.merge(base, left, right) {
94 MergeOutcome::Resolved(text) => {
95 return PipelineResult {
96 outcome: MergeOutcome::Resolved(text),
97 strategy: s.name(),
98 }
99 }
100 c @ MergeOutcome::Conflict { .. } => {
101 last_conflict = Some(PipelineResult {
102 outcome: c,
103 strategy: s.name(),
104 })
105 }
106 MergeOutcome::Unavailable(_) => continue,
107 }
108 }
109 last_conflict.expect("pipeline contains at least one applicable strategy")
110 }
111}
112
113/// Cheapest checks: unchanged sides and identical edits.
114pub struct TrivialMerge;
115
116impl MergeStrategy for TrivialMerge {
117 fn name(&self) -> &'static str {
118 "trivial"
119 }
120 fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome {
121 if left == right {
122 return MergeOutcome::Resolved(left.to_string());
123 }
124 if left == base {
125 return MergeOutcome::Resolved(right.to_string());
126 }
127 if right == base {
128 return MergeOutcome::Resolved(left.to_string());
129 }
130 MergeOutcome::Conflict {
131 annotated: format!("<<<<<<< left\n{left}=======\n{right}>>>>>>> right\n"),
132 }
133 }
134}
135
136/// Line-based 3-way merge (diffy), the histogram/ORT analog in the plan.
137pub struct LineMerge;
138
139impl MergeStrategy for LineMerge {
140 fn name(&self) -> &'static str {
141 "line"
142 }
143 fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome {
144 match diffy::merge(base, left, right) {
145 Ok(clean) => MergeOutcome::Resolved(clean),
146 Err(conflicted) => MergeOutcome::Conflict {
147 annotated: conflicted,
148 },
149 }
150 }
151}
152
153/// Structured (AST) merge via the mergiraf binary, subprocess only (GPLv3).
154/// CLI verified against mergiraf 0.18.0: `mergiraf merge <BASE> <LEFT> <RIGHT>
155/// -o <OUT>`; language is detected from the input file extension.
156pub struct MergirafMerge {
157 binary: std::path::PathBuf,
158 /// File extension used for language detection (e.g. "rs", "py").
159 pub extension: String,
160}
161
162impl MergirafMerge {
163 /// Returns `None` when mergiraf is not installed; the pipeline then simply
164 /// skips this slot (D4 fallback: line merge + first-class conflicts).
165 pub fn detect(extension: &str) -> Option<Self> {
166 let out = std::process::Command::new("which")
167 .arg("mergiraf")
168 .output()
169 .ok()?;
170 if !out.status.success() {
171 return None;
172 }
173 let path = String::from_utf8(out.stdout).ok()?.trim().to_string();
174 Some(Self {
175 binary: path.into(),
176 extension: extension.to_string(),
177 })
178 }
179
180 fn run(&self, base: &str, left: &str, right: &str) -> std::io::Result<MergeOutcome> {
181 let dir = std::env::temp_dir().join(format!(
182 "choir-mergiraf-{}-{:?}",
183 std::process::id(),
184 std::thread::current().id()
185 ));
186 std::fs::create_dir_all(&dir)?;
187 let ext = &self.extension;
188 let b = dir.join(format!("base.{ext}"));
189 let l = dir.join(format!("left.{ext}"));
190 let r = dir.join(format!("right.{ext}"));
191 let o = dir.join(format!("merged.{ext}"));
192 std::fs::write(&b, base)?;
193 std::fs::write(&l, left)?;
194 std::fs::write(&r, right)?;
195 let out = std::process::Command::new(&self.binary)
196 .arg("merge")
197 .arg(&b)
198 .arg(&l)
199 .arg(&r)
200 .arg("-o")
201 .arg(&o)
202 .output();
203 let merged = std::fs::read_to_string(&o).unwrap_or_default();
204 std::fs::remove_dir_all(&dir).ok();
205 let out = out?;
206 if out.status.success() {
207 Ok(MergeOutcome::Resolved(merged))
208 } else if !merged.is_empty() {
209 Ok(MergeOutcome::Conflict { annotated: merged })
210 } else {
211 Ok(MergeOutcome::Unavailable(
212 String::from_utf8_lossy(&out.stderr).into_owned(),
213 ))
214 }
215 }
216}
217
218impl MergeStrategy for MergirafMerge {
219 fn name(&self) -> &'static str {
220 "mergiraf"
221 }
222 fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome {
223 match self.run(base, left, right) {
224 Ok(outcome) => outcome,
225 Err(e) => MergeOutcome::Unavailable(e.to_string()),
226 }
227 }
228}
229
230/// The position-independent content of a change: the deleted and
231/// inserted lines of its diff, with hunk positions and context stripped —
232/// the cheap analog of `git patch-id --stable` (DECISIONS.md
233/// D15: metadata, never a new merge substrate). Two authorings of the
234/// same edit on different bases — the change before and after the train
235/// rewrites or rebases it — normalize to the same string, which is what
236/// lets a queue or bridge recognize an already-landed change instead of
237/// re-merging it. It lives here because this crate already owns the diff
238/// dependency; callers hash the result.
239pub fn normalized_diff(base: &str, proposed: &str) -> String {
240 let patch = diffy::create_patch(base, proposed);
241 let mut out = String::new();
242 let mut push = |marker: char, text: &str| {
243 out.push(marker);
244 out.push_str(text);
245 // A final line without a terminating newline would otherwise
246 // fuse with the next marker and alias a different change.
247 if !text.ends_with('\n') {
248 out.push('\n');
249 }
250 };
251 for hunk in patch.hunks() {
252 for line in hunk.lines() {
253 match line {
254 diffy::Line::Delete(text) => push('-', text),
255 diffy::Line::Insert(text) => push('+', text),
256 diffy::Line::Context(_) => {}
257 }
258 }
259 }
260 out
261}