use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use crate::inline_tests::{InlineTestOptions, run_inline_tests_with_options};
use crate::problem::Compiler;
use crate::simulate::{Alg, SolveOptions};
use crate::types::EsmFile;
const REFUSAL_CODES: [&str; 5] = [
"compiler_refused_rule",
"compiler_unavailable",
"unevaluable_operator",
"unlowered_operator",
"unsupported_construct",
];
pub struct Args {
pub manifest: PathBuf,
pub output: PathBuf,
pub compiler: Compiler,
}
pub fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Args, String> {
let mut manifest: Option<PathBuf> = None;
let mut output: Option<PathBuf> = None;
let mut compiler: Option<Compiler> = None;
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--manifest" => manifest = it.next().map(PathBuf::from),
"--output" => output = it.next().map(PathBuf::from),
"--compiler" => {
let v = it.next().ok_or("--compiler needs a value")?;
compiler = Some(Compiler::parse_named(&v)?);
}
other => return Err(format!("unexpected argument {other:?}")),
}
}
match (manifest, output, compiler) {
(Some(manifest), Some(output), Some(compiler)) => Ok(Args {
manifest,
output,
compiler,
}),
_ => Err("--manifest, --output and --compiler are required".to_string()),
}
}
pub struct Report {
pub payload: Value,
pub failed: Vec<String>,
}
fn tests_root(manifest_path: &Path) -> PathBuf {
let abs = std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf());
let mut cur = abs.as_path();
while let Some(parent) = cur.parent() {
if parent.file_name().is_some_and(|n| n == "tests") {
return parent.to_path_buf();
}
cur = parent;
}
abs.parent().unwrap_or(Path::new(".")).to_path_buf()
}
fn fixture_path(manifest_dir: &Path, tests: &Path, rel: &str) -> PathBuf {
let local = manifest_dir.join(rel);
if local.is_file() {
return local;
}
tests.join(rel)
}
fn refusal_code(message: &str) -> Option<String> {
for code in REFUSAL_CODES {
if message.contains(code) {
return Some(code.to_string());
}
}
let start = message.find("E_TREEWALK_")?;
let end = message[start..]
.find(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'))
.map(|i| start + i)
.unwrap_or(message.len());
Some(message[start..end].to_string())
}
fn solver_alg(file: &EsmFile) -> Alg {
match file.solver.as_ref().and_then(|s| s.stiffness.as_deref()) {
Some("high") => Alg::Bdf,
_ => Alg::Erk,
}
}
enum Answer {
Entry(Value),
Unavailable(String),
}
fn truncate(s: &str, n: usize) -> String {
let end = s.char_indices().nth(n).map(|(i, _)| i).unwrap_or(s.len());
s[..end].to_string()
}
fn run_fixture(
fx: &Value,
manifest: &Value,
manifest_dir: &Path,
tests: &Path,
compiler: Compiler,
) -> Result<Answer, String> {
let rel = fx["path"].as_str().ok_or("fixture.path missing")?;
let path = fixture_path(manifest_dir, tests, rel);
let text = fs::read_to_string(&path)
.map_err(|e| format!("fixture document not found at {}: {e}", path.display()))?;
let file = crate::load_string(&text).map_err(|e| format!("load: {e:?}"))?;
let model = fx["model"].as_str().ok_or("fixture.model missing")?;
let integ = &manifest["integrators"]["rust"];
let opts = InlineTestOptions {
model_name: Some(model.to_string()),
solve: SolveOptions {
alg: solver_alg(&file),
reltol: integ["reltol"].as_f64(),
abstol: integ["abstol"].as_f64(),
maxiters: Some(1_000_000),
..Default::default()
},
base_dir: path.parent().map(Path::to_path_buf),
compiler: Some(compiler),
..Default::default()
};
let results = run_inline_tests_with_options(&file, &opts, None);
if results.is_empty() {
return Err(format!(
"the document declares no inline assertions for model {model:?}"
));
}
let codes: Vec<Option<String>> = results
.iter()
.filter(|r| !r.passed)
.map(|r| refusal_code(&r.message))
.collect();
if codes.len() == results.len()
&& !codes.is_empty()
&& codes.iter().all(Option::is_some)
&& codes.windows(2).all(|w| w[0] == w[1])
{
let code = codes[0].clone().unwrap_or_default();
let reason = truncate(&results[0].message, 400);
if code == "compiler_unavailable" {
return Ok(Answer::Unavailable(reason));
}
return Ok(Answer::Entry(json!({
"status": "refused",
"code": code,
"reason": reason,
})));
}
let entries: Vec<Value> = results
.iter()
.map(|r| {
json!({
"test_id": r.test_id,
"assertion_idx": r.assertion_idx,
"variable": r.variable,
"passed": r.passed,
"actual": r.actual,
"message": r.message,
})
})
.collect();
Ok(Answer::Entry(json!({ "assertions": entries })))
}
pub fn run_manifest(manifest_path: &Path, compiler: Compiler) -> Result<Report, String> {
let text = fs::read_to_string(manifest_path)
.map_err(|e| format!("failed to read manifest {}: {e}", manifest_path.display()))?;
let manifest: Value =
serde_json::from_str(&text).map_err(|e| format!("failed to parse manifest: {e}"))?;
let abs = std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf());
let manifest_dir = abs.parent().unwrap_or(Path::new(".")).to_path_buf();
let tests = tests_root(manifest_path);
let empty: Vec<Value> = Vec::new();
let mut fixtures = Map::new();
let mut failed = Vec::new();
for fx in manifest["fixtures"].as_array().unwrap_or(&empty) {
let id = fx["id"].as_str().unwrap_or("<unknown>").to_string();
let entry = match run_fixture(fx, &manifest, &manifest_dir, &tests, compiler) {
Ok(Answer::Entry(v)) => v,
Ok(Answer::Unavailable(reason)) => {
return Ok(Report {
payload: json!({
"binding": "rust",
"compiler": compiler.as_str(),
"status": "unavailable",
"reason": reason,
}),
failed: Vec::new(),
});
}
Err(e) => {
if let Some(code) = refusal_code(&e) {
json!({ "status": "refused", "code": code, "reason": truncate(&e, 400) })
} else {
eprintln!("fixture {id}: {e}");
failed.push(id.clone());
json!({ "error": truncate(&e, 800) })
}
}
};
fixtures.insert(id, entry);
}
Ok(Report {
payload: json!({
"binding": "rust",
"compiler": compiler.as_str(),
"fixtures": Value::Object(fixtures),
}),
failed,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refusal_code_names_only_coded_diagnostics() {
assert_eq!(refusal_code("expected 1.0, got 2.0 (rel 1.0)"), None);
assert_eq!(
refusal_code("simulation failed: unevaluable_operator: operator 'false'"),
Some("unevaluable_operator".to_string())
);
assert_eq!(
refusal_code("build: E_TREEWALK_CONSTARRAY_OOB at cell 5"),
Some("E_TREEWALK_CONSTARRAY_OOB".to_string())
);
}
#[test]
fn parse_args_requires_all_three() {
assert!(parse_args(["--manifest".into(), "m.json".into()]).is_err());
let a = parse_args([
"--manifest".to_string(),
"m.json".to_string(),
"--output".to_string(),
"o.json".to_string(),
"--compiler".to_string(),
"interpreter".to_string(),
])
.expect("parses");
assert_eq!(a.compiler, Compiler::Interpreter);
}
}