use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[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,
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, Serialize, Deserialize)]
pub struct LineContext {
pub new_line: Option<u32>,
pub old_line: Option<u32>,
pub content: String,
}
#[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>,
}
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,
}
}
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),
}
}
}
#[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);
}
}
}