use std::{
fs,
path::{Path, PathBuf},
process::Output,
};
use anyhow::{Context as _, Result};
use assert_cmd::Command;
use tempfile::tempdir;
#[path = "invariants.rs"]
mod invariants;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) enum TransformFlag {
Renumber,
Breaks,
Ellipsis,
Fences,
Footnotes,
CodeEmphasis,
Headings,
}
impl TransformFlag {
pub(crate) fn as_arg(self) -> &'static str {
match self {
Self::Renumber => "--renumber",
Self::Breaks => "--breaks",
Self::Ellipsis => "--ellipsis",
Self::Fences => "--fences",
Self::Footnotes => "--footnotes",
Self::CodeEmphasis => "--code-emphasis",
Self::Headings => "--headings",
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct BaseCase {
pub(crate) id: &'static str,
pub(crate) fixture: &'static str,
pub(crate) flags: &'static [TransformFlag],
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) enum WrapVariant {
Wrapped,
Unwrapped,
}
impl WrapVariant {
fn id_part(self) -> &'static str {
match self {
Self::Wrapped => "wrap",
Self::Unwrapped => "nowrap",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) enum ExecutionMode {
Stdout,
InPlace,
}
impl ExecutionMode {
fn id_part(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::InPlace => "in_place",
}
}
}
#[derive(Clone)]
pub(crate) struct LogicalCase {
pub(crate) id: String,
pub(crate) fixture: &'static str,
pub(crate) is_wrapped: bool,
pub(crate) flags: Vec<TransformFlag>,
}
pub(crate) struct PhysicalCase {
pub(crate) logical: LogicalCase,
pub(crate) mode: ExecutionMode,
}
pub(crate) struct RunResult {
pub(crate) output: Output,
pub(crate) file_content: Vec<u8>,
}
pub(crate) const ALL_FLAGS: &[TransformFlag] = &[
TransformFlag::Renumber,
TransformFlag::Breaks,
TransformFlag::Ellipsis,
TransformFlag::Fences,
TransformFlag::Footnotes,
TransformFlag::CodeEmphasis,
TransformFlag::Headings,
];
pub(crate) const BASE_MATRIX_CASES: &[BaseCase] = &[
BaseCase {
id: "row_000",
fixture: "table-prose.dat",
flags: &[],
},
BaseCase {
id: "row_001",
fixture: "fences-ellipsis.dat",
flags: &[
TransformFlag::Ellipsis,
TransformFlag::Footnotes,
TransformFlag::CodeEmphasis,
TransformFlag::Headings,
],
},
BaseCase {
id: "row_010",
fixture: "footnotes.dat",
flags: &[
TransformFlag::Breaks,
TransformFlag::Fences,
TransformFlag::CodeEmphasis,
TransformFlag::Headings,
],
},
BaseCase {
id: "row_011",
fixture: "frontmatter-breaks.dat",
flags: &[
TransformFlag::Breaks,
TransformFlag::Ellipsis,
TransformFlag::Fences,
TransformFlag::Footnotes,
],
},
BaseCase {
id: "row_100",
fixture: "table-prose.dat",
flags: &[
TransformFlag::Renumber,
TransformFlag::Fences,
TransformFlag::Footnotes,
TransformFlag::Headings,
],
},
BaseCase {
id: "row_101",
fixture: "fences-ellipsis.dat",
flags: &[
TransformFlag::Renumber,
TransformFlag::Ellipsis,
TransformFlag::Fences,
TransformFlag::CodeEmphasis,
],
},
BaseCase {
id: "row_110",
fixture: "footnotes.dat",
flags: &[
TransformFlag::Renumber,
TransformFlag::Breaks,
TransformFlag::Footnotes,
TransformFlag::CodeEmphasis,
],
},
BaseCase {
id: "row_111",
fixture: "frontmatter-breaks.dat",
flags: &[
TransformFlag::Renumber,
TransformFlag::Breaks,
TransformFlag::Ellipsis,
TransformFlag::Headings,
],
},
];
impl RunResult {
pub(crate) fn envelope(&self, case: &PhysicalCase) -> String {
let stdout = String::from_utf8_lossy(&self.output.stdout);
let stderr = String::from_utf8_lossy(&self.output.stderr);
let file = if case.mode == ExecutionMode::InPlace {
String::from_utf8_lossy(&self.file_content).into_owned()
} else {
"<not applicable>\n".to_string()
};
format!(
"case: {}\nmode: {}\nargs: {}\nstatus: {}\n\n[stdout]\n{}\n[stderr]\n{}\n[file]\n{}",
case.logical.id,
case.mode.id_part(),
case.args().join(" "),
self.output.status,
stdout,
stderr,
file,
)
}
}
impl PhysicalCase {
pub(crate) fn snapshot_name(&self) -> String {
format!("{}_{}", self.logical.id, self.mode.id_part())
}
pub(crate) fn args(&self) -> Vec<&'static str> {
let mut args = Vec::new();
if self.logical.is_wrapped {
args.push("--wrap");
}
args.extend(self.logical.flags.iter().map(|flag| flag.as_arg()));
if self.mode == ExecutionMode::InPlace {
args.push("--in-place");
}
args
}
}
pub(crate) fn logical_cases() -> Vec<LogicalCase> {
BASE_MATRIX_CASES
.iter()
.flat_map(|case| {
[WrapVariant::Unwrapped, WrapVariant::Wrapped].map(move |variant| LogicalCase {
id: format!("{}_{}", case.id, variant.id_part()),
fixture: case.fixture,
is_wrapped: variant == WrapVariant::Wrapped,
flags: case.flags.to_vec(),
})
})
.collect()
}
pub(crate) fn physical_cases() -> Vec<PhysicalCase> {
logical_cases()
.into_iter()
.flat_map(|logical| {
[ExecutionMode::Stdout, ExecutionMode::InPlace].map(move |mode| PhysicalCase {
logical: logical.clone(),
mode,
})
})
.collect()
}
pub(crate) fn assert_transform_invariants(logical: &LogicalCase, stdout: &[u8]) -> Result<()> {
invariants::assert_transform_invariants(logical, stdout)
}
pub(crate) fn stage_fixture(case: &PhysicalCase, dir: &Path) -> Result<PathBuf> {
let fixture = fixture_path(case.logical.fixture);
let file_path = dir
.join("input")
.with_extension(fixture.extension().unwrap_or_default());
fs::copy(&fixture, &file_path).with_context(|| {
format!(
"copy fixture '{}' to '{}'",
case.logical.fixture,
file_path.display(),
)
})?;
Ok(file_path)
}
pub(crate) fn collect_result(
output: Output,
file_path: &Path,
mode: ExecutionMode,
) -> Result<RunResult> {
let file_content = match mode {
ExecutionMode::Stdout | ExecutionMode::InPlace => fs::read(file_path)
.with_context(|| format!("read file '{}' after {:?} run", file_path.display(), mode))?,
};
Ok(RunResult {
output,
file_content,
})
}
pub(crate) fn run_physical_case(case: &PhysicalCase) -> Result<RunResult> {
let dir = tempdir().context("create temporary directory for matrix case")?;
let file_path = stage_fixture(case, dir.path())?;
let mut command = Command::cargo_bin("mdtablefix").context("create mdtablefix test command")?;
command.args(case.args()).arg(&file_path);
let output = command.output().with_context(|| {
format!(
"execute mdtablefix for matrix case '{}'",
case.snapshot_name()
)
})?;
collect_result(output, &file_path, case.mode)
}
pub(crate) fn fixture_path(file_name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("data")
.join("cli-matrix")
.join(file_name)
}
pub(crate) fn is_case_id(id: &str) -> bool {
!id.is_empty()
&& id.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-'
})
}
pub(crate) fn non_wrap_signature(fixture: &str, flags: &[TransformFlag]) -> String {
let args = flags
.iter()
.map(|flag| flag.as_arg())
.collect::<Vec<_>>()
.join(",");
format!("{fixture}:{args}")
}
pub(crate) fn has_flag(case: &BaseCase, flag: TransformFlag) -> bool { case.flags.contains(&flag) }
#[cfg(test)]
#[rustfmt::skip]
mod tests {
use super::{BaseCase, TransformFlag, has_flag, is_case_id, non_wrap_signature};
use rstest::rstest;
#[rstest]
#[case("row_001", true)] #[case("row-001", true)] #[case("abc123", true)]
#[case("", false)] #[case("Row_001", false)] #[case("row 001", false)]
fn is_case_id_returns_expected_value(#[case] id: &str, #[case] expected: bool) {
assert_eq!(is_case_id(id), expected);
}
#[test] fn non_wrap_signature_ignores_wrap_variant() {
let flags = [TransformFlag::Renumber, TransformFlag::Fences]; let (unwrapped, wrapped) = (false, true);
assert_ne!(unwrapped, wrapped); assert_eq!(non_wrap_signature("fixture.dat", &flags), non_wrap_signature("fixture.dat", &flags)); }
#[test] fn non_wrap_signature_distinguishes_flag_lists() {
assert_ne!(non_wrap_signature("fixture.dat", &[TransformFlag::Renumber]), non_wrap_signature("fixture.dat", &[TransformFlag::Fences])); }
#[rstest]
#[case(TransformFlag::Renumber, true)] #[case(TransformFlag::Fences, false)]
fn has_flag_returns_expected_value(#[case] flag: TransformFlag, #[case] expected: bool) {
let case = BaseCase { id: "row_001", fixture: "fixture.dat", flags: &[TransformFlag::Renumber] };
assert_eq!(has_flag(&case, flag), expected);
}
}