use std::collections::HashMap;
use std::io;
use std::path::{Component, Path, PathBuf};
use anyhow::anyhow;
use dynamic_cli::error::ExecutionError;
use dynamic_cli::{CommandHandler, DynamicCliError, ExecutionContext};
use crate::config::{model::load_model, scenario::load_scenario, solver::load_solver};
use crate::models::{LangmuirMulti, LangmuirSingle};
use crate::output::export::{CsvConfig, CsvExporter, Exporter, to_json};
use crate::output::visualization::{plot_chromatogram, plot_chromatogram_multi};
use crate::physics::Exportable;
use crate::solver::{EulerSolver, RK4Solver, Scenario, SimulationResult, Solver};
#[derive(Debug)]
pub enum ContextError {
PathTraversal(PathBuf),
NotADirectory(PathBuf),
PermissionDenied(PathBuf, io::Error),
}
impl std::fmt::Display for ContextError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContextError::PathTraversal(p) => write!(
f,
"project-dir '{}' contains '..': path traversal is not allowed",
p.display()
),
ContextError::NotADirectory(p) => write!(
f,
"project-dir '{}' does not exist or is not a directory",
p.display()
),
ContextError::PermissionDenied(p, e) => write!(
f,
"project-dir '{}': insufficient permissions — {e}",
p.display()
),
}
}
}
impl std::error::Error for ContextError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ContextError::PermissionDenied(_, e) => Some(e),
_ => None,
}
}
}
pub struct ChromContext {
project_dir: PathBuf,
}
impl ChromContext {
pub fn new() -> Self {
Self {
project_dir: PathBuf::from("."),
}
}
pub fn project_dir(&self) -> &Path {
&self.project_dir
}
pub fn set_project_dir(&mut self, path: impl Into<PathBuf>) -> Result<(), ContextError> {
let path = path.into();
if path.components().any(|c| c == Component::ParentDir) {
return Err(ContextError::PathTraversal(path));
}
if !path.is_dir() {
return Err(ContextError::NotADirectory(path));
}
std::fs::read_dir(&path).map_err(|e| ContextError::PermissionDenied(path.clone(), e))?;
let probe = path.join(".chrom_rs_write_probe");
std::fs::File::create(&probe)
.map_err(|e| ContextError::PermissionDenied(path.clone(), e))?;
let _ = std::fs::remove_file(&probe);
self.project_dir = path;
Ok(())
}
}
impl Default for ChromContext {
fn default() -> Self {
Self::new()
}
}
impl ExecutionContext for ChromContext {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
fn to_cli_err(e: impl Into<anyhow::Error>) -> DynamicCliError {
ExecutionError::CommandFailed(e.into()).into()
}
pub(super) fn resolve_species_names(model_path: &Path) -> anyhow::Result<Vec<String>> {
let (format, content) = read_model_file(model_path)?;
let root_key = peek_root_key(format, &content, model_path)?;
if root_key != "LangmuirMulti" {
return Ok(vec![]);
}
let model = deserialise_inner::<LangmuirMulti>(format, &content, "LangmuirMulti", model_path)?;
Ok(model
.species_names()
.iter()
.map(|s| s.to_string())
.collect())
}
pub(super) fn resolve_export_map(
model_path: &Path,
result: &SimulationResult,
) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
let (format, content) = read_model_file(model_path)?;
let root_key = peek_root_key(format, &content, model_path)?;
match root_key.as_str() {
"LangmuirMulti" => {
let model =
deserialise_inner::<LangmuirMulti>(format, &content, "LangmuirMulti", model_path)?;
Ok(model.to_map(
&result.time_points,
&result.state_trajectory,
&result.metadata,
))
}
_ => {
let model = deserialise_inner::<LangmuirSingle>(
format,
&content,
"LangmuirSingle",
model_path,
)?;
Ok(model.to_map(
&result.time_points,
&result.state_trajectory,
&result.metadata,
))
}
}
}
use crate::config::Format;
fn read_model_file(model_path: &Path) -> anyhow::Result<(Format, String)> {
use crate::config::format_from_path;
let path_str = model_path
.to_str()
.ok_or_else(|| anyhow!("model path is not valid UTF-8"))?;
let format =
format_from_path(path_str).map_err(|e| anyhow!("unsupported model file format: {e}"))?;
let content = std::fs::read_to_string(model_path)
.map_err(|e| anyhow!("cannot read '{}': {e}", model_path.display()))?;
Ok((format, content))
}
fn peek_root_key(format: Format, content: &str, model_path: &Path) -> anyhow::Result<String> {
let key = match format {
Format::Yaml => {
let value: serde_yaml::Value = serde_yaml::from_str(content)
.map_err(|e| anyhow!("YAML parse error in '{}': {e}", model_path.display()))?;
value
.as_mapping()
.and_then(|m| m.keys().next())
.and_then(|k| k.as_str())
.unwrap_or("")
.to_string()
}
Format::Json => {
let value: serde_json::Value = serde_json::from_str(content)
.map_err(|e| anyhow!("JSON parse error in '{}': {e}", model_path.display()))?;
value
.as_object()
.and_then(|m| m.keys().next())
.map(|k| k.as_str())
.unwrap_or("")
.to_string()
}
};
Ok(key)
}
fn deserialise_inner<T>(
format: Format,
content: &str,
key: &str,
model_path: &Path,
) -> anyhow::Result<T>
where
T: serde::de::DeserializeOwned,
{
let root: serde_json::Value = match format {
Format::Yaml => serde_yaml::from_str(content)
.map_err(|e| anyhow!("YAML parse error in '{}': {e}", model_path.display()))?,
Format::Json => serde_json::from_str(content)
.map_err(|e| anyhow!("JSON parse error in '{}': {e}", model_path.display()))?,
};
let inner = root
.get(key)
.cloned()
.ok_or_else(|| anyhow!("missing '{}' key in '{}'", key, model_path.display()))?;
serde_json::from_value(inner).map_err(|e| {
anyhow!(
"deserialisation error for '{}' in '{}': {e}",
key,
model_path.display()
)
})
}
pub struct RunHandler;
impl CommandHandler for RunHandler {
fn execute(
&self,
ctx: &mut dyn ExecutionContext,
args: &HashMap<String, String>,
) -> dynamic_cli::Result<()> {
let chrom_ctx = ctx
.as_any_mut()
.downcast_mut::<ChromContext>()
.ok_or_else(|| {
DynamicCliError::from(ExecutionError::ContextDowncastFailed {
expected_type: "ChromContext".to_string(),
suggestion: None,
})
})?;
let project_dir_str = args.get("project-dir").map(|s| s.as_str()).unwrap_or(".");
chrom_ctx
.set_project_dir(project_dir_str)
.map_err(|e| to_cli_err(anyhow!("{e}")))?;
let project_dir: PathBuf = chrom_ctx.project_dir().to_path_buf();
let model_path = resolve_input_path(&project_dir, args, "model").map_err(to_cli_err)?;
let scenario_path =
resolve_input_path(&project_dir, args, "scenario").map_err(to_cli_err)?;
let solver_path = resolve_input_path(&project_dir, args, "solver").map_err(to_cli_err)?;
let species_names = resolve_species_names(&model_path).map_err(to_cli_err)?;
let is_multi = !species_names.is_empty();
let model_path_str = path_to_str(&model_path).map_err(to_cli_err)?;
let scenario_path_str = path_to_str(&scenario_path).map_err(to_cli_err)?;
let solver_path_str = path_to_str(&solver_path).map_err(to_cli_err)?;
let mut model =
load_model(model_path_str).map_err(|e| to_cli_err(anyhow!("loading model: {e}")))?;
let boundaries = load_scenario(scenario_path_str, &mut *model)
.map_err(|e| to_cli_err(anyhow!("loading scenario: {e}")))?;
let solver_cfg =
load_solver(solver_path_str).map_err(|e| to_cli_err(anyhow!("loading solver: {e}")))?;
let n_points = model.points();
let scenario = Scenario::new(model, boundaries);
let result = match solver_cfg.solver_name.as_str() {
"RK4" => RK4Solver::new()
.solve(&scenario, &solver_cfg.config)
.map_err(|e| to_cli_err(anyhow!("RK4 solver: {e}")))?,
"Euler" => EulerSolver::new()
.solve(&scenario, &solver_cfg.config)
.map_err(|e| to_cli_err(anyhow!("Euler solver: {e}")))?,
other => {
return Err(to_cli_err(anyhow!(
"unknown solver '{}' — expected 'RK4' or 'Euler'",
other
)));
}
};
println!(
"Simulation complete — {} time points",
result.time_points.len()
);
if let Some(csv_name) = args.get("output-csv") {
let csv_buf = project_dir.join(csv_name);
let csv_path = path_to_str(&csv_buf).map_err(to_cli_err)?;
let exporter = CsvExporter::new(CsvConfig::default());
if is_multi {
let name_refs: Vec<&str> = species_names.iter().map(|s| s.as_str()).collect();
exporter
.export_multi(&result, None, &name_refs, csv_path)
.map_err(|e| to_cli_err(anyhow!("CSV export: {e}")))?;
} else {
exporter
.export_single(&result, None, csv_path)
.map_err(|e| to_cli_err(anyhow!("CSV export: {e}")))?;
}
println!("CSV written → {csv_path}");
}
if let Some(plot_name) = args.get("output-plot") {
let plot_buf = project_dir.join(plot_name);
let plot_path = path_to_str(&plot_buf).map_err(to_cli_err)?;
if is_multi {
let name_refs: Vec<&str> = species_names.iter().map(|s| s.as_str()).collect();
plot_chromatogram_multi(&result, n_points, &name_refs, plot_path, None)
.map_err(|e| to_cli_err(anyhow!("plot: {e}")))?;
} else {
plot_chromatogram(&result, n_points, plot_path, None)
.map_err(|e| to_cli_err(anyhow!("plot: {e}")))?;
}
println!("Plot written → {plot_path}");
}
if let Some(json_name) = args.get("export-json") {
let json_buf = project_dir.join(json_name);
let json_path = path_to_str(&json_buf).map_err(to_cli_err)?;
let map = resolve_export_map(&model_path, &result)
.map_err(|e| to_cli_err(anyhow!("building export map: {e}")))?;
to_json(&map, json_path).map_err(|e| to_cli_err(anyhow!("JSON export: {e}")))?;
println!("JSON written → {json_path}");
}
Ok(())
}
}
fn resolve_input_path(
project_dir: &Path,
args: &HashMap<String, String>,
key: &str,
) -> anyhow::Result<PathBuf> {
let name = args
.get(key)
.ok_or_else(|| anyhow!("missing required option '--{key}'"))?;
Ok(project_dir.join(name))
}
fn path_to_str(path: &Path) -> anyhow::Result<&str> {
path.to_str()
.ok_or_else(|| anyhow!("path '{}' contains non-UTF-8 characters", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::physics::{PhysicalData, PhysicalModel, PhysicalQuantity, PhysicalState};
use crate::solver::SimulationResult;
use dynamic_cli::downcast_ref;
use std::io::Write;
const SINGLE_YAML: &str = "\
LangmuirSingle:
lambda: 1.2
langmuir_k: 0.4
port_number: 2.0
column_length: 0.25
n_points: 100
dz: 0.0025
fe: 1.5
ue: 0.0025
injection:
type: None
";
const MULTI_YAML: &str = "\
LangmuirMulti:
species:
- name: A
lambda: 1.0
langmuir_k: 0.5
port_number: 1
injection:
type: None
- name: B
lambda: 1.0
langmuir_k: 2.0
port_number: 1
injection:
type: None
n_points: 50
porosity: 0.4
velocity: 0.001
column_length: 0.25
dz: 0.005
fe: 1.5
ue: 0.0025
stationary_fraction: 0.6
";
fn tmp_yaml(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::Builder::new().suffix(".yml").tempfile().unwrap();
write!(f, "{content}").unwrap();
f
}
fn tmp_json(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
write!(f, "{content}").unwrap();
f
}
fn minimal_result() -> SimulationResult {
let state = PhysicalState::new(
PhysicalQuantity::Concentration,
PhysicalData::Vector(nalgebra::DVector::from_vec(vec![0.0; 100])),
);
SimulationResult::new(
vec![0.0, 1.0, 2.0],
vec![state.clone(), state.clone(), state.clone()],
state,
)
}
#[test]
fn test_new_defaults_to_current_dir() {
let ctx = ChromContext::new();
assert_eq!(ctx.project_dir(), Path::new("."));
}
#[test]
fn test_default_equals_new() {
let ctx = ChromContext::default();
assert_eq!(ctx.project_dir(), Path::new("."));
}
#[test]
fn test_set_project_dir_valid() {
let dir = tempfile::tempdir().unwrap();
let mut ctx = ChromContext::new();
ctx.set_project_dir(dir.path()).unwrap();
assert_eq!(ctx.project_dir(), dir.path());
}
#[test]
fn test_set_project_dir_rejects_parent_component() {
let mut ctx = ChromContext::new();
assert!(matches!(
ctx.set_project_dir("some/../other"),
Err(ContextError::PathTraversal(_))
));
}
#[test]
fn test_set_project_dir_rejects_leading_parent() {
let mut ctx = ChromContext::new();
assert!(matches!(
ctx.set_project_dir("../sibling"),
Err(ContextError::PathTraversal(_))
));
}
#[test]
fn test_set_project_dir_rejects_missing_path() {
let mut ctx = ChromContext::new();
assert!(matches!(
ctx.set_project_dir("/tmp/chrom_rs_does_not_exist_xyz"),
Err(ContextError::NotADirectory(_))
));
}
#[test]
fn test_set_project_dir_rejects_file_path() {
let file = tempfile::NamedTempFile::new().unwrap();
let mut ctx = ChromContext::new();
assert!(matches!(
ctx.set_project_dir(file.path()),
Err(ContextError::NotADirectory(_))
));
}
#[test]
fn test_as_any_downcast_ref() {
let dir = tempfile::tempdir().unwrap();
let mut ctx = ChromContext::new();
ctx.set_project_dir(dir.path()).unwrap();
let boxed: Box<dyn ExecutionContext> = Box::new(ctx);
let recovered = downcast_ref::<ChromContext>(boxed.as_ref()).unwrap();
assert_eq!(recovered.project_dir(), dir.path());
}
#[test]
fn test_as_any_mut_downcast_mut() {
let dir1 = tempfile::tempdir().unwrap();
let dir2 = tempfile::tempdir().unwrap();
let mut ctx = ChromContext::new();
ctx.set_project_dir(dir1.path()).unwrap();
let any_mut = ctx.as_any_mut();
let recovered = any_mut.downcast_mut::<ChromContext>().unwrap();
recovered.set_project_dir(dir2.path()).unwrap();
assert_eq!(ctx.project_dir(), dir2.path());
}
#[test]
fn test_display_path_traversal() {
let e = ContextError::PathTraversal(PathBuf::from("a/../b"));
assert!(e.to_string().contains(".."));
assert!(e.to_string().contains("path traversal"));
}
#[test]
fn test_display_not_a_directory() {
let e = ContextError::NotADirectory(PathBuf::from("/no/such/dir"));
assert!(e.to_string().contains("no/such/dir"));
}
#[test]
fn test_display_permission_denied() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let e = ContextError::PermissionDenied(PathBuf::from("/locked"), io_err);
assert!(e.to_string().contains("locked"));
assert!(e.to_string().contains("permissions"));
}
#[test]
fn test_source_permission_denied_has_source() {
use std::error::Error;
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let e = ContextError::PermissionDenied(PathBuf::from("/locked"), io_err);
assert!(e.source().is_some());
}
#[test]
fn test_source_path_traversal_is_none() {
use std::error::Error;
let e = ContextError::PathTraversal(PathBuf::from("a/b"));
assert!(e.source().is_none());
}
#[test]
fn test_to_cli_err_produces_command_failed() {
use dynamic_cli::error::DynamicCliError;
let err = to_cli_err(anyhow!("test error"));
assert!(matches!(err, DynamicCliError::Execution(_)));
}
#[test]
fn test_path_to_str_valid_utf8() {
let p = PathBuf::from("/tmp/results.csv");
assert_eq!(path_to_str(&p).unwrap(), "/tmp/results.csv");
}
#[test]
fn test_path_to_str_rejects_non_utf8() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let bad = OsStr::from_bytes(&[0xff, 0xfe]);
let p = PathBuf::from(bad);
assert!(path_to_str(&p).is_err());
}
#[test]
fn test_resolve_input_path_found() {
let mut args = HashMap::new();
args.insert("model".to_string(), "model.yml".to_string());
let result = resolve_input_path(Path::new("/proj"), &args, "model").unwrap();
assert_eq!(result, PathBuf::from("/proj/model.yml"));
}
#[test]
fn test_resolve_input_path_missing_key() {
let args = HashMap::new();
assert!(resolve_input_path(Path::new("."), &args, "model").is_err());
}
#[test]
fn test_read_model_file_yaml() {
let f = tmp_yaml(SINGLE_YAML);
let (format, content) = read_model_file(f.path()).unwrap();
assert_eq!(format, Format::Yaml);
assert!(content.contains("LangmuirSingle"));
}
#[test]
fn test_read_model_file_json() {
let json = r#"{"LangmuirSingle": {"lambda": 1.0}}"#;
let f = tmp_json(json);
let (format, _) = read_model_file(f.path()).unwrap();
assert_eq!(format, Format::Json);
}
#[test]
fn test_read_model_file_missing() {
let p = PathBuf::from("/tmp/chrom_rs_missing_model.yml");
assert!(read_model_file(&p).is_err());
}
#[test]
fn test_peek_root_key_yaml_single() {
let f = tmp_yaml(SINGLE_YAML);
let (fmt, content) = read_model_file(f.path()).unwrap();
let key = peek_root_key(fmt, &content, f.path()).unwrap();
assert_eq!(key, "LangmuirSingle");
}
#[test]
fn test_peek_root_key_yaml_multi() {
let f = tmp_yaml(MULTI_YAML);
let (fmt, content) = read_model_file(f.path()).unwrap();
let key = peek_root_key(fmt, &content, f.path()).unwrap();
assert_eq!(key, "LangmuirMulti");
}
#[test]
fn test_peek_root_key_json() {
let json = r#"{"LangmuirSingle": {}}"#;
let f = tmp_json(json);
let (fmt, content) = read_model_file(f.path()).unwrap();
let key = peek_root_key(fmt, &content, f.path()).unwrap();
assert_eq!(key, "LangmuirSingle");
}
#[test]
fn test_resolve_species_names_single_returns_empty() {
let f = tmp_yaml(SINGLE_YAML);
let names = resolve_species_names(f.path()).unwrap();
assert!(names.is_empty());
}
#[test]
fn test_resolve_species_names_multi_returns_names() {
let f = tmp_yaml(MULTI_YAML);
let names = resolve_species_names(f.path()).unwrap();
assert_eq!(names, vec!["A", "B"]);
}
#[test]
fn test_resolve_species_names_missing_file() {
let p = PathBuf::from("/tmp/chrom_rs_no_model.yml");
assert!(resolve_species_names(&p).is_err());
}
#[test]
fn test_deserialise_inner_single_yaml() {
let f = tmp_yaml(SINGLE_YAML);
let (fmt, content) = read_model_file(f.path()).unwrap();
let model: crate::models::LangmuirSingle =
deserialise_inner(fmt, &content, "LangmuirSingle", f.path()).unwrap();
assert_eq!(
model.name(),
"Langmuir single specie with temporal injection"
);
}
#[test]
fn test_deserialise_inner_multi_yaml() {
let f = tmp_yaml(MULTI_YAML);
let (fmt, content) = read_model_file(f.path()).unwrap();
let model: crate::models::LangmuirMulti =
deserialise_inner(fmt, &content, "LangmuirMulti", f.path()).unwrap();
assert_eq!(model.species_names(), vec!["A", "B"]);
}
#[test]
fn test_deserialise_inner_missing_key_returns_error() {
let f = tmp_yaml(SINGLE_YAML);
let (fmt, content) = read_model_file(f.path()).unwrap();
let result: anyhow::Result<crate::models::LangmuirMulti> =
deserialise_inner(fmt, &content, "LangmuirMulti", f.path());
assert!(result.is_err());
}
#[test]
fn test_resolve_export_map_single() {
let f = tmp_yaml(SINGLE_YAML);
let result = minimal_result();
let map = resolve_export_map(f.path(), &result).unwrap();
assert!(!map.is_empty());
}
#[test]
fn test_resolve_export_map_multi() {
let f = tmp_yaml(MULTI_YAML);
let state = PhysicalState::new(
PhysicalQuantity::Concentration,
PhysicalData::Matrix(nalgebra::DMatrix::zeros(50, 2)),
);
let result =
SimulationResult::new(vec![0.0, 1.0], vec![state.clone(), state.clone()], state);
let map = resolve_export_map(f.path(), &result).unwrap();
assert!(!map.is_empty());
}
#[test]
fn test_resolve_export_map_missing_file() {
let p = PathBuf::from("/tmp/chrom_rs_no_model.yml");
let result = minimal_result();
assert!(resolve_export_map(&p, &result).is_err());
}
}