use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthorKind {
#[default]
Human,
McpAgent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LineSide {
Old,
#[default]
New,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineRange {
pub start: u32,
pub end: u32,
}
impl LineRange {
pub fn new(start: u32, end: u32) -> Self {
Self {
start: start.min(end),
end: start.max(end),
}
}
pub fn single(line: u32) -> Self {
Self {
start: line,
end: line,
}
}
pub fn is_single(&self) -> bool {
self.start == self.end
}
pub fn contains(&self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum CommentType {
#[default]
Note,
Suggestion,
Issue,
Praise,
Question,
Spec,
Custom(String),
}
impl CommentType {
pub fn from_id(id: &str) -> Self {
match id.to_ascii_lowercase().as_str() {
"note" => Self::Note,
"suggestion" => Self::Suggestion,
"issue" => Self::Issue,
"praise" => Self::Praise,
"question" => Self::Question,
"spec" => Self::Spec,
_ => Self::Custom(id.to_string()),
}
}
#[must_use]
pub fn id(&self) -> &str {
match self {
CommentType::Note => "note",
CommentType::Suggestion => "suggestion",
CommentType::Issue => "issue",
CommentType::Praise => "praise",
CommentType::Question => "question",
CommentType::Spec => "spec",
CommentType::Custom(id) => id.as_str(),
}
}
#[must_use]
pub fn to_label(&self) -> String {
self.id().to_ascii_uppercase()
}
#[deprecated(note = "use to_label() instead")]
pub fn as_str(&self) -> String {
self.to_label()
}
}
impl Serialize for CommentType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.id())
}
}
impl<'de> Deserialize<'de> for CommentType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::from_id(&value))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineContext {
pub new_line: Option<u32>,
pub old_line: Option<u32>,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum AnchorState {
Anchored {
line: u32,
side: LineSide,
#[serde(default)]
reanchored_at: Option<DateTime<Utc>>,
},
Orphaned {
was_line: u32,
was_side: LineSide,
last_seen_content: String,
#[serde(default)]
orphaned_at: Option<DateTime<Utc>>,
},
}
impl Default for AnchorState {
fn default() -> Self {
Self::Anchored {
line: 0,
side: LineSide::New,
reanchored_at: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: String,
pub content: String,
pub comment_type: CommentType,
pub created_at: DateTime<Utc>,
pub line_context: Option<LineContext>,
#[serde(default)]
pub side: Option<LineSide>,
#[serde(default)]
pub line_range: Option<LineRange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_id: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor: Option<AnchorState>,
#[serde(default)]
pub author_kind: AuthorKind,
#[serde(default, skip_serializing_if = "is_false")]
pub resolved: bool,
}
#[inline]
fn is_false(b: &bool) -> bool {
!*b
}
impl Comment {
#[must_use]
pub fn new(content: String, comment_type: CommentType, side: Option<LineSide>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
content,
comment_type,
created_at: Utc::now(),
line_context: None,
side,
line_range: None,
remote_id: None,
anchor: None,
author_kind: AuthorKind::Human,
resolved: false,
}
}
#[must_use]
pub fn new_with_author_kind(
content: String,
comment_type: CommentType,
side: Option<LineSide>,
author_kind: AuthorKind,
) -> Self {
Self {
author_kind,
..Self::new(content, comment_type, side)
}
}
pub fn new_with_range(
content: String,
comment_type: CommentType,
side: Option<LineSide>,
line_range: LineRange,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
content,
comment_type,
created_at: Utc::now(),
line_context: None,
side,
line_range: Some(line_range),
remote_id: None,
anchor: None,
author_kind: AuthorKind::Human,
resolved: false,
}
}
}
const LEGACY_MCP_AUTHOR_MARKER: &str = "\n\n_(via MCP agent)_";
pub fn migrate_legacy_mcp_author_marker(comment: &mut Comment) {
let content = &comment.content;
let Some(marker_start) = content.rfind(LEGACY_MCP_AUTHOR_MARKER) else {
return;
};
let marker_end = marker_start + LEGACY_MCP_AUTHOR_MARKER.len();
let tail = &content[marker_end..];
let is_tail_empty = tail.is_empty();
let is_tail_tour_tag = tail.starts_with("\n\n_(tour stop ") && tail.ends_with(")_");
if !is_tail_empty && !is_tail_tour_tag {
return;
}
let mut new_content = String::with_capacity(content.len());
new_content.push_str(&content[..marker_start]);
new_content.push_str(tail);
comment.content = new_content;
comment.author_kind = AuthorKind::McpAgent;
}
#[cfg(test)]
mod tests {
use super::*;
mod line_range_tests {
use super::*;
#[test]
fn new_creates_range_with_correct_bounds() {
let range = LineRange::new(10, 20);
assert_eq!(range.start, 10);
assert_eq!(range.end, 20);
}
#[test]
fn new_normalizes_reversed_bounds() {
let range = LineRange::new(20, 10);
assert_eq!(range.start, 10);
assert_eq!(range.end, 20);
}
#[test]
fn single_creates_single_line_range() {
let range = LineRange::single(42);
assert_eq!(range.start, 42);
assert_eq!(range.end, 42);
}
#[test]
fn is_single_returns_true_for_single_line() {
let range = LineRange::single(10);
assert!(range.is_single());
}
#[test]
fn is_single_returns_false_for_multi_line() {
let range = LineRange::new(10, 15);
assert!(!range.is_single());
}
#[test]
fn contains_returns_true_for_start_line() {
let range = LineRange::new(10, 20);
assert!(range.contains(10));
}
#[test]
fn contains_returns_true_for_end_line() {
let range = LineRange::new(10, 20);
assert!(range.contains(20));
}
#[test]
fn contains_returns_true_for_middle_line() {
let range = LineRange::new(10, 20);
assert!(range.contains(15));
}
#[test]
fn contains_returns_false_for_line_before_range() {
let range = LineRange::new(10, 20);
assert!(!range.contains(5));
}
#[test]
fn contains_returns_false_for_line_after_range() {
let range = LineRange::new(10, 20);
assert!(!range.contains(25));
}
#[test]
fn single_line_range_contains_only_that_line() {
let range = LineRange::single(42);
assert!(!range.contains(41));
assert!(range.contains(42));
assert!(!range.contains(43));
}
#[test]
fn line_range_serializes_correctly() {
let range = LineRange::new(10, 20);
let json = serde_json::to_string(&range).unwrap();
assert!(json.contains("\"start\":10"));
assert!(json.contains("\"end\":20"));
}
#[test]
fn line_range_deserializes_correctly() {
let json = r#"{"start":10,"end":20}"#;
let range: LineRange = serde_json::from_str(json).unwrap();
assert_eq!(range.start, 10);
assert_eq!(range.end, 20);
}
}
mod comment_tests {
use super::*;
#[test]
fn comment_type_serializes_question_type_as_string() {
let comment_type = CommentType::from_id("question");
assert_eq!(comment_type, CommentType::Question);
let json = serde_json::to_string(&comment_type).unwrap();
assert_eq!(json, "\"question\"");
}
#[test]
fn comment_type_deserializes_question_type_from_string() {
let json = "\"question\"";
let comment_type: CommentType = serde_json::from_str(json).unwrap();
assert_eq!(comment_type, CommentType::Question);
assert_eq!(comment_type.id(), "question");
}
#[test]
fn comment_type_roundtrips_spec() {
let t = CommentType::Spec;
assert_eq!(t.id(), "spec");
let json = serde_json::to_string(&t).unwrap();
assert_eq!(json, "\"spec\"");
let parsed: CommentType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, CommentType::Spec);
}
#[test]
fn comment_type_from_id_spec_case_insensitive() {
assert_eq!(CommentType::from_id("Spec"), CommentType::Spec);
assert_eq!(CommentType::from_id("SPEC"), CommentType::Spec);
}
#[test]
fn new_creates_comment_without_line_range() {
let comment = Comment::new(
"Test comment".to_string(),
CommentType::Note,
Some(LineSide::New),
);
assert!(comment.line_range.is_none());
assert_eq!(comment.content, "Test comment");
assert_eq!(comment.comment_type, CommentType::Note);
assert_eq!(comment.side, Some(LineSide::New));
}
#[test]
fn new_with_range_creates_comment_with_line_range() {
let range = LineRange::new(10, 15);
let comment = Comment::new_with_range(
"Range comment".to_string(),
CommentType::Issue,
Some(LineSide::Old),
range,
);
assert!(comment.line_range.is_some());
let stored_range = comment.line_range.unwrap();
assert_eq!(stored_range.start, 10);
assert_eq!(stored_range.end, 15);
assert_eq!(comment.side, Some(LineSide::Old));
}
#[test]
fn comment_with_line_range_serializes_correctly() {
let range = LineRange::new(10, 15);
let comment = Comment::new_with_range(
"Test".to_string(),
CommentType::Note,
Some(LineSide::New),
range,
);
let json = serde_json::to_string(&comment).unwrap();
assert!(json.contains("\"line_range\""));
assert!(json.contains("\"start\":10"));
assert!(json.contains("\"end\":15"));
}
#[test]
fn comment_without_line_range_deserializes_with_none() {
let json = r#"{
"id": "test-id",
"content": "Test comment",
"comment_type": "note",
"created_at": "2024-01-01T00:00:00Z",
"line_context": null,
"side": "new"
}"#;
let comment: Comment = serde_json::from_str(json).unwrap();
assert!(comment.line_range.is_none());
assert_eq!(comment.content, "Test comment");
}
#[test]
fn comment_with_line_range_deserializes_correctly() {
let json = r#"{
"id": "test-id",
"content": "Range comment",
"comment_type": "issue",
"created_at": "2024-01-01T00:00:00Z",
"line_context": null,
"side": "old",
"line_range": {"start": 10, "end": 15}
}"#;
let comment: Comment = serde_json::from_str(json).unwrap();
assert!(comment.line_range.is_some());
let range = comment.line_range.unwrap();
assert_eq!(range.start, 10);
assert_eq!(range.end, 15);
}
}
mod anchor_state_tests {
use super::*;
#[test]
fn anchor_state_anchored_deserializes_without_reanchored_at() {
let json = r#"{"state":"anchored","line":42,"side":"new"}"#;
let anchor: AnchorState = serde_json::from_str(json).unwrap();
match anchor {
AnchorState::Anchored {
line,
side,
reanchored_at,
} => {
assert_eq!(line, 42);
assert_eq!(side, LineSide::New);
assert_eq!(reanchored_at, None);
}
_ => panic!("expected Anchored"),
}
}
#[test]
fn anchor_state_orphaned_deserializes_without_orphaned_at() {
let json = r#"{
"state":"orphaned",
"was_line":7,
"was_side":"old",
"last_seen_content":"let x = 1;"
}"#;
let anchor: AnchorState = serde_json::from_str(json).unwrap();
match anchor {
AnchorState::Orphaned {
was_line,
was_side,
last_seen_content,
orphaned_at,
} => {
assert_eq!(was_line, 7);
assert_eq!(was_side, LineSide::Old);
assert_eq!(last_seen_content, "let x = 1;");
assert_eq!(orphaned_at, None);
}
_ => panic!("expected Orphaned"),
}
}
#[test]
fn anchor_state_tag_uses_snake_case_stable_names() {
let anchored = AnchorState::Anchored {
line: 1,
side: LineSide::New,
reanchored_at: None,
};
let json = serde_json::to_string(&anchored).unwrap();
assert!(
json.contains(r#""state":"anchored""#),
"tag must be `anchored`, got {json}"
);
let orphaned = AnchorState::Orphaned {
was_line: 2,
was_side: LineSide::New,
last_seen_content: "code".to_string(),
orphaned_at: None,
};
let json = serde_json::to_string(&orphaned).unwrap();
assert!(
json.contains(r#""state":"orphaned""#),
"tag must be `orphaned`, got {json}"
);
}
}
mod author_kind_tests {
use super::*;
#[test]
fn comment_without_author_kind_field_defaults_to_human() {
let json = r#"{
"id": "test-id",
"content": "legacy",
"comment_type": "note",
"created_at": "2024-01-01T00:00:00Z",
"line_context": null,
"side": "new"
}"#;
let comment: Comment = serde_json::from_str(json).unwrap();
assert_eq!(comment.author_kind, AuthorKind::Human);
}
#[test]
fn author_kind_round_trips_through_json() {
let comment = Comment::new_with_author_kind(
"agent said".to_string(),
CommentType::Note,
Some(LineSide::New),
AuthorKind::McpAgent,
);
let json = serde_json::to_string(&comment).unwrap();
assert!(json.contains(r#""author_kind":"mcp_agent""#));
let back: Comment = serde_json::from_str(&json).unwrap();
assert_eq!(back.author_kind, AuthorKind::McpAgent);
}
#[test]
fn migrate_legacy_mcp_marker_strips_suffix_and_promotes_kind() {
let mut comment = Comment::new(
"agent said\n\n_(via MCP agent)_".to_string(),
CommentType::Note,
Some(LineSide::New),
);
assert_eq!(comment.author_kind, AuthorKind::Human);
migrate_legacy_mcp_author_marker(&mut comment);
assert_eq!(comment.content, "agent said");
assert_eq!(comment.author_kind, AuthorKind::McpAgent);
}
#[test]
fn migrate_legacy_mcp_marker_leaves_human_comments_untouched() {
let original = "plain human comment".to_string();
let mut comment =
Comment::new(original.clone(), CommentType::Note, Some(LineSide::New));
migrate_legacy_mcp_author_marker(&mut comment);
assert_eq!(comment.content, original);
assert_eq!(comment.author_kind, AuthorKind::Human);
}
#[test]
fn migrate_legacy_mcp_marker_is_idempotent_on_already_migrated_agent_comment() {
let mut comment = Comment::new_with_author_kind(
"already migrated".to_string(),
CommentType::Note,
Some(LineSide::New),
AuthorKind::McpAgent,
);
migrate_legacy_mcp_author_marker(&mut comment);
assert_eq!(comment.content, "already migrated");
assert_eq!(comment.author_kind, AuthorKind::McpAgent);
}
#[test]
fn migrate_legacy_mcp_marker_only_matches_exact_suffix() {
let mut comment = Comment::new(
"see _(via MCP agent)_ in the logs".to_string(),
CommentType::Note,
Some(LineSide::New),
);
migrate_legacy_mcp_author_marker(&mut comment);
assert_eq!(comment.content, "see _(via MCP agent)_ in the logs");
assert_eq!(comment.author_kind, AuthorKind::Human);
}
#[test]
fn migrate_legacy_mcp_marker_handles_tour_tag_after_marker() {
let mut comment = Comment::new(
"agent said\n\n_(via MCP agent)_\n\n_(tour stop 1/3 @ abcdef1)_".to_string(),
CommentType::Note,
Some(LineSide::New),
);
migrate_legacy_mcp_author_marker(&mut comment);
assert_eq!(
comment.content, "agent said\n\n_(tour stop 1/3 @ abcdef1)_",
"tour-tag must survive marker removal",
);
assert_eq!(comment.author_kind, AuthorKind::McpAgent);
}
#[test]
fn migrate_legacy_mcp_marker_does_not_corrupt_body_mentioning_marker_mid_sentence() {
let body = "_(via MCP agent)_ was the old way to tag\n\nNow we have author_kind.";
let mut comment =
Comment::new(body.to_string(), CommentType::Note, Some(LineSide::New));
migrate_legacy_mcp_author_marker(&mut comment);
assert_eq!(comment.content, body);
assert_eq!(comment.author_kind, AuthorKind::Human);
}
}
}