use std::{fs, path::Path, time::SystemTime};
use assert_cmd::Command;
use rstest_bdd::Slot;
use rstest_bdd_macros::{ScenarioState, given, then, when};
use tempfile::TempDir;
pub const RAGGED: &str = "|A|B|\n|---|---|\n|1|2|\n";
pub const CLEAN: &str = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
#[derive(Clone, Debug)]
pub struct Run {
pub status: i32,
pub stdout: String,
pub stderr: String,
}
pub type Fingerprint = Vec<(String, Vec<u8>, Option<SystemTime>)>;
#[derive(Default, ScenarioState)]
pub struct ReportingState {
dir: Slot<TempDir>,
files: Slot<Vec<String>>,
before: Slot<Fingerprint>,
run: Slot<Run>,
outputs: Slot<Vec<String>>,
}
fn directory_path(state: &ReportingState) -> std::path::PathBuf {
state
.dir
.get_or_insert_with(|| tempfile::tempdir().expect("temporary directory"))
.path()
.to_path_buf()
}
fn create_file(state: &ReportingState, name: &str, content: &str) {
fs::write(directory_path(state).join(name), content).expect("write fixture");
state
.files
.get_or_insert_with(Vec::new)
.push(name.to_string());
}
fn fingerprint(directory: &Path) -> Fingerprint {
let mut entries: Fingerprint = fs::read_dir(directory)
.expect("read the directory")
.map(|entry| {
let entry = entry.expect("read a directory entry");
(
entry.file_name().to_string_lossy().into_owned(),
fs::read(entry.path()).expect("read a fixture"),
entry
.metadata()
.expect("read fixture metadata")
.modified()
.ok(),
)
})
.collect();
entries.sort();
entries
}
fn last_run(state: &ReportingState) -> Run {
state
.run
.get()
.expect("the scenario must run mdtablefix before asserting on it")
}
#[given("a Markdown file {name:string} that is already formatted")]
fn already_formatted(state: &ReportingState, name: String) { create_file(state, &name, CLEAN); }
#[given("a Markdown file {name:string} with an unaligned table")]
fn unaligned_table(state: &ReportingState, name: String) { create_file(state, &name, RAGGED); }
#[given("a Markdown file {name:string} already formatted with CRLF endings")]
fn crlf_formatted(state: &ReportingState, name: String) {
create_file(state, &name, &CLEAN.replace('\n', "\r\n"));
}
#[given("a Markdown file {name:string} with a byte-order mark and an unaligned table")]
fn marked_unaligned(state: &ReportingState, name: String) {
create_file(state, &name, &format!("\u{FEFF}{RAGGED}"));
}
#[given("a path {name:string} that does not exist")]
fn missing_path(state: &ReportingState, name: String) {
state.files.get_or_insert_with(Vec::new).push(name);
}
fn run_once(state: &ReportingState, flags: &str) -> Run {
let directory = directory_path(state);
let files = state.files.get().unwrap_or_default();
let output = Command::cargo_bin("mdtablefix")
.expect("cargo binary")
.current_dir(&directory)
.args(flags.split_whitespace())
.args(&files)
.output()
.expect("run mdtablefix");
Run {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
#[when("mdtablefix runs with {flags:string} against those files")]
fn runs_against_files(state: &ReportingState, flags: String) {
state.before.set(fingerprint(&directory_path(state)));
state.run.set(run_once(state, &flags));
}
#[when("mdtablefix runs with {flags:string} against those files ten times")]
fn runs_ten_times(state: &ReportingState, flags: String) {
let mut outputs = Vec::new();
for _ in 0..10 {
let run = run_once(state, &flags);
outputs.push(run.stdout.clone());
state.run.set(run);
}
state.outputs.set(outputs);
}
#[then("the exit status is {expected:i32}")]
fn exit_status_is(state: &ReportingState, expected: i32) {
let run = last_run(state);
assert_eq!(run.status, expected, "exit status, stderr: {}", run.stderr);
}
#[then("standard output is empty")]
fn stdout_is_empty(state: &ReportingState) {
let run = last_run(state);
assert!(
run.stdout.is_empty(),
"standard output was {:?}",
run.stdout
);
}
#[then("standard output is {expected:string}")]
fn stdout_is(state: &ReportingState, expected: String) {
let run = last_run(state);
assert_eq!(run.stdout, format!("{expected}\n"), "standard output");
}
#[then("standard output lists {first:string} before {second:string}")]
fn stdout_lists_before(state: &ReportingState, first: String, second: String) {
let run = last_run(state);
let position = |needle: &str| {
run.stdout
.find(needle)
.unwrap_or_else(|| panic!("{needle} is not reported in {:?}", run.stdout))
};
assert!(
position(&first) < position(&second),
"{first} must be reported before {second}: {:?}",
run.stdout
);
}
#[then("the diff header names {name:string} on both sides")]
fn diff_header_names_both_sides(state: &ReportingState, name: String) {
let run = last_run(state);
let header = format!("--- {name}\n+++ {name}\n");
assert!(
run.stdout.contains(&header),
"the diff must name {name} on both sides: {:?}",
run.stdout
);
}
#[then("the diff contains a hunk header")]
fn diff_contains_a_hunk_header(state: &ReportingState) {
let run = last_run(state);
assert!(
run.stdout.lines().any(|line| line.starts_with("@@ ")),
"the diff must contain a hunk header: {:?}",
run.stdout
);
}
#[then("every run produced identical standard output")]
fn every_run_produced_identical_output(state: &ReportingState) {
let outputs = state
.outputs
.get()
.expect("the scenario must repeat the run before comparing its outputs");
let first = outputs.first().expect("ten runs produce ten outputs");
assert!(
!first.is_empty(),
"ten identical empty outputs would be vacuous: {}",
last_run(state).stderr
);
for (index, output) in outputs.iter().enumerate() {
assert_eq!(output, first, "run {index} differed from the first run");
}
}
#[then("the summary reads {expected:string}")]
fn summary_reads(state: &ReportingState, expected: String) {
let run = last_run(state);
assert_eq!(run.stderr, format!("{expected}\n"), "standard error");
}
#[then("standard error mentions {text:string}")]
fn stderr_mentions(state: &ReportingState, text: String) {
let run = last_run(state);
assert!(
run.stderr.contains(&text),
"standard error must mention {text:?}: {:?}",
run.stderr
);
}
#[then("the summary reports {count:usize} file could not be read")]
fn summary_reports_unreadable(state: &ReportingState, count: usize) {
let run = last_run(state);
let noun = if count == 1 { "file" } else { "files" };
let phrase = format!("{count} {noun} could not be read");
assert!(
run.stderr.contains(&phrase),
"standard error must contain {phrase:?}: {:?}",
run.stderr
);
}
#[then("the working directory is byte-identical")]
fn directory_unchanged(state: &ReportingState) {
let before = state
.before
.get()
.expect("the run must record the directory's identity first");
let after = fingerprint(&directory_path(state));
assert_eq!(after, before, "the working directory must not change");
}
#[then("{name:string} is reformatted")]
fn is_reformatted(state: &ReportingState, name: String) {
let content =
fs::read_to_string(directory_path(state).join(&name)).expect("read the rewritten file");
assert_eq!(content, CLEAN, "{name} must hold the formatted table");
}