use std::collections::HashMap;
use crate::github::detail::{PrDetail, ReviewThread};
#[derive(Debug, Default)]
pub(crate) struct ThreadIndex {
active_by_line: HashMap<(String, u32), Vec<usize>>,
overflow_by_file: HashMap<String, Vec<usize>>,
counts_per_file: HashMap<String, usize>,
unresolved_per_file: HashMap<String, usize>,
}
impl ThreadIndex {
pub(crate) fn build(threads: &[ReviewThread]) -> Self {
let mut index = Self::default();
for (i, thread) in threads.iter().enumerate() {
*index.counts_per_file.entry(thread.path.clone()).or_insert(0) += 1;
if !thread.is_resolved && !thread.is_outdated {
*index.unresolved_per_file.entry(thread.path.clone()).or_insert(0) += 1;
}
match (thread.is_outdated, thread.line) {
(false, Some(ln)) => {
index.active_by_line.entry((thread.path.clone(), ln)).or_default().push(i);
}
_ => {
index.overflow_by_file.entry(thread.path.clone()).or_default().push(i);
}
}
}
index
}
pub(crate) fn active_at(&self, path: &str, line: u32) -> &[usize] {
self.active_by_line.get(&(path.to_owned(), line)).map_or(&[], Vec::as_slice)
}
pub(crate) fn overflow(&self, path: &str) -> &[usize] {
self.overflow_by_file.get(path).map_or(&[], Vec::as_slice)
}
pub(crate) fn total_for(&self, path: &str) -> usize {
self.counts_per_file.get(path).copied().unwrap_or(0)
}
pub(crate) fn unresolved_for(&self, path: &str) -> usize {
self.unresolved_per_file.get(path).copied().unwrap_or(0)
}
#[cfg(test)]
pub(crate) fn distinct_files(&self) -> usize {
self.counts_per_file.len()
}
}
pub(crate) fn build_for(detail: &PrDetail) -> ThreadIndex {
ThreadIndex::build(&detail.review_threads)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use chrono::Utc;
fn mk_thread(path: &str, line: Option<u32>, outdated: bool, resolved: bool) -> ReviewThread {
ReviewThread {
path: path.to_owned(),
line,
start_line: None,
is_resolved: resolved,
is_outdated: outdated,
diff_hunk: None,
comments: vec![crate::github::detail::ReviewComment {
author: "u".to_owned(),
body_markdown: "c".to_owned(),
created_at: Utc::now(),
diff_hunk: None,
original_commit_id: None,
}],
}
}
#[test]
fn active_line_threads_are_indexed_per_file_and_line() {
let threads = vec![
mk_thread("src/a.rs", Some(10), false, false),
mk_thread("src/a.rs", Some(20), false, false),
mk_thread("src/b.rs", Some(5), false, false),
];
let idx = ThreadIndex::build(&threads);
assert_eq!(idx.active_at("src/a.rs", 10).len(), 1);
assert_eq!(idx.active_at("src/a.rs", 20).len(), 1);
assert_eq!(idx.active_at("src/b.rs", 5).len(), 1);
assert_eq!(idx.active_at("src/a.rs", 99).len(), 0, "unrelated line returns empty");
assert_eq!(idx.total_for("src/a.rs"), 2);
assert_eq!(idx.total_for("src/b.rs"), 1);
assert_eq!(idx.unresolved_for("src/a.rs"), 2);
assert_eq!(idx.distinct_files(), 2);
}
#[test]
fn outdated_and_file_level_go_to_overflow() {
let threads = vec![
mk_thread("src/a.rs", None, false, false), mk_thread("src/a.rs", Some(10), true, false), ];
let idx = ThreadIndex::build(&threads);
assert_eq!(idx.active_at("src/a.rs", 10).len(), 0, "outdated must NOT show at line 10");
assert_eq!(
idx.overflow("src/a.rs").len(),
2,
"both file-level and outdated go to overflow"
);
assert_eq!(idx.total_for("src/a.rs"), 2);
assert_eq!(
idx.unresolved_for("src/a.rs"),
1,
"file-level unresolved counts; outdated doesn't"
);
}
#[test]
fn resolved_thread_does_not_count_as_unresolved() {
let threads = vec![
mk_thread("src/a.rs", Some(1), false, true), mk_thread("src/a.rs", Some(2), false, false), ];
let idx = ThreadIndex::build(&threads);
assert_eq!(idx.total_for("src/a.rs"), 2);
assert_eq!(idx.unresolved_for("src/a.rs"), 1);
}
#[test]
fn empty_threads_produce_empty_index() {
let idx = ThreadIndex::build(&[]);
assert_eq!(idx.total_for("anywhere"), 0);
assert_eq!(idx.unresolved_for("anywhere"), 0);
assert_eq!(idx.active_at("anywhere", 1).len(), 0);
assert_eq!(idx.overflow("anywhere").len(), 0);
}
}