tedi 0.16.1

Personal productivity CLI for task tracking, time management, and GitHub issue integration
Documentation
//! Merge trait for combining Issue states.
//!
//! The merge operation combines two Issue states, using timestamps to determine
//! which fields to keep when both have values.
//!
//! ## Merge semantics
//!
//! For each field:
//! - If `other` has a newer timestamp → take `other`'s value
//! - If `other` has `Some` timestamp and `self` has `None` → take `other`'s value
//! - If both have `None` timestamps → keep `self`
//! - If timestamps are equal → keep `self` (caller can reverse order to prefer other)
//!
//! When `force=true`, always take `other`'s value regardless of timestamps.
//!
//! ## Special cases
//!
//! - Virtual issues cannot be merged (error)
//! - Pending issues have timestamps initialized to `default()` before merge

use std::collections::HashMap;

use jiff::Timestamp;
use tedi::{Issue, IssueSelector};
use thiserror::Error;

/// Extension trait for merging Issues.
pub trait Merge {
	/// Merge `other` into `self`, using timestamps to resolve conflicts.
	///
	/// When `force=true`, always takes `other`'s values regardless of timestamps.
	/// When `force=false`, uses timestamp comparison to decide.
	///
	/// Returns error if either issue is virtual-only.
	fn merge(&mut self, other: &Issue, force: bool) -> Result<(), MergeError>;
}
/// Error from merge operations.
#[derive(Debug, Error)]
pub enum MergeError {
	/// Cannot merge virtual-only issues.
	#[error("cannot merge virtual-only issue: virtual issues are local-only and should not participate in sync")]
	VirtualIssue,
}

impl Merge for Issue {
	fn merge(&mut self, other: &Issue, force: bool) -> Result<(), MergeError> {
		// Virtual issues cannot participate in merge
		if self.identity.is_virtual || other.identity.is_virtual {
			return Err(MergeError::VirtualIssue);
		}

		// Get timestamps, initializing pending issues to default
		let self_ts = self.identity.timestamps().cloned().unwrap_or_default();
		let other_ts = other.identity.timestamps().cloned().unwrap_or_default();

		// Helper closure: returns true if other dominates self for this field
		let dominated_by = |self_field_ts: Option<Timestamp>, other_field_ts: Option<Timestamp>| -> bool {
			if force {
				return true;
			}
			match (self_field_ts, other_field_ts) {
				(_, None) => false,          // other has no timestamp, keep self
				(None, Some(_)) => true,     // self has no timestamp, take other
				(Some(s), Some(o)) => o > s, // compare timestamps, take if other is newer
			}
		};

		// Merge title
		if dominated_by(self_ts.title, other_ts.title) {
			self.contents.title = other.contents.title.clone();
		}

		// Merge labels
		if dominated_by(self_ts.labels, other_ts.labels) {
			self.contents.labels = other.contents.labels.clone();
		}

		// Merge description (body + blockers)
		if dominated_by(self_ts.description, other_ts.description) {
			let blocker_set_state = self.contents.blockers.set_state;
			// Body is the first comment
			if let Some(other_body) = other.contents.comments.first() {
				if let Some(self_body) = self.contents.comments.first_mut() {
					self_body.body = other_body.body.clone();
				} else {
					self.contents.comments.insert(0, other_body.clone());
				}
			}
			self.contents.blockers = other.contents.blockers.clone();
			self.contents.blockers.set_state = blocker_set_state; // it's not invalid for remote to change local contents, even if we requested blocker set for them. It's possible that their values didn't actually change, while remote did. And then it's still reasonable to say that user semantically wanted _blockers section_ on this issue to be set; while not having had expressed opinions about contents.
		}

		// Merge state
		if dominated_by(self_ts.state, other_ts.state) {
			self.contents.state = other.contents.state.clone();
		}

		// Merge comments (excluding body which is first comment)
		// Compare using max timestamp from each side's comment timestamps
		let self_comments_ts = self_ts.comments.iter().max().copied();
		let other_comments_ts = other_ts.comments.iter().max().copied();
		if dominated_by(self_comments_ts, other_comments_ts) {
			// Replace all non-body comments with other's
			let self_body = self.contents.comments.first().cloned();
			self.contents.comments = other.contents.comments.clone();
			// Restore self's body if we didn't take other's description
			if !dominated_by(self_ts.description, other_ts.description)
				&& let Some(body) = self_body
			{
				if self.contents.comments.is_empty() {
					self.contents.comments.push(body);
				} else {
					self.contents.comments[0] = body;
				}
			}
		}

		// Update timestamps on self to reflect the merge
		//DEPRECATE: if `self.post_update` correctly takes care of this
		//if let Some(linked) = self.identity.mut_linked_issue_meta() {
		//	if dominated_by(self_ts.title, other_ts.title) {
		//		linked.timestamps.title = other_ts.title;
		//	}
		//	if dominated_by(self_ts.labels, other_ts.labels) {
		//		linked.timestamps.labels = other_ts.labels;
		//	}
		//	if dominated_by(self_ts.description, other_ts.description) {
		//		linked.timestamps.description = other_ts.description;
		//	}
		//	if dominated_by(self_ts.state, other_ts.state) {
		//		linked.timestamps.state = other_ts.state;
		//	}
		//	if dominated_by(self_comments_ts, other_comments_ts) {
		//		linked.timestamps.comments = other_ts.comments.clone();
		//	}
		//}

		// Merge children by selector
		merge_children(&mut self.children, &other.children, force)?;

		self.post_update(other);

		Ok(())
	}
}

