//! The conflict resolver (hub spec §6.10): a conflicted text file of a //! bookmark, region by region. For each region the user keeps one side, //! every side in order, none, or text of their own; the file is put back //! together around the unconflicted lines and committed through the //! web-edit path, which makes it a `resolution` change depending on every //! change that contributed to the conflict. use super::edit::{after_web_commit, signer_for}; use super::settings::{csrf, require}; use super::{lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Viewer}; use crate::repos::edits; use crate::repos::view::{self, FileBody}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, State}; use axum::response::{IntoResponse, Redirect, Response}; use oxy_core::repo::FileEdit; use std::collections::HashMap; /// A piece of a file rendered with `oxy` conflict markers (spec §9.5). #[derive(Clone, Debug, PartialEq, Eq)] pub enum Piece { /// Lines outside any conflict, kept as they are. Plain(String), Region(Region), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Region { /// 1-based, as the markers number it. pub n: usize, pub sides: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Side { /// "change 3f2a…", or "deleted by change …". pub label: String, /// The side's lines, each ending in `\n`. pub text: String, /// A deletion side: `text` is what was deleted, shown for context; /// keeping this side keeps the deletion, so it contributes nothing. pub deletion: bool, } impl Side { /// What keeping this side puts in the file. pub fn kept(&self) -> &str { if self.deletion { "" } else { &self.text } } } impl Region { /// Every side in order: what keeping all of them puts in the file. pub fn all(&self) -> String { self.sides.iter().map(Side::kept).collect() } } /// Split a marked rendering into plain text and regions. `None` when the /// markers are malformed (the page then offers the plain editor). pub fn parse(marked: &str) -> Option> { let mut out = Vec::new(); let mut plain = String::new(); let mut region: Option = None; let mut lines = marked.split_inclusive('\n').peekable(); while let Some(line) = lines.next() { let bare = line.trim_end_matches('\n'); if let Some(rest) = bare.strip_prefix("<<<<<<< oxy conflict ") { if region.is_some() { return None; } if !plain.is_empty() { out.push(Piece::Plain(std::mem::take(&mut plain))); } let (n, label) = header(rest)?; let mut side = Side { label, text: String::new(), deletion: false }; if lines.peek().map(|l| l.trim_end_matches('\n')) == Some("-------") { lines.next(); side.deletion = true; } region = Some(Region { n, sides: vec![side] }); } else if let Some(rest) = bare.strip_prefix("======= oxy conflict ") { let r = region.as_mut()?; let (n, label) = header(rest)?; if n != r.n { return None; } r.sides.push(Side { label, text: String::new(), deletion: false }); } else if bare.starts_with(">>>>>>> oxy conflict ") { out.push(Piece::Region(region.take()?)); } else { match region.as_mut() { Some(r) => r.sides.last_mut()?.text.push_str(line), None => plain.push_str(line), } } } if region.is_some() { return None; } if !plain.is_empty() { out.push(Piece::Plain(plain)); } Some(out) } /// `N of T, side I of K: LABEL` → (N, LABEL). fn header(rest: &str) -> Option<(usize, String)> { let (n, tail) = rest.split_once(" of ")?; let label = tail.split_once(": ").map(|(_, l)| l.to_string()).unwrap_or_default(); Some((n.trim().parse().ok()?, label)) } /// A user's choice for one region. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Choice { Side(usize), All, None, Text(String), } /// The file with each region replaced by what was chosen for it; every /// region needs a choice. pub fn assemble(pieces: &[Piece], choices: &HashMap) -> Result { let mut out = String::new(); for p in pieces { match p { Piece::Plain(t) => out.push_str(t), Piece::Region(r) => match choices.get(&r.n) { Some(Choice::Side(i)) => { out.push_str(r.sides.get(i.wrapping_sub(1)).ok_or_else(|| format!("conflict {} has no side {i}", r.n))?.kept()) } Some(Choice::All) => out.push_str(&r.all()), Some(Choice::None) => {} Some(Choice::Text(t)) => { let t = t.replace("\r\n", "\n"); out.push_str(&t); if !t.is_empty() && !t.ends_with('\n') { out.push('\n'); } } None => return Err(format!("choose how to resolve conflict {}", r.n)), }, } } Ok(out) } #[derive(Template)] #[template(path = "resolve.html")] pub struct ResolvePage { shell: Shell, repo: RepoShell, bookmark: String, path: String, state: String, pieces: Vec, delegation: Option, error: Option, message: String, } /// The bookmark's state, and the file rendered there when it is a /// conflicted text file. async fn load(app: &Shared, shell: &Shell, repo: &crate::repos::RepoName, bookmark: &str, path: &str) -> Result<(String, String), Error> { let limit = app.config.limits.render_limit.0; let (b, p) = (bookmark.to_string(), path.to_string()); let (state, file) = app .repos .read(repo, move |r, cache| { let state = r.view()?.bookmarks.get(&b).map(|s| s.to_hex()); let target = view::resolve(r, cache, &b)?; Ok((state, view::file(r, cache, target, &p, limit)?)) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let Some(state) = state else { return Err(Error::bad(shell.clone(), "Conflicts are resolved on a bookmark.")) }; match file.body { FileBody::Text(t) if file.conflicted => Ok((state, t)), FileBody::Text(_) => Err(Error::bad(shell.clone(), "That file has no conflicts on this bookmark.")), _ => Err(Error::bad(shell.clone(), "Only text files within the render limit are resolved in the browser.")), } } #[allow(clippy::too_many_arguments)] async fn page( app: &Shared, viewer: &Viewer, owner: &str, name: &str, bookmark: &str, path: &str, error: Option, message: Option, ) -> Result, Error> { let shell = Shell::new(app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), viewer); let me = require(viewer, &shell)?; let (repo, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Write).await?; let (state, text) = load(app, &shell, &repo, bookmark, path).await?; let Some(pieces) = parse(&text) else { return Err(Error::bad(shell, "The conflict markers could not be read; resolve the file in the plain editor.")); }; let delegation = signer_for(app, me.account.id).await.err(); Ok(Page(ResolvePage { shell, repo: RepoShell::new(app, &row, "conflicts").with_role(role), bookmark: bookmark.to_string(), path: path.to_string(), state, pieces, delegation, error, message: message.unwrap_or_else(|| format!("Resolve the conflicts in {path}")), })) } pub async fn form( State(app): State, viewer: Viewer, Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, ) -> Result { // Malformed markers: the plain editor still resolves the file. let shell = Shell::new(&app, "", &viewer); let (repo, _, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let (_, text) = load(&app, &shell, &repo, &bookmark, &path).await?; if parse(&text).is_none() { return Ok(Redirect::to(&format!("/{owner}/{name}/edit/{bookmark}/{path}")).into_response()); } Ok(page(&app, &viewer, &owner, &name, &bookmark, &path, None, None).await?.into_response()) } pub async fn save( State(app): State, viewer: Viewer, Path((owner, name, bookmark, path)): Path<(String, String, String, String)>, Form(f): Form>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/resolve/{bookmark}/{path}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, f.get("_csrf").map(String::as_str).unwrap_or(""), &shell)?; let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let message = f.get("message").map(|m| m.trim().to_string()).unwrap_or_default(); let again = |msg: String| { let (app, viewer, owner, name, bookmark, path, message) = (app.clone(), viewer.clone(), owner.clone(), name.clone(), bookmark.clone(), path.clone(), message.clone()); async move { page(&app, &viewer, &owner, &name, &bookmark, &path, Some(msg), Some(message)).await.map(IntoResponse::into_response) } }; let (state, text) = load(&app, &shell, &repo, &bookmark, &path).await?; if f.get("state") != Some(&state) { return again(format!("{bookmark} has moved since this page was loaded; the conflicts below are as they are now.")).await; } let Some(pieces) = parse(&text) else { return again("The conflict markers could not be read.".into()).await }; let mut choices = HashMap::new(); for p in &pieces { if let Piece::Region(r) = p { let choice = match f.get(&format!("r{}", r.n)).map(String::as_str) { Some("all") => Choice::All, Some("none") => Choice::None, Some("text") => Choice::Text(f.get(&format!("t{}", r.n)).cloned().unwrap_or_default()), Some(s) => match s.strip_prefix("side").and_then(|i| i.parse().ok()) { Some(i) => Choice::Side(i), None => continue, }, None => continue, }; choices.insert(r.n, choice); } } let content = match assemble(&pieces, &choices) { Ok(c) => c, Err(msg) => return again(msg).await, }; if message.is_empty() { return again("Describe the resolution.".into()).await; } let signer = match signer_for(&app, me.account.id).await { Ok(s) => s, Err(msg) => return again(msg).await, }; let edit = vec![FileEdit { path: path.clone(), content: Some(content.into_bytes()) }]; let done = match edits::commit(&app.repos, &repo, bookmark.clone(), bookmark.clone(), edit, message.clone(), signer).await { Ok(d) => d, Err(e) => return again(e.to_string()).await, }; after_web_commit(&app, &me.account, &row, &owner, &name, &done, &message).await; Ok(Redirect::to(&format!("/{owner}/{name}/change/{}", done.change.to_hex())).into_response()) } #[cfg(test)] mod tests { use super::*; const MARKED: &str = "a\n<<<<<<< oxy conflict 1 of 2, side 1 of 2: change 0202\nfrom-2\n======= oxy conflict 1 of 2, side 2 of 2: change 0303\nfrom-3\nmore-3\n>>>>>>> oxy conflict 1 of 2 end\nb\n<<<<<<< oxy conflict 2 of 2, side 1 of 2: deleted by change 0404\n-------\ngone\n======= oxy conflict 2 of 2, side 2 of 2: change 0505\nadded\n>>>>>>> oxy conflict 2 of 2 end\nc\n"; #[test] fn markers_parse_into_regions_and_sides() { let p = parse(MARKED).unwrap(); assert_eq!(p.len(), 5); assert_eq!(p[0], Piece::Plain("a\n".into())); let Piece::Region(r1) = &p[1] else { panic!() }; assert_eq!((r1.n, r1.sides.len()), (1, 2)); assert_eq!((r1.sides[0].label.as_str(), r1.sides[0].text.as_str()), ("change 0202", "from-2\n")); assert_eq!(r1.sides[1].text, "from-3\nmore-3\n"); let Piece::Region(r2) = &p[3] else { panic!() }; assert!(r2.sides[0].deletion && r2.sides[0].text == "gone\n" && r2.sides[0].kept().is_empty()); assert_eq!(r2.sides[0].label, "deleted by change 0404"); assert_eq!(p[4], Piece::Plain("c\n".into())); assert!(parse("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\nno end\n").is_none()); assert_eq!(parse("plain\n").unwrap(), vec![Piece::Plain("plain\n".into())]); } #[test] fn choices_assemble_the_file() { let p = parse(MARKED).unwrap(); let pick = |a: Choice, b: Choice| assemble(&p, &HashMap::from([(1, a), (2, b)])); assert_eq!(pick(Choice::Side(2), Choice::Side(1)).unwrap(), "a\nfrom-3\nmore-3\nb\nc\n", "the deletion wins: nothing"); assert_eq!(pick(Choice::All, Choice::Side(2)).unwrap(), "a\nfrom-2\nfrom-3\nmore-3\nb\nadded\nc\n"); assert_eq!(pick(Choice::None, Choice::Text("mine".into())).unwrap(), "a\nb\nmine\nc\n", "own text ends its line"); assert!(pick(Choice::Side(3), Choice::None).unwrap_err().contains("no side 3")); assert!(assemble(&p, &HashMap::from([(1, Choice::None)])).unwrap_err().contains("conflict 2")); } }