use std::collections::BTreeMap;
use crate::status::MachineState;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Reading {
pub(crate) states: BTreeMap<String, String>,
pub(crate) provider: Option<String>,
}
fn fields(line: &str) -> Option<(&str, &str, &str)> {
let mut parts = line.trim_end_matches('\r').splitn(4, ',');
let (_ts, target, kind, data) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
Some((target, kind, data))
}
pub(crate) fn parse(out: &str) -> Reading {
let mut read = Reading::default();
for (target, kind, data) in out.lines().filter_map(fields) {
match kind {
"state" if !target.is_empty() => {
read.states.insert(target.to_owned(), data.to_owned());
}
"provider-name" if !data.is_empty() && read.provider.is_none() => read.provider = Some(data.to_owned()),
_ => {}
}
}
read
}
pub(crate) fn plugins(out: &str) -> Vec<String> {
out.lines()
.filter_map(fields)
.filter(|(_, kind, data)| *kind == "plugin-name" && !data.is_empty())
.map(|(_, _, data)| data.to_owned())
.collect()
}
pub(crate) fn host_name(out: &str) -> Option<String> {
out.lines()
.filter_map(|line| line.trim().strip_prefix("HostName"))
.map(str::trim)
.find(|host| !host.is_empty())
.map(str::to_owned)
}
pub(crate) fn machine_state(label: &str) -> MachineState {
match label {
"running" => MachineState::Running,
"not_created" => MachineState::Absent,
"poweroff" | "powered_off" | "off" | "shutoff" | "stopped" | "not_running" | "saved" | "suspended" | "paused" | "pmsuspended" | "aborted"
| "aborted-saved" => MachineState::Stopped,
other => MachineState::Unknown(other.to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssh_configs_host_name_is_the_vms_address() {
let out = "Host web\r\n HostName 172.16.1.53\r\n User vagrant\r\n Port 22\r\n";
assert_eq!(host_name(out).as_deref(), Some("172.16.1.53"));
assert_eq!(host_name("Host web\n HostName \n User vagrant\n"), None);
assert_eq!(host_name(""), None);
}
#[test]
fn reads_machine_readable_status() {
let out = "1700000000,pfsense-1,metadata,provider,virtualbox\n\
1700000000,pfsense-1,provider-name,virtualbox\n\
1700000000,pfsense-1,state,running\n\
1700000000,debian-1,state,poweroff\n\
1700000000,,ui,info,Current machine states:\n";
let r = parse(out);
assert_eq!(r.states.len(), 2);
assert_eq!(r.states["pfsense-1"], "running");
assert_eq!(r.states["debian-1"], "poweroff");
assert_eq!(r.provider.as_deref(), Some("virtualbox"));
}
#[test]
fn windows_line_endings_and_blank_lines_are_read_the_same() {
let out = "1,dc01,provider-name,hyperv\r\n\r\n1,dc01,state,running\r\n1,isoloom-controller,state,off\r\n";
let r = parse(out);
assert_eq!(r.provider.as_deref(), Some("hyperv"), "no carriage return in the provider");
assert_eq!(r.states["dc01"], "running");
assert_eq!(machine_state(&r.states["isoloom-controller"]), MachineState::Stopped);
}
#[test]
fn a_state_with_vagrants_comma_escape_is_kept_whole_and_is_not_running() {
let r = parse("1,a,state,running%!(VAGRANT_COMMA) with a note\n");
assert_eq!(r.states["a"], "running%!(VAGRANT_COMMA) with a note");
assert!(matches!(machine_state(&r.states["a"]), MachineState::Unknown(_)));
}
#[test]
fn the_provider_is_the_first_named_one_and_a_blank_one_is_skipped() {
let r = parse("1,dc01,provider-name,\n1,ws01,provider-name,parallels\n1,dc01,state,running\n");
assert_eq!(r.provider.as_deref(), Some("parallels"));
assert_eq!(parse("1,dc01,provider-name\n1,dc01,state,running\n").provider, None);
let two = parse("1,dc01,provider-name,\n1,dc01,provider-name,virtualbox\n1,ws01,provider-name,libvirt\n");
assert_eq!(two.provider.as_deref(), Some("virtualbox"), "the first named provider stays");
}
#[test]
fn non_state_lines_and_malformed_output_give_no_machine() {
let out = "ts,,ui,info,Current machine states:\nts,box,metadata,provider,virtualbox\nts,,state,running\nshort,line\n";
assert!(parse(out).states.is_empty());
assert!(parse("").states.is_empty());
}
#[test]
fn every_providers_labels_map_to_a_state() {
assert_eq!(machine_state("running"), MachineState::Running);
assert_eq!(machine_state("not_created"), MachineState::Absent);
for stopped in [
"poweroff",
"saved",
"aborted",
"aborted-saved",
"suspended",
"shutoff",
"off",
"not_running",
"powered_off",
"stopped",
"paused",
] {
assert_eq!(machine_state(stopped), MachineState::Stopped, "{stopped}");
}
assert_eq!(machine_state("gurumeditation"), MachineState::Unknown("gurumeditation".into()));
}
#[test]
fn plugins_are_read_by_name() {
let out = "1,,plugin-name,vagrant-libvirt\n1,vagrant-libvirt,plugin-version,0.12.2%!(VAGRANT_COMMA) global\n1,,plugin-name,vagrant-qemu\n";
assert_eq!(plugins(out), ["vagrant-libvirt", "vagrant-qemu"]);
assert_eq!(plugins(""), Vec::<String>::new());
}
}
#[cfg(test)]
mod proptests {
use proptest::prelude::*;
use super::*;
fn machine() -> impl Strategy<Value = String> {
prop_oneof![Just("isoloom-controller".to_owned()), "[a-z][a-z0-9_-]{0,12}"]
}
fn state() -> impl Strategy<Value = String> {
prop_oneof![
Just("running".to_owned()),
Just("poweroff".to_owned()),
Just("not_created".to_owned()),
"[a-z_ ,]{1,16}"
]
}
proptest! {
#[test]
fn any_output_is_read_without_panicking(text in "(?s).{0,400}") {
let _ = parse(&text);
let _ = plugins(&text);
}
#[test]
fn every_machines_last_state_reads_back_whole(
machines in proptest::collection::vec((machine(), state(), "[0-9]{10}"), 0..6),
noise in proptest::collection::vec("[0-9]{10},[a-z]{0,6},(metadata|ui),[^\n]{0,20}", 0..4),
) {
let mut lines: Vec<String> = machines.iter().map(|(name, state, ts)| format!("{ts},{name},state,{state}")).collect();
lines.extend(noise);
let read = parse(&lines.join("\n"));
let mut want = BTreeMap::new();
for (name, state, _) in &machines {
want.insert(name.clone(), state.clone());
}
prop_assert_eq!(read.states, want);
}
}
}