use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::editor::position::Position;
use crate::issue_registry::{Issue, NewIssue, Severity};
#[derive(Debug, Clone)]
pub struct PersistentNewIssue {
pub source: String,
pub path: Option<std::path::PathBuf>,
pub range: Option<(Position, Position)>,
pub message: String,
pub severity: Severity,
}
impl PersistentNewIssue {
pub(crate) fn into_new_issue(self) -> NewIssue {
NewIssue {
marker: None,
source: self.source,
path: self.path,
range: self.range,
message: self.message,
severity: self.severity,
}
}
}
const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueFile {
pub format_version: u32,
pub issues: Vec<IssueRecord>,
}
impl Default for IssueFile {
fn default() -> Self {
Self {
format_version: FORMAT_VERSION,
issues: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueRecord {
pub source: String,
pub severity: String,
pub message: String,
pub dismissed: bool,
pub resolved: bool,
pub created_at_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range_start_line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range_start_col: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range_end_line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range_end_col: Option<usize>,
}
impl IssueRecord {
pub fn to_new_issue(&self) -> NewIssue {
let severity = match self.severity.to_ascii_lowercase().as_str() {
"error" => Severity::Error,
"warning" | "warn" => Severity::Warning,
_ => Severity::Info,
};
let range = match (
self.range_start_line,
self.range_start_col,
self.range_end_line,
self.range_end_col,
) {
(Some(sl), Some(sc), Some(el), Some(ec)) => Some((
Position { line: sl, column: sc },
Position { line: el, column: ec },
)),
_ => None,
};
NewIssue {
marker: None, source: self.source.clone(),
path: self.path.clone(),
range,
message: self.message.clone(),
severity,
}
}
pub fn from_issue(issue: &Issue) -> Self {
let severity = match issue.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Todo => "info",
}
.to_string();
let created_at_secs = issue
.created_at
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let (rsl, rsc, rel, rec) = match &issue.range {
Some((s, e)) => (Some(s.line), Some(s.column), Some(e.line), Some(e.column)),
None => (None, None, None, None),
};
Self {
source: issue.source.clone(),
severity,
message: issue.message.clone(),
dismissed: issue.dismissed,
resolved: issue.resolved,
created_at_secs,
path: issue.path.clone(),
range_start_line: rsl,
range_start_col: rsc,
range_end_line: rel,
range_end_col: rec,
}
}
}
pub fn load(path: &Path) -> Result<Vec<NewIssue>> {
let data = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
log::warn!("persistent issues: failed to read {:?}: {}", path, e);
return Ok(Vec::new());
}
};
let file: IssueFile = match serde_saphyr::from_str(&data) {
Ok(f) => f,
Err(e) => {
log::warn!("persistent issues: YAML parse error in {:?}: {}", path, e);
return Ok(Vec::new());
}
};
if file.format_version != FORMAT_VERSION {
log::warn!(
"persistent issues: unknown format_version {} in {:?}, loading anyway",
file.format_version,
path
);
}
Ok(file.issues.iter().map(IssueRecord::to_new_issue).collect())
}
pub fn save_atomic(path: &Path, issues: &[Issue]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let records: Vec<IssueRecord> = issues.iter().map(IssueRecord::from_issue).collect();
let file = IssueFile {
format_version: FORMAT_VERSION,
issues: records,
};
let yaml = serde_saphyr::to_string(&file)?;
let tmp_path = path.with_extension("yaml.tmp");
std::fs::write(&tmp_path, &yaml)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::issue_registry::Severity;
use std::time::SystemTime;
use tempfile::tempdir;
fn make_issue(id: u64, source: &str, sev: Severity, msg: &str, marker: Option<&str>) -> Issue {
Issue {
id,
marker: marker.map(|s| s.to_string()),
source: source.to_string(),
path: None,
range: None,
message: msg.to_string(),
severity: sev,
dismissed: false,
resolved: false,
created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_706_745_600),
}
}
#[test]
fn load_missing_file_returns_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
let result = load(&path).unwrap();
assert!(result.is_empty());
}
#[test]
fn load_corrupt_yaml_returns_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
std::fs::write(&path, b"not: valid: yaml: :::").unwrap();
let result = load(&path).unwrap();
assert!(result.is_empty());
}
#[test]
fn load_empty_issues_list() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
std::fs::write(&path, b"format_version: 1\nissues: []\n").unwrap();
let result = load(&path).unwrap();
assert!(result.is_empty());
}
#[test]
fn load_parses_issues() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
let yaml = r#"
format_version: 1
issues:
- source: "user"
severity: "warning"
message: "Fix me"
dismissed: false
resolved: true
created_at_secs: 1000
path: "src/main.rs"
range_start_line: 5
range_start_col: 0
range_end_line: 5
range_end_col: 20
"#;
std::fs::write(&path, yaml).unwrap();
let issues = load(&path).unwrap();
assert_eq!(issues.len(), 1);
let ni = &issues[0];
assert_eq!(ni.marker, None);
assert_eq!(ni.source, "user");
assert_eq!(ni.severity, Severity::Warning);
assert_eq!(ni.message, "Fix me");
assert_eq!(ni.path, Some(PathBuf::from("src/main.rs")));
let (s, e) = ni.range.unwrap();
assert_eq!(s.line, 5);
assert_eq!(s.column, 0);
assert_eq!(e.line, 5);
assert_eq!(e.column, 20);
}
#[test]
fn load_severity_fallback_to_info() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
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";
std::fs::write(&path, yaml).unwrap();
let issues = load(&path).unwrap();
assert_eq!(issues[0].severity, Severity::Info);
}
#[test]
fn save_atomic_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
let issue = make_issue(1, "user", Severity::Error, "oops", None);
save_atomic(&path, &[issue]).unwrap();
assert!(path.exists());
assert!(!dir.path().join("issues.yaml.tmp").exists(), "tmp file should be renamed away");
}
#[test]
fn save_atomic_creates_parent_dir() {
let dir = tempdir().unwrap();
let path = dir.path().join("subdir").join("issues.yaml");
let issue = make_issue(1, "user", Severity::Info, "note", None);
save_atomic(&path, &[issue]).unwrap();
assert!(path.exists());
}
#[test]
fn round_trip_preserves_fields() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
let mut issue = make_issue(1, "lsp", Severity::Error, "null ptr", None);
issue.dismissed = true;
issue.resolved = false;
issue.path = Some(PathBuf::from("src/lib.rs"));
issue.range = Some((
Position { line: 10, column: 3 },
Position { line: 10, column: 15 },
));
save_atomic(&path, &[issue]).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded.len(), 1);
let ni = &loaded[0];
assert_eq!(ni.marker, None);
assert_eq!(ni.source, "lsp");
assert_eq!(ni.severity, Severity::Error);
assert_eq!(ni.message, "null ptr");
assert_eq!(ni.path, Some(PathBuf::from("src/lib.rs")));
let (s, e) = ni.range.unwrap();
assert_eq!(s, Position { line: 10, column: 3 });
assert_eq!(e, Position { line: 10, column: 15 });
}
#[test]
fn save_empty_issues_then_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
save_atomic(&path, &[]).unwrap();
let loaded = load(&path).unwrap();
assert!(loaded.is_empty());
}
#[test]
fn ephemeral_issue_not_persisted_by_design() {
let dir = tempdir().unwrap();
let path = dir.path().join("issues.yaml");
let ephemeral = make_issue(2, "build", Severity::Warning, "unused", Some("build"));
save_atomic(&path, &[ephemeral]).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].marker, None, "records always load as persistent");
}
}