use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Info,
Warning,
Error,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageFinding {
pub severity: FindingSeverity,
pub code: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realm: Option<String>,
}
impl StorageFinding {
pub fn new(
severity: FindingSeverity,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity,
code: code.into(),
message: message.into(),
path: None,
realm: None,
}
}
#[must_use]
pub fn with_path(mut self, path: PathBuf) -> Self {
self.path = Some(path);
self
}
#[must_use]
pub fn with_realm(mut self, realm: impl Into<String>) -> Self {
self.realm = Some(realm.into());
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseInventory {
pub path: PathBuf,
#[serde(default)]
pub domains: Vec<(String, Option<i64>)>,
}
impl DatabaseInventory {
pub fn new(path: PathBuf) -> Self {
Self {
path,
domains: Vec::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageInventoryEntry {
pub realm: String,
pub root: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<String>,
#[serde(default)]
pub databases: Vec<DatabaseInventory>,
}
impl StorageInventoryEntry {
pub fn new(realm: impl Into<String>, root: PathBuf) -> Self {
Self {
realm: realm.into(),
root,
backend: None,
databases: Vec::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StorageDiagnosis {
#[serde(default)]
pub findings: Vec<StorageFinding>,
#[serde(default)]
pub inventory: Vec<StorageInventoryEntry>,
}
impl StorageDiagnosis {
pub fn count(&self, severity: FindingSeverity) -> usize {
self.findings
.iter()
.filter(|f| f.severity == severity)
.count()
}
pub fn has_errors(&self) -> bool {
self.findings
.iter()
.any(|f| f.severity == FindingSeverity::Error)
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiagnoseScope {
pub state_roots: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realm: Option<String>,
}
impl DiagnoseScope {
pub fn new(state_roots: Vec<PathBuf>) -> Self {
Self {
state_roots,
realm: None,
}
}
#[must_use]
pub fn with_realm(mut self, realm: impl Into<String>) -> Self {
self.realm = Some(realm.into());
self
}
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum StorageDiagnosticsError {
#[error("storage diagnosis unsupported: {0}")]
Unsupported(String),
#[error("storage diagnosis failed: {0}")]
Backend(String),
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait StorageMigrator: Send + Sync {
async fn diagnose(
&self,
scope: &DiagnoseScope,
) -> Result<StorageDiagnosis, StorageDiagnosticsError>;
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn finding_json_shape_is_stable() {
let finding = StorageFinding::new(
FindingSeverity::Error,
"split-brain-realm",
"realm 'team' exists under two roots",
)
.with_path(PathBuf::from("/a/team"))
.with_realm("team");
let json = serde_json::to_value(&finding).expect("serialize");
assert_eq!(
json,
serde_json::json!({
"severity": "error",
"code": "split-brain-realm",
"message": "realm 'team' exists under two roots",
"path": "/a/team",
"realm": "team",
})
);
}
#[test]
fn optional_fields_are_omitted_and_default_on_read() {
let finding = StorageFinding::new(FindingSeverity::Info, "no-schema-ledger", "pre-arc db");
let json = serde_json::to_string(&finding).expect("serialize");
assert!(!json.contains("path"));
assert!(!json.contains("realm"));
let parsed: StorageFinding = serde_json::from_str(
r#"{"severity":"warning","code":"orphaned-lease","message":"m","future_field":1}"#,
)
.expect("deserialize with unknown field");
assert_eq!(parsed.severity, FindingSeverity::Warning);
assert!(parsed.path.is_none());
}
#[test]
fn diagnosis_error_detection() {
let mut diagnosis = StorageDiagnosis::default();
assert!(!diagnosis.has_errors());
diagnosis.findings.push(StorageFinding::new(
FindingSeverity::Warning,
"orphaned-lease",
"1 stale lease",
));
assert!(!diagnosis.has_errors());
diagnosis.findings.push(StorageFinding::new(
FindingSeverity::Error,
"dangling-blob-reference",
"missing blob",
));
assert!(diagnosis.has_errors());
assert_eq!(diagnosis.count(FindingSeverity::Error), 1);
assert_eq!(diagnosis.count(FindingSeverity::Warning), 1);
assert_eq!(diagnosis.count(FindingSeverity::Info), 0);
}
#[test]
fn diagnosis_round_trips_through_json() {
let mut diagnosis = StorageDiagnosis::default();
let mut entry = StorageInventoryEntry::new("team", PathBuf::from("/roots/a/team"));
entry.backend = Some("sqlite".to_string());
let mut db = DatabaseInventory::new(PathBuf::from("/roots/a/team/sessions.sqlite3"));
db.domains.push(("session-store".to_string(), Some(1)));
db.domains.push(("schedule-store".to_string(), None));
entry.databases.push(db);
diagnosis.inventory.push(entry);
let json = serde_json::to_string(&diagnosis).expect("serialize");
let parsed: StorageDiagnosis = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.inventory.len(), 1);
assert_eq!(parsed.inventory[0].backend.as_deref(), Some("sqlite"));
assert_eq!(
parsed.inventory[0].databases[0].domains,
vec![
("session-store".to_string(), Some(1)),
("schedule-store".to_string(), None),
]
);
}
}