use std::collections::HashMap;
use crate::model::inbox::{Inbox, InboxItem};
use crate::model::task::Task;
use crate::model::track::{SectionKind, Track, TrackNode};
#[derive(Debug, Clone)]
pub struct Conflict {
pub key: String,
pub reason: ConflictReason,
pub theirs: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictReason {
BothEdited,
EditedAndDeleted,
DeletedAndEdited,
AmbiguousTitle,
}
#[derive(Debug)]
pub struct Reconciled {
pub track: Track,
pub conflicts: Vec<Conflict>,
pub took_theirs: usize,
pub deleted: usize,
}
impl Reconciled {
pub fn changed_anything(&self) -> bool {
self.took_theirs > 0 || self.deleted > 0
}
}
pub fn reconcile_track(base: &Track, ours: &Track, theirs: &Track) -> Reconciled {
let bi = index(base);
let oi = index(ours);
let ti = index(theirs);
let ambiguous = ambiguous_keys(&[base, ours, theirs]);
let mut keys: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for k in oi
.order
.iter()
.chain(ti.order.iter())
.chain(bi.order.iter())
{
if seen.insert(k.clone()) {
keys.push(k.clone());
}
}
let mut conflicts = Vec::new();
let mut took_theirs = 0usize;
let mut deleted = 0usize;
let mut resolved: Vec<(SectionKind, Task)> = Vec::new();
for key in &keys {
let b = bi.entries.get(key);
let o = oi.entries.get(key);
let t = ti.entries.get(key);
if ambiguous.contains(key) {
if let Some(o) = o {
resolved.push((o.section, o.task.clone()));
if let Some(t) = t {
conflicts.push(Conflict {
key: key.clone(),
reason: ConflictReason::AmbiguousTitle,
theirs: own_lines(&t.task),
});
}
}
continue;
}
let outcome = decide(b, o, t);
match outcome {
Outcome::Delete => {
deleted += 1;
}
Outcome::Ours => {
let o = o.expect("Outcome::Ours requires our side");
resolved.push((
section_for(b, Some(o), t),
merged_task(
&o.task,
b.map(|e| &e.task),
t.map(|e| &e.task),
&mut conflicts,
),
));
}
Outcome::Theirs => {
let t = t.expect("Outcome::Theirs requires their side");
took_theirs += 1;
resolved.push((
section_for(b, o, Some(t)),
merged_task(
&t.task,
b.map(|e| &e.task),
o.map(|e| &e.task),
&mut conflicts,
),
));
}
Outcome::Conflict(reason) => {
match (o, t) {
(Some(o), Some(t)) => {
conflicts.push(Conflict {
key: key.clone(),
reason,
theirs: own_lines(&t.task),
});
resolved.push((
section_for(b, Some(o), Some(t)),
merged_task(&o.task, b.map(|e| &e.task), Some(&t.task), &mut conflicts),
));
}
(Some(o), None) => {
conflicts.push(Conflict {
key: key.clone(),
reason,
theirs: Vec::new(),
});
resolved.push((o.section, o.task.clone()));
}
(None, Some(t)) => {
took_theirs += 1;
resolved.push((t.section, t.task.clone()));
}
(None, None) => {}
}
}
}
}
Reconciled {
track: rebuild(ours, theirs, resolved),
conflicts,
took_theirs,
deleted,
}
}
#[derive(Debug)]
pub struct ReconciledInbox {
pub inbox: Inbox,
pub took_theirs: usize,
pub deleted: usize,
}
impl ReconciledInbox {
pub fn changed_anything(&self) -> bool {
self.took_theirs > 0 || self.deleted > 0
}
}
pub fn reconcile_inbox(base: &Inbox, ours: &Inbox, theirs: &Inbox) -> ReconciledInbox {
let base_counts = counts(&base.items);
let our_counts = counts(&ours.items);
let their_counts = counts(&theirs.items);
let mut wanted: HashMap<String, usize> = HashMap::new();
for key in our_counts
.keys()
.chain(their_counts.keys())
.chain(base_counts.keys())
{
if wanted.contains_key(key) {
continue;
}
let b = *base_counts.get(key).unwrap_or(&0) as isize;
let o = *our_counts.get(key).unwrap_or(&0) as isize;
let t = *their_counts.get(key).unwrap_or(&0) as isize;
wanted.insert(key.clone(), (o + t - b).max(0) as usize);
}
let mut remaining = wanted.clone();
let mut items: Vec<InboxItem> = Vec::new();
let mut deleted = 0usize;
for item in &ours.items {
let key = item_key(item);
match remaining.get_mut(&key) {
Some(n) if *n > 0 => {
*n -= 1;
items.push(item.clone());
}
_ => deleted += 1,
}
}
let mut took_theirs = 0usize;
for item in &theirs.items {
let key = item_key(item);
if let Some(n) = remaining.get_mut(&key)
&& *n > 0
{
*n -= 1;
items.push(item.clone());
took_theirs += 1;
}
}
let header_lines = if ours.header_lines == base.header_lines {
theirs.header_lines.clone()
} else {
ours.header_lines.clone()
};
ReconciledInbox {
inbox: Inbox {
header_lines,
items,
source_lines: ours.source_lines.clone(),
},
took_theirs,
deleted,
}
}
fn counts(items: &[InboxItem]) -> HashMap<String, usize> {
let mut out: HashMap<String, usize> = HashMap::new();
for item in items {
*out.entry(item_key(item)).or_default() += 1;
}
out
}
fn item_key(item: &InboxItem) -> String {
format!(
"{}\u{1}{}\u{1}{}",
item.title,
item.tags.join(","),
item.body.as_deref().unwrap_or("")
)
}
enum Outcome {
Ours,
Theirs,
Delete,
Conflict(ConflictReason),
}
fn decide(b: Option<&Entry>, o: Option<&Entry>, t: Option<&Entry>) -> Outcome {
match (b, o, t) {
(_, None, None) => Outcome::Delete,
(None, Some(_), None) => Outcome::Ours,
(None, None, Some(_)) => Outcome::Theirs,
(None, Some(o), Some(t)) => {
if same(o, t) {
Outcome::Ours
} else {
Outcome::Conflict(ConflictReason::BothEdited)
}
}
(Some(b), Some(o), None) => {
if same(b, o) {
Outcome::Delete
} else {
Outcome::Conflict(ConflictReason::EditedAndDeleted)
}
}
(Some(b), None, Some(t)) => {
if same(b, t) {
Outcome::Delete
} else {
Outcome::Conflict(ConflictReason::DeletedAndEdited)
}
}
(Some(b), Some(o), Some(t)) => match (!same(b, o), !same(b, t)) {
(false, false) => Outcome::Ours,
(true, false) => Outcome::Ours,
(false, true) => Outcome::Theirs,
(true, true) => {
if same(o, t) {
Outcome::Ours
} else {
Outcome::Conflict(ConflictReason::BothEdited)
}
}
},
}
}
fn section_for(b: Option<&Entry>, o: Option<&Entry>, t: Option<&Entry>) -> SectionKind {
match (b, o, t) {
(Some(b), Some(o), Some(t)) if o.section == b.section && t.section != b.section => {
t.section
}
(_, Some(o), _) => o.section,
(_, None, Some(t)) => t.section,
(Some(b), None, None) => b.section,
(None, None, None) => SectionKind::Backlog,
}
}
fn merged_task(
winner: &Task,
base: Option<&Task>,
other: Option<&Task>,
conflicts: &mut Vec<Conflict>,
) -> Task {
let mut out = winner.clone();
let Some(other) = other else {
return out;
};
let empty: Vec<Task> = Vec::new();
let base_subs = base.map(|t| &t.subtasks).unwrap_or(&empty);
let (subs, mut sub_conflicts) =
reconcile_task_lists(base_subs, &winner.subtasks, &other.subtasks);
out.subtasks = subs;
conflicts.append(&mut sub_conflicts);
out
}
fn reconcile_task_lists(
base: &[Task],
ours: &[Task],
theirs: &[Task],
) -> (Vec<Task>, Vec<Conflict>) {
let bi = index_tasks(base, SectionKind::Backlog);
let oi = index_tasks(ours, SectionKind::Backlog);
let ti = index_tasks(theirs, SectionKind::Backlog);
let mut keys: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for k in oi
.order
.iter()
.chain(ti.order.iter())
.chain(bi.order.iter())
{
if seen.insert(k.clone()) {
keys.push(k.clone());
}
}
let mut out = Vec::new();
let mut conflicts = Vec::new();
for key in &keys {
let b = bi.entries.get(key);
let o = oi.entries.get(key);
let t = ti.entries.get(key);
match decide(b, o, t) {
Outcome::Delete => {}
Outcome::Ours => {
if let Some(o) = o {
out.push(merged_task(
&o.task,
b.map(|e| &e.task),
t.map(|e| &e.task),
&mut conflicts,
));
}
}
Outcome::Theirs => {
if let Some(t) = t {
out.push(merged_task(
&t.task,
b.map(|e| &e.task),
o.map(|e| &e.task),
&mut conflicts,
));
}
}
Outcome::Conflict(reason) => match (o, t) {
(Some(o), t) => {
conflicts.push(Conflict {
key: key.clone(),
reason,
theirs: t.map(|e| own_lines(&e.task)).unwrap_or_default(),
});
out.push(merged_task(
&o.task,
b.map(|e| &e.task),
t.map(|e| &e.task),
&mut conflicts,
));
}
(None, Some(t)) => out.push(t.task.clone()),
(None, None) => {}
},
}
}
(out, conflicts)
}
struct Entry {
section: SectionKind,
task: Task,
}
struct Index {
entries: HashMap<String, Entry>,
order: Vec<String>,
}
fn index(track: &Track) -> Index {
let mut entries = HashMap::new();
let mut order = Vec::new();
for node in &track.nodes {
if let TrackNode::Section { kind, tasks, .. } = node {
for task in tasks {
let key = task_key(task);
if entries
.insert(
key.clone(),
Entry {
section: *kind,
task: task.clone(),
},
)
.is_none()
{
order.push(key);
}
}
}
}
Index { entries, order }
}
fn index_tasks(tasks: &[Task], section: SectionKind) -> Index {
let mut entries = HashMap::new();
let mut order = Vec::new();
for task in tasks {
let key = task_key(task);
if entries
.insert(
key.clone(),
Entry {
section,
task: task.clone(),
},
)
.is_none()
{
order.push(key);
}
}
Index { entries, order }
}
fn task_key(task: &Task) -> String {
match &task.id {
Some(id) => format!("#{id}"),
None => format!("~{}", task.title),
}
}
fn ambiguous_keys(tracks: &[&Track]) -> std::collections::HashSet<String> {
let mut ambiguous = std::collections::HashSet::new();
for track in tracks {
let mut counts: HashMap<String, usize> = HashMap::new();
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
for task in tasks {
if task.id.is_none() {
*counts.entry(task_key(task)).or_default() += 1;
}
}
}
}
for (key, n) in counts {
if n > 1 {
ambiguous.insert(key);
}
}
}
ambiguous
}
fn own_lines(task: &Task) -> Vec<String> {
let mut bare = task.clone();
bare.subtasks.clear();
crate::parse::serialize_tasks(std::slice::from_ref(&bare), 0)
}
fn same(a: &Entry, b: &Entry) -> bool {
a.section == b.section && same_content(&a.task, &b.task)
}
fn same_content(a: &Task, b: &Task) -> bool {
a.state == b.state
&& a.title == b.title
&& a.tags == b.tags
&& a.metadata == b.metadata
&& a.leading_lines == b.leading_lines
&& a.id.as_ref().map(|i| i.to_string()) == b.id.as_ref().map(|i| i.to_string())
}
fn rebuild(ours: &Track, theirs: &Track, resolved: Vec<(SectionKind, Task)>) -> Track {
let mut track = ours.clone();
let mut by_section: HashMap<SectionKind, Vec<Task>> = HashMap::new();
for (kind, task) in resolved {
by_section.entry(kind).or_default().push(task);
}
for kind in [SectionKind::Backlog, SectionKind::Parked, SectionKind::Done] {
if by_section.contains_key(&kind) && track.section_tasks_mut(kind).is_none() {
track.ensure_section(kind);
if let Some(their_header) = section_header(theirs, kind)
&& let Some(node) = track
.nodes
.iter_mut()
.find(|n| matches!(n, TrackNode::Section { kind: k, .. } if *k == kind))
&& let TrackNode::Section { header_lines, .. } = node
{
*header_lines = their_header;
}
}
}
for node in &mut track.nodes {
if let TrackNode::Section { kind, tasks, .. } = node {
*tasks = by_section.remove(kind).unwrap_or_default();
}
}
track
}
fn section_header(track: &Track, kind: SectionKind) -> Option<Vec<String>> {
track.nodes.iter().find_map(|n| match n {
TrackNode::Section {
kind: k,
header_lines,
..
} if *k == kind => Some(header_lines.clone()),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::{parse_track, serialize_track};
fn t(text: &str) -> Track {
parse_track(text)
}
const BASE: &str = "\
# A
## Backlog
- [ ] `A-001` One
- [ ] `A-002` Two
## Done
";
#[test]
fn independent_additions_both_survive() {
let base = t(BASE);
let ours = t(
"# A\n\n## Backlog\n\n- [ ] `A-001` One, edited here\n- [ ] `A-002` Two\n\n## Done\n",
);
let theirs = t(
"# A\n\n## Backlog\n\n- [ ] `A-001` One\n- [ ] `A-002` Two\n- [ ] `A-003` Three\n\n## Done\n",
);
let r = reconcile_track(&base, &ours, &theirs);
let out = serialize_track(&r.track);
assert!(out.contains("One, edited here"), "our edit: {out}");
assert!(out.contains("A-003` Three"), "their addition: {out}");
assert!(r.conflicts.is_empty(), "{:?}", r.conflicts);
}
#[test]
fn their_state_change_to_an_untouched_task_is_taken() {
let base = t(BASE);
let ours = t(
"# A\n\n## Backlog\n\n- [ ] `A-001` One, edited here\n- [ ] `A-002` Two\n\n## Done\n",
);
let theirs = t("# A\n\n## Backlog\n\n- [ ] `A-001` One\n\n## Done\n\n- [x] `A-002` Two\n");
let r = reconcile_track(&base, &ours, &theirs);
let out = serialize_track(&r.track);
assert!(out.contains("One, edited here"), "{out}");
let done = r.track.done();
assert_eq!(done.len(), 1, "their move to Done should land: {out}");
assert_eq!(done[0].title, "Two");
assert!(r.conflicts.is_empty(), "{:?}", r.conflicts);
}
#[test]
fn our_edit_wins_over_an_untouched_task_on_their_side() {
let base = t(BASE);
let ours = t("# A\n\n## Backlog\n\n- [ ] `A-001` Ours\n- [ ] `A-002` Two\n\n## Done\n");
let theirs = t(BASE);
let r = reconcile_track(&base, &ours, &theirs);
assert!(serialize_track(&r.track).contains("Ours"));
assert_eq!(r.took_theirs, 0);
}
#[test]
fn a_genuine_conflict_keeps_ours_and_reports_theirs() {
let base = t(BASE);
let ours = t("# A\n\n## Backlog\n\n- [ ] `A-001` Ours\n- [ ] `A-002` Two\n\n## Done\n");
let theirs = t("# A\n\n## Backlog\n\n- [ ] `A-001` Theirs\n- [ ] `A-002` Two\n\n## Done\n");
let r = reconcile_track(&base, &ours, &theirs);
assert!(serialize_track(&r.track).contains("Ours"));
assert_eq!(r.conflicts.len(), 1);
assert_eq!(r.conflicts[0].reason, ConflictReason::BothEdited);
assert!(
r.conflicts[0].theirs.join("\n").contains("Theirs"),
"their version must be preserved for the log: {:?}",
r.conflicts[0].theirs
);
}
#[test]
fn a_deletion_both_sides_agree_on_is_applied() {
let base = t(BASE);
let ours = t(BASE);
let theirs = t("# A\n\n## Backlog\n\n- [ ] `A-001` One\n\n## Done\n");
let r = reconcile_track(&base, &ours, &theirs);
let out = serialize_track(&r.track);
assert!(!out.contains("A-002"), "their deletion should apply: {out}");
assert_eq!(r.deleted, 1);
}
#[test]
fn our_edit_beats_their_delete() {
let base = t(BASE);
let ours =
t("# A\n\n## Backlog\n\n- [ ] `A-001` One\n- [ ] `A-002` Two, edited\n\n## Done\n");
let theirs = t("# A\n\n## Backlog\n\n- [ ] `A-001` One\n\n## Done\n");
let r = reconcile_track(&base, &ours, &theirs);
let out = serialize_track(&r.track);
assert!(out.contains("Two, edited"), "{out}");
assert_eq!(r.conflicts[0].reason, ConflictReason::EditedAndDeleted);
}
#[test]
fn their_edit_beats_our_delete() {
let base = t(BASE);
let ours = t("# A\n\n## Backlog\n\n- [ ] `A-001` One\n\n## Done\n");
let theirs =
t("# A\n\n## Backlog\n\n- [ ] `A-001` One\n- [ ] `A-002` Two, edited\n\n## Done\n");
let r = reconcile_track(&base, &ours, &theirs);
let out = serialize_track(&r.track);
assert!(out.contains("Two, edited"), "{out}");
}
#[test]
fn subtasks_merge_independently_of_their_parent() {
let base =
t("# A\n\n## Backlog\n\n- [ ] `A-001` Parent\n - [ ] `A-001.1` Child\n\n## Done\n");
let ours = t(
"# A\n\n## Backlog\n\n- [ ] `A-001` Parent, ours\n - [ ] `A-001.1` Child\n\n## Done\n",
);
let theirs = t(
"# A\n\n## Backlog\n\n- [ ] `A-001` Parent\n - [ ] `A-001.1` Child\n - [ ] `A-001.2` Second child\n\n## Done\n",
);
let r = reconcile_track(&base, &ours, &theirs);
let out = serialize_track(&r.track);
assert!(out.contains("Parent, ours"), "{out}");
assert!(out.contains("A-001.2` Second child"), "{out}");
assert!(r.conflicts.is_empty(), "{:?}", r.conflicts);
}
#[test]
fn merging_identical_sides_changes_nothing() {
let base = t(BASE);
let r = reconcile_track(&base, &base.clone(), &base.clone());
assert_eq!(serialize_track(&r.track), serialize_track(&base));
assert!(!r.changed_anything());
assert!(r.conflicts.is_empty());
}
#[test]
fn taking_their_side_wholesale_reproduces_it() {
let base = t(BASE);
let theirs = t(
"# A\n\n## Backlog\n\n- [ ] `A-001` One\n- [ ] `A-002` Two\n- [ ] `A-003` Three\n\n## Done\n",
);
let r = reconcile_track(&base, &base.clone(), &theirs);
assert_eq!(serialize_track(&r.track), serialize_track(&theirs));
}
#[test]
fn a_repeated_untitled_task_is_not_guessed_at() {
let base = t("# A\n\n## Backlog\n\n- [ ] Same\n- [ ] Same\n\n## Done\n");
let ours = t("# A\n\n## Backlog\n\n- [ ] Same\n- [ ] Same\n\n## Done\n");
let theirs = t("# A\n\n## Backlog\n\n- [ ] Same\n- [ ] Different\n\n## Done\n");
let r = reconcile_track(&base, &ours, &theirs);
assert!(serialize_track(&r.track).contains("Same"));
}
fn ib(text: &str) -> Inbox {
crate::parse::parse_inbox(text).0
}
fn titles(inbox: &Inbox) -> Vec<&str> {
inbox.items.iter().map(|i| i.title.as_str()).collect()
}
const INBOX_BASE: &str = "# Inbox\n\n- one\n- two\n";
#[test]
fn captures_on_both_sides_survive() {
let base = ib(INBOX_BASE);
let ours = ib("# Inbox\n\n- one\n- two\n- ours\n");
let theirs = ib("# Inbox\n\n- one\n- two\n- theirs\n");
let r = reconcile_inbox(&base, &ours, &theirs);
assert_eq!(titles(&r.inbox), vec!["one", "two", "ours", "theirs"]);
assert_eq!(r.took_theirs, 1);
}
#[test]
fn their_removal_is_applied() {
let base = ib(INBOX_BASE);
let ours = ib(INBOX_BASE);
let theirs = ib("# Inbox\n\n- one\n");
let r = reconcile_inbox(&base, &ours, &theirs);
assert_eq!(titles(&r.inbox), vec!["one"]);
assert_eq!(r.deleted, 1);
}
#[test]
fn our_removal_is_kept_when_they_did_not_touch_it() {
let base = ib(INBOX_BASE);
let ours = ib("# Inbox\n\n- one\n");
let theirs = ib(INBOX_BASE);
let r = reconcile_inbox(&base, &ours, &theirs);
assert_eq!(titles(&r.inbox), vec!["one"]);
}
#[test]
fn our_edit_does_not_resurrect_the_original() {
let base = ib(INBOX_BASE);
let ours = ib("# Inbox\n\n- one, edited\n- two\n");
let theirs = ib(INBOX_BASE);
let r = reconcile_inbox(&base, &ours, &theirs);
assert_eq!(titles(&r.inbox), vec!["one, edited", "two"]);
}
#[test]
fn their_edit_is_taken() {
let base = ib(INBOX_BASE);
let ours = ib(INBOX_BASE);
let theirs = ib("# Inbox\n\n- one, theirs\n- two\n");
let r = reconcile_inbox(&base, &ours, &theirs);
assert_eq!(titles(&r.inbox), vec!["two", "one, theirs"]);
}
#[test]
fn a_double_edit_keeps_both_rather_than_choosing() {
let base = ib(INBOX_BASE);
let ours = ib("# Inbox\n\n- one, ours\n- two\n");
let theirs = ib("# Inbox\n\n- one, theirs\n- two\n");
let r = reconcile_inbox(&base, &ours, &theirs);
let t = titles(&r.inbox);
assert!(t.contains(&"one, ours"), "{t:?}");
assert!(t.contains(&"one, theirs"), "{t:?}");
}
#[test]
fn identical_items_are_counted_not_deduplicated() {
let base = ib("# Inbox\n\n- same\n");
let ours = ib("# Inbox\n\n- same\n");
let theirs = ib("# Inbox\n\n- same\n- same\n");
let r = reconcile_inbox(&base, &ours, &theirs);
assert_eq!(
titles(&r.inbox),
vec!["same", "same"],
"their capture lands"
);
}
#[test]
fn tags_distinguish_two_items_with_the_same_title() {
let base = ib("# Inbox\n\n- note #a\n");
let ours = ib("# Inbox\n\n- note #a\n");
let theirs = ib("# Inbox\n\n- note #b\n");
let r = reconcile_inbox(&base, &ours, &theirs);
assert_eq!(r.inbox.items.len(), 1);
assert_eq!(r.inbox.items[0].tags, vec!["b".to_string()]);
}
#[test]
fn merging_identical_inboxes_changes_nothing() {
let base = ib(INBOX_BASE);
let r = reconcile_inbox(&base, &base.clone(), &base.clone());
assert_eq!(titles(&r.inbox), vec!["one", "two"]);
assert!(!r.changed_anything());
}
#[test]
fn an_inbox_we_did_not_touch_takes_their_side_wholesale() {
let base = ib(INBOX_BASE);
let theirs = ib("# Inbox\n\n- one\n- two\n- three\n");
let r = reconcile_inbox(&base, &base.clone(), &theirs);
assert_eq!(titles(&r.inbox), vec!["one", "two", "three"]);
}
#[test]
fn a_task_only_we_have_is_kept() {
let base = t(BASE);
let ours = t(
"# A\n\n## Backlog\n\n- [ ] `A-001` One\n- [ ] `A-002` Two\n- [ ] `A-009` Ours only\n\n## Done\n",
);
let theirs = t(BASE);
let r = reconcile_track(&base, &ours, &theirs);
assert!(serialize_track(&r.track).contains("Ours only"));
}
}