Skip to main content

oo_ide/
persistent_issues.rs

1//! Persistent issue storage — reads and writes `.oo/issues.yaml`.
2//!
3//! This module is the **only** place in the IDE that performs disk I/O on
4//! behalf of the issue system.  The crate::issue_registry::IssueRegistry itself is purely
5//! in-memory; this module bridges the gap between disk and registry by:
6//!
7//! * **Loading** at IDE startup: reading `.oo/issues.yaml`, converting each
8//!   record to a [`NewIssue`] with `marker: None` (persistent), and sending
9//!   [`crate::operation::Operation::AddIssue`] operations via `op_tx`.
10//!
11//! * **Saving** after mutations: whenever a persistent issue changes, the
12//!   caller (in `apply_operation`) calls [`save_atomic`] with a snapshot of
13//!   all current persistent issues.  The write is fire-and-forget inside a
14//!   `tokio::task::spawn_blocking` closure.
15//!
16//! # File format
17//!
18//! ```yaml
19//! format_version: 1
20//! issues:
21//!   - source: "user"
22//!     severity: "warning"
23//!     message: "TODO: improve error handling"
24//!     dismissed: false
25//!     resolved: false
26//!     created_at_secs: 1706745600
27//!     # optional fields:
28//!     path: "src/main.rs"
29//!     range_start_line: 42
30//!     range_start_col: 0
31//!     range_end_line: 42
32//!     range_end_col: 10
33//! ```
34//!
35//! # Error handling
36//!
37//! * Missing file → `Ok(vec![])` (first-run friendly).
38//! * Unreadable or corrupt YAML → `log::warn!` then `Ok(vec![])` (no crash).
39//! * Save failures → the caller logs a warning; the registry state is never
40//!   rolled back.
41
42use std::path::{Path, PathBuf};
43use std::time::{Duration, UNIX_EPOCH};
44
45use anyhow::Result;
46use serde::{Deserialize, Serialize};
47
48use crate::editor::position::Position;
49use crate::issue_registry::{Issue, NewIssue, Severity};
50
51// ---------------------------------------------------------------------------
52// Public input type for the PersistentIssueOp::Add operation
53// ---------------------------------------------------------------------------
54
55/// Input data for creating a new persistent issue via
56/// [`crate::operation::PersistentIssueOp::Add`].
57///
58/// Unlike [`NewIssue`] there is no `marker` field — persistent issues are
59/// never ephemeral and cannot be batch-cleared by a marker.
60#[derive(Debug, Clone)]
61pub struct PersistentNewIssue {
62    /// Human-readable source tag, e.g. `"user"`, `"note"`.
63    pub source: String,
64    pub path: Option<std::path::PathBuf>,
65    pub range: Option<(Position, Position)>,
66    pub message: String,
67    pub severity: Severity,
68}
69
70impl PersistentNewIssue {
71    /// Convert to a [`NewIssue`] with `marker: None`.
72    pub(crate) fn into_new_issue(self) -> NewIssue {
73        NewIssue {
74            marker: None,
75            source: self.source,
76            path: self.path,
77            range: self.range,
78            message: self.message,
79            severity: self.severity,
80        }
81    }
82}
83
84// ---------------------------------------------------------------------------
85// On-disk representation
86// ---------------------------------------------------------------------------
87
88const FORMAT_VERSION: u32 = 1;
89
90/// Top-level container written to `issues.yaml`.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct IssueFile {
93    pub format_version: u32,
94    pub issues: Vec<IssueRecord>,
95}
96
97impl Default for IssueFile {
98    fn default() -> Self {
99        Self {
100            format_version: FORMAT_VERSION,
101            issues: Vec::new(),
102        }
103    }
104}
105
106/// A single serialisable issue record.  All fields that reference internal
107/// types use plain primitives so no extra serde derives are needed on
108/// [`Position`] or [`Severity`].
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct IssueRecord {
111    pub source: String,
112    pub severity: String,
113    pub message: String,
114    pub dismissed: bool,
115    pub resolved: bool,
116    /// Unix timestamp (seconds since epoch).
117    pub created_at_secs: u64,
118
119    // ── Optional location ────────────────────────────────────────────────────
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub path: Option<PathBuf>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub range_start_line: Option<usize>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub range_start_col: Option<usize>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub range_end_line: Option<usize>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub range_end_col: Option<usize>,
130}
131
132// ---------------------------------------------------------------------------
133// Conversions between IssueRecord and NewIssue / Issue
134// ---------------------------------------------------------------------------
135
136impl IssueRecord {
137    /// Build a [`NewIssue`] with `marker: None` (persistent) from this record.
138    pub fn to_new_issue(&self) -> NewIssue {
139        let severity = match self.severity.to_ascii_lowercase().as_str() {
140            "error" => Severity::Error,
141            "warning" | "warn" => Severity::Warning,
142            _ => Severity::Info,
143        };
144
145        let range = match (
146            self.range_start_line,
147            self.range_start_col,
148            self.range_end_line,
149            self.range_end_col,
150        ) {
151            (Some(sl), Some(sc), Some(el), Some(ec)) => Some((
152                Position { line: sl, column: sc },
153                Position { line: el, column: ec },
154            )),
155            _ => None,
156        };
157
158        NewIssue {
159            marker: None, // persistent — immune to ClearIssuesByMarker
160            source: self.source.clone(),
161            path: self.path.clone(),
162            range,
163            message: self.message.clone(),
164            severity,
165        }
166    }
167
168    /// Build a record from an in-memory [`Issue`].
169    pub fn from_issue(issue: &Issue) -> Self {
170        let severity = match issue.severity {
171            Severity::Error => "error",
172            Severity::Warning => "warning",
173            Severity::Info => "info",
174            // Todo issues are always ephemeral; they are never written to
175            // .oo/issues.yaml so this branch is unreachable in practice.
176            // Fall back to "info" so the serialisation doesn't panic.
177            Severity::Todo => "info",
178        }
179        .to_string();
180
181        let created_at_secs = issue
182            .created_at
183            .duration_since(UNIX_EPOCH)
184            .unwrap_or(Duration::ZERO)
185            .as_secs();
186
187        let (rsl, rsc, rel, rec) = match &issue.range {
188            Some((s, e)) => (Some(s.line), Some(s.column), Some(e.line), Some(e.column)),
189            None => (None, None, None, None),
190        };
191
192        Self {
193            source: issue.source.clone(),
194            severity,
195            message: issue.message.clone(),
196            dismissed: issue.dismissed,
197            resolved: issue.resolved,
198            created_at_secs,
199            path: issue.path.clone(),
200            range_start_line: rsl,
201            range_start_col: rsc,
202            range_end_line: rel,
203            range_end_col: rec,
204        }
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Public API
210// ---------------------------------------------------------------------------
211
212/// Load persistent issues from `path`.
213///
214/// * If `path` does not exist, returns `Ok(vec![])` (first-run friendly).
215/// * If the file is unreadable or the YAML is malformed, logs a warning and
216///   returns `Ok(vec![])` — the IDE continues with an empty persistent list.
217pub fn load(path: &Path) -> Result<Vec<NewIssue>> {
218    let data = match std::fs::read_to_string(path) {
219        Ok(s) => s,
220        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
221        Err(e) => {
222            log::warn!("persistent issues: failed to read {:?}: {}", path, e);
223            return Ok(Vec::new());
224        }
225    };
226
227    let file: IssueFile = match serde_saphyr::from_str(&data) {
228        Ok(f) => f,
229        Err(e) => {
230            log::warn!("persistent issues: YAML parse error in {:?}: {}", path, e);
231            return Ok(Vec::new());
232        }
233    };
234
235    if file.format_version != FORMAT_VERSION {
236        log::warn!(
237            "persistent issues: unknown format_version {} in {:?}, loading anyway",
238            file.format_version,
239            path
240        );
241    }
242
243    Ok(file.issues.iter().map(IssueRecord::to_new_issue).collect())
244}
245
246/// Atomically write `issues` to `path`.
247///
248/// Writes to `path` with a `.tmp` extension, then renames to `path` so that
249/// readers never see a half-written file.  The parent directory is created if
250/// it does not exist.
251pub fn save_atomic(path: &Path, issues: &[Issue]) -> Result<()> {
252    if let Some(parent) = path.parent() {
253        std::fs::create_dir_all(parent)?;
254    }
255
256    let records: Vec<IssueRecord> = issues.iter().map(IssueRecord::from_issue).collect();
257    let file = IssueFile {
258        format_version: FORMAT_VERSION,
259        issues: records,
260    };
261
262    let yaml = serde_saphyr::to_string(&file)?;
263
264    let tmp_path = path.with_extension("yaml.tmp");
265    std::fs::write(&tmp_path, &yaml)?;
266    std::fs::rename(&tmp_path, path)?;
267
268    Ok(())
269}
270
271// ---------------------------------------------------------------------------
272// Tests
273// ---------------------------------------------------------------------------
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::issue_registry::Severity;
279    use std::time::SystemTime;
280    use tempfile::tempdir;
281
282    fn make_issue(id: u64, source: &str, sev: Severity, msg: &str, marker: Option<&str>) -> Issue {
283        Issue {
284            id,
285            marker: marker.map(|s| s.to_string()),
286            source: source.to_string(),
287            path: None,
288            range: None,
289            message: msg.to_string(),
290            severity: sev,
291            dismissed: false,
292            resolved: false,
293            created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_706_745_600),
294        }
295    }
296
297    // ── load ────────────────────────────────────────────────────────────────
298
299    #[test]
300    fn load_missing_file_returns_empty() {
301        let dir = tempdir().unwrap();
302        let path = dir.path().join("issues.yaml");
303        let result = load(&path).unwrap();
304        assert!(result.is_empty());
305    }
306
307    #[test]
308    fn load_corrupt_yaml_returns_empty() {
309        let dir = tempdir().unwrap();
310        let path = dir.path().join("issues.yaml");
311        std::fs::write(&path, b"not: valid: yaml: :::").unwrap();
312        let result = load(&path).unwrap();
313        assert!(result.is_empty());
314    }
315
316    #[test]
317    fn load_empty_issues_list() {
318        let dir = tempdir().unwrap();
319        let path = dir.path().join("issues.yaml");
320        std::fs::write(&path, b"format_version: 1\nissues: []\n").unwrap();
321        let result = load(&path).unwrap();
322        assert!(result.is_empty());
323    }
324
325    #[test]
326    fn load_parses_issues() {
327        let dir = tempdir().unwrap();
328        let path = dir.path().join("issues.yaml");
329        let yaml = r#"
330format_version: 1
331issues:
332  - source: "user"
333    severity: "warning"
334    message: "Fix me"
335    dismissed: false
336    resolved: true
337    created_at_secs: 1000
338    path: "src/main.rs"
339    range_start_line: 5
340    range_start_col: 0
341    range_end_line: 5
342    range_end_col: 20
343"#;
344        std::fs::write(&path, yaml).unwrap();
345        let issues = load(&path).unwrap();
346        assert_eq!(issues.len(), 1);
347        let ni = &issues[0];
348        assert_eq!(ni.marker, None);
349        assert_eq!(ni.source, "user");
350        assert_eq!(ni.severity, Severity::Warning);
351        assert_eq!(ni.message, "Fix me");
352        assert_eq!(ni.path, Some(PathBuf::from("src/main.rs")));
353        let (s, e) = ni.range.unwrap();
354        assert_eq!(s.line, 5);
355        assert_eq!(s.column, 0);
356        assert_eq!(e.line, 5);
357        assert_eq!(e.column, 20);
358    }
359
360    #[test]
361    fn load_severity_fallback_to_info() {
362        let dir = tempdir().unwrap();
363        let path = dir.path().join("issues.yaml");
364        let yaml = "format_version: 1\nissues:\n  - source: x\n    severity: banana\n    message: hi\n    dismissed: false\n    resolved: false\n    created_at_secs: 0\n";
365        std::fs::write(&path, yaml).unwrap();
366        let issues = load(&path).unwrap();
367        assert_eq!(issues[0].severity, Severity::Info);
368    }
369
370    // ── save_atomic ─────────────────────────────────────────────────────────
371
372    #[test]
373    fn save_atomic_creates_file() {
374        let dir = tempdir().unwrap();
375        let path = dir.path().join("issues.yaml");
376        let issue = make_issue(1, "user", Severity::Error, "oops", None);
377        save_atomic(&path, &[issue]).unwrap();
378        assert!(path.exists());
379        assert!(!dir.path().join("issues.yaml.tmp").exists(), "tmp file should be renamed away");
380    }
381
382    #[test]
383    fn save_atomic_creates_parent_dir() {
384        let dir = tempdir().unwrap();
385        let path = dir.path().join("subdir").join("issues.yaml");
386        let issue = make_issue(1, "user", Severity::Info, "note", None);
387        save_atomic(&path, &[issue]).unwrap();
388        assert!(path.exists());
389    }
390
391    #[test]
392    fn round_trip_preserves_fields() {
393        let dir = tempdir().unwrap();
394        let path = dir.path().join("issues.yaml");
395
396        let mut issue = make_issue(1, "lsp", Severity::Error, "null ptr", None);
397        issue.dismissed = true;
398        issue.resolved = false;
399        issue.path = Some(PathBuf::from("src/lib.rs"));
400        issue.range = Some((
401            Position { line: 10, column: 3 },
402            Position { line: 10, column: 15 },
403        ));
404
405        save_atomic(&path, &[issue]).unwrap();
406        let loaded = load(&path).unwrap();
407
408        assert_eq!(loaded.len(), 1);
409        let ni = &loaded[0];
410        assert_eq!(ni.marker, None);
411        assert_eq!(ni.source, "lsp");
412        assert_eq!(ni.severity, Severity::Error);
413        assert_eq!(ni.message, "null ptr");
414        assert_eq!(ni.path, Some(PathBuf::from("src/lib.rs")));
415        let (s, e) = ni.range.unwrap();
416        assert_eq!(s, Position { line: 10, column: 3 });
417        assert_eq!(e, Position { line: 10, column: 15 });
418    }
419
420    #[test]
421    fn save_empty_issues_then_load() {
422        let dir = tempdir().unwrap();
423        let path = dir.path().join("issues.yaml");
424        save_atomic(&path, &[]).unwrap();
425        let loaded = load(&path).unwrap();
426        assert!(loaded.is_empty());
427    }
428
429    #[test]
430    fn ephemeral_issue_not_persisted_by_design() {
431        // Callers in apply_operation filter to persistent (marker=None) before calling
432        // save_atomic. This test verifies the from_issue round-trip ignores the marker
433        // field (it is not stored in IssueRecord — persistent issues never have a marker).
434        let dir = tempdir().unwrap();
435        let path = dir.path().join("issues.yaml");
436        let ephemeral = make_issue(2, "build", Severity::Warning, "unused", Some("build"));
437        // A well-behaved caller would never pass ephemeral issues to save_atomic.
438        // But if it did, the record has no marker field so it would reload as persistent.
439        save_atomic(&path, &[ephemeral]).unwrap();
440        let loaded = load(&path).unwrap();
441        assert_eq!(loaded.len(), 1);
442        assert_eq!(loaded[0].marker, None, "records always load as persistent");
443    }
444}