use crate::command_interpreter::task_parser_bvp::{
parse_bvp_task_from_str, run_bvp_task, BvpTaskError, BvpTaskRunResult, BvpTaskSpec,
};
use crate::command_interpreter::task_parser_ivp::{
parse_ivp_task_from_str, run_ivp_task, IvpMethodSpec, IvpTaskError, IvpTaskRunResult,
IvpTaskSpec,
};
use std::fs;
use std::path::{Path, PathBuf};
use tabled::{Table, Tabled};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskDocumentKind {
Ivp,
Bvp,
}
#[derive(Debug)]
pub enum ParsedTaskSpec {
Ivp(IvpTaskSpec),
Bvp(BvpTaskSpec),
}
#[derive(Debug)]
pub enum TaskRunResult {
Ivp(IvpTaskRunResult),
Bvp(BvpTaskRunResult),
}
#[derive(Debug)]
pub enum TaskRunnerError {
Io {
path: PathBuf,
source: std::io::Error,
},
MissingSolverField,
UnsupportedSolver {
value: String,
},
Ivp(IvpTaskError),
Bvp(BvpTaskError),
}
impl std::fmt::Display for TaskRunnerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => {
write!(
f,
"failed to read task document `{}`: {source}",
path.display()
)
}
Self::MissingSolverField => write!(
f,
"task document must contain a `solver: IVP|BVP` field in the `task` section"
),
Self::UnsupportedSolver { value } => {
write!(
f,
"unsupported solver `{value}` in task document (expected IVP or BVP)"
)
}
Self::Ivp(err) => write!(f, "{err}"),
Self::Bvp(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for TaskRunnerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Ivp(err) => Some(err),
Self::Bvp(err) => Some(err),
_ => None,
}
}
}
impl TaskRunnerError {
pub fn category(&self) -> &'static str {
match self {
Self::Io { .. } => "io",
Self::MissingSolverField | Self::UnsupportedSolver { .. } => "parse",
Self::Ivp(error) => error.category(),
Self::Bvp(_) => "bvp",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchTaskStatus {
Passed,
Failed,
}
impl BatchTaskStatus {
fn as_str(self) -> &'static str {
match self {
Self::Passed => "passed",
Self::Failed => "failed",
}
}
}
#[derive(Debug)]
pub struct BatchTaskSummary {
pub path: PathBuf,
pub status: BatchTaskStatus,
pub kind: Option<TaskDocumentKind>,
pub error_category: Option<&'static str>,
pub error: Option<String>,
}
#[derive(Debug, Tabled)]
struct BatchTaskSummaryRow {
path: String,
status: String,
kind: String,
error_category: String,
error: String,
}
#[derive(Debug, Default)]
pub struct BatchTaskReport {
pub entries: Vec<BatchTaskSummary>,
}
impl BatchTaskReport {
pub fn passed_count(&self) -> usize {
self.entries
.iter()
.filter(|entry| entry.status == BatchTaskStatus::Passed)
.count()
}
pub fn failed_count(&self) -> usize {
self.entries
.iter()
.filter(|entry| entry.status == BatchTaskStatus::Failed)
.count()
}
pub fn render_table(&self) -> String {
let rows = self
.entries
.iter()
.map(|entry| BatchTaskSummaryRow {
path: entry.path.display().to_string(),
status: entry.status.as_str().to_string(),
kind: entry
.kind
.map(task_kind_label)
.unwrap_or("unknown")
.to_string(),
error_category: entry.error_category.unwrap_or("-").to_string(),
error: entry.error.as_deref().unwrap_or("-").to_string(),
})
.collect::<Vec<_>>();
Table::new(rows).to_string()
}
pub fn write_table(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
fs::write(path, self.render_table())
}
}
pub fn run_task_batch<I, P>(paths: I) -> BatchTaskReport
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
let entries = paths
.into_iter()
.map(Into::into)
.map(|path| {
let source = match fs::read_to_string(&path) {
Ok(source) => source,
Err(source) => {
let error = TaskRunnerError::Io {
path: path.clone(),
source,
};
return BatchTaskSummary {
path,
status: BatchTaskStatus::Failed,
kind: None,
error_category: Some(error.category()),
error: Some(error.to_string()),
};
}
};
let detected_kind = detect_task_kind_from_str(&source).ok();
match run_task_from_str(&source) {
Ok(result) => BatchTaskSummary {
path,
status: BatchTaskStatus::Passed,
kind: Some(result.kind()),
error_category: None,
error: None,
},
Err(error) => BatchTaskSummary {
path,
status: BatchTaskStatus::Failed,
kind: detected_kind,
error_category: Some(error.category()),
error: Some(error.to_string()),
},
}
})
.collect();
BatchTaskReport { entries }
}
fn task_kind_label(kind: TaskDocumentKind) -> &'static str {
match kind {
TaskDocumentKind::Ivp => "ivp",
TaskDocumentKind::Bvp => "bvp",
}
}
impl TaskRunResult {
pub fn kind(&self) -> TaskDocumentKind {
match self {
Self::Ivp(_) => TaskDocumentKind::Ivp,
Self::Bvp(_) => TaskDocumentKind::Bvp,
}
}
}
impl ParsedTaskSpec {
pub fn kind(&self) -> TaskDocumentKind {
match self {
Self::Ivp(_) => TaskDocumentKind::Ivp,
Self::Bvp(_) => TaskDocumentKind::Bvp,
}
}
}
pub fn detect_task_kind_from_str(input: &str) -> Result<TaskDocumentKind, TaskRunnerError> {
for line in input.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
if !key.trim().eq_ignore_ascii_case("solver") {
continue;
}
let solver = value.trim().to_ascii_lowercase();
return match solver.as_str() {
"ivp" => Ok(TaskDocumentKind::Ivp),
"bvp" => Ok(TaskDocumentKind::Bvp),
_ => Err(TaskRunnerError::UnsupportedSolver { value: solver }),
};
}
Err(TaskRunnerError::MissingSolverField)
}
pub fn parse_task_spec_from_str(input: &str) -> Result<ParsedTaskSpec, TaskRunnerError> {
match detect_task_kind_from_str(input)? {
TaskDocumentKind::Ivp => parse_ivp_task_from_str(input)
.map(ParsedTaskSpec::Ivp)
.map_err(TaskRunnerError::Ivp),
TaskDocumentKind::Bvp => parse_bvp_task_from_str(input)
.map(ParsedTaskSpec::Bvp)
.map_err(TaskRunnerError::Bvp),
}
}
pub fn run_task_from_str(input: &str) -> Result<TaskRunResult, TaskRunnerError> {
let spec = parse_task_spec_from_str(input)?;
run_task_from_spec(spec)
}
pub fn run_task_from_spec(spec: ParsedTaskSpec) -> Result<TaskRunResult, TaskRunnerError> {
match spec {
ParsedTaskSpec::Ivp(spec) => run_ivp_task(spec)
.map(TaskRunResult::Ivp)
.map_err(TaskRunnerError::Ivp),
ParsedTaskSpec::Bvp(spec) => run_bvp_task(spec)
.map(TaskRunResult::Bvp)
.map_err(TaskRunnerError::Bvp),
}
}
pub fn parse_task_spec_from_file(
path: impl AsRef<Path>,
) -> Result<ParsedTaskSpec, TaskRunnerError> {
let path_ref = path.as_ref();
let text = fs::read_to_string(path_ref).map_err(|source| TaskRunnerError::Io {
path: path_ref.to_path_buf(),
source,
})?;
parse_task_spec_from_str(&text)
}
pub fn run_task_from_file(path: impl AsRef<Path>) -> Result<TaskRunResult, TaskRunnerError> {
let path_ref = path.as_ref();
let text = fs::read_to_string(path_ref).map_err(|source| TaskRunnerError::Io {
path: path_ref.to_path_buf(),
source,
})?;
run_task_from_str(&text)
}
pub fn render_task_preview(spec: &ParsedTaskSpec) -> String {
match spec {
ParsedTaskSpec::Ivp(ivp) => render_ivp_preview(ivp),
ParsedTaskSpec::Bvp(bvp) => render_bvp_preview(bvp),
}
}
pub fn render_task_check(spec: &ParsedTaskSpec) -> String {
match spec {
ParsedTaskSpec::Ivp(ivp) => render_ivp_check(ivp),
ParsedTaskSpec::Bvp(bvp) => render_bvp_check(bvp),
}
}
fn render_ivp_preview(spec: &IvpTaskSpec) -> String {
let method = ivp_method_label(&spec.solver.method);
let mut out = String::new();
out.push_str("[Task preview] IVP\n");
out.push_str(&format!(
"solver=IVP, method={}, arg={}, t0={}, t_end={}\n",
method, spec.equations.arg, spec.initial_conditions.t0, spec.initial_conditions.t_end
));
out.push_str("equations:\n");
out.push_str("idx | unknown | rhs\n");
out.push_str("-------------------\n");
for (idx, (name, rhs)) in spec
.equations
.unknowns
.iter()
.zip(spec.equations.rhs.iter())
.enumerate()
{
out.push_str(&format!("{idx:>3} | {name} | {rhs}\n"));
}
out
}
fn render_bvp_preview(spec: &BvpTaskSpec) -> String {
let mut out = String::new();
out.push_str("[Task preview] BVP\n");
out.push_str(&format!(
"solver=BVP, strategy={:?}, backend={:?}, arg={}, t0={}, t_end={}, n_steps={}\n",
spec.solver.strategy,
spec.solver.backend,
spec.equations.arg,
spec.mesh.t0,
spec.mesh.t_end,
spec.mesh.n_steps
));
out.push_str("equations:\n");
out.push_str("idx | unknown | rhs\n");
out.push_str("-------------------\n");
for (idx, (name, rhs)) in spec
.equations
.unknowns
.iter()
.zip(spec.equations.rhs.iter())
.enumerate()
{
out.push_str(&format!("{idx:>3} | {name} | {rhs}\n"));
}
out
}
fn render_ivp_check(spec: &IvpTaskSpec) -> String {
let mut out = String::new();
out.push_str("[Task check] IVP\n");
out.push_str(&format!(
"solver=IVP, method={}, arg={}, t0={}, t_end={}\n",
ivp_method_label(&spec.solver.method),
spec.equations.arg,
spec.initial_conditions.t0,
spec.initial_conditions.t_end
));
out.push_str("fully substituted equations:\n");
out.push_str("idx | unknown | rhs\n");
out.push_str("-------------------\n");
for (idx, (name, rhs)) in spec
.equations
.unknowns
.iter()
.zip(spec.equations.rhs.iter())
.enumerate()
{
out.push_str(&format!("{idx:>3} | {name} | {rhs}\n"));
}
out
}
fn render_bvp_check(spec: &BvpTaskSpec) -> String {
let mut out = String::new();
out.push_str("[Task check] BVP\n");
out.push_str(&format!(
"solver=BVP, strategy={:?}, backend={:?}, arg={}, t0={}, t_end={}, n_steps={}\n",
spec.solver.strategy,
spec.solver.backend,
spec.equations.arg,
spec.mesh.t0,
spec.mesh.t_end,
spec.mesh.n_steps
));
out.push_str("fully substituted equations:\n");
out.push_str("idx | unknown | rhs\n");
out.push_str("-------------------\n");
for (idx, (name, rhs)) in spec
.equations
.unknowns
.iter()
.zip(spec.equations.rhs.iter())
.enumerate()
{
out.push_str(&format!("{idx:>3} | {name} | {rhs}\n"));
}
out
}
fn ivp_method_label(method: &IvpMethodSpec) -> String {
match method {
IvpMethodSpec::NonStiff(name) => name.clone(),
IvpMethodSpec::Radau5 => "Radau5".to_string(),
IvpMethodSpec::Bdf => "BDF".to_string(),
IvpMethodSpec::BackwardEuler => "BackwardEuler".to_string(),
IvpMethodSpec::Lsode => "LSODE".to_string(),
IvpMethodSpec::Lsoda => "LSODA".to_string(),
IvpMethodSpec::Lsode2 => "LSODE2".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn detect_task_kind_accepts_ivp_and_bvp_solver_lines() {
let ivp = "task\nsolver: IVP\nmethod: RK45\n";
let bvp = "task\nsolver: BVP\nstrategy: Damped\n";
assert_eq!(
detect_task_kind_from_str(ivp).unwrap(),
TaskDocumentKind::Ivp
);
assert_eq!(
detect_task_kind_from_str(bvp).unwrap(),
TaskDocumentKind::Bvp
);
}
#[test]
fn run_task_from_str_routes_ivp_document() {
let ivp_doc = r#"
task
solver: IVP
method: RK45
equations
arg: t
y: -y
initial_conditions
t0: 0.0
t_end: 0.1
y0: 1.0
solver_options
step_size: 1e-3
"#;
let result = run_task_from_str(ivp_doc).expect("IVP document should run");
assert_eq!(result.kind(), TaskDocumentKind::Ivp);
}
#[test]
fn render_task_preview_includes_solver_kind_and_equations() {
let ivp_doc = r#"
task
solver: IVP
method: LSODE2
equations
arg: t
y: -2.0*y
initial_conditions
t0: 0.0
t_end: 0.1
y0: 1.0
solver_options
max_step: 1e-2
"#;
let spec = parse_task_spec_from_str(ivp_doc).expect("IVP document should parse");
let preview = render_task_preview(&spec);
assert!(preview.contains("[Task preview] IVP"));
assert!(preview.contains("method=LSODE2"));
assert!(preview.contains("y |"));
assert!(preview.contains("2"));
}
#[test]
fn render_task_check_shows_fully_substituted_equations() {
let bvp_doc = r#"
task
solver: BVP
strategy: Damped
backend: Sparse
equations
arg: x
parameters: a
parameter_values: 4.0
unknowns: y
rhs: gain * y
where
base: 2 * x
gain: base + a
boundary_conditions
y_left: 1.0
y_right: 0.0
mesh
t0: 0.0
t_end: 1.0
n_steps: 10
initial_guess
y: 0.0
"#;
let spec = parse_task_spec_from_str(bvp_doc).expect("BVP document should parse");
let check = render_task_check(&spec);
assert!(check.contains("[Task check] BVP"));
assert!(check.contains("fully substituted equations"));
assert!(check.contains('4'));
assert!(!check.contains("gain:"));
}
#[test]
fn parse_new_ivp_task_doc_with_where_section() {
let path = format!(
"{}/examples/task_docs/ivp_solid_combustion_with_sublimation_task.txt",
env!("CARGO_MANIFEST_DIR")
);
let text = fs::read_to_string(path).expect("IVP task doc should be readable");
let spec = parse_task_spec_from_str(&text).expect("IVP task doc should parse");
assert_eq!(spec.kind(), TaskDocumentKind::Ivp);
}
#[test]
fn parse_new_bvp_task_doc_combustion_like() {
let path = format!(
"{}/examples/task_docs/bvp_combustion_1000_task.txt",
env!("CARGO_MANIFEST_DIR")
);
let text = fs::read_to_string(path).expect("BVP task doc should be readable");
let spec = parse_task_spec_from_str(&text).expect("BVP task doc should parse");
assert_eq!(spec.kind(), TaskDocumentKind::Bvp);
}
#[test]
fn batch_runner_continues_after_failure_and_writes_compact_typed_table() {
let directory = tempdir().expect("temporary task directory");
let valid_before = directory.path().join("valid_before.txt");
let invalid = directory.path().join("invalid.txt");
let valid_after = directory.path().join("valid_after.txt");
let valid = r#"
task
solver: IVP
method: RK45
equations
arg: t
y: -y
initial_conditions
t0: 0.0
t_end: 0.01
y0: 1.0
solver_options
step_size: 1e-3
"#;
fs::write(&valid_before, valid).expect("write first task");
fs::write(&invalid, "task\nsolver: IVP\nmethod: UnknownMethod\n")
.expect("write invalid task");
fs::write(&valid_after, valid).expect("write second task");
let report = run_task_batch([valid_before, invalid, valid_after]);
assert_eq!(report.entries.len(), 3);
assert_eq!(report.passed_count(), 2);
assert_eq!(report.failed_count(), 1);
assert_eq!(report.entries[1].kind, Some(TaskDocumentKind::Ivp));
assert_eq!(report.entries[1].error_category, Some("configuration"));
let table = report.render_table();
assert!(table.contains("passed"));
assert!(table.contains("failed"));
assert!(table.contains("configuration"));
let table_path = directory.path().join("summary.md");
report
.write_table(&table_path)
.expect("write batch summary");
assert_eq!(fs::read_to_string(table_path).unwrap(), table);
}
}