1use 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#[derive(Debug, Clone)]
61pub struct PersistentNewIssue {
62 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 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
84const FORMAT_VERSION: u32 = 1;
89
90#[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#[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 pub created_at_secs: u64,
118
119 #[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
132impl IssueRecord {
137 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, source: self.source.clone(),
161 path: self.path.clone(),
162 range,
163 message: self.message.clone(),
164 severity,
165 }
166 }
167
168 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 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
208pub 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
246pub 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#[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 #[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 #[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 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 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}