use std::path::{Path, PathBuf};
use brink_converter::convert;
use brink_json::InkJson;
use brink_runtime::{DotNetRng, Line, Story};
fn format_text_with_tags(text: &str, tags: &[Vec<String>], output: &mut String) {
use std::fmt::Write;
if tags.iter().all(Vec::is_empty) {
output.push_str(text);
return;
}
let mut line_start = 0;
let mut line_idx = 0;
for (i, ch) in text.char_indices() {
if ch == '\n' {
let line = &text[line_start..i];
output.push_str(line);
if let Some(lt) = tags.get(line_idx)
&& !lt.is_empty()
{
let _ = write!(output, "\n# tags: {}", lt.join(", "));
}
output.push('\n');
line_start = i + 1;
line_idx += 1;
}
}
let remaining = &text[line_start..];
output.push_str(remaining);
if let Some(lt) = tags.get(line_idx)
&& !lt.is_empty()
{
let _ = write!(output, "# tags: {}", lt.join(", "));
}
}
fn run_story_from_json(json_str: &str, inputs: &[usize]) -> Result<String, String> {
use std::fmt::Write;
let ink: InkJson =
serde_json::from_str(json_str).map_err(|e| format!("json parse error: {e}"))?;
let data = convert(&ink).map_err(|e| format!("convert error: {e}"))?;
let (program, line_tables) =
brink_runtime::link(&data).map_err(|e| format!("link error: {e}"))?;
let mut story = Story::<DotNetRng>::new(&program, line_tables);
let mut output = String::new();
let mut input_idx = 0;
let max_steps = 100_000;
for _ in 0..max_steps {
match story
.continue_single()
.map_err(|e| format!("runtime error: {e}"))?
{
Line::Text { text, tags } => {
format_text_with_tags(&text, &[tags], &mut output);
}
Line::Done { text, tags } | Line::End { text, tags } => {
format_text_with_tags(&text, &[tags], &mut output);
break;
}
Line::Choices {
text,
choices,
tags,
} => {
format_text_with_tags(&text, &[tags], &mut output);
if choices.is_empty() {
return Err("no choices available".into());
}
if input_idx >= inputs.len() {
break;
}
output.push('\n');
for choice in &choices {
let trimmed = choice.text.trim();
let _ = writeln!(output, "{}: {trimmed}", choice.index + 1);
}
output.push_str("?> ");
let choice_idx = inputs[input_idx];
input_idx += 1;
if choice_idx >= choices.len() {
return Err(format!(
"choice index {choice_idx} out of range (only {} choices)",
choices.len()
));
}
story
.choose(choice_idx)
.map_err(|e| format!("choose error: {e}"))?;
}
}
}
Ok(output)
}
fn parse_inputs(path: &Path) -> Vec<usize> {
let Ok(content) = std::fs::read_to_string(path) else {
return Vec::new();
};
content
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| l.trim().parse::<usize>().ok())
.collect()
}
fn is_runtime_test(metadata_path: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(metadata_path) else {
return false;
};
content.contains("mode = \"runtime\"") && !content.contains("[skip]")
}
fn find_test_cases(base: &Path) -> Vec<PathBuf> {
let mut cases = Vec::new();
if !base.is_dir() {
return cases;
}
walk_dir(base, &mut cases);
cases.sort();
cases
}
fn walk_dir(dir: &Path, cases: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let json_path = path.join("story.ink.json");
let transcript_path = path.join("transcript.txt");
if json_path.exists() && transcript_path.exists() {
cases.push(path.clone());
}
walk_dir(&path, cases);
}
}
}
#[expect(clippy::print_stderr, clippy::unwrap_used)]
fn run_corpus(tier: &str) {
let corpus_base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("../../tests/{tier}"));
let test_cases = find_test_cases(&corpus_base);
if test_cases.is_empty() {
eprintln!("WARNING: no test cases found in {}", corpus_base.display());
return;
}
let mut passed: i32 = 0;
let mut failed: i32 = 0;
let mut skipped: i32 = 0;
let mut failures: Vec<String> = Vec::new();
for case_dir in &test_cases {
let case_name = case_dir
.strip_prefix(&corpus_base)
.unwrap_or(case_dir)
.display()
.to_string();
let metadata_path = case_dir.join("metadata.toml");
if !is_runtime_test(&metadata_path) {
skipped += 1;
continue;
}
let json_path = case_dir.join("story.ink.json");
let transcript_path = case_dir.join("transcript.txt");
let input_path = case_dir.join("input.txt");
let json_str = std::fs::read_to_string(&json_path).unwrap();
let expected = std::fs::read_to_string(&transcript_path).unwrap();
let inputs = parse_inputs(&input_path);
let result = std::panic::catch_unwind(|| run_story_from_json(&json_str, &inputs));
let result = match result {
Ok(r) => r,
Err(e) => {
let msg = e
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| e.downcast_ref::<&str>().copied())
.unwrap_or("unknown panic");
Err(format!("panic: {msg}"))
}
};
match result {
Ok(actual) => {
let actual_normalized = actual.trim_end();
let expected_normalized = expected.trim_end();
if actual_normalized == expected_normalized {
passed += 1;
} else {
failed += 1;
failures.push(format!(
"FAIL: {case_name}\n expected: {expected_normalized:?}\n actual: {actual_normalized:?}",
));
}
}
Err(e) => {
failed += 1;
failures.push(format!("ERROR: {case_name}: {e}"));
}
}
}
let total = passed + failed + skipped;
eprintln!("\n=== Corpus Results ({tier}) ===");
eprintln!("Total: {total}, Passed: {passed}, Failed: {failed}, Skipped: {skipped}");
if !failures.is_empty() {
eprintln!("\nFailures:");
for f in &failures {
eprintln!(" {f}");
}
}
let rate = if passed + failed > 0 {
(f64::from(passed) / f64::from(passed + failed)) * 100.0
} else {
0.0
};
eprintln!("\nPass rate: {passed}/{} ({rate:.0}%)", passed + failed);
}
#[test]
fn corpus_tier1() {
run_corpus("tier1");
}
#[test]
fn corpus_tier2() {
run_corpus("tier2");
}
#[test]
fn corpus_tier3() {
run_corpus("tier3");
}