use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct VmId(String);
impl VmId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for VmId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for VmId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for VmId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum VmCommand {
Execute { command: String },
Shutdown,
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum VmEvent {
Ready,
Output { stream: OutputStream, data: String },
CommandCompleted { exit_code: i32 },
Pong,
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputStream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LogStream {
Stdout,
Stderr,
Supervisor,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogLine {
pub stream: LogStream,
pub line: String,
pub timestamp: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Low = 0,
Normal = 1,
High = 2,
Critical = 3,
}
impl Default for Priority {
fn default() -> Self {
Priority::Normal
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Priority::Low => f.write_str("low"),
Priority::Normal => f.write_str("normal"),
Priority::High => f.write_str("high"),
Priority::Critical => f.write_str("critical"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct VmConfig {
#[serde(default)]
pub cpus: Option<u32>,
#[serde(default)]
pub memory_mb: Option<u32>,
#[serde(default)]
pub priority: Priority,
#[serde(default)]
pub env: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServiceCommand {
Allocate { image: String, config: VmConfig },
Deallocate { vm_id: VmId },
Send { vm_id: VmId, command: VmCommand },
Snapshot { vm_id: VmId, name: String },
Restore { vm_id: VmId, snapshot: String },
Status,
TailLogs { vm_id: VmId, lines: usize },
SubscribeLogs { vm_id: Option<VmId> },
UnsubscribeLogs,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServiceEvent {
VmAllocated { vm_id: VmId, image: String },
VmReady { vm_id: VmId },
VmEvent { vm_id: VmId, event: VmEvent },
VmStopped { vm_id: VmId },
VmCrashed { vm_id: VmId, error: String },
PoolStatus {
total: usize,
available: usize,
allocated: usize,
},
VmLog {
vm_id: VmId,
stream: LogStream,
line: String,
},
LogTail { vm_id: VmId, lines: Vec<LogLine> },
LogsSubscribed { vm_id: Option<VmId> },
Error { message: String },
}
pub fn encode_json_line<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
let mut json = serde_json::to_string(value)?;
json.push('\n');
Ok(json)
}
pub fn decode_json_line<'a, T: Deserialize<'a>>(line: &'a str) -> Result<T, serde_json::Error> {
serde_json::from_str(line.trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vm_id_display() {
let id = VmId::new("vm-abc123");
assert_eq!(id.to_string(), "vm-abc123");
assert_eq!(id.as_str(), "vm-abc123");
}
#[test]
fn vm_id_serde_transparent() {
let id = VmId::new("vm-abc123");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"vm-abc123\"");
let parsed: VmId = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, id);
}
#[test]
fn vm_id_equality_and_hash() {
use std::collections::HashSet;
let a = VmId::new("vm-1");
let b = VmId::from("vm-1".to_string());
let c: VmId = "vm-1".into();
assert_eq!(a, b);
assert_eq!(b, c);
let mut set = HashSet::new();
set.insert(a);
assert!(set.contains(&b));
}
#[test]
fn vm_command_execute_roundtrip() {
let cmd = VmCommand::Execute {
command: "ls -la".into(),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"type\":\"execute\""));
let parsed: VmCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn vm_command_shutdown_roundtrip() {
let cmd = VmCommand::Shutdown;
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(json, "{\"type\":\"shutdown\"}");
let parsed: VmCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn vm_command_ping_roundtrip() {
let cmd = VmCommand::Ping;
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(json, "{\"type\":\"ping\"}");
let parsed: VmCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn vm_event_ready_roundtrip() {
let event = VmEvent::Ready;
let json = serde_json::to_string(&event).unwrap();
assert_eq!(json, "{\"type\":\"ready\"}");
let parsed: VmEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn vm_event_output_roundtrip() {
let event = VmEvent::Output {
stream: OutputStream::Stdout,
data: "hello world\n".into(),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: VmEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn vm_event_command_completed_roundtrip() {
let event = VmEvent::CommandCompleted { exit_code: 42 };
let json = serde_json::to_string(&event).unwrap();
let parsed: VmEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn service_command_allocate_roundtrip() {
let cmd = ServiceCommand::Allocate {
image: "agent:v1.0.0".into(),
config: VmConfig {
cpus: Some(2),
memory_mb: Some(4096),
priority: Priority::High,
env: vec![("KEY".into(), "VALUE".into())],
},
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: ServiceCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn service_command_status_roundtrip() {
let cmd = ServiceCommand::Status;
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(json, "{\"type\":\"status\"}");
let parsed: ServiceCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn service_command_send_roundtrip() {
let cmd = ServiceCommand::Send {
vm_id: VmId::new("vm-abc"),
command: VmCommand::Execute {
command: "echo hi".into(),
},
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: ServiceCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn service_event_error_roundtrip() {
let event = ServiceEvent::Error {
message: "pool exhausted".into(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"error\""));
let parsed: ServiceEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn service_event_pool_status_roundtrip() {
let event = ServiceEvent::PoolStatus {
total: 6,
available: 4,
allocated: 2,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: ServiceEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn service_event_log_tail_roundtrip() {
let event = ServiceEvent::LogTail {
vm_id: VmId::new("vm-1"),
lines: vec![
LogLine {
stream: LogStream::Stdout,
line: "output line".into(),
timestamp: 1234567890,
},
LogLine {
stream: LogStream::Stderr,
line: "error line".into(),
timestamp: 1234567891,
},
],
};
let json = serde_json::to_string(&event).unwrap();
let parsed: ServiceEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn vm_config_defaults() {
let config = VmConfig::default();
assert_eq!(config.cpus, None);
assert_eq!(config.memory_mb, None);
assert!(config.env.is_empty());
}
#[test]
fn vm_config_missing_fields_deserialize() {
let json = "{}";
let config: VmConfig = serde_json::from_str(json).unwrap();
assert_eq!(config, VmConfig::default());
}
#[test]
fn encode_decode_json_line() {
let cmd = VmCommand::Ping;
let line = encode_json_line(&cmd).unwrap();
assert!(line.ends_with('\n'));
assert!(!line[..line.len() - 1].contains('\n'));
let parsed: VmCommand = decode_json_line(&line).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn log_stream_variants() {
let streams = [LogStream::Stdout, LogStream::Stderr, LogStream::Supervisor];
for stream in streams {
let json = serde_json::to_string(&stream).unwrap();
let parsed: LogStream = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, stream);
}
}
#[test]
fn output_stream_variants() {
let streams = [OutputStream::Stdout, OutputStream::Stderr];
for stream in streams {
let json = serde_json::to_string(&stream).unwrap();
let parsed: OutputStream = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, stream);
}
}
#[test]
fn service_command_subscribe_logs_with_vm_id() {
let cmd = ServiceCommand::SubscribeLogs {
vm_id: Some(VmId::new("vm-1")),
};
let json = serde_json::to_string(&cmd).unwrap();
let parsed: ServiceCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
#[test]
fn service_command_subscribe_logs_all() {
let cmd = ServiceCommand::SubscribeLogs { vm_id: None };
let json = serde_json::to_string(&cmd).unwrap();
let parsed: ServiceCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, cmd);
}
}