use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use serde_json::Value;
const DATA: &str = include_str!("data.txt");
fn print_failed() {
println!("failed");
}
fn print_ok() {
println!("ok");
}
fn run_test(
name: &str,
test_fn: impl FnOnce() -> Result<(), String> + std::panic::UnwindSafe,
) -> bool {
print!("Test {name} ... ");
std::io::stdout().flush().expect("flush stdout");
match std::panic::catch_unwind(test_fn) {
Ok(Ok(())) => {
print_ok();
true
}
Ok(Err(reason)) => {
print_failed();
eprintln!("{reason}");
false
}
Err(_) => {
print_failed();
false
}
}
}
fn main() {
let bin = env!("CARGO_BIN_EXE_readseek");
let mut failed_count = 0u32;
let mut test_count = 0u32;
for (index, raw) in DATA.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let case = Case::parse(index + 1, line);
if let Some(platform) = case.platform {
if platform == "unix" && !cfg!(unix) {
continue;
}
if platform == "windows" && !cfg!(windows) {
continue;
}
}
test_count += 1;
if !run_test(&case.name, || case.run(bin)) {
failed_count += 1;
}
}
eprintln!("\n{test_count} tests run.");
if failed_count > 0 {
eprintln!("{failed_count} test(s) failed.");
std::process::exit(1);
}
eprintln!("All tests passed.");
}
struct Symbol<'a> {
kind: &'a str,
name: &'a str,
fields: Vec<(&'a str, &'a str)>,
}
struct Case<'a> {
name: String,
path: Option<&'a str>,
content: Option<&'a str>,
stdin: Option<&'a str>,
args: &'a str,
status: Option<i32>,
stderr: Option<&'a str>,
platform: Option<&'a str>,
output_file: bool,
symbols: Vec<Symbol<'a>>,
checks: Vec<(&'a str, &'a str)>,
}
impl<'a> Case<'a> {
fn parse(line_no: usize, line: &'a str) -> Self {
let mut case = Case {
name: format!("line {line_no}"),
path: None,
content: None,
stdin: None,
args: "",
status: None,
stderr: None,
platform: None,
output_file: false,
symbols: Vec::new(),
checks: Vec::new(),
};
for field in line.split(" | ") {
let (key, value) = field
.split_once('=')
.unwrap_or_else(|| panic!("{}: field without '=': {field}", case.name));
match key {
"name" => case.name = value.to_owned(),
"path" => case.path = Some(value),
"content" => case.content = Some(value),
"stdin" => case.stdin = Some(value),
"args" => case.args = value,
"status" => {
case.status = Some(value.parse().expect("status must be an integer"));
}
"stderr" => case.stderr = Some(value),
"platform" => case.platform = Some(value),
"output_file" => {
case.output_file = value.parse().expect("output_file must be a boolean");
}
"symbol" => case.symbols.push(parse_symbol(value)),
_ => case.checks.push((key, value)),
}
}
case
}
fn run(&self, bin: &str) -> Result<(), String> {
let dir = std::env::temp_dir().join(format!(
"readseek-data-{}-{}",
std::process::id(),
self.name.replace(|c: char| !c.is_ascii_alphanumeric(), "-")
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create case dir");
let file_path = self.path.map(|path| {
let target = dir.join(path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("create parent dir");
}
std::fs::write(&target, unescape_bytes(self.content.unwrap_or("")))
.expect("write file");
target
});
let result = self.invoke(bin, file_path.as_ref());
let _ = std::fs::remove_dir_all(&dir);
result
}
fn invoke(&self, bin: &str, file_path: Option<&PathBuf>) -> Result<(), String> {
let path_str = file_path
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let args: Vec<String> = self
.args
.split_whitespace()
.map(|token| token.replace("%s", &path_str))
.collect();
let mut command = Command::new(bin);
command
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if self.stdin.is_some() {
command.stdin(Stdio::piped());
}
let mut child = command.spawn().map_err(|e| format!("spawn: {e}"))?;
if let Some(stdin) = self.stdin {
child
.stdin
.take()
.unwrap()
.write_all(&unescape_bytes(stdin))
.map_err(|e| format!("write stdin: {e}"))?;
}
let output = child.wait_with_output().map_err(|e| format!("wait: {e}"))?;
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
if let Some(expected) = self.status {
if code != expected {
return Err(format!(
"exit {code}, expected {expected}; stderr={}",
stderr.trim()
));
}
if let Some(needle) = self.stderr
&& !stderr.contains(needle)
{
return Err(format!("stderr missing `{needle}`: {}", stderr.trim()));
}
return Ok(());
}
if code != 0 {
return Err(format!("exit {code}; stderr={}", stderr.trim()));
}
let json: Value = if self.output_file {
if !output.stdout.is_empty() {
return Err("stdout was not empty with --output".to_owned());
}
let output_path = file_path.ok_or("--output test requires a path")?;
let bytes =
std::fs::read(output_path).map_err(|error| format!("read output file: {error}"))?;
serde_json::from_slice(&bytes)
.map_err(|error| format!("invalid output json: {error}"))?
} else {
serde_json::from_slice(&output.stdout)
.map_err(|error| format!("invalid json: {error}"))?
};
self.check(&json)
}
fn check(&self, json: &Value) -> Result<(), String> {
for (path, expected) in &self.checks {
check_path(json, path, expected, json)?;
}
for symbol in &self.symbols {
let entry = json
.get("symbols")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|item| {
item.get("kind").and_then(Value::as_str) == Some(symbol.kind)
&& item.get("name").and_then(Value::as_str) == Some(symbol.name)
})
.ok_or_else(|| format!("missing symbol {} `{}`", symbol.kind, symbol.name))?;
for (field, expected) in &symbol.fields {
check_path(entry, field, expected, entry)?;
}
}
Ok(())
}
}
fn parse_symbol(value: &str) -> Symbol<'_> {
let mut parts = value.split(',');
let kind = parts.next().expect("symbol kind");
let name = parts.next().expect("symbol name");
let fields = parts
.map(|field| field.split_once('=').expect("symbol field needs '='"))
.collect();
Symbol { kind, name, fields }
}
fn check_path(base: &Value, path: &str, expected: &str, root: &Value) -> Result<(), String> {
if let Some(prefix) = path.strip_suffix(".absent") {
let actual = resolve(base, prefix).is_none();
let expected = match expected {
"true" => true,
"false" => false,
_ => return Err(format!("{path}: expected true or false, got {expected}")),
};
return if actual == expected {
Ok(())
} else {
Err(format!("{prefix}.absent = {actual}, expected {expected}"))
};
}
if let Some(prefix) = path.strip_suffix(".len") {
let value = resolve(base, prefix).ok_or_else(|| format!("{path}: not found"))?;
let len = match value {
Value::Array(items) => items.len(),
Value::String(text) => text.chars().count(),
other => return Err(format!("{prefix}: not array or string ({other})")),
};
let want: usize = expected
.parse()
.map_err(|_| format!("{path}: bad length {expected}"))?;
return if len == want {
Ok(())
} else {
Err(format!("{path} = {len}, expected {want}"))
};
}
let actual = resolve(base, path).ok_or_else(|| format!("{path}: not found"))?;
if compare(actual, expected, base, root) {
Ok(())
} else {
Err(format!("{path} = {actual}, expected {expected}"))
}
}
fn resolve<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
let mut current = value;
for component in path.split('.') {
current = match current {
Value::Array(items) => items.get(component.parse::<usize>().ok()?)?,
_ => current.get(component)?,
};
}
Some(current)
}
fn compare(actual: &Value, expected: &str, base: &Value, root: &Value) -> bool {
if let Some(reference) = expected.strip_prefix('@') {
let from = match reference.strip_prefix("root.") {
Some(rest) => resolve(root, rest),
None => resolve(base, reference),
};
return from == Some(actual);
}
match expected {
"true" => actual.as_bool() == Some(true),
"false" => actual.as_bool() == Some(false),
_ => {
if let Ok(number) = expected.parse::<i64>() {
actual.as_i64() == Some(number)
} else {
actual.as_str() == Some(unescape_str(expected).as_str())
}
}
}
}
fn unescape_str(text: &str) -> String {
String::from_utf8(unescape_bytes(text)).expect("escaped value is not UTF-8")
}
fn unescape_bytes(text: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
let mut buf = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
continue;
}
match chars.next().expect("trailing backslash") {
'n' => out.push(b'\n'),
't' => out.push(b'\t'),
'r' => out.push(b'\r'),
'\\' => out.push(b'\\'),
'|' => out.push(b'|'),
'x' => {
let hex: String = (0..2)
.map(|_| chars.next().expect("\\x needs two hex digits"))
.collect();
out.push(u8::from_str_radix(&hex, 16).expect("invalid \\x escape"));
}
'u' => {
let hex: String = (0..4)
.map(|_| chars.next().expect("\\u needs four hex digits"))
.collect();
let code = u32::from_str_radix(&hex, 16).expect("invalid \\u escape");
let ch = char::from_u32(code).expect("invalid \\u code point");
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
other => panic!("unknown escape \\{other}"),
}
}
out
}