use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnnotationCategory {
Bug,
Style,
Performance,
Security,
Suggestion,
Question,
Nitpick,
}
impl AnnotationCategory {
pub fn label(&self) -> &'static str {
match self {
Self::Bug => "Bug",
Self::Style => "Style",
Self::Performance => "Perf",
Self::Security => "Security",
Self::Suggestion => "Suggestion",
Self::Question => "Question",
Self::Nitpick => "Nitpick",
}
}
pub fn shortcut(&self) -> char {
match self {
Self::Bug => 'b',
Self::Style => 's',
Self::Performance => 'p',
Self::Security => 'x',
Self::Suggestion => 'g',
Self::Question => 'q',
Self::Nitpick => 'n',
}
}
pub fn all() -> &'static [AnnotationCategory] {
&[
Self::Bug,
Self::Style,
Self::Performance,
Self::Security,
Self::Suggestion,
Self::Question,
Self::Nitpick,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnnotationSeverity {
Critical,
Major,
Minor,
Info,
}
impl AnnotationSeverity {
pub fn label(&self) -> &'static str {
match self {
Self::Critical => "Critical",
Self::Major => "Major",
Self::Minor => "Minor",
Self::Info => "Info",
}
}
pub fn shortcut(&self) -> char {
match self {
Self::Critical => 'c',
Self::Major => 'M',
Self::Minor => 'm',
Self::Info => 'i',
}
}
pub fn all() -> &'static [AnnotationSeverity] {
&[Self::Critical, Self::Major, Self::Minor, Self::Info]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineAnchor {
pub file_path: String,
pub old_range: Option<(u32, u32)>, pub new_range: Option<(u32, u32)>, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineScore {
pub file_path: String,
pub old_range: Option<(u32, u32)>,
pub new_range: Option<(u32, u32)>,
pub score: u8, pub created_at: String,
}
impl LineScore {
pub fn sort_line(&self) -> u32 {
self.new_range
.map(|(s, _)| s)
.or(self.old_range.map(|(s, _)| s))
.unwrap_or(0)
}
}
impl LineAnchor {
pub fn sort_line(&self) -> u32 {
self.new_range
.map(|(s, _)| s)
.or(self.old_range.map(|(s, _)| s))
.unwrap_or(0)
}
pub fn covers_old(&self, lineno: u32) -> bool {
self.old_range
.is_some_and(|(s, e)| lineno >= s && lineno <= e)
}
pub fn covers_new(&self, lineno: u32) -> bool {
self.new_range
.is_some_and(|(s, e)| lineno >= s && lineno <= e)
}
pub fn covers(&self, old_lineno: Option<u32>, new_lineno: Option<u32>) -> bool {
if let Some(n) = new_lineno {
if self.covers_new(n) {
return true;
}
}
if let Some(n) = old_lineno {
if self.covers_old(n) {
return true;
}
}
false
}
fn overlaps(&self, old_range: Option<(u32, u32)>, new_range: Option<(u32, u32)>) -> bool {
let old_overlaps = match (self.old_range, old_range) {
(Some((s1, e1)), Some((s2, e2))) => s1 <= e2 && s2 <= e1,
_ => false,
};
let new_overlaps = match (self.new_range, new_range) {
(Some((s1, e1)), Some((s2, e2))) => s1 <= e2 && s2 <= e1,
_ => false,
};
old_overlaps || new_overlaps
}
fn matches(&self, old_range: Option<(u32, u32)>, new_range: Option<(u32, u32)>) -> bool {
self.old_range == old_range && self.new_range == new_range
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Annotation {
pub anchor: LineAnchor,
pub comment: String,
pub created_at: String,
#[serde(default = "default_category")]
pub category: AnnotationCategory,
#[serde(default = "default_severity")]
pub severity: AnnotationSeverity,
}
fn default_category() -> AnnotationCategory {
AnnotationCategory::Suggestion
}
fn default_severity() -> AnnotationSeverity {
AnnotationSeverity::Minor
}
#[derive(Debug, Default)]
pub struct AnnotationState {
pub annotations: BTreeMap<String, Vec<Annotation>>,
pub scores: BTreeMap<String, Vec<LineScore>>,
}
impl AnnotationState {
pub fn add(&mut self, annotation: Annotation) {
let key = annotation.anchor.file_path.clone();
self.annotations.entry(key).or_default().push(annotation);
}
pub fn has_annotation_at(
&self,
file_path: &str,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
) -> bool {
if let Some(anns) = self.annotations.get(file_path) {
anns.iter().any(|a| a.anchor.covers(old_lineno, new_lineno))
} else {
false
}
}
pub fn delete_at(
&mut self,
file_path: &str,
old_range: Option<(u32, u32)>,
new_range: Option<(u32, u32)>,
) {
if let Some(anns) = self.annotations.get_mut(file_path) {
anns.retain(|a| !a.anchor.overlaps(old_range, new_range));
if anns.is_empty() {
self.annotations.remove(file_path);
}
}
}
pub fn all_sorted(&self) -> Vec<&Annotation> {
let mut result: Vec<&Annotation> =
self.annotations.values().flat_map(|v| v.iter()).collect();
result.sort_by_key(|a| (&a.anchor.file_path, a.anchor.sort_line()));
result
}
pub fn annotations_overlapping(
&self,
file_path: &str,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
) -> Vec<&Annotation> {
if let Some(anns) = self.annotations.get(file_path) {
anns.iter()
.filter(|a| a.anchor.covers(old_lineno, new_lineno))
.collect()
} else {
Vec::new()
}
}
pub fn delete_annotation(
&mut self,
file_path: &str,
old_range: Option<(u32, u32)>,
new_range: Option<(u32, u32)>,
comment: &str,
) {
if let Some(anns) = self.annotations.get_mut(file_path) {
if let Some(pos) = anns
.iter()
.position(|a| a.anchor.matches(old_range, new_range) && a.comment == comment)
{
anns.remove(pos);
}
if anns.is_empty() {
self.annotations.remove(file_path);
}
}
}
pub fn update_comment(
&mut self,
file_path: &str,
old_range: Option<(u32, u32)>,
new_range: Option<(u32, u32)>,
old_comment: &str,
new_comment: &str,
) {
if let Some(anns) = self.annotations.get_mut(file_path) {
if let Some(ann) = anns
.iter_mut()
.find(|a| a.anchor.matches(old_range, new_range) && a.comment == old_comment)
{
ann.comment = new_comment.to_string();
}
}
}
pub fn count(&self) -> usize {
self.annotations.values().map(|v| v.len()).sum()
}
pub fn next_after(&self, file_path: &str, lineno: u32) -> Option<(&str, u32)> {
let sorted = self.all_sorted();
for ann in &sorted {
let sl = ann.anchor.sort_line();
if ann.anchor.file_path.as_str() > file_path
|| (ann.anchor.file_path == file_path && sl > lineno)
{
return Some((&ann.anchor.file_path, sl));
}
}
sorted
.first()
.map(|a| (a.anchor.file_path.as_str(), a.anchor.sort_line()))
}
pub fn prev_before(&self, file_path: &str, lineno: u32) -> Option<(&str, u32)> {
let sorted = self.all_sorted();
for ann in sorted.iter().rev() {
let sl = ann.anchor.sort_line();
if ann.anchor.file_path.as_str() < file_path
|| (ann.anchor.file_path == file_path && sl < lineno)
{
return Some((&ann.anchor.file_path, sl));
}
}
sorted
.last()
.map(|a| (a.anchor.file_path.as_str(), a.anchor.sort_line()))
}
pub fn set_score(&mut self, score: LineScore) {
let key = score.file_path.clone();
let entries = self.scores.entry(key).or_default();
entries.retain(|s| s.old_range != score.old_range || s.new_range != score.new_range);
entries.push(score);
}
pub fn remove_score(
&mut self,
file_path: &str,
old_range: Option<(u32, u32)>,
new_range: Option<(u32, u32)>,
) {
if let Some(scores) = self.scores.get_mut(file_path) {
scores.retain(|s| s.old_range != old_range || s.new_range != new_range);
if scores.is_empty() {
self.scores.remove(file_path);
}
}
}
pub fn score_at(
&self,
file_path: &str,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
) -> Option<u8> {
self.scores.get(file_path).and_then(|scores| {
scores.iter().find_map(|s| {
let covers_new = matches!(
(new_lineno, s.new_range),
(Some(n), Some((start, end))) if n >= start && n <= end
);
let covers_old = matches!(
(old_lineno, s.old_range),
(Some(n), Some((start, end))) if n >= start && n <= end
);
let covers = covers_new || covers_old;
if covers {
Some(s.score)
} else {
None
}
})
})
}
pub fn all_scores_sorted(&self) -> Vec<&LineScore> {
let mut result: Vec<&LineScore> = self.scores.values().flat_map(|v| v.iter()).collect();
result.sort_by_key(|s| (&s.file_path, s.sort_line()));
result
}
pub fn score_count(&self) -> usize {
self.scores.values().map(|v| v.len()).sum()
}
pub fn files_with_annotations(&self) -> usize {
let mut files = std::collections::BTreeSet::new();
files.extend(self.annotations.keys().map(String::as_str));
files.extend(self.scores.keys().map(String::as_str));
files.len()
}
}