use serde::{Deserialize, Serialize};
use crate::engine::FsmState;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MigrationStatus {
pub current_state: FsmState,
pub run_id: String,
pub active_file: String,
pub retry_count: u32,
pub total_applied: usize,
pub last_applied: String,
pub last_applied_at_unix: Option<u64>,
pub pending_count: usize,
pub manifest_checksum: String,
pub generator_version: String,
pub drift_count: usize,
pub drift_summary: Vec<String>,
pub lint_error_count: usize,
pub lint_warning_count: usize,
pub last_error: Option<ErrorSummary>,
pub last_completed_at_unix: Option<u64>,
pub checked_at_unix: u64,
}
impl Default for MigrationStatus {
fn default() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
Self {
current_state: FsmState::Idle,
run_id: String::new(),
active_file: String::new(),
retry_count: 0,
total_applied: 0,
last_applied: String::new(),
last_applied_at_unix: None,
pending_count: 0,
manifest_checksum: String::new(),
generator_version: String::new(),
drift_count: 0,
drift_summary: Vec::new(),
lint_error_count: 0,
lint_warning_count: 0,
last_error: None,
last_completed_at_unix: None,
checked_at_unix: now,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ErrorSummary {
pub id: i64,
pub run_id: String,
pub fsm_state: String,
pub filename: String,
pub error_message: String,
pub failed_at_unix: u64,
pub resolved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ErrorLogEntry {
pub id: i64,
pub run_id: String,
pub fsm_state: String,
pub filename: String,
pub error_message: String,
pub error_detail_json: String,
pub failed_at_unix: u64,
pub resolved: bool,
pub recovery_note: String,
}
impl MigrationStatus {
pub fn format_summary(&self) -> String {
let mut lines = Vec::new();
lines
.push("━━━━━━━━━━━━━━━━━━━━━━━━ Migration Status ━━━━━━━━━━━━━━━━━━━━━━━━".to_string());
lines.push(format!(" State : {}", self.current_state));
lines.push(format!(" Run ID : {}", self.run_id));
lines.push(format!(" Applied : {} file(s)", self.total_applied));
lines.push(format!(" Pending : {} file(s)", self.pending_count));
if !self.last_applied.is_empty() {
lines.push(format!(" Last Applied : {}", self.last_applied));
}
if !self.active_file.is_empty() {
lines.push(format!(" Active File : {}", self.active_file));
}
if self.retry_count > 0 {
lines.push(format!(
" Retry Count : {}/{}",
self.retry_count,
crate::engine::MAX_RETRIES
));
}
if !self.manifest_checksum.is_empty() {
lines.push(format!(
" Manifest : {}",
&self.manifest_checksum[..16.min(self.manifest_checksum.len())]
));
}
if self.drift_count > 0 {
lines.push(format!(" Drift : {} item(s)", self.drift_count));
for summary in &self.drift_summary {
lines.push(format!(" ↳ {summary}"));
}
}
if self.lint_error_count > 0 || self.lint_warning_count > 0 {
lines.push(format!(
" Lint : {} error(s), {} warning(s)",
self.lint_error_count, self.lint_warning_count
));
}
if let Some(err) = &self.last_error
&& !err.resolved
{
lines.push(format!(
" Last Error : [{}] {} (file: {}, at: {})",
err.fsm_state, err.error_message, err.filename, err.failed_at_unix
));
}
lines
.push("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string());
lines.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_status_idle_state() {
let s = MigrationStatus::default();
assert_eq!(s.current_state, FsmState::Idle);
assert_eq!(s.total_applied, 0);
assert!(s.last_error.is_none());
}
#[test]
fn format_summary_contains_state() {
let s = MigrationStatus::default();
let summary = s.format_summary();
assert!(summary.contains("IDLE"), "summary must include FSM state");
assert!(
summary.contains("Migration Status"),
"summary must include header"
);
}
}