1use std::collections::{HashMap, HashSet};
40use std::path::{Path, PathBuf};
41use std::time::SystemTime;
42
43use crate::editor::position::Position;
44
45pub type IssueId = u64;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub enum Severity {
56 Info,
57 Warning,
58 Error,
59 Todo,
63}
64
65#[derive(Debug, Clone)]
67pub struct Issue {
68 pub id: IssueId,
70 pub marker: Option<String>,
73 pub source: String,
75 pub path: Option<PathBuf>,
77 pub range: Option<(Position, Position)>,
79 pub message: String,
80 pub severity: Severity,
81 pub dismissed: bool,
82 pub resolved: bool,
83 pub created_at: SystemTime,
84}
85
86#[derive(Debug, Clone)]
89pub struct NewIssue {
90 pub marker: Option<String>,
93 pub source: String,
94 pub path: Option<PathBuf>,
95 pub range: Option<(Position, Position)>,
96 pub message: String,
97 pub severity: Severity,
98}
99
100pub struct IssueRegistry {
111 next_id: IssueId,
112 issues: HashMap<IssueId, Issue>,
113 markers: HashMap<String, HashSet<IssueId>>,
115 by_file: HashMap<PathBuf, HashSet<IssueId>>,
117}
118
119impl std::fmt::Debug for IssueRegistry {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("IssueRegistry")
122 .field("issue_count", &self.issues.len())
123 .field("marker_count", &self.markers.len())
124 .finish_non_exhaustive()
125 }
126}
127
128impl Default for IssueRegistry {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl IssueRegistry {
135 pub fn new() -> Self {
137 Self {
138 next_id: 1,
139 issues: HashMap::new(),
140 markers: HashMap::new(),
141 by_file: HashMap::new(),
142 }
143 }
144
145 pub fn add_issue(&mut self, new: NewIssue) -> IssueId {
157 let id = self.next_id;
158 self.next_id += 1;
159
160 let issue = Issue {
161 id,
162 marker: new.marker.clone(),
163 source: new.source,
164 path: new.path,
165 range: new.range,
166 message: new.message,
167 severity: new.severity,
168 dismissed: false,
169 resolved: false,
170 created_at: SystemTime::now(),
171 };
172
173 if let Some(path) = &issue.path {
174 self.by_file.entry(path.clone()).or_default().insert(id);
175 }
176 if let Some(m) = &new.marker {
177 self.markers.entry(m.clone()).or_default().insert(id);
178 }
179
180 self.issues.insert(id, issue);
181 id
182 }
183
184 pub(crate) fn remove_issue(&mut self, id: IssueId) -> bool {
189 let Some(issue) = self.issues.remove(&id) else { return false };
190 self.remove_from_indexes(id, &issue);
191 true
192 }
193
194 pub(crate) fn resolve_issue(&mut self, id: IssueId) -> bool {
200 let Some(issue) = self.issues.get_mut(&id) else { return false };
201 if issue.resolved { return false; }
202 issue.resolved = true;
203 true
204 }
205
206 pub(crate) fn dismiss_issue(&mut self, id: IssueId) -> bool {
212 let Some(issue) = self.issues.get_mut(&id) else { return false };
213 if issue.dismissed { return false; }
214 issue.dismissed = true;
215 true
216 }
217
218 pub(crate) fn clear_by_marker(&mut self, marker: &str) -> Vec<IssueId> {
225 let Some(ids) = self.markers.remove(marker) else { return Vec::new() };
226 let removed: Vec<IssueId> = ids.into_iter().collect();
227 for &id in &removed {
228 if let Some(issue) = self.issues.remove(&id)
229 && let Some(path) = &issue.path {
230 let empty = self
231 .by_file
232 .get_mut(path)
233 .map(|s| { s.remove(&id); s.is_empty() })
234 .unwrap_or(false);
235 if empty { self.by_file.remove(path); }
236 }
237 }
238 removed
239 }
240
241 pub fn list_all(&self) -> Vec<&Issue> {
247 let mut v: Vec<&Issue> = self.issues.values().collect();
248 v.sort_by_key(|i| i.id);
249 v
250 }
251
252 pub fn list_by_file(&self, path: &Path) -> Vec<&Issue> {
254 let Some(ids) = self.by_file.get(path) else { return Vec::new() };
255 let mut v: Vec<&Issue> = ids.iter().filter_map(|id| self.issues.get(id)).collect();
256 v.sort_by_key(|i| i.id);
257 v
258 }
259
260 pub fn list_by_severity(&self, severity: Severity) -> Vec<&Issue> {
262 let mut v: Vec<&Issue> = self.issues.values().filter(|i| i.severity == severity).collect();
263 v.sort_by_key(|i| i.id);
264 v
265 }
266
267 pub fn get(&self, id: IssueId) -> Option<&Issue> {
269 self.issues.get(&id)
270 }
271
272 pub fn len(&self) -> usize {
274 self.issues.len()
275 }
276
277 pub fn is_empty(&self) -> bool {
279 self.issues.is_empty()
280 }
281
282 pub fn list_persistent(&self) -> Vec<Issue> {
288 let mut v: Vec<Issue> = self
289 .issues
290 .values()
291 .filter(|i| i.marker.is_none())
292 .cloned()
293 .collect();
294 v.sort_by_key(|i| i.id);
295 v
296 }
297
298 fn remove_from_indexes(&mut self, id: IssueId, issue: &Issue) {
303 if let Some(path) = &issue.path {
304 let empty = self
305 .by_file
306 .get_mut(path)
307 .map(|s| { s.remove(&id); s.is_empty() })
308 .unwrap_or(false);
309 if empty { self.by_file.remove(path); }
310 }
311 if let Some(marker) = &issue.marker {
312 let empty = self
313 .markers
314 .get_mut(marker.as_str())
315 .map(|s| { s.remove(&id); s.is_empty() })
316 .unwrap_or(false);
317 if empty { self.markers.remove(marker.as_str()); }
318 }
319 }
320}
321
322#[cfg(test)]
327mod tests {
328 use std::path::PathBuf;
329
330 use super::*;
331
332 fn make(msg: &str, sev: Severity) -> NewIssue {
333 NewIssue { marker: None, source: "test".into(), path: None, range: None, message: msg.into(), severity: sev }
334 }
335
336 fn make_marked(msg: &str, marker: &str) -> NewIssue {
337 NewIssue { marker: Some(marker.into()), source: "test".into(), path: None, range: None, message: msg.into(), severity: Severity::Warning }
338 }
339
340 fn make_file(msg: &str, path: &str) -> NewIssue {
341 NewIssue { marker: None, source: "test".into(), path: Some(PathBuf::from(path)), range: None, message: msg.into(), severity: Severity::Warning }
342 }
343
344 #[test]
347 fn new_registry_is_empty() {
348 let r = IssueRegistry::new();
349 assert!(r.is_empty());
350 assert_eq!(r.len(), 0);
351 assert!(r.list_all().is_empty());
352 }
353
354 #[test]
355 fn add_returns_sequential_ids() {
356 let mut r = IssueRegistry::new();
357 let a = r.add_issue(make("a", Severity::Info));
358 let b = r.add_issue(make("b", Severity::Warning));
359 let c = r.add_issue(make("c", Severity::Error));
360 assert!(a < b && b < c);
361 }
362
363 #[test]
364 fn add_and_get() {
365 let mut r = IssueRegistry::new();
366 let id = r.add_issue(make("oops", Severity::Error));
367 let i = r.get(id).unwrap();
368 assert_eq!(i.message, "oops");
369 assert_eq!(i.severity, Severity::Error);
370 assert!(!i.dismissed && !i.resolved);
371 assert!(i.marker.is_none());
372 }
373
374 #[test]
375 fn remove_existing_returns_true() {
376 let mut r = IssueRegistry::new();
377 let id = r.add_issue(make("x", Severity::Info));
378 assert!(r.remove_issue(id));
379 assert!(r.get(id).is_none());
380 assert!(r.is_empty());
381 }
382
383 #[test]
384 fn remove_missing_returns_false() {
385 let mut r = IssueRegistry::new();
386 assert!(!r.remove_issue(99));
387 }
388
389 #[test]
390 fn resolve_sets_flag() {
391 let mut r = IssueRegistry::new();
392 let id = r.add_issue(make("x", Severity::Warning));
393 assert!(r.resolve_issue(id));
394 assert!(r.get(id).unwrap().resolved);
395 }
396
397 #[test]
398 fn resolve_idempotent() {
399 let mut r = IssueRegistry::new();
400 let id = r.add_issue(make("x", Severity::Info));
401 assert!(r.resolve_issue(id));
402 assert!(!r.resolve_issue(id));
403 }
404
405 #[test]
406 fn resolve_missing_returns_false() {
407 let mut r = IssueRegistry::new();
408 assert!(!r.resolve_issue(42));
409 }
410
411 #[test]
412 fn dismiss_sets_flag() {
413 let mut r = IssueRegistry::new();
414 let id = r.add_issue(make("x", Severity::Info));
415 assert!(r.dismiss_issue(id));
416 assert!(r.get(id).unwrap().dismissed);
417 }
418
419 #[test]
420 fn dismiss_idempotent() {
421 let mut r = IssueRegistry::new();
422 let id = r.add_issue(make("x", Severity::Info));
423 assert!(r.dismiss_issue(id));
424 assert!(!r.dismiss_issue(id));
425 }
426
427 #[test]
428 fn dismiss_missing_returns_false() {
429 let mut r = IssueRegistry::new();
430 assert!(!r.dismiss_issue(99));
431 }
432
433 #[test]
436 fn marker_stored_on_issue() {
437 let mut r = IssueRegistry::new();
438 let id = r.add_issue(make_marked("e", "build"));
439 assert_eq!(r.get(id).unwrap().marker.as_deref(), Some("build"));
440 }
441
442 #[test]
443 fn no_marker_for_persistent() {
444 let mut r = IssueRegistry::new();
445 let id = r.add_issue(make("p", Severity::Warning));
446 assert!(r.get(id).unwrap().marker.is_none());
447 }
448
449 #[test]
450 fn clear_by_marker_removes_correct_issues() {
451 let mut r = IssueRegistry::new();
452 let a = r.add_issue(make_marked("a", "build"));
453 let b = r.add_issue(make_marked("b", "build"));
454 let c = r.add_issue(make_marked("c", "lint"));
455 let d = r.add_issue(make("d", Severity::Info)); let removed = r.clear_by_marker("build");
458 assert_eq!(removed.len(), 2);
459 assert!(removed.contains(&a) && removed.contains(&b));
460 assert!(r.get(c).is_some(), "different marker must survive");
461 assert!(r.get(d).is_some(), "persistent must survive");
462 }
463
464 #[test]
465 fn clear_by_marker_unknown_returns_empty() {
466 let mut r = IssueRegistry::new();
467 assert!(r.clear_by_marker("nope").is_empty());
468 }
469
470 #[test]
471 fn clear_by_marker_cleans_internal_index() {
472 let mut r = IssueRegistry::new();
473 r.add_issue(make_marked("x", "m"));
474 r.clear_by_marker("m");
475 assert!(r.clear_by_marker("m").is_empty()); }
477
478 #[test]
479 fn new_registry_has_no_ephemeral_issues() {
480 assert!(IssueRegistry::new().is_empty());
481 }
482
483 #[test]
486 fn list_by_file_filters() {
487 let mut r = IssueRegistry::new();
488 r.add_issue(make_file("a", "src/main.rs"));
489 r.add_issue(make_file("b", "src/main.rs"));
490 r.add_issue(make_file("c", "src/lib.rs"));
491 let main = r.list_by_file(Path::new("src/main.rs"));
492 assert_eq!(main.len(), 2);
493 assert!(main.iter().all(|i| i.path.as_deref() == Some(Path::new("src/main.rs"))));
494 }
495
496 #[test]
497 fn list_by_file_empty_for_unknown() {
498 assert!(IssueRegistry::new().list_by_file(Path::new("x.rs")).is_empty());
499 }
500
501 #[test]
502 fn file_index_cleaned_on_remove() {
503 let mut r = IssueRegistry::new();
504 let id = r.add_issue(make_file("x", "foo.rs"));
505 r.remove_issue(id);
506 assert!(r.list_by_file(Path::new("foo.rs")).is_empty());
507 }
508
509 #[test]
510 fn file_index_cleaned_on_clear_by_marker() {
511 let mut r = IssueRegistry::new();
512 let mut ni = make_file("e", "foo.rs");
513 ni.marker = Some("build".into());
514 r.add_issue(ni);
515 r.clear_by_marker("build");
516 assert!(r.list_by_file(Path::new("foo.rs")).is_empty());
517 }
518
519 #[test]
522 fn list_by_severity() {
523 let mut r = IssueRegistry::new();
524 r.add_issue(make("e1", Severity::Error));
525 r.add_issue(make("w1", Severity::Warning));
526 r.add_issue(make("e2", Severity::Error));
527 assert_eq!(r.list_by_severity(Severity::Error).len(), 2);
528 assert_eq!(r.list_by_severity(Severity::Info).len(), 0);
529 }
530
531 #[test]
534 fn list_all_insertion_order() {
535 let mut r = IssueRegistry::new();
536 let id1 = r.add_issue(make("1", Severity::Info));
537 let id2 = r.add_issue(make("2", Severity::Info));
538 let id3 = r.add_issue(make("3", Severity::Info));
539 let all = r.list_all();
540 assert_eq!([all[0].id, all[1].id, all[2].id], [id1, id2, id3]);
541 }
542
543 #[test]
546 fn list_persistent_excludes_ephemeral() {
547 let mut r = IssueRegistry::new();
548 let pid = r.add_issue(make("persistent", Severity::Warning));
549 let _eid = r.add_issue(make_marked("ephemeral", "build"));
550 let persistent = r.list_persistent();
551 assert_eq!(persistent.len(), 1);
552 assert_eq!(persistent[0].id, pid);
553 assert!(persistent[0].marker.is_none());
554 }
555
556 #[test]
557 fn list_persistent_empty_when_all_ephemeral() {
558 let mut r = IssueRegistry::new();
559 r.add_issue(make_marked("e1", "lint"));
560 r.add_issue(make_marked("e2", "build"));
561 assert!(r.list_persistent().is_empty());
562 }
563
564 #[test]
565 fn list_persistent_sorted_by_id() {
566 let mut r = IssueRegistry::new();
567 let a = r.add_issue(make("a", Severity::Info));
568 let b = r.add_issue(make("b", Severity::Error));
569 let c = r.add_issue(make("c", Severity::Warning));
570 let p = r.list_persistent();
571 assert_eq!([p[0].id, p[1].id, p[2].id], [a, b, c]);
572 }
573}