use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, 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, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CommentLifecycleState {
#[default]
LocalDraft,
PushedDraft,
Submitted,
}
impl CommentLifecycleState {
pub fn is_locked(self) -> bool {
!matches!(self, CommentLifecycleState::LocalDraft)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum CommentType {
#[default]
Note,
Suggestion,
Issue,
Praise,
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,
_ => Self::Custom(id.to_string()),
}
}
pub fn id(&self) -> &str {
match self {
CommentType::Note => "note",
CommentType::Suggestion => "suggestion",
CommentType::Issue => "issue",
CommentType::Praise => "praise",
CommentType::Custom(id) => id.as_str(),
}
}
pub fn as_str(&self) -> String {
self.id().to_ascii_uppercase()
}
}
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, PartialEq, Eq, 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)]
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)]
pub lifecycle_state: CommentLifecycleState,
#[serde(default)]
pub remote_review_id: Option<String>,
#[serde(default)]
pub remote_comment_id: Option<String>,
}
impl Comment {
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,
lifecycle_state: CommentLifecycleState::default(),
remote_review_id: None,
remote_comment_id: None,
}
}
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),
lifecycle_state: CommentLifecycleState::default(),
remote_review_id: None,
remote_comment_id: None,
}
}
pub fn is_locked(&self) -> bool {
self.lifecycle_state.is_locked()
}
}
#[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_custom_type_as_string() {
let comment_type = CommentType::from_id("question");
let json = serde_json::to_string(&comment_type).unwrap();
assert_eq!(json, "\"question\"");
}
#[test]
fn comment_type_deserializes_custom_type_from_string() {
let json = "\"question\"";
let comment_type: CommentType = serde_json::from_str(json).unwrap();
assert_eq!(comment_type.id(), "question");
}
#[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);
}
#[test]
fn should_default_lifecycle_state_to_local_draft_for_new_comment() {
let comment = Comment::new("hi".to_string(), CommentType::Note, None);
assert_eq!(comment.lifecycle_state, CommentLifecycleState::LocalDraft);
assert!(!comment.is_locked());
assert!(comment.remote_review_id.is_none());
assert!(comment.remote_comment_id.is_none());
}
#[test]
fn should_report_pushed_and_submitted_comments_as_locked() {
let mut pushed = Comment::new("p".to_string(), CommentType::Note, None);
pushed.lifecycle_state = CommentLifecycleState::PushedDraft;
let mut submitted = Comment::new("s".to_string(), CommentType::Note, None);
submitted.lifecycle_state = CommentLifecycleState::Submitted;
assert!(pushed.is_locked());
assert!(submitted.is_locked());
}
#[test]
fn should_roundtrip_lifecycle_fields_via_serde() {
let mut original = Comment::new("body".to_string(), CommentType::Issue, None);
original.lifecycle_state = CommentLifecycleState::Submitted;
original.remote_review_id = Some("R_kgDOEx".to_string());
original.remote_comment_id = Some("RC_kgDOEx".to_string());
let json = serde_json::to_string(&original).unwrap();
let restored: Comment = serde_json::from_str(&json).unwrap();
assert_eq!(restored.lifecycle_state, CommentLifecycleState::Submitted);
assert_eq!(restored.remote_review_id.as_deref(), Some("R_kgDOEx"));
assert_eq!(restored.remote_comment_id.as_deref(), Some("RC_kgDOEx"));
}
#[test]
fn should_default_lifecycle_fields_for_pre_pr5_comment_json() {
let json = r#"{
"id": "legacy",
"content": "pre-pr5",
"comment_type": "note",
"created_at": "2024-01-01T00:00:00Z",
"line_context": null
}"#;
let comment: Comment =
serde_json::from_str(json).expect("pre-PR-5 comment JSON should parse");
assert_eq!(comment.lifecycle_state, CommentLifecycleState::LocalDraft);
assert!(comment.remote_review_id.is_none());
assert!(comment.remote_comment_id.is_none());
assert_eq!(comment.id, "legacy");
assert_eq!(comment.content, "pre-pr5");
}
}
}