use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Display;
use std::marker::PhantomData;
use std::path::PathBuf;
use std::time::Duration;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use serde::de;
use serde::de::MapAccess;
use serde::de::Visitor;
use crate::signal::KillSignal;
pub const DEFAULT_DOCUMENT_TIMEOUT: u64 = 900;
pub const DEFAULT_SKIP_DOCUMENT_CODE: i32 = 80;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct DocumentConfig {
#[serde(skip_serializing_if = "<[_]>::is_empty")]
pub append: Vec<PathBuf>,
#[serde(skip_serializing_if = "TestCaseConfig::is_empty")]
pub defaults: TestCaseConfig,
#[serde(skip_serializing_if = "<[_]>::is_empty")]
pub prepend: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<PathBuf>,
#[serde(
skip_serializing_if = "is_none_or_default_timeout",
deserialize_with = "parse_duration_opt",
serialize_with = "render_duration_opt"
)]
pub total_timeout: Option<Duration>,
}
impl DocumentConfig {
pub fn empty() -> Self {
Self::default()
}
pub fn default_markdown() -> Self {
Self {
total_timeout: Some(Duration::from_secs(DEFAULT_DOCUMENT_TIMEOUT)),
..Default::default()
}
}
pub fn default_cram() -> Self {
Self {
total_timeout: Some(Duration::from_secs(DEFAULT_DOCUMENT_TIMEOUT)),
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.shell.is_none()
&& self.total_timeout.is_none()
&& self.prepend.is_empty()
&& self.append.is_empty()
&& self.defaults.is_empty()
}
pub fn with_defaults_from(&self, defaults: &Self) -> Self {
let mut append = defaults.append.clone();
append.extend(self.append.clone());
let mut prepend = self.prepend.clone();
prepend.extend(defaults.prepend.clone());
Self {
append,
prepend,
defaults: self.defaults.with_defaults_from(&defaults.defaults),
shell: self.shell.clone().or_else(|| defaults.shell.clone()),
total_timeout: self.total_timeout.or(defaults.total_timeout),
}
}
pub fn with_overrides_from(&self, overrides: &Self) -> Self {
overrides.with_defaults_from(self)
}
}
impl Display for DocumentConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let out = serde_json::to_string(&self).map_err(|_| std::fmt::Error)?;
write!(f, "{}", out)
}
}
fn is_none_or_default_timeout(timeout: &Option<Duration>) -> bool {
if let Some(timeout) = timeout {
timeout.as_secs() == DEFAULT_DOCUMENT_TIMEOUT
} else {
false
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputStreamControl {
Stdout,
Stderr,
Combined,
}
impl Display for OutputStreamControl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct TestCaseWait {
#[serde(
deserialize_with = "parse_duration",
serialize_with = "render_duration"
)]
pub timeout: Duration,
pub path: Option<PathBuf>,
}
impl TestCaseWait {
fn parse<'de, D>(deserializer: D) -> Result<Option<TestCaseWait>, D::Error>
where
D: Deserializer<'de>,
{
struct TestCaseWaitParser(PhantomData<fn() -> Option<TestCaseWait>>);
impl<'de> Visitor<'de> for TestCaseWaitParser {
type Value = Option<TestCaseWait>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("string or map")
}
fn visit_str<E>(self, value: &str) -> Result<Option<TestCaseWait>, E>
where
E: de::Error,
{
let timeout = humantime::parse_duration(value).map_err(de::Error::custom)?;
Ok(Some(TestCaseWait {
timeout,
path: None,
}))
}
fn visit_map<M>(self, map: M) -> Result<Option<TestCaseWait>, M::Error>
where
M: MapAccess<'de>,
{
let wait = TestCaseWait::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(Some(wait))
}
}
deserializer.deserialize_any(TestCaseWaitParser(PhantomData))
}
}
impl Display for TestCaseWait {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let out = serde_json::to_string(&self).map_err(|_| std::fmt::Error)?;
write!(f, "{}", out)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct TestCaseConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub detached: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detached_kill_signal: Option<KillSignal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fail_fast: Option<bool>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub environment: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_crlf: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_stream: Option<OutputStreamControl>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_document_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strip_ansi_escaping: Option<bool>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "parse_duration_opt",
serialize_with = "render_duration_opt"
)]
pub timeout: Option<Duration>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "TestCaseWait::parse"
)]
pub wait: Option<TestCaseWait>,
}
impl TestCaseConfig {
pub fn empty() -> Self {
Self::default() }
pub fn default_markdown() -> Self {
Self {
output_stream: Some(OutputStreamControl::Stdout),
skip_document_code: Some(DEFAULT_SKIP_DOCUMENT_CODE),
detached_kill_signal: Some(KillSignal::default()),
..Default::default()
}
}
pub fn default_cram() -> Self {
Self {
output_stream: Some(OutputStreamControl::Combined),
keep_crlf: Some(true),
skip_document_code: Some(DEFAULT_SKIP_DOCUMENT_CODE),
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.output_stream.is_none()
&& self.keep_crlf.is_none()
&& self.timeout.is_none()
&& self.detached.is_none()
&& self.fail_fast.is_none()
&& self.wait.is_none()
&& self.skip_document_code.is_none()
&& self.strip_ansi_escaping.is_none()
&& self.environment.is_empty()
}
pub fn with_defaults_from(&self, defaults: &Self) -> Self {
Self {
output_stream: self
.output_stream
.clone()
.or_else(|| defaults.output_stream.clone()),
keep_crlf: self.keep_crlf.or(defaults.keep_crlf),
timeout: self.timeout.or(defaults.timeout),
environment: self
.environment
.clone()
.into_iter()
.chain(defaults.environment.clone())
.collect(),
detached: self.detached.or(defaults.detached),
detached_kill_signal: self
.detached_kill_signal
.clone()
.or_else(|| defaults.detached_kill_signal.clone()),
fail_fast: self.fail_fast.or(defaults.fail_fast),
wait: self.wait.clone().or_else(|| defaults.wait.clone()),
skip_document_code: self.skip_document_code.or(defaults.skip_document_code),
strip_ansi_escaping: self.strip_ansi_escaping.or(defaults.strip_ansi_escaping),
}
}
pub fn with_overrides_from(&self, overrides: &Self) -> Self {
overrides.with_defaults_from(self)
}
pub fn with_environment(&self, environment: &BTreeMap<&str, &str>) -> Self {
let mut config = self.clone();
for (key, value) in environment {
config.environment.insert((*key).into(), (*value).into());
}
config
}
pub fn without_environment(&self, environment: &BTreeMap<&str, &str>) -> Self {
let mut config = self.clone();
for (key, value) in environment {
if config.environment.get(*key) == Some(&value.to_string()) {
config.environment.remove(*key);
}
}
config
}
pub fn diff(&self, other: &Self) -> Self {
let mut diff = Self::empty();
if self.output_stream != other.output_stream {
diff.output_stream = self.output_stream.clone();
}
if self.keep_crlf != other.keep_crlf {
diff.keep_crlf = self.keep_crlf;
}
if self.timeout != other.timeout {
diff.timeout = self.timeout;
}
if self.detached != other.detached {
diff.detached = self.detached;
}
if self.fail_fast != other.fail_fast {
diff.fail_fast = self.fail_fast;
}
if self.skip_document_code != other.skip_document_code {
diff.skip_document_code = self.skip_document_code;
}
if self.strip_ansi_escaping != other.strip_ansi_escaping {
diff.strip_ansi_escaping = self.strip_ansi_escaping;
}
if self.wait != other.wait {
diff.wait = self.wait.clone();
}
if self.environment != other.environment {
let mut env_diff = self.environment.clone();
for (k, v) in other.environment.iter() {
if env_diff.get(k) != Some(v) {
env_diff.remove(k);
}
}
diff.environment = env_diff;
}
diff
}
pub fn to_yaml_one_liner(&self) -> String {
let mut output = vec![];
if let Some(ref value) = self.output_stream {
output.push(format!(
"output_stream: {}",
value.to_string().to_lowercase()
));
}
if let Some(value) = self.keep_crlf {
output.push(format!("keep_crlf: {}", value))
}
if let Some(value) = self.timeout {
output.push(format!("timeout: {}", humantime::format_duration(value)))
}
if let Some(value) = self.detached {
output.push(format!("detached: {}", value))
}
if let Some(value) = self.fail_fast {
output.push(format!("fail_fast: {}", value))
}
if let Some(value) = self.skip_document_code {
output.push(format!("skip_document_code: {}", value))
}
if let Some(value) = self.strip_ansi_escaping {
output.push(format!("strip_ansi_escaping: {}", value))
}
if let Some(ref wait) = self.wait {
let duration = humantime::format_duration(wait.timeout).to_string();
if let Some(ref path) = wait.path {
output.push(format!(
"wait: {{timeout: {}, path: {}}}",
duration,
path.to_string_lossy(),
))
} else {
output.push(format!("wait: {}", duration))
}
}
if !self.environment.is_empty() {
let mut envvars = vec![];
for (key, value) in self.environment.iter() {
envvars.push(format!("{}: \"{}\"", key, value))
}
output.push(format!("environment: {{{}}}", envvars.join(", ")));
}
format!("{{{}}}", output.join(", "))
}
pub fn get_skip_document_code(&self) -> i32 {
self.skip_document_code
.unwrap_or(DEFAULT_SKIP_DOCUMENT_CODE)
}
pub fn get_fail_fast(&self) -> bool {
self.fail_fast.unwrap_or(false)
}
}
impl Display for TestCaseConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let out = serde_json::to_string(&self).map_err(|_| std::fmt::Error)?;
write!(f, "{}", out)
}
}
fn parse_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let value: String = Deserialize::deserialize(deserializer)?;
let duration = humantime::parse_duration(&value).map_err(de::Error::custom)?;
Ok(duration)
}
fn parse_duration_opt<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
let value: String = Deserialize::deserialize(deserializer)?;
if value.is_empty() || value == "null" {
return Ok(None);
}
let duration = humantime::parse_duration(&value).map_err(de::Error::custom)?;
Ok(Some(duration))
}
fn render_duration<S>(value: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let value = humantime::format_duration(*value).to_string();
serializer.serialize_str(&value)
}
fn render_duration_opt<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let value = if let Some(value) = value {
humantime::format_duration(*value).to_string()
} else {
"null".to_string()
};
serializer.serialize_str(&value)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use super::DocumentConfig;
use super::KillSignal;
use super::TestCaseWait;
use crate::config::OutputStreamControl;
use crate::config::TestCaseConfig;
const FULL_DOCUMENT_CONFIG: &str = "
append:
- app1
- app2
defaults:
detached: true
detached_kill_signal: quit
fail_fast: true
environment:
BAZ: zoing
FOO: bar
keep_crlf: true
output_stream: stdout
skip_document_code: 123
strip_ansi_escaping: true
timeout: 6m 4s
wait:
timeout: 2m 1s
path: the-wait-path
prepend:
- prep1
- prep2
shell: the-shell
total_timeout: 5m 3s
";
#[test]
fn test_parse_full_document_config() {
let config: DocumentConfig =
serde_yaml::from_str(FULL_DOCUMENT_CONFIG).expect("parse full document config");
assert_eq!(
config,
DocumentConfig {
shell: Some("the-shell".into()),
total_timeout: Some(Duration::from_secs(5 * 60 + 3)),
prepend: vec!["prep1".into(), "prep2".into()],
append: vec!["app1".into(), "app2".into()],
defaults: TestCaseConfig {
output_stream: Some(OutputStreamControl::Stdout),
keep_crlf: Some(true),
timeout: Some(Duration::from_secs(6 * 60 + 4)),
environment: {
let mut m = BTreeMap::new();
m.insert("FOO".to_string(), "bar".to_string());
m.insert("BAZ".to_string(), "zoing".to_string());
m
},
detached: Some(true),
detached_kill_signal: Some(KillSignal::test_default()),
fail_fast: Some(true),
wait: Some(TestCaseWait {
timeout: Duration::from_secs(2 * 60 + 1),
path: Some(PathBuf::from("the-wait-path")),
}),
skip_document_code: Some(123),
strip_ansi_escaping: Some(true),
}
}
)
}
#[test]
fn test_render_full_document_config() {
let config = DocumentConfig {
shell: Some("the-shell".into()),
total_timeout: Some(Duration::from_secs(5 * 60 + 3)),
prepend: vec!["prep1".into(), "prep2".into()],
append: vec!["app1".into(), "app2".into()],
defaults: TestCaseConfig {
output_stream: Some(OutputStreamControl::Stdout),
keep_crlf: Some(true),
timeout: Some(Duration::from_secs(6 * 60 + 4)),
environment: {
let mut m = BTreeMap::new();
m.insert("FOO".to_string(), "bar".to_string());
m.insert("BAZ".to_string(), "zoing".to_string());
m
},
detached: Some(true),
detached_kill_signal: Some(KillSignal::test_default()),
fail_fast: Some(true),
wait: Some(TestCaseWait {
timeout: Duration::from_secs(2 * 60 + 1),
path: Some(PathBuf::from("the-wait-path")),
}),
skip_document_code: Some(123),
strip_ansi_escaping: Some(true),
},
};
assert_eq!(
serde_yaml::to_string(&config).expect("render document config to YAML"),
FULL_DOCUMENT_CONFIG.to_string().trim_start(),
)
}
const FULL_TESTCASE_CONFIG: &str = "
detached: true
detached_kill_signal: quit
fail_fast: true
environment:
BAZ: zoing
FOO: bar
keep_crlf: true
output_stream: stderr
skip_document_code: 123
strip_ansi_escaping: true
timeout: 6m 4s
wait:
timeout: 2m 1s
path: the-wait-path
";
#[test]
fn test_parse_full_testcase_config() {
let config: TestCaseConfig =
serde_yaml::from_str(FULL_TESTCASE_CONFIG).expect("parse full testcase config");
assert_eq!(
config,
TestCaseConfig {
output_stream: Some(OutputStreamControl::Stderr),
keep_crlf: Some(true),
timeout: Some(Duration::from_secs(6 * 60 + 4)),
environment: {
let mut m = BTreeMap::new();
m.insert("FOO".to_string(), "bar".to_string());
m.insert("BAZ".to_string(), "zoing".to_string());
m
},
detached: Some(true),
detached_kill_signal: Some(KillSignal::test_default()),
fail_fast: Some(true),
wait: Some(TestCaseWait {
timeout: Duration::from_secs(2 * 60 + 1),
path: Some(PathBuf::from("the-wait-path")),
}),
skip_document_code: Some(123),
strip_ansi_escaping: Some(true),
}
)
}
#[test]
fn test_render_full_testcase_config() {
let config = TestCaseConfig {
output_stream: Some(OutputStreamControl::Stderr),
keep_crlf: Some(true),
timeout: Some(Duration::from_secs(6 * 60 + 4)),
environment: {
let mut m = BTreeMap::new();
m.insert("FOO".to_string(), "bar".to_string());
m.insert("BAZ".to_string(), "zoing".to_string());
m
},
detached: Some(true),
detached_kill_signal: Some(KillSignal::test_default()),
fail_fast: Some(true),
wait: Some(TestCaseWait {
timeout: Duration::from_secs(2 * 60 + 1),
path: Some(PathBuf::from("the-wait-path")),
}),
skip_document_code: Some(123),
strip_ansi_escaping: Some(true),
};
assert_eq!(
serde_yaml::to_string(&config).expect("render testcase config to YAML"),
FULL_TESTCASE_CONFIG.to_string().trim_start(),
)
}
#[test]
fn test_testcase_config_yaml_one_liner() {
let tests = [
(TestCaseConfig::empty(), "{}"),
(
TestCaseConfig {
keep_crlf: Some(true),
..Default::default()
},
"{keep_crlf: true}",
),
(
TestCaseConfig {
wait: Some(TestCaseWait {
timeout: Duration::from_secs(123),
path: None,
}),
..Default::default()
},
"{wait: 2m 3s}",
),
(
TestCaseConfig {
output_stream: Some(OutputStreamControl::Stderr),
keep_crlf: Some(true),
detached: Some(false),
detached_kill_signal: None,
fail_fast: Some(false),
environment: BTreeMap::from([("foo".to_string(), "bar".to_string())]),
skip_document_code: Some(123),
strip_ansi_escaping: Some(true),
timeout: Some(Duration::from_secs(234)),
wait: Some(TestCaseWait {
timeout: Duration::from_secs(123),
path: Some(PathBuf::from("/tmp/wait")),
}),
},
"{output_stream: stderr, keep_crlf: true, timeout: 3m 54s, detached: false, fail_fast: false, skip_document_code: 123, strip_ansi_escaping: true, wait: {timeout: 2m 3s, path: /tmp/wait}, environment: {foo: \"bar\"}}",
),
];
for (idx, (config, expected)) in tests.iter().enumerate() {
let yaml = config.to_yaml_one_liner();
assert_eq!(
expected.to_string(),
yaml,
"test {idx}: for config {config:?}"
);
}
}
#[test]
fn test_parse_test_case_wait() {
let tests = vec![
(
"wait: 3m 4s",
Some(TestCaseWait {
timeout: Duration::from_secs(3 * 60 + 4),
path: None,
}),
),
(
"wait:\n timeout: 3m 5s\n path: some/file/name",
Some(TestCaseWait {
timeout: Duration::from_secs(3 * 60 + 5),
path: Some(PathBuf::from("some/file/name")),
}),
),
];
for (raw, expect) in tests {
let config: TestCaseConfig =
serde_yaml::from_str(raw).unwrap_or_else(|err| panic!("parse {raw:?}: {err}"));
assert_eq!(config.wait, expect, "for input {raw:?}");
}
}
}