use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RemoteCommentSide {
Right,
Left,
}
impl RemoteCommentSide {
pub fn parse(value: &str) -> Self {
match value.to_ascii_uppercase().as_str() {
"LEFT" => RemoteCommentSide::Left,
_ => RemoteCommentSide::Right,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteReviewComment {
pub id: String,
pub author: Option<String>,
pub body: String,
pub created_at: Option<DateTime<Utc>>,
pub in_reply_to: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteReviewThread {
pub id: String,
pub path: String,
pub line: Option<u32>,
pub side: RemoteCommentSide,
pub is_resolved: bool,
pub is_outdated: bool,
pub comments: Vec<RemoteReviewComment>,
}
impl RemoteReviewThread {
pub fn is_active(&self) -> bool {
!self.is_resolved && !self.is_outdated
}
pub fn root(&self) -> Option<&RemoteReviewComment> {
self.comments.first()
}
pub fn replies(&self) -> impl Iterator<Item = &RemoteReviewComment> {
self.comments.iter().skip(1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrCommentsVisibility {
#[default]
Unresolved,
All,
Hide,
}
impl PrCommentsVisibility {
pub fn render_decision(&self, thread: &RemoteReviewThread) -> Option<bool> {
match self {
PrCommentsVisibility::Hide => None,
PrCommentsVisibility::Unresolved => {
if thread.is_active() {
Some(false)
} else {
None
}
}
PrCommentsVisibility::All => Some(!thread.is_active()),
}
}
pub fn label(&self) -> &'static str {
match self {
PrCommentsVisibility::Unresolved => "unresolved",
PrCommentsVisibility::All => "all",
PrCommentsVisibility::Hide => "hidden",
}
}
}
pub fn filter_threads(
threads: &[RemoteReviewThread],
visibility: PrCommentsVisibility,
) -> Vec<&RemoteReviewThread> {
threads
.iter()
.filter(|t| visibility.render_decision(t).is_some())
.collect()
}
pub fn thread_display_lines(thread: &RemoteReviewThread) -> usize {
let mut total = 0;
for comment in &thread.comments {
total += 1 + comment.body.split('\n').count();
}
total += 1;
total
}
pub fn group_threads_by_path(
threads: &[RemoteReviewThread],
) -> Vec<(&str, Vec<&RemoteReviewThread>)> {
let mut groups: Vec<(&str, Vec<&RemoteReviewThread>)> = Vec::new();
for thread in threads {
if let Some((_, bucket)) = groups.iter_mut().find(|(p, _)| *p == thread.path.as_str()) {
bucket.push(thread);
} else {
groups.push((thread.path.as_str(), vec![thread]));
}
}
groups
}
#[cfg(test)]
mod tests {
use super::*;
fn make_thread(
id: &str,
path: &str,
line: Option<u32>,
is_resolved: bool,
is_outdated: bool,
) -> RemoteReviewThread {
RemoteReviewThread {
id: id.to_string(),
path: path.to_string(),
line,
side: RemoteCommentSide::Right,
is_resolved,
is_outdated,
comments: vec![RemoteReviewComment {
id: format!("{id}-root"),
author: Some("alice".to_string()),
body: "Root body".to_string(),
created_at: None,
in_reply_to: None,
url: format!("https://example.com/{id}"),
}],
}
}
#[test]
fn should_default_visibility_to_unresolved() {
let v = PrCommentsVisibility::default();
assert_eq!(v, PrCommentsVisibility::Unresolved);
}
#[test]
fn should_show_only_active_threads_when_unresolved() {
let v = PrCommentsVisibility::Unresolved;
let active = make_thread("a", "src/lib.rs", Some(10), false, false);
let resolved = make_thread("b", "src/lib.rs", Some(20), true, false);
let outdated = make_thread("c", "src/lib.rs", Some(30), false, true);
assert_eq!(v.render_decision(&active), Some(false));
assert_eq!(v.render_decision(&resolved), None);
assert_eq!(v.render_decision(&outdated), None);
}
#[test]
fn should_show_all_threads_with_muted_for_inactive_when_all() {
let v = PrCommentsVisibility::All;
let active = make_thread("a", "src/lib.rs", Some(10), false, false);
let resolved = make_thread("b", "src/lib.rs", Some(20), true, false);
let outdated = make_thread("c", "src/lib.rs", Some(30), false, true);
assert_eq!(v.render_decision(&active), Some(false));
assert_eq!(v.render_decision(&resolved), Some(true));
assert_eq!(v.render_decision(&outdated), Some(true));
}
#[test]
fn should_show_no_threads_when_hidden() {
let v = PrCommentsVisibility::Hide;
let active = make_thread("a", "src/lib.rs", Some(10), false, false);
assert_eq!(v.render_decision(&active), None);
}
#[test]
fn should_filter_threads_preserving_order() {
let threads = vec![
make_thread("a", "src/lib.rs", Some(10), false, false),
make_thread("b", "src/lib.rs", Some(20), true, false),
make_thread("c", "src/main.rs", Some(30), false, false),
];
let unresolved = filter_threads(&threads, PrCommentsVisibility::Unresolved);
assert_eq!(unresolved.len(), 2);
assert_eq!(unresolved[0].id, "a");
assert_eq!(unresolved[1].id, "c");
}
#[test]
fn should_round_trip_visibility_via_serde() {
let cases = [
PrCommentsVisibility::Unresolved,
PrCommentsVisibility::All,
PrCommentsVisibility::Hide,
];
for c in cases {
let json = serde_json::to_string(&c).unwrap();
let back: PrCommentsVisibility = serde_json::from_str(&json).unwrap();
assert_eq!(back, c);
}
}
#[test]
fn should_group_threads_by_file_preserving_order() {
let threads = vec![
make_thread("a", "src/lib.rs", Some(10), false, false),
make_thread("b", "src/main.rs", Some(5), false, false),
make_thread("c", "src/lib.rs", Some(20), false, false),
];
let groups = group_threads_by_path(&threads);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].0, "src/lib.rs");
assert_eq!(groups[0].1.len(), 2);
assert_eq!(groups[1].0, "src/main.rs");
assert_eq!(groups[1].1.len(), 1);
}
#[test]
fn should_parse_remote_comment_side() {
assert_eq!(RemoteCommentSide::parse("LEFT"), RemoteCommentSide::Left);
assert_eq!(RemoteCommentSide::parse("RIGHT"), RemoteCommentSide::Right);
assert_eq!(RemoteCommentSide::parse("left"), RemoteCommentSide::Left);
assert_eq!(RemoteCommentSide::parse(""), RemoteCommentSide::Right);
}
}