/// Merge children maps by selector.
fn merge_children(self_children: &mut HashMap<IssueSelector, Issue>, other_children: &HashMap<IssueSelector, Issue>, force: bool) -> Result<(), MergeError> {
	// Merge existing children that match by selector
	for (selector, other_child) in other_children {
		if let Some(self_child) = self_children.get_mut(selector) {
			self_child.merge(other_child, force)?;
		} else {
			// Add new children from other (ones that weren't in self)
			// Only add if not virtual
			if !other_child.identity.is_virtual {
				self_children.insert(*selector, other_child.clone());
			}
		}
	}

	Ok(())
}

#[cfg(test)]
mod tests {
	use tedi::{IssueContents, IssueIdentity, IssueIndex, IssueLink, IssueTimestamps, RepoInfo};

	use super::*;

	fn test_repo() -> RepoInfo {
		RepoInfo::new("test", "repo")
	}

	fn make_linked_issue(title: &str, number: u64, timestamps: IssueTimestamps) -> Issue {
		let url = format!("https://github.com/test/repo/issues/{number}");
		let link = IssueLink::parse(&url).unwrap();
		let parent_index = IssueIndex::repo_only(test_repo());
		let identity = IssueIdentity::new_linked(Some(parent_index), None, link, timestamps);
		Issue {
			identity,
			contents: IssueContents {
				title: title.to_string(),
				..Default::default()
			},
			children: HashMap::default(),
		}
	}

	fn make_pending_issue(title: &str) -> Issue {
		let parent_index = IssueIndex::repo_only(test_repo());
		let identity = IssueIdentity::pending(parent_index);
		Issue {
			identity,
			contents: IssueContents {
				title: title.to_string(),
				..Default::default()
			},
			children: HashMap::default(),
		}
	}

	#[test]
	fn test_merge_virtual_error() {
		let parent_index = IssueIndex::repo_only(test_repo());
		// Create a virtual issue (local-only, never synced to Github)
		let mut virtual_issue = Issue {
			identity: IssueIdentity::virtual_issue(parent_index),
			contents: IssueContents::default(),
			children: HashMap::default(),
		};

		let ts = Timestamp::now();
		let timestamps = IssueTimestamps {
			title: Some(ts),
			description: Some(ts),
			labels: Some(ts),
			state: Some(ts),
			comments: vec![],
		};
		let linked = make_linked_issue("test", 1, timestamps);

		let result = virtual_issue.merge(&linked, false);
		assert!(matches!(result, Err(MergeError::VirtualIssue)));
	}

	#[test]
	fn test_merge_newer_wins() {
		let old_ts = Timestamp::from_second(1000).unwrap();
		let new_ts = Timestamp::from_second(2000).unwrap();

		let old_timestamps = IssueTimestamps {
			title: Some(old_ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};
		let new_timestamps = IssueTimestamps {
			title: Some(new_ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};

		let mut self_issue = make_linked_issue("old title", 1, old_timestamps);
		let other_issue = make_linked_issue("new title", 1, new_timestamps);

		self_issue.merge(&other_issue, false).unwrap();

		assert_eq!(self_issue.contents.title, "new title");
	}

	#[test]
	fn test_merge_older_keeps_self() {
		let old_ts = Timestamp::from_second(1000).unwrap();
		let new_ts = Timestamp::from_second(2000).unwrap();

		let new_timestamps = IssueTimestamps {
			title: Some(new_ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};
		let old_timestamps = IssueTimestamps {
			title: Some(old_ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};

		let mut self_issue = make_linked_issue("newer title", 1, new_timestamps);
		let other_issue = make_linked_issue("older title", 1, old_timestamps);

		self_issue.merge(&other_issue, false).unwrap();

		assert_eq!(self_issue.contents.title, "newer title");
	}

	#[test]
	fn test_merge_none_takes_some() {
		let ts = Timestamp::from_second(1000).unwrap();

		let none_timestamps = IssueTimestamps::default();
		let some_timestamps = IssueTimestamps {
			title: Some(ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};

		let mut self_issue = make_linked_issue("self title", 1, none_timestamps);
		let other_issue = make_linked_issue("other title", 1, some_timestamps);

		self_issue.merge(&other_issue, false).unwrap();

		assert_eq!(self_issue.contents.title, "other title");
	}

	#[test]
	fn test_merge_force_always_takes_other() {
		let old_ts = Timestamp::from_second(1000).unwrap();
		let new_ts = Timestamp::from_second(2000).unwrap();

		// self has newer timestamp
		let new_timestamps = IssueTimestamps {
			title: Some(new_ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};
		let old_timestamps = IssueTimestamps {
			title: Some(old_ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};

		let mut self_issue = make_linked_issue("newer title", 1, new_timestamps);
		let other_issue = make_linked_issue("older title", 1, old_timestamps);

		// With force=true, other should win even though it's older
		self_issue.merge(&other_issue, true).unwrap();

		assert_eq!(self_issue.contents.title, "older title");
	}

	#[test]
	fn test_merge_pending_uses_default_timestamps() {
		let ts = Timestamp::from_second(1000).unwrap();
		let timestamps = IssueTimestamps {
			title: Some(ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};

		let mut pending = make_pending_issue("pending title");
		let linked = make_linked_issue("linked title", 1, timestamps);

		// Pending has no timestamps (None), so linked should win
		pending.merge(&linked, false).unwrap();

		assert_eq!(pending.contents.title, "linked title");
	}

	#[test]
	fn test_merge_same_timestamp_keeps_self() {
		let ts = Timestamp::from_second(1000).unwrap();

		let timestamps = IssueTimestamps {
			title: Some(ts),
			description: None,
			labels: None,
			state: None,
			comments: vec![],
		};

		let mut self_issue = make_linked_issue("self title", 1, timestamps.clone());
		let other_issue = make_linked_issue("other title", 1, timestamps);

		self_issue.merge(&other_issue, false).unwrap();

		// Same timestamp -> keep self
		assert_eq!(self_issue.contents.title, "self title");
	}
}