use std::collections::HashMap;
use indexmap::IndexMap;
use crate::model::config::{ProjectConfig, TrackConfig};
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,
}
impl ConflictReason {
pub fn slug(self) -> &'static str {
match self {
ConflictReason::BothEdited => "both-edited",
ConflictReason::EditedAndDeleted => "edited-and-deleted",
ConflictReason::DeletedAndEdited => "deleted-and-edited",
ConflictReason::AmbiguousTitle => "ambiguous-title",
}
}
pub fn describe(self) -> &'static str {
match self {
ConflictReason::BothEdited => "both sides edited it differently; kept ours",
ConflictReason::EditedAndDeleted => "we edited it, they removed it; kept ours",
ConflictReason::DeletedAndEdited => "we removed it, they edited it; took theirs",
ConflictReason::AmbiguousTitle => {
"two untitled-ID tasks share a title, so identity is ambiguous; kept ours"
}
}
}
}
#[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;
let mut orphaned: Vec<String> = Vec::new();
for item in &ours.items {
let key = item_key(item);
match remaining.get_mut(&key) {
Some(n) if *n > 0 => {
*n -= 1;
let mut kept = item.clone();
if !orphaned.is_empty() {
let mut prefix = std::mem::take(&mut orphaned);
prefix.extend(kept.trailing_lines);
kept.trailing_lines = prefix;
}
items.push(kept);
}
_ => {
deleted += 1;
orphaned.extend(item.trailing_lines.iter().cloned());
}
}
}
if !orphaned.is_empty()
&& let Some(last) = items.last_mut()
{
last.trailing_lines.extend(std::mem::take(&mut orphaned));
}
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 mut header_lines = if ours.header_lines == base.header_lines {
theirs.header_lines.clone()
} else {
ours.header_lines.clone()
};
header_lines.extend(orphaned);
ReconciledInbox {
inbox: Inbox {
header_lines,
items,
eol: ours.eol,
},
took_theirs,
deleted,
}
}
#[derive(Debug, Clone)]
pub struct ConfigConflict {
pub key: String,
pub reason: ConfigConflictReason,
pub set_aside: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigConflictReason {
BothEdited,
BothAdded,
RemovedAndEdited,
EditedAndRemoved,
EditedAndMoved,
}
impl ConfigConflictReason {
pub fn slug(self) -> &'static str {
match self {
ConfigConflictReason::BothEdited => "both-edited",
ConfigConflictReason::BothAdded => "both-added",
ConfigConflictReason::RemovedAndEdited => "removed-and-edited",
ConfigConflictReason::EditedAndRemoved => "edited-and-removed",
ConfigConflictReason::EditedAndMoved => "edited-and-moved",
}
}
pub fn describe(self) -> &'static str {
match self {
ConfigConflictReason::BothEdited => "both sides changed it differently; kept ours",
ConfigConflictReason::BothAdded => "both sides added it; kept ours",
ConfigConflictReason::RemovedAndEdited => "we removed it, they changed it; kept ours",
ConfigConflictReason::EditedAndRemoved => {
"we changed it, they removed it; kept the version on disk"
}
ConfigConflictReason::EditedAndMoved => {
"both sides changed it, and theirs moved the file; kept the version on disk"
}
}
}
pub fn ours_lost(self) -> bool {
matches!(
self,
ConfigConflictReason::EditedAndRemoved | ConfigConflictReason::EditedAndMoved
)
}
}
fn state_moves_the_file(from: &str, to: &str) -> bool {
(from == "archived") != (to == "archived")
}
#[derive(Debug, Default)]
pub struct ReconciledConfig {
pub conflicts: Vec<ConfigConflict>,
pub took_theirs: usize,
}
impl ReconciledConfig {
pub fn rejected(&self) -> impl Iterator<Item = &ConfigConflict> {
self.conflicts.iter().filter(|c| c.reason.ours_lost())
}
}
pub fn reconcile_config(
base: &ProjectConfig,
ours: &ProjectConfig,
theirs: &ProjectConfig,
doc: &mut toml_edit::DocumentMut,
) -> ReconciledConfig {
let mut out = ReconciledConfig::default();
reconcile_track_entries(base, ours, theirs, doc, &mut out);
reconcile_string_map(
"prefix",
&["ids", "prefixes"],
&base.ids.prefixes,
&ours.ids.prefixes,
&theirs.ids.prefixes,
doc,
crate::io::config_io::set_prefix,
crate::io::config_io::remove_prefix,
&mut out,
);
reconcile_string_map(
"tag colour",
&["ui", "tag_colors"],
&base.ui.tag_colors,
&ours.ui.tag_colors,
&theirs.ui.tag_colors,
doc,
crate::io::config_io::set_tag_color,
crate::io::config_io::clear_tag_color,
&mut out,
);
reconcile_cc_focus(base, ours, theirs, doc, &mut out);
out
}
fn reconcile_track_entries(
base: &ProjectConfig,
ours: &ProjectConfig,
theirs: &ProjectConfig,
doc: &mut toml_edit::DocumentMut,
out: &mut ReconciledConfig,
) {
let by_id = |c: &ProjectConfig| -> HashMap<String, TrackConfig> {
c.tracks.iter().map(|t| (t.id.clone(), t.clone())).collect()
};
let (bi, oi, ti) = (by_id(base), by_id(ours), by_id(theirs));
for id in union_keys(
ours.tracks.iter().map(|t| &t.id),
base.tracks.iter().map(|t| &t.id),
theirs.tracks.iter().map(|t| &t.id),
) {
let (b, o, t) = (bi.get(&id), oi.get(&id), ti.get(&id));
match (b, o) {
(None, Some(o)) => match t {
None => {
let index = ours.tracks.iter().position(|t| t.id == id).unwrap_or(0);
crate::io::config_io::insert_track_in_config(doc, o, index);
}
Some(t) if t == o => {}
Some(t) => {
out.conflicts.push(ConfigConflict {
key: format!("track {id:?}"),
reason: ConfigConflictReason::BothAdded,
set_aside: fragment(t),
});
apply_track_fields(doc, &id, o);
}
},
(Some(b), None) => match t {
None => {}
Some(t) => {
if t != b {
out.conflicts.push(ConfigConflict {
key: format!("track {id:?}"),
reason: ConfigConflictReason::RemovedAndEdited,
set_aside: fragment(t),
});
}
crate::io::config_io::remove_track_from_config(doc, &id);
}
},
(Some(b), Some(o)) => {
if o == b {
match t {
None => out.took_theirs += 1,
Some(t) if t != b => out.took_theirs += 1,
Some(_) => {}
}
continue;
}
let Some(t) = t else {
out.conflicts.push(ConfigConflict {
key: format!("track {id:?}"),
reason: ConfigConflictReason::EditedAndRemoved,
set_aside: fragment(o),
});
continue;
};
for (field, bv, ov, tv) in [
("name", &b.name, &o.name, &t.name),
("state", &b.state, &o.state, &t.state),
("file", &b.file, &o.file, &t.file),
] {
if ov == bv {
if tv != bv {
out.took_theirs += 1;
}
continue;
}
if tv == ov {
continue;
}
if tv != bv {
if field == "state"
&& state_moves_the_file(bv, tv)
&& !state_moves_the_file(bv, ov)
{
out.conflicts.push(ConfigConflict {
key: format!("track {id:?} {field}"),
reason: ConfigConflictReason::EditedAndMoved,
set_aside: format!("{field} = {ov:?}"),
});
out.took_theirs += 1;
continue;
}
out.conflicts.push(ConfigConflict {
key: format!("track {id:?} {field}"),
reason: ConfigConflictReason::BothEdited,
set_aside: format!("{field} = {tv:?}"),
});
}
crate::io::config_io::set_track_field(doc, &id, field, ov);
}
}
(None, None) => out.took_theirs += 1,
}
}
if we_reordered(base, ours) {
let mut order: Vec<String> = ours.tracks.iter().map(|t| t.id.clone()).collect();
for t in &theirs.tracks {
if !oi.contains_key(&t.id) && !bi.contains_key(&t.id) {
order.push(t.id.clone());
}
}
crate::io::config_io::set_track_order(doc, &order);
}
}
fn we_reordered(base: &ProjectConfig, ours: &ProjectConfig) -> bool {
let base_ids: Vec<&String> = base.tracks.iter().map(|t| &t.id).collect();
let common_in_base: Vec<&String> = base_ids
.iter()
.copied()
.filter(|id| ours.tracks.iter().any(|t| &t.id == *id))
.collect();
let common_in_ours: Vec<&String> = ours
.tracks
.iter()
.map(|t| &t.id)
.filter(|id| base_ids.contains(id))
.collect();
if common_in_base != common_in_ours {
return true;
}
ours.tracks
.iter()
.enumerate()
.filter(|(_, t)| !base_ids.contains(&&t.id))
.any(|(i, _)| i + 1 < ours.tracks.len())
}
#[allow(clippy::too_many_arguments)]
fn reconcile_string_map(
label: &str,
path: &[&str],
base: &IndexMap<String, String>,
ours: &IndexMap<String, String>,
theirs: &IndexMap<String, String>,
doc: &mut toml_edit::DocumentMut,
set: fn(&mut toml_edit::DocumentMut, &str, &str),
clear: fn(&mut toml_edit::DocumentMut, &str),
out: &mut ReconciledConfig,
) {
let mut placed_a_key = false;
for key in union_keys(ours.keys(), base.keys(), theirs.keys()) {
let (b, o, t) = (base.get(&key), ours.get(&key), theirs.get(&key));
if o == b {
if t != b {
out.took_theirs += 1;
}
continue;
}
if t == o {
continue;
}
if t != b {
let (reason, set_aside) = match (o, t) {
(Some(o), None) => (ConfigConflictReason::EditedAndRemoved, o.clone()),
(None, Some(t)) => (ConfigConflictReason::RemovedAndEdited, t.clone()),
(Some(_), Some(t)) if b.is_none() => (ConfigConflictReason::BothAdded, t.clone()),
(_, t) => (
ConfigConflictReason::BothEdited,
t.cloned().unwrap_or_default(),
),
};
let ours_lost = reason.ours_lost();
out.conflicts.push(ConfigConflict {
key: format!("{label} {key:?}"),
reason,
set_aside,
});
if ours_lost {
continue;
}
}
match o {
Some(v) => {
if b.is_none() && t.is_none() {
let index = ours.get_index_of(&key);
placed_a_key |= index.is_some_and(|i| i + 1 < ours.len());
}
set(doc, &key, v);
}
None => clear(doc, &key),
}
}
if placed_a_key {
let mut order: Vec<String> = ours.keys().cloned().collect();
for key in theirs.keys() {
if !ours.contains_key(key) && !base.contains_key(key) {
order.push(key.clone());
}
}
crate::io::config_io::set_map_order(doc, path, &order);
}
}
fn reconcile_cc_focus(
base: &ProjectConfig,
ours: &ProjectConfig,
theirs: &ProjectConfig,
doc: &mut toml_edit::DocumentMut,
out: &mut ReconciledConfig,
) {
let (b, o, t) = (
base.agent.cc_focus.as_deref(),
ours.agent.cc_focus.as_deref(),
theirs.agent.cc_focus.as_deref(),
);
if o == b {
if t != b {
out.took_theirs += 1;
}
return;
}
if t == o {
return;
}
if t != b {
out.conflicts.push(ConfigConflict {
key: "cc_focus".to_string(),
reason: ConfigConflictReason::BothEdited,
set_aside: format!("cc_focus = {:?}", t.unwrap_or("")),
});
}
match o {
Some(v) => crate::io::config_io::set_cc_focus(doc, v),
None => crate::io::config_io::clear_cc_focus(doc),
}
}
fn union_keys<'a>(
ours: impl Iterator<Item = &'a String>,
base: impl Iterator<Item = &'a String>,
theirs: impl Iterator<Item = &'a String>,
) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for key in ours.chain(base).chain(theirs) {
if !seen.contains(key) {
seen.push(key.clone());
}
}
seen
}
fn fragment(t: &TrackConfig) -> String {
format!(
"[[tracks]]\nid = {:?}\nname = {:?}\nstate = {:?}\nfile = {:?}",
t.id, t.name, t.state, t.file
)
}
fn apply_track_fields(doc: &mut toml_edit::DocumentMut, id: &str, t: &TrackConfig) {
for (field, value) in [("name", &t.name), ("state", &t.state), ("file", &t.file)] {
crate::io::config_io::set_track_field(doc, id, field, value);
}
}
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 }
}
pub 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.trailing_lines == b.trailing_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 a_stranded_run_survives_their_deletion_of_its_anchor() {
let base = ib("# Inbox\n\n- one\n\nSTRANDED\n\n- two\n");
let ours = base.clone();
let theirs = ib("# Inbox\n\n- two\n");
let r = reconcile_inbox(&base, &ours, &theirs);
let out = crate::parse::serialize_inbox(&r.inbox);
assert!(out.contains("STRANDED"), "{out}");
assert!(!out.contains("- one"), "their delete still applies: {out}");
let (reread, _) = crate::parse::parse_inbox(&out);
assert_eq!(reread.items.len(), 1);
assert_eq!(reread.items[0].body, None, "not body: {out}");
}
#[test]
fn a_stranded_run_falls_back_to_the_header() {
let base = ib("# Inbox\n\n- one\n\nSTRANDED\n");
let ours = base.clone();
let theirs = ib("# Inbox\n");
let r = reconcile_inbox(&base, &ours, &theirs);
let out = crate::parse::serialize_inbox(&r.inbox);
assert!(out.contains("STRANDED"), "{out}");
}
#[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"));
}
mod config {
use super::*;
const CFG: &str = r#"# Frame project configuration
[project]
name = "test"
[agent]
cc_focus = "" # which track `fr ready --cc` looks at
# Tracks
# ------
# Each entry defines a workstream.
[[tracks]]
id = "api"
name = "API"
state = "active"
file = "tracks/api.md"
[[tracks]]
id = "ui"
name = "UI"
state = "active"
file = "tracks/ui.md"
[ids.prefixes]
api = "API"
ui = "UI"
"#;
fn cfg(text: &str) -> (ProjectConfig, toml_edit::DocumentMut) {
(toml::from_str(text).unwrap(), text.parse().unwrap())
}
fn merge(
base: &str,
ours: &str,
theirs: &str,
) -> (ReconciledConfig, String, ProjectConfig) {
let (base, _) = cfg(base);
let (ours, _) = cfg(ours);
let (theirs, mut doc) = cfg(theirs);
let r = reconcile_config(&base, &ours, &theirs, &mut doc);
let text = doc.to_string();
let parsed = toml::from_str(&text).unwrap();
(r, text, parsed)
}
fn ids(c: &ProjectConfig) -> Vec<&str> {
c.tracks.iter().map(|t| t.id.as_str()).collect()
}
fn with_track(text: &str, id: &str, name: &str) -> String {
format!(
"{text}\n[[tracks]]\nid = {id:?}\nname = {name:?}\nstate = \"active\"\nfile = \"tracks/{id}.md\"\n"
)
}
#[test]
fn a_track_only_they_added_survives_our_write() {
let theirs = with_track(CFG, "docs", "Docs");
let ours = CFG.replace(r#"name = "UI""#, r#"name = "Interface""#);
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(ids(&merged), vec!["api", "ui", "docs"]);
assert_eq!(merged.tracks[1].name, "Interface");
assert_eq!(r.took_theirs, 1);
assert!(r.conflicts.is_empty());
}
#[test]
fn comments_and_unmodelled_keys_survive_a_merge() {
let base = format!("{CFG}\n[experimental]\nnot_in_the_struct = true\n");
let ours = with_track(&base, "docs", "Docs");
let (_, text, _) = merge(&base, &ours, &base);
assert!(text.contains("# Frame project configuration"));
assert!(text.contains("# Each entry defines a workstream."));
assert!(text.contains("# which track `fr ready --cc` looks at"));
assert!(text.contains("not_in_the_struct = true"));
assert!(text.contains(r#"id = "docs""#));
}
#[test]
fn our_add_and_our_removal_both_apply() {
let ours = with_track(CFG, "docs", "Docs").replace(
"[[tracks]]\nid = \"ui\"\nname = \"UI\"\nstate = \"active\"\nfile = \"tracks/ui.md\"\n",
"",
);
let (r, _, merged) = merge(CFG, &ours, CFG);
assert_eq!(ids(&merged), vec!["api", "docs"]);
assert!(r.conflicts.is_empty());
}
#[test]
fn both_added_the_same_id_keeps_ours() {
let ours = with_track(CFG, "docs", "Our Docs");
let theirs = with_track(CFG, "docs", "Their Docs");
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(merged.tracks[2].name, "Our Docs");
assert_eq!(r.conflicts.len(), 1);
assert_eq!(r.conflicts[0].reason, ConfigConflictReason::BothAdded);
assert!(r.conflicts[0].set_aside.contains("Their Docs"));
assert_eq!(r.rejected().count(), 0);
}
#[test]
fn we_renamed_a_track_they_removed_and_ours_is_the_loser() {
let ours = CFG.replace(r#"name = "UI""#, r#"name = "Interface""#);
let theirs = CFG.replace(
"[[tracks]]\nid = \"ui\"\nname = \"UI\"\nstate = \"active\"\nfile = \"tracks/ui.md\"\n",
"",
);
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(ids(&merged), vec!["api"]);
assert_eq!(r.rejected().count(), 1);
assert_eq!(
r.conflicts[0].reason,
ConfigConflictReason::EditedAndRemoved
);
assert!(r.conflicts[0].set_aside.contains("Interface"));
}
#[test]
fn we_removed_a_track_they_edited_and_the_removal_stands() {
let ours = CFG.replace(
"[[tracks]]\nid = \"ui\"\nname = \"UI\"\nstate = \"active\"\nfile = \"tracks/ui.md\"\n",
"",
);
let theirs = CFG.replace(r#"name = "UI""#, r#"name = "Theirs""#);
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(ids(&merged), vec!["api"]);
assert_eq!(
r.conflicts[0].reason,
ConfigConflictReason::RemovedAndEdited
);
assert!(r.conflicts[0].set_aside.contains("Theirs"));
assert_eq!(r.rejected().count(), 0);
}
#[test]
fn we_renamed_and_they_shelved_the_same_track() {
let ours = CFG.replace(r#"name = "UI""#, r#"name = "Interface""#);
let theirs = CFG.replace(
"id = \"ui\"\nname = \"UI\"\nstate = \"active\"",
"id = \"ui\"\nname = \"UI\"\nstate = \"shelved\"",
);
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(merged.tracks[1].name, "Interface");
assert_eq!(merged.tracks[1].state, "shelved");
assert!(r.conflicts.is_empty());
assert_eq!(r.took_theirs, 1);
}
fn with_state(text: &str, id: &str, state: &str) -> String {
let block = text
.find(&format!("id = {id:?}\n"))
.expect("the track is in the config");
let line = block
+ text[block..]
.find("state = ")
.expect("the track has a state line");
let end = line + text[line..].find('\n').expect("the line ends");
format!("{}state = {state:?}{}", &text[..line], &text[end..])
}
#[test]
fn their_archive_beats_our_shelve() {
let ours = with_state(CFG, "ui", "shelved");
let theirs = with_state(CFG, "ui", "archived");
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(merged.tracks[1].state, "archived");
assert_eq!(r.conflicts.len(), 1);
assert_eq!(
r.conflicts[0].reason,
ConfigConflictReason::EditedAndMoved,
"ours is the version set aside, so the status line has to say so"
);
assert!(r.conflicts[0].set_aside.contains("shelved"));
assert!(r.conflicts[0].reason.ours_lost());
}
#[test]
fn our_archive_beats_their_shelve() {
let ours = with_state(CFG, "ui", "archived");
let theirs = with_state(CFG, "ui", "shelved");
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(merged.tracks[1].state, "archived");
assert_eq!(r.conflicts[0].reason, ConfigConflictReason::BothEdited);
assert!(r.conflicts[0].set_aside.contains("shelved"));
}
#[test]
fn both_crossing_the_archive_boundary_keeps_ours() {
let base = with_state(CFG, "ui", "archived");
let ours = with_state(CFG, "ui", "active");
let theirs = with_state(CFG, "ui", "shelved");
let (r, _, merged) = merge(&base, &ours, &theirs);
assert_eq!(merged.tracks[1].state, "active");
assert_eq!(r.conflicts[0].reason, ConfigConflictReason::BothEdited);
}
#[test]
fn both_renamed_the_same_track_keeps_ours() {
let ours = CFG.replace(r#"name = "UI""#, r#"name = "Ours""#);
let theirs = CFG.replace(r#"name = "UI""#, r#"name = "Theirs""#);
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(merged.tracks[1].name, "Ours");
assert_eq!(r.conflicts[0].reason, ConfigConflictReason::BothEdited);
assert!(r.conflicts[0].set_aside.contains("Theirs"));
}
#[test]
fn a_reorder_we_made_is_applied_and_their_new_track_is_kept() {
let ours = CFG
.replace(
"[[tracks]]\nid = \"api\"\nname = \"API\"\nstate = \"active\"\nfile = \"tracks/api.md\"\n\n",
"",
)
.replace(
"[ids.prefixes]",
"[[tracks]]\nid = \"api\"\nname = \"API\"\nstate = \"active\"\nfile = \"tracks/api.md\"\n\n[ids.prefixes]",
);
assert_eq!(ids(&cfg(&ours).0), vec!["ui", "api"]);
let theirs = with_track(CFG, "docs", "Docs");
let (_, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(ids(&merged), vec!["ui", "api", "docs"]);
}
#[test]
fn a_reorder_only_they_made_is_left_alone() {
let ours = CFG.replace(
r#"state = "active"
file = "tracks/ui.md""#,
r#"state = "shelved"
file = "tracks/ui.md""#,
);
let theirs = CFG
.replace(
"[[tracks]]\nid = \"api\"\nname = \"API\"\nstate = \"active\"\nfile = \"tracks/api.md\"\n\n",
"",
)
.replace(
"[ids.prefixes]",
"[[tracks]]\nid = \"api\"\nname = \"API\"\nstate = \"active\"\nfile = \"tracks/api.md\"\n\n[ids.prefixes]",
);
let (_, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(ids(&merged), vec!["ui", "api"]);
assert_eq!(merged.tracks[0].state, "shelved");
}
#[test]
fn prefixes_merge_by_key() {
let ours = CFG.replace("api = \"API\"", "api = \"AP\"");
let theirs = format!("{CFG}docs = \"DOC\"\n");
let (r, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(merged.ids.prefixes.get("api").unwrap(), "AP");
assert_eq!(merged.ids.prefixes.get("docs").unwrap(), "DOC");
assert!(r.conflicts.is_empty());
}
#[test]
fn a_tag_colour_only_they_set_survives() {
let theirs = format!("{CFG}\n[ui.tag_colors]\nbug = \"#FF4444\"\n");
let ours = CFG.replace(r#"name = "UI""#, r#"name = "Interface""#);
let (_, _, merged) = merge(CFG, &ours, &theirs);
assert_eq!(merged.ui.tag_colors.get("bug").unwrap(), "#FF4444");
assert_eq!(merged.tracks[1].name, "Interface");
}
#[test]
fn cc_focus_is_ours_when_we_changed_it_and_theirs_when_we_did_not() {
let ours = CFG.replace(r#"cc_focus = """#, r#"cc_focus = "api""#);
let (_, _, merged) = merge(CFG, &ours, CFG);
assert_eq!(merged.agent.cc_focus.as_deref(), Some("api"));
let theirs = CFG.replace(r#"cc_focus = """#, r#"cc_focus = "ui""#);
let (r, _, merged) = merge(CFG, CFG, &theirs);
assert_eq!(merged.agent.cc_focus.as_deref(), Some("ui"));
assert_eq!(r.took_theirs, 1);
}
#[test]
fn an_empty_delta_changes_nothing() {
let (r, text, _) = merge(CFG, CFG, CFG);
assert_eq!(text, CFG);
assert_eq!(r.took_theirs, 0);
assert!(r.conflicts.is_empty());
}
}
}