use std::path::Path;
use crate::model::task::{Metadata, Task};
use crate::model::track::{Track, TrackNode};
use crate::ops::reconcile::{self, Conflict};
use crate::parse::{parse_inbox, parse_track, serialize_inbox, serialize_track};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
Track,
Inbox,
}
impl FileKind {
pub fn label(self) -> &'static str {
match self {
FileKind::Track => "track",
FileKind::Inbox => "inbox",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"track" => Some(FileKind::Track),
"inbox" => Some(FileKind::Inbox),
_ => None,
}
}
}
#[derive(Debug)]
pub struct MergeReport {
pub kind: FileKind,
pub took_theirs: usize,
pub deleted: usize,
pub conflicts: Vec<Conflict>,
}
impl MergeReport {
pub fn is_clean(&self) -> bool {
self.conflicts.is_empty()
}
}
pub fn kind_for_path(path: &str) -> Option<FileKind> {
let normalized = path.replace('\\', "/");
let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
let file = components.last()?;
if !file.ends_with(".md") {
return None;
}
if *file == "inbox.md" {
return Some(FileKind::Inbox);
}
let in_track_dir = components
.iter()
.rev()
.skip(1)
.any(|c| *c == "tracks" || *c == "archive");
in_track_dir.then_some(FileKind::Track)
}
pub fn merge_track_text(
base: &str,
ours: &str,
theirs: &str,
stamp: &str,
) -> (String, MergeReport) {
let mut result =
reconcile::reconcile_track(&parse_track(base), &parse_track(ours), &parse_track(theirs));
mark_conflicts(&mut result.track, &result.conflicts, stamp);
(
serialize_track(&result.track),
MergeReport {
kind: FileKind::Track,
took_theirs: result.took_theirs,
deleted: result.deleted,
conflicts: result.conflicts,
},
)
}
fn mark_conflicts(track: &mut Track, conflicts: &[Conflict], stamp: &str) {
if conflicts.is_empty() {
return;
}
let wanted: std::collections::HashMap<&str, &Conflict> =
conflicts.iter().map(|c| (c.key.as_str(), c)).collect();
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node {
mark_in_tasks(tasks, &wanted, stamp);
}
}
}
fn mark_in_tasks(
tasks: &mut [Task],
wanted: &std::collections::HashMap<&str, &Conflict>,
stamp: &str,
) {
for task in tasks {
if let Some(conflict) = wanted.get(reconcile::task_key(task).as_str()) {
task.metadata
.retain(|m| !matches!(m, Metadata::Conflict(_)));
task.metadata.push(Metadata::Conflict(format!(
"{} {stamp}",
conflict.reason.slug()
)));
task.dirty = true;
}
mark_in_tasks(&mut task.subtasks, wanted, stamp);
}
}
pub fn merge_inbox_text(base: &str, ours: &str, theirs: &str) -> (String, MergeReport) {
let (base, _) = parse_inbox(base);
let (ours, _) = parse_inbox(ours);
let (theirs, _) = parse_inbox(theirs);
let result = reconcile::reconcile_inbox(&base, &ours, &theirs);
(
serialize_inbox(&result.inbox),
MergeReport {
kind: FileKind::Inbox,
took_theirs: result.took_theirs,
deleted: result.deleted,
conflicts: Vec::new(),
},
)
}
pub fn merge_text(
kind: FileKind,
base: &str,
ours: &str,
theirs: &str,
stamp: &str,
) -> (String, MergeReport) {
match kind {
FileKind::Track => merge_track_text(base, ours, theirs, stamp),
FileKind::Inbox => merge_inbox_text(base, ours, theirs),
}
}
#[derive(Debug)]
pub enum MergeFileError {
Read {
path: std::path::PathBuf,
source: std::io::Error,
},
Write {
path: std::path::PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for MergeFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MergeFileError::Read { path, source } => {
write!(f, "cannot read {}: {}", path.display(), source)
}
MergeFileError::Write { path, source } => {
write!(f, "cannot write {}: {}", path.display(), source)
}
}
}
}
impl std::error::Error for MergeFileError {}
pub fn merge_files(
kind: FileKind,
base: &Path,
ours: &Path,
theirs: &Path,
stamp: &str,
) -> Result<MergeReport, MergeFileError> {
let base_text = match std::fs::read_to_string(base) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(MergeFileError::Read {
path: base.to_path_buf(),
source: e,
});
}
};
let ours_text = std::fs::read_to_string(ours).map_err(|e| MergeFileError::Read {
path: ours.to_path_buf(),
source: e,
})?;
let theirs_text = std::fs::read_to_string(theirs).map_err(|e| MergeFileError::Read {
path: theirs.to_path_buf(),
source: e,
})?;
let (merged, report) = merge_text(kind, &base_text, &ours_text, &theirs_text, stamp);
crate::io::recovery::atomic_write(ours, merged.as_bytes()).map_err(|e| {
MergeFileError::Write {
path: ours.to_path_buf(),
source: e,
}
})?;
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
const STAMP: &str = "2026-08-03T04:08:38Z";
#[test]
fn track_paths_are_recognized() {
assert_eq!(kind_for_path("frame/tracks/main.md"), Some(FileKind::Track));
assert_eq!(
kind_for_path("frame/archive/main.md"),
Some(FileKind::Track)
);
assert_eq!(
kind_for_path("frame/archive/_tracks/old.md"),
Some(FileKind::Track)
);
assert_eq!(
kind_for_path("sub/dir/frame/tracks/main.md"),
Some(FileKind::Track)
);
}
#[test]
fn inbox_is_recognized_by_name() {
assert_eq!(kind_for_path("frame/inbox.md"), Some(FileKind::Inbox));
}
#[test]
fn other_paths_are_declined() {
assert_eq!(kind_for_path("frame/project.toml"), None);
assert_eq!(kind_for_path("frame/actors.toml"), None);
assert_eq!(kind_for_path("README.md"), None);
assert_eq!(kind_for_path("doc/tracks.txt"), None);
}
#[test]
fn windows_separators_are_normalized() {
assert_eq!(
kind_for_path(r"frame\tracks\main.md"),
Some(FileKind::Track)
);
}
fn track(tasks_backlog: &[&str], tasks_done: &[&str]) -> String {
let mut s = String::from("# Main\n\n## Backlog\n\n");
for t in tasks_backlog {
s.push_str(t);
s.push('\n');
}
s.push_str("\n## Parked\n\n## Done\n\n");
for t in tasks_done {
s.push_str(t);
s.push('\n');
}
s
}
#[test]
fn a_move_and_an_append_both_land() {
let base = track(&["- [ ] `BAC-179` Fix the thing"], &[]);
let ours = track(&[], &["- [x] `BAC-179` Fix the thing"]);
let theirs = track(
&[
"- [ ] `BAC-179` Fix the thing",
"- [ ] `BAC-180` Another thing",
],
&[],
);
let (merged, report) = merge_track_text(&base, &ours, &theirs, STAMP);
assert!(report.is_clean(), "conflicts: {:?}", report.conflicts);
assert_eq!(merged.matches("BAC-179").count(), 1, "merged:\n{merged}");
assert!(merged.contains("BAC-180"));
let done = merged.split_once("## Done").unwrap().1;
assert!(done.contains("- [x] `BAC-179`"), "merged:\n{merged}");
}
#[test]
fn section_headers_survive() {
let base = track(&["- [ ] `BAC-1` One"], &[]);
let ours = track(&["- [ ] `BAC-1` One", "- [ ] `BAC-2` Two"], &[]);
let theirs = track(&[], &["- [x] `BAC-1` One"]);
let (merged, _) = merge_track_text(&base, &ours, &theirs, STAMP);
assert!(merged.contains("## Backlog"));
assert!(merged.contains("## Parked"));
assert!(merged.contains("## Done"));
}
#[test]
fn empty_base_keeps_both_sides() {
let ours = track(&["- [ ] `BAC-1` Ours"], &[]);
let theirs = track(&["- [ ] `BAC-2` Theirs"], &[]);
let (merged, report) = merge_track_text("", &ours, &theirs, STAMP);
assert!(report.is_clean(), "conflicts: {:?}", report.conflicts);
assert!(merged.contains("BAC-1"));
assert!(merged.contains("BAC-2"));
}
#[test]
fn a_double_edit_conflicts_and_keeps_ours() {
let base = track(&["- [ ] `BAC-1` Original"], &[]);
let ours = track(&["- [ ] `BAC-1` Our title"], &[]);
let theirs = track(&["- [ ] `BAC-1` Their title"], &[]);
let (merged, report) = merge_track_text(&base, &ours, &theirs, STAMP);
assert!(!report.is_clean());
assert_eq!(report.conflicts.len(), 1);
assert_eq!(report.conflicts[0].key, "#BAC-1");
assert!(merged.contains("Our title"));
assert!(
report.conflicts[0]
.theirs
.join("\n")
.contains("Their title")
);
}
#[test]
fn output_round_trips_even_when_conflicted() {
let base = track(&["- [ ] `BAC-1` Original"], &[]);
let ours = track(&["- [ ] `BAC-1` Our title"], &[]);
let theirs = track(&["- [ ] `BAC-1` Their title"], &[]);
let (merged, report) = merge_track_text(&base, &ours, &theirs, STAMP);
assert!(!report.is_clean());
assert!(!merged.contains("<<<<<<<"));
let reparsed = serialize_track(&parse_track(&merged));
assert_eq!(reparsed, merged, "merged output is not stable");
}
#[test]
fn inbox_captures_from_both_sides_survive() {
let base = "# Inbox\n\n- one\n";
let ours = "# Inbox\n\n- one\n- ours\n";
let theirs = "# Inbox\n\n- one\n- theirs\n";
let (merged, report) = merge_inbox_text(base, ours, theirs);
assert!(report.is_clean());
assert!(merged.contains("- one"));
assert!(merged.contains("- ours"));
assert!(merged.contains("- theirs"));
}
#[test]
fn inbox_removal_agreed_by_both_sides_sticks() {
let base = "# Inbox\n\n- one\n- two\n";
let ours = "# Inbox\n\n- two\n";
let theirs = "# Inbox\n\n- two\n";
let (merged, _) = merge_inbox_text(base, ours, theirs);
assert!(!merged.contains("- one"));
assert!(merged.contains("- two"));
}
#[test]
fn merge_files_writes_over_ours() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("base.md");
let ours_path = dir.path().join("ours.md");
let theirs_path = dir.path().join("theirs.md");
std::fs::write(&base_path, track(&["- [ ] `BAC-1` One"], &[])).unwrap();
std::fs::write(&ours_path, track(&["- [ ] `BAC-1` One"], &[])).unwrap();
std::fs::write(
&theirs_path,
track(&["- [ ] `BAC-1` One", "- [ ] `BAC-2` Two"], &[]),
)
.unwrap();
let report =
merge_files(FileKind::Track, &base_path, &ours_path, &theirs_path, STAMP).unwrap();
assert!(report.is_clean());
assert_eq!(report.took_theirs, 1);
let written = std::fs::read_to_string(&ours_path).unwrap();
assert!(written.contains("BAC-2"));
assert!(
!std::fs::read_to_string(&theirs_path)
.unwrap()
.contains("<<<")
);
}
#[test]
fn missing_base_file_is_treated_as_empty() {
let dir = tempfile::tempdir().unwrap();
let ours_path = dir.path().join("ours.md");
let theirs_path = dir.path().join("theirs.md");
std::fs::write(&ours_path, track(&["- [ ] `BAC-1` Ours"], &[])).unwrap();
std::fs::write(&theirs_path, track(&["- [ ] `BAC-2` Theirs"], &[])).unwrap();
let report = merge_files(
FileKind::Track,
&dir.path().join("nonexistent.md"),
&ours_path,
&theirs_path,
STAMP,
)
.unwrap();
assert!(report.is_clean());
let written = std::fs::read_to_string(&ours_path).unwrap();
assert!(written.contains("BAC-1") && written.contains("BAC-2"));
}
#[test]
fn a_missing_side_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let ours_path = dir.path().join("ours.md");
std::fs::write(&ours_path, track(&[], &[])).unwrap();
let err = merge_files(
FileKind::Track,
&dir.path().join("base.md"),
&ours_path,
&dir.path().join("theirs.md"),
STAMP,
);
assert!(matches!(err, Err(MergeFileError::Read { .. })));
}
}