use serde::{Deserialize, Serialize};
use std::path::Path;
use boxlite_shared::errors::BoxliteError;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[doc(hidden)]
#[serde(rename_all = "snake_case")]
pub enum ExitErrorKind {
#[default]
Engine,
Unsupported,
}
impl ExitErrorKind {
pub fn of(error: &BoxliteError) -> Self {
match error {
BoxliteError::Unsupported(_) => Self::Unsupported,
_ => Self::Engine,
}
}
fn is_engine(&self) -> bool {
matches!(self, Self::Engine)
}
}
impl<'de> Deserialize<'de> for ExitErrorKind {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(match String::deserialize(deserializer)?.as_str() {
"unsupported" => Self::Unsupported,
_ => Self::Engine,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ExitInfo {
Signal { exit_code: i32, signal: String },
Panic {
exit_code: i32,
message: String,
location: String,
},
Error {
exit_code: i32,
message: String,
#[doc(hidden)]
#[serde(default, skip_serializing_if = "ExitErrorKind::is_engine")]
error_kind: ExitErrorKind,
},
}
impl ExitInfo {
pub fn from_file(path: &Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn exit_code(&self) -> i32 {
match self {
ExitInfo::Signal { exit_code, .. } => *exit_code,
ExitInfo::Panic { exit_code, .. } => *exit_code,
ExitInfo::Error { exit_code, .. } => *exit_code,
}
}
pub fn signal_name(&self) -> Option<&str> {
match self {
ExitInfo::Signal { signal, .. } => Some(signal),
ExitInfo::Panic { .. } | ExitInfo::Error { .. } => None,
}
}
pub fn panic_message(&self) -> Option<&str> {
match self {
ExitInfo::Panic { message, .. } => Some(message),
ExitInfo::Signal { .. } | ExitInfo::Error { .. } => None,
}
}
pub fn error_message(&self) -> Option<&str> {
match self {
ExitInfo::Error { message, .. } => Some(message),
ExitInfo::Signal { .. } | ExitInfo::Panic { .. } => None,
}
}
pub fn error_kind(&self) -> ExitErrorKind {
match self {
ExitInfo::Error { error_kind, .. } => *error_kind,
ExitInfo::Signal { .. } | ExitInfo::Panic { .. } => ExitErrorKind::Engine,
}
}
pub fn is_signal(&self) -> bool {
matches!(self, ExitInfo::Signal { .. })
}
pub fn is_panic(&self) -> bool {
matches!(self, ExitInfo::Panic { .. })
}
pub fn is_error(&self) -> bool {
matches!(self, ExitInfo::Error { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signal_serialization() {
let info = ExitInfo::Signal {
exit_code: 134,
signal: "SIGABRT".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""type":"signal""#));
assert!(json.contains(r#""exit_code":134"#));
assert!(json.contains(r#""signal":"SIGABRT""#));
let parsed: ExitInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.exit_code(), 134);
assert_eq!(parsed.signal_name(), Some("SIGABRT"));
assert!(parsed.is_signal());
}
#[test]
fn test_panic_serialization() {
let info = ExitInfo::Panic {
exit_code: 101,
message: "explicit panic".to_string(),
location: "main.rs:42:5".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""type":"panic""#));
assert!(json.contains(r#""exit_code":101"#));
assert!(json.contains(r#""message":"explicit panic""#));
let parsed: ExitInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.exit_code(), 101);
assert_eq!(parsed.panic_message(), Some("explicit panic"));
assert!(parsed.is_panic());
}
#[test]
fn test_from_file_not_found() {
let result = ExitInfo::from_file(Path::new("/nonexistent/path"));
assert!(result.is_none());
}
#[test]
fn test_from_file_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("exit");
std::fs::write(&path, "not valid json").unwrap();
let result = ExitInfo::from_file(&path);
assert!(result.is_none());
}
#[test]
fn test_from_file_valid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("exit");
std::fs::write(
&path,
r#"{"type":"signal","exit_code":134,"signal":"SIGABRT"}"#,
)
.unwrap();
let result = ExitInfo::from_file(&path);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.exit_code(), 134);
assert_eq!(info.signal_name(), Some("SIGABRT"));
}
#[test]
fn test_error_serialization() {
let info = ExitInfo::Error {
exit_code: 1,
message: "Failed to create VM instance".to_string(),
error_kind: ExitErrorKind::Engine,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""type":"error""#));
assert!(json.contains(r#""exit_code":1"#));
assert!(json.contains(r#""message":"Failed to create VM instance""#));
assert!(!json.contains("error_kind"));
let parsed: ExitInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.exit_code(), 1);
assert_eq!(parsed.error_message(), Some("Failed to create VM instance"));
assert_eq!(parsed.error_kind(), ExitErrorKind::Engine);
assert!(parsed.is_error());
}
#[test]
fn test_error_kind_roundtrips_through_wire_names() {
for kind in [ExitErrorKind::Engine, ExitErrorKind::Unsupported] {
let info = ExitInfo::Error {
exit_code: 1,
message: "boom".to_string(),
error_kind: kind,
};
let json = serde_json::to_string(&info).unwrap();
let parsed: ExitInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.error_kind(), kind, "{json}");
}
}
#[test]
fn test_unsupported_error_kind_uses_expected_wire_name() {
let json = serde_json::to_string(&ExitInfo::Error {
exit_code: 1,
message: "nested virtualization is unavailable".to_string(),
error_kind: ExitErrorKind::Unsupported,
})
.unwrap();
assert!(json.contains(r#""error_kind":"unsupported""#));
}
#[test]
fn test_unknown_error_kind_keeps_the_record_readable() {
let json =
r#"{"type":"error","exit_code":1,"message":"new error","error_kind":"future_kind"}"#;
let parsed: ExitInfo = serde_json::from_str(json).expect("unknown category must not fail");
assert_eq!(parsed.error_message(), Some("new error"));
assert_eq!(parsed.error_kind(), ExitErrorKind::Engine);
}
#[test]
fn test_error_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("exit");
std::fs::write(
&path,
r#"{"type":"error","exit_code":1,"message":"test error"}"#,
)
.unwrap();
let result = ExitInfo::from_file(&path);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.exit_code(), 1);
assert_eq!(info.error_message(), Some("test error"));
assert_eq!(info.error_kind(), ExitErrorKind::Engine);
assert!(info.is_error());
}
#[test]
fn test_boxlite_error_mapping_only_special_cases_unsupported() {
assert_eq!(
ExitErrorKind::of(&BoxliteError::Unsupported("feature".into())),
ExitErrorKind::Unsupported
);
assert_eq!(
ExitErrorKind::of(&BoxliteError::Engine("vmm".into())),
ExitErrorKind::Engine
);
assert_eq!(
ExitErrorKind::of(&BoxliteError::Config("invalid".into())),
ExitErrorKind::Engine
);
}
}