Skip to main content

oo_ide/
issue_registry.rs

1//! In-memory issue registry for the IDE.
2//!
3//! [`IssueRegistry`] is the single source of truth for all issues visible to
4//! the editor.  It holds ephemeral issues (produced by async tasks such as
5//! build, lint, or test runners) and persistent issues (loaded from disk and
6//! injected via the [`crate::operation::Operation::AddIssue`] operation at
7//! startup by a separate Persistent Component).
8//!
9//! # Design constraints
10//!
11//! * **No disk I/O** — the registry is pure in-memory.
12//! * **Single-threaded** — lives on the main thread as part of
13//!   [`crate::app_state::AppState`].  Async tasks that need to add issues send
14//!   an [`crate::operation::Operation::AddIssue`] through the existing
15//!   `op_tx` channel; the main loop applies it.
16//! * **Operation-driven** — all mutations arrive as `Operation` variants;
17//!   after each mutation the main loop enqueues the corresponding
18//!   `IssueAdded` / `IssueRemoved` / `IssueUpdated` event operation so that
19//!   views can react.
20//!
21//! # Lifecycle
22//!
23//! ```text
24//! IDE start
25//!   Persistent Component reads disk, sends:
26//!     op_tx.send(vec![Operation::AddIssue { issue: NewIssue { marker: None, … } }])
27//!
28//! Async task (build, lint, …) — first clear stale results:
29//!     op_tx.send(vec![Operation::ClearIssuesByMarker { marker: "build".into() }])
30//!   then add fresh ones:
31//!     op_tx.send(vec![Operation::AddIssue { issue: NewIssue { marker: Some("build"), … } }])
32//!
33//! User dismisses an issue:
34//!     queue.push_back(Operation::DismissIssue { id })
35//!
36//! IDE restart: new empty IssueRegistry — no ephemeral issues.
37//! ```
38
39use std::collections::{HashMap, HashSet};
40use std::path::{Path, PathBuf};
41use std::time::SystemTime;
42
43use crate::editor::position::Position;
44
45// ---------------------------------------------------------------------------
46// Public types
47// ---------------------------------------------------------------------------
48
49/// Unique, monotonically-increasing identifier assigned by the registry.
50/// Never reused within a single registry lifetime.
51pub type IssueId = u64;
52
53/// Severity level.  Ordered `Info < Warning < Error`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub enum Severity {
56    Info,
57    Warning,
58    Error,
59    /// A TODO / FIXME / HACK comment found in task output.
60    /// Ordered above `Error` so it sorts separately; not treated as a build
61    /// failure.  Ephemeral only — never persisted to `.oo/issues.yaml`.
62    Todo,
63}
64
65/// A single diagnostic issue.
66#[derive(Debug, Clone)]
67pub struct Issue {
68    /// Unique identifier assigned by the registry.
69    pub id: IssueId,
70    /// When `Some`, the issue is *ephemeral* and belongs to this marker group.
71    /// `None` means the issue is *persistent* and immune to marker clears.
72    pub marker: Option<String>,
73    /// Human-readable source tag, e.g. `"lsp"`, `"build"`, `"lint"`.
74    pub source: String,
75    /// File the issue belongs to, if applicable.
76    pub path: Option<PathBuf>,
77    /// Byte-column range within the file, if known.
78    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/// Input data for the `AddIssue` operation.  The registry assigns `id`,
87/// `dismissed`, `resolved`, and `created_at`.
88#[derive(Debug, Clone)]
89pub struct NewIssue {
90    /// `Some(marker)` → ephemeral (removable via `ClearIssuesByMarker`).
91    /// `None`         → persistent (never removed by marker clears).
92    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
100// ---------------------------------------------------------------------------
101// Registry
102// ---------------------------------------------------------------------------
103
104/// Central in-memory store for all IDE issues.
105///
106/// All mutations are performed through the `pub(crate)` methods called by
107/// `apply_operation` in `app.rs`.  After each successful mutation a
108/// corresponding `IssueAdded / IssueRemoved / IssueUpdated` operation is
109/// enqueued by the caller so views can react.
110pub struct IssueRegistry {
111    next_id: IssueId,
112    issues: HashMap<IssueId, Issue>,
113    /// marker → set of associated ephemeral issue IDs.
114    markers: HashMap<String, HashSet<IssueId>>,
115    /// file path → set of issue IDs for fast per-file lookup.
116    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    /// Create an empty registry.
136    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    // -----------------------------------------------------------------------
146    // Mutations — pub(crate) so only app.rs / apply_operation can call them.
147    // Callers are responsible for pushing the matching event operation.
148    // -----------------------------------------------------------------------
149
150    /// Add a new issue and return its [`IssueId`].
151    ///
152    /// Typically called by `apply_operation(Operation::AddIssue { .. })`,
153    /// which also enqueues `Operation::IssueAdded { id }` so views can react.
154    /// For direct use (e.g., integration tests), the `IssueAdded` event is not
155    /// emitted automatically — callers must notify views if needed.
156    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    /// Remove an issue by ID.  Returns `true` if it existed.
185    ///
186    /// Called by `apply_operation(Operation::RemoveIssue { .. })`.
187    /// The caller enqueues `Operation::IssueRemoved { id }` on success.
188    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    /// Mark an issue as resolved.  Returns `true` if it existed and was not
195    /// already resolved.
196    ///
197    /// Called by `apply_operation(Operation::ResolveIssue { .. })`.
198    /// The caller enqueues `Operation::IssueUpdated { id }` on success.
199    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    /// Mark an issue as dismissed.  Returns `true` if it existed and was not
207    /// already dismissed.
208    ///
209    /// Called by `apply_operation(Operation::DismissIssue { .. })`.
210    /// The caller enqueues `Operation::IssueUpdated { id }` on success.
211    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    /// Remove all ephemeral issues belonging to `marker`.  Returns the IDs
219    /// that were removed so the caller can enqueue `IssueRemoved` events.
220    ///
221    /// Persistent issues (`marker = None`) are never affected.
222    ///
223    /// Called by `apply_operation(Operation::ClearIssuesByMarker { .. })`.
224    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    // -----------------------------------------------------------------------
242    // Queries — public; used by views, editor, and extensions.
243    // -----------------------------------------------------------------------
244
245    /// All issues, sorted by ascending ID (insertion order).
246    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    /// Issues for a specific file, sorted by ascending ID.
253    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    /// Issues with a specific severity, sorted by ascending ID.
261    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    /// Look up a single issue by ID.
268    pub fn get(&self, id: IssueId) -> Option<&Issue> {
269        self.issues.get(&id)
270    }
271
272    /// Total number of issues currently held.
273    pub fn len(&self) -> usize {
274        self.issues.len()
275    }
276
277    /// `true` when the registry contains no issues.
278    pub fn is_empty(&self) -> bool {
279        self.issues.is_empty()
280    }
281
282    /// Cloned snapshot of all **persistent** issues (`marker: None`), sorted
283    /// by ascending ID.
284    ///
285    /// Used by the Persistent Component to build a save snapshot after any
286    /// mutation that affects a persistent issue.
287    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    // -----------------------------------------------------------------------
299    // Private helpers
300    // -----------------------------------------------------------------------
301
302    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// ---------------------------------------------------------------------------
323// Tests
324// ---------------------------------------------------------------------------
325
326#[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    // --- Basic operations ---------------------------------------------------
345
346    #[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    // --- Markers and ephemeral clears ----------------------------------------
434
435    #[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)); // persistent
456
457        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()); // no panic, no double-count
476    }
477
478    #[test]
479    fn new_registry_has_no_ephemeral_issues() {
480        assert!(IssueRegistry::new().is_empty());
481    }
482
483    // --- File index ---------------------------------------------------------
484
485    #[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    // --- Severity filter ----------------------------------------------------
520
521    #[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    // --- list_all ordering --------------------------------------------------
532
533    #[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    // --- list_persistent ----------------------------------------------------
544
545    #[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}