use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::editor::position::Position;
pub type IssueId = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Severity {
Info,
Warning,
Error,
Todo,
}
#[derive(Debug, Clone)]
pub struct Issue {
pub id: IssueId,
pub marker: Option<String>,
pub source: String,
pub path: Option<PathBuf>,
pub range: Option<(Position, Position)>,
pub message: String,
pub severity: Severity,
pub dismissed: bool,
pub resolved: bool,
pub created_at: SystemTime,
}
#[derive(Debug, Clone)]
pub struct NewIssue {
pub marker: Option<String>,
pub source: String,
pub path: Option<PathBuf>,
pub range: Option<(Position, Position)>,
pub message: String,
pub severity: Severity,
}
pub struct IssueRegistry {
next_id: IssueId,
issues: HashMap<IssueId, Issue>,
markers: HashMap<String, HashSet<IssueId>>,
by_file: HashMap<PathBuf, HashSet<IssueId>>,
}
impl std::fmt::Debug for IssueRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IssueRegistry")
.field("issue_count", &self.issues.len())
.field("marker_count", &self.markers.len())
.finish_non_exhaustive()
}
}
impl Default for IssueRegistry {
fn default() -> Self {
Self::new()
}
}
impl IssueRegistry {
pub fn new() -> Self {
Self {
next_id: 1,
issues: HashMap::new(),
markers: HashMap::new(),
by_file: HashMap::new(),
}
}
pub fn add_issue(&mut self, new: NewIssue) -> IssueId {
let id = self.next_id;
self.next_id += 1;
let issue = Issue {
id,
marker: new.marker.clone(),
source: new.source,
path: new.path,
range: new.range,
message: new.message,
severity: new.severity,
dismissed: false,
resolved: false,
created_at: SystemTime::now(),
};
if let Some(path) = &issue.path {
self.by_file.entry(path.clone()).or_default().insert(id);
}
if let Some(m) = &new.marker {
self.markers.entry(m.clone()).or_default().insert(id);
}
self.issues.insert(id, issue);
id
}
pub(crate) fn remove_issue(&mut self, id: IssueId) -> bool {
let Some(issue) = self.issues.remove(&id) else { return false };
self.remove_from_indexes(id, &issue);
true
}
pub(crate) fn resolve_issue(&mut self, id: IssueId) -> bool {
let Some(issue) = self.issues.get_mut(&id) else { return false };
if issue.resolved { return false; }
issue.resolved = true;
true
}
pub(crate) fn dismiss_issue(&mut self, id: IssueId) -> bool {
let Some(issue) = self.issues.get_mut(&id) else { return false };
if issue.dismissed { return false; }
issue.dismissed = true;
true
}
pub fn clear_by_marker(&mut self, marker: &str) -> Vec<IssueId> {
let Some(ids) = self.markers.remove(marker) else { return Vec::new() };
let removed: Vec<IssueId> = ids.into_iter().collect();
for &id in &removed {
if let Some(issue) = self.issues.remove(&id)
&& let Some(path) = &issue.path {
let empty = self
.by_file
.get_mut(path)
.map(|s| { s.remove(&id); s.is_empty() })
.unwrap_or(false);
if empty { self.by_file.remove(path); }
}
}
removed
}
pub fn list_all(&self) -> Vec<&Issue> {
let mut v: Vec<&Issue> = self.issues.values().collect();
v.sort_by_key(|i| i.id);
v
}
pub fn list_by_file(&self, path: &Path) -> Vec<&Issue> {
let Some(ids) = self.by_file.get(path) else { return Vec::new() };
let mut v: Vec<&Issue> = ids.iter().filter_map(|id| self.issues.get(id)).collect();
v.sort_by_key(|i| i.id);
v
}
pub fn list_by_severity(&self, severity: Severity) -> Vec<&Issue> {
let mut v: Vec<&Issue> = self.issues.values().filter(|i| i.severity == severity).collect();
v.sort_by_key(|i| i.id);
v
}
pub fn get(&self, id: IssueId) -> Option<&Issue> {
self.issues.get(&id)
}
pub fn len(&self) -> usize {
self.issues.len()
}
pub fn is_empty(&self) -> bool {
self.issues.is_empty()
}
pub fn list_persistent(&self) -> Vec<Issue> {
let mut v: Vec<Issue> = self
.issues
.values()
.filter(|i| i.marker.is_none())
.cloned()
.collect();
v.sort_by_key(|i| i.id);
v
}
fn remove_from_indexes(&mut self, id: IssueId, issue: &Issue) {
if let Some(path) = &issue.path {
let empty = self
.by_file
.get_mut(path)
.map(|s| { s.remove(&id); s.is_empty() })
.unwrap_or(false);
if empty { self.by_file.remove(path); }
}
if let Some(marker) = &issue.marker {
let empty = self
.markers
.get_mut(marker.as_str())
.map(|s| { s.remove(&id); s.is_empty() })
.unwrap_or(false);
if empty { self.markers.remove(marker.as_str()); }
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn make(msg: &str, sev: Severity) -> NewIssue {
NewIssue { marker: None, source: "test".into(), path: None, range: None, message: msg.into(), severity: sev }
}
fn make_marked(msg: &str, marker: &str) -> NewIssue {
NewIssue { marker: Some(marker.into()), source: "test".into(), path: None, range: None, message: msg.into(), severity: Severity::Warning }
}
fn make_file(msg: &str, path: &str) -> NewIssue {
NewIssue { marker: None, source: "test".into(), path: Some(PathBuf::from(path)), range: None, message: msg.into(), severity: Severity::Warning }
}
#[test]
fn new_registry_is_empty() {
let r = IssueRegistry::new();
assert!(r.is_empty());
assert_eq!(r.len(), 0);
assert!(r.list_all().is_empty());
}
#[test]
fn add_returns_sequential_ids() {
let mut r = IssueRegistry::new();
let a = r.add_issue(make("a", Severity::Info));
let b = r.add_issue(make("b", Severity::Warning));
let c = r.add_issue(make("c", Severity::Error));
assert!(a < b && b < c);
}
#[test]
fn add_and_get() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make("oops", Severity::Error));
let i = r.get(id).unwrap();
assert_eq!(i.message, "oops");
assert_eq!(i.severity, Severity::Error);
assert!(!i.dismissed && !i.resolved);
assert!(i.marker.is_none());
}
#[test]
fn remove_existing_returns_true() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make("x", Severity::Info));
assert!(r.remove_issue(id));
assert!(r.get(id).is_none());
assert!(r.is_empty());
}
#[test]
fn remove_missing_returns_false() {
let mut r = IssueRegistry::new();
assert!(!r.remove_issue(99));
}
#[test]
fn resolve_sets_flag() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make("x", Severity::Warning));
assert!(r.resolve_issue(id));
assert!(r.get(id).unwrap().resolved);
}
#[test]
fn resolve_idempotent() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make("x", Severity::Info));
assert!(r.resolve_issue(id));
assert!(!r.resolve_issue(id));
}
#[test]
fn resolve_missing_returns_false() {
let mut r = IssueRegistry::new();
assert!(!r.resolve_issue(42));
}
#[test]
fn dismiss_sets_flag() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make("x", Severity::Info));
assert!(r.dismiss_issue(id));
assert!(r.get(id).unwrap().dismissed);
}
#[test]
fn dismiss_idempotent() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make("x", Severity::Info));
assert!(r.dismiss_issue(id));
assert!(!r.dismiss_issue(id));
}
#[test]
fn dismiss_missing_returns_false() {
let mut r = IssueRegistry::new();
assert!(!r.dismiss_issue(99));
}
#[test]
fn marker_stored_on_issue() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make_marked("e", "build"));
assert_eq!(r.get(id).unwrap().marker.as_deref(), Some("build"));
}
#[test]
fn no_marker_for_persistent() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make("p", Severity::Warning));
assert!(r.get(id).unwrap().marker.is_none());
}
#[test]
fn clear_by_marker_removes_correct_issues() {
let mut r = IssueRegistry::new();
let a = r.add_issue(make_marked("a", "build"));
let b = r.add_issue(make_marked("b", "build"));
let c = r.add_issue(make_marked("c", "lint"));
let d = r.add_issue(make("d", Severity::Info));
let removed = r.clear_by_marker("build");
assert_eq!(removed.len(), 2);
assert!(removed.contains(&a) && removed.contains(&b));
assert!(r.get(c).is_some(), "different marker must survive");
assert!(r.get(d).is_some(), "persistent must survive");
}
#[test]
fn clear_by_marker_unknown_returns_empty() {
let mut r = IssueRegistry::new();
assert!(r.clear_by_marker("nope").is_empty());
}
#[test]
fn clear_by_marker_cleans_internal_index() {
let mut r = IssueRegistry::new();
r.add_issue(make_marked("x", "m"));
r.clear_by_marker("m");
assert!(r.clear_by_marker("m").is_empty()); }
#[test]
fn new_registry_has_no_ephemeral_issues() {
assert!(IssueRegistry::new().is_empty());
}
#[test]
fn list_by_file_filters() {
let mut r = IssueRegistry::new();
r.add_issue(make_file("a", "src/main.rs"));
r.add_issue(make_file("b", "src/main.rs"));
r.add_issue(make_file("c", "src/lib.rs"));
let main = r.list_by_file(Path::new("src/main.rs"));
assert_eq!(main.len(), 2);
assert!(main.iter().all(|i| i.path.as_deref() == Some(Path::new("src/main.rs"))));
}
#[test]
fn list_by_file_empty_for_unknown() {
assert!(IssueRegistry::new().list_by_file(Path::new("x.rs")).is_empty());
}
#[test]
fn file_index_cleaned_on_remove() {
let mut r = IssueRegistry::new();
let id = r.add_issue(make_file("x", "foo.rs"));
r.remove_issue(id);
assert!(r.list_by_file(Path::new("foo.rs")).is_empty());
}
#[test]
fn file_index_cleaned_on_clear_by_marker() {
let mut r = IssueRegistry::new();
let mut ni = make_file("e", "foo.rs");
ni.marker = Some("build".into());
r.add_issue(ni);
r.clear_by_marker("build");
assert!(r.list_by_file(Path::new("foo.rs")).is_empty());
}
#[test]
fn list_by_severity() {
let mut r = IssueRegistry::new();
r.add_issue(make("e1", Severity::Error));
r.add_issue(make("w1", Severity::Warning));
r.add_issue(make("e2", Severity::Error));
assert_eq!(r.list_by_severity(Severity::Error).len(), 2);
assert_eq!(r.list_by_severity(Severity::Info).len(), 0);
}
#[test]
fn list_all_insertion_order() {
let mut r = IssueRegistry::new();
let id1 = r.add_issue(make("1", Severity::Info));
let id2 = r.add_issue(make("2", Severity::Info));
let id3 = r.add_issue(make("3", Severity::Info));
let all = r.list_all();
assert_eq!([all[0].id, all[1].id, all[2].id], [id1, id2, id3]);
}
#[test]
fn list_persistent_excludes_ephemeral() {
let mut r = IssueRegistry::new();
let pid = r.add_issue(make("persistent", Severity::Warning));
let _eid = r.add_issue(make_marked("ephemeral", "build"));
let persistent = r.list_persistent();
assert_eq!(persistent.len(), 1);
assert_eq!(persistent[0].id, pid);
assert!(persistent[0].marker.is_none());
}
#[test]
fn list_persistent_empty_when_all_ephemeral() {
let mut r = IssueRegistry::new();
r.add_issue(make_marked("e1", "lint"));
r.add_issue(make_marked("e2", "build"));
assert!(r.list_persistent().is_empty());
}
#[test]
fn list_persistent_sorted_by_id() {
let mut r = IssueRegistry::new();
let a = r.add_issue(make("a", Severity::Info));
let b = r.add_issue(make("b", Severity::Error));
let c = r.add_issue(make("c", Severity::Warning));
let p = r.list_persistent();
assert_eq!([p[0].id, p[1].id, p[2].id], [a, b, c]);
}
}