From 9dcbb9c9a6ba1b3ad7dd65bb0e04369c4964df63 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:43:58 -0400 Subject: [PATCH] Engine: a parameter error carries its key and problem (EngineError::Param) A form can show the problem beside the field it is about. The message still names the key, as before, for agents. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/block.rs | 2 +- crates/engine/src/cmd/cells.rs | 4 ++-- crates/engine/src/cmd/contact.rs | 2 +- crates/engine/src/cmd/datum.rs | 2 +- crates/engine/src/cmd/forms.rs | 6 +++++- crates/engine/src/cmd/geometry.rs | 4 ++-- crates/engine/src/cmd/hex.rs | 16 +++++++--------- crates/engine/src/cmd/mesh.rs | 20 ++++++++++---------- crates/engine/src/cmd/mirror.rs | 2 +- crates/engine/src/cmd/mod.rs | 23 ++++++++++------------- crates/engine/src/cmd/results.rs | 8 ++++---- crates/engine/src/cmd/solve.rs | 6 +++--- crates/engine/src/cmd/tags.rs | 2 +- crates/engine/src/lib.rs | 12 ++++++++++++ 14 files changed, 60 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/block.rs b/crates/engine/src/cmd/block.rs index 8855f87..00fc8b8 100644 --- a/crates/engine/src/cmd/block.rs +++ b/crates/engine/src/cmd/block.rs @@ -109,7 +109,7 @@ fn block_mesh(s: &mut Session, p: &Value) -> Result { let region = match (planar, &p.face) { (true, face) => GeometryRef::face(p.body.clone(), face.clone().unwrap_or_else(|| "area".into()))?, (false, None) => GeometryRef::body(p.body.clone())?, - (false, Some(_)) => return Err(EngineError::Params("`face` names the region of 2D elements; a solid block fills its body".into())), + (false, Some(_)) => return Err(EngineError::param("face", "names the region of 2D elements; a solid block fills its body")), }; if planar && !p.faces.is_empty() { return Err(EngineError::Params("a 2D patch has no faces to name: name its edges".into())); diff --git a/crates/engine/src/cmd/cells.rs b/crates/engine/src/cmd/cells.rs index 446c9c8..00d036f 100644 --- a/crates/engine/src/cmd/cells.rs +++ b/crates/engine/src/cmd/cells.rs @@ -81,14 +81,14 @@ fn cells(s: &mut Session, p: &Value) -> Result { Some(v) => match v.as_u64() { Some(1) => false, Some(2) => true, - _ => return Err(EngineError::Params("`order` must be 1 or 2".into())), + _ => return Err(EngineError::param("order", "must be 1 or 2")), }, }; let default = p.get("default").map(Method::parse).transpose()?.unwrap_or(Method::Auto); let methods: BTreeMap = match p.get("methods") { None => BTreeMap::new(), Some(Value::Object(m)) => m.iter().map(|(k, v)| Ok((k.clone(), Method::parse(v)?))).collect::>()?, - Some(_) => return Err(EngineError::Params("`methods` must map cell names to methods".into())), + Some(_) => return Err(EngineError::param("methods", "must map cell names to methods")), }; let design = s.design.clone().ok_or_else(|| EngineError::Params("open a design first".into()))?; let names: Vec = match p.get("bodies") { diff --git a/crates/engine/src/cmd/contact.rs b/crates/engine/src/cmd/contact.rs index 4019af1..e8e1c34 100644 --- a/crates/engine/src/cmd/contact.rs +++ b/crates/engine/src/cmd/contact.rs @@ -34,7 +34,7 @@ fn contact(p: &Value, surfaces: ContactSurfaces) -> Result { /// A required name-valued parameter. fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str> { - p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::Params(format!("`{key}` must be a set name"))) + p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::param(key, "must be a set name")) } pub const PAIR: CommandSpec = CommandSpec { diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index defc5f2..16bafab 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -126,7 +126,7 @@ fn plane_datum(by: PlaneBy) -> Result { Ok(match by { PlaneBy::Offset { plane, offset } => { if !offset.is_finite() { - return Err(EngineError::Params("`offset` must be a finite number".into())); + return Err(EngineError::param("offset", "must be a finite number")); } let normal = match plane { Principal::Yz => [1.0, 0.0, 0.0], diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 3ddb27a..c362e80 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1046,7 +1046,11 @@ mod tests { fill_required(&s, form, &mut p); } let missing: Vec = problems(form, &p).into_iter().filter(|p| p.message == "required").map(|p| p.path).collect(); - match s.execute(c.id, &p) { + let r = s.execute(c.id, &p).map_err(|e| match e { + EngineError::Param { .. } => EngineError::Params(e.to_string()), + e => e, + }); + match r { // State, not parameters: the meshers' default is the open design's bodies, the // bar has no explicit step; activation and probes need one of several fields. Err(EngineError::Params(e)) if STATE.iter().any(|m| e.contains(m)) => {} diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index d054c8b..fcab884 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -151,14 +151,14 @@ fn bodies(s: &mut Session, _: &Value) -> Result { } fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> { - let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`body` must be a body name".into()))?; + let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::param("body", "must be a body name"))?; s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)"))) } /// The body named by `body`, tessellated within `tolerance`. pub(crate) fn body_mesh<'a>(s: &'a Session, p: &Value) -> Result<(&'a Shape, SurfaceMesh)> { let shape = body(s, p)?; - let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::Params("`tolerance` must be a number (mm)".into()))?; + let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::param("tolerance", "must be a number (mm)"))?; Ok((shape, shape.tessellate(tolerance)?)) } diff --git a/crates/engine/src/cmd/hex.rs b/crates/engine/src/cmd/hex.rs index e25ea4f..fc1824f 100644 --- a/crates/engine/src/cmd/hex.rs +++ b/crates/engine/src/cmd/hex.rs @@ -53,11 +53,9 @@ fn analytic(s: FaceSurface) -> Surface { pub(super) fn number(p: &Value, key: &str) -> Result> { match p.get(key) { None => Ok(None), - Some(v) => v - .as_f64() - .filter(|x| x.is_finite() && *x > 0.0) - .map(Some) - .ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive number (mm)"))), + Some(v) => { + v.as_f64().filter(|x| x.is_finite() && *x > 0.0).map(Some).ok_or_else(|| EngineError::param(key, "must be a positive number (mm)")) + } } } @@ -80,7 +78,7 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result>> { let tolerance = number(p, "tolerance")?; match (p.get("body"), p.get("surface")) { (Some(b), None) => { - let name = b.as_str().ok_or_else(|| EngineError::Params("`body` must be a body name".into()))?; + let name = b.as_str().ok_or_else(|| EngineError::param("body", "must be a body name"))?; let shape = s .design .as_deref() @@ -153,14 +151,14 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result { let shapes: Vec<&Shape> = match p.get("bodies") { None => design.bodies.iter().collect(), Some(v) => { - let names = v.as_array().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?; + let names = v.as_array().ok_or_else(|| EngineError::param("bodies", "must be a list of body names"))?; // Bounded before the duplicate check, which is quadratic in the list. if names.len() > MAX_CELLS { return Err(EngineError::Params(format!("`bodies` lists {} bodies; at most {MAX_CELLS} are meshed together", names.len()))); } let mut out: Vec<&Shape> = Vec::with_capacity(names.len()); for n in names { - let name = n.as_str().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?; + let name = n.as_str().ok_or_else(|| EngineError::param("bodies", "must be a list of body names"))?; let shape = design.body(name).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?; if out.iter().any(|b| b.name == shape.name) { return Err(EngineError::Params(format!("body `{name}` is listed twice"))); @@ -261,7 +259,7 @@ fn order(p: &Value) -> Result { Some(v) => match v.as_u64() { Some(1) => Ok(false), Some(2) => Ok(true), - _ => Err(EngineError::Params("`order` must be 1 (hex8) or 2 (hex20)".into())), + _ => Err(EngineError::param("order", "must be 1 (hex8) or 2 (hex20)")), }, } } diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index fe90728..9a3d75c 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -126,9 +126,9 @@ pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops Some(v) => v .as_u64() .filter(|n| range.contains(n)) - .ok_or_else(|| EngineError::Params(format!("`{key}` must be a whole number in {}..={}", range.start(), range.end())))?, + .ok_or_else(|| EngineError::param(key, format!("must be a whole number in {}..={}", range.start(), range.end())))?, }; - usize::try_from(v).map_err(|_| EngineError::Params(format!("`{key}` is too large"))) + usize::try_from(v).map_err(|_| EngineError::param(key, "is too large")) } /// The boundary options of `mesh.surface.check`, `mesh.tet.generate` and the hex meshers. @@ -144,7 +144,7 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result) -> Result { let v = optional_number(p, key, default)?; - if range.contains(&v) { Ok(v) } else { Err(EngineError::Params(format!("`{key}` must be a number in {}..={}", range.start(), range.end()))) } + if range.contains(&v) { Ok(v) } else { Err(EngineError::param(key, format!("must be a number in {}..={}", range.start(), range.end()))) } } /// The quality options of `mesh.tet.generate`, and whether the quality stage runs at all. @@ -155,7 +155,7 @@ pub(super) fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOption Some(v) => Some( v.as_f64() .filter(|h| *h > 0.0 && h.is_finite() && *h <= fieldcraft_model::MAX_COORD) - .ok_or_else(|| EngineError::Params("`size` must be a positive number (mm)".into()))?, + .ok_or_else(|| EngineError::param("size", "must be a positive number (mm)"))?, ), }; let order = match p.get("order") { @@ -163,7 +163,7 @@ pub(super) fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOption Some(v) => match v.as_str() { Some("linear") => Order::Linear, Some("quadratic") => Order::Quadratic, - _ => return Err(EngineError::Params("`order` must be \"linear\" or \"quadratic\"".into())), + _ => return Err(EngineError::param("order", "must be \"linear\" or \"quadratic\"")), }, }; let target = number_in(p, "quality_target", 40.0, 5.0..=60.0)?; @@ -389,14 +389,14 @@ fn planar(s: &mut Session, p: &Value) -> Result { .get("size") .and_then(Value::as_f64) .filter(|v| *v > 0.0 && v.is_finite()) - .ok_or_else(|| EngineError::Params("`size` must be a positive number (the element size)".into()))?; + .ok_or_else(|| EngineError::param("size", "must be a positive number (the element size)"))?; let kind = match p.get("kind") { None => ElementKind::Quad4, Some(k) => k .as_str() .and_then(ElementKind::from_name) .filter(|k| k.dimension() == 2) - .ok_or_else(|| EngineError::Params("`kind` must be quad4, quad8, tri3 or tri6".into()))?, + .ok_or_else(|| EngineError::param("kind", "must be quad4, quad8, tri3 or tri6"))?, }; let opts = fieldcraft_mesh::quad::QuadOptions { max_elements: optional_count(p, "max_elements", 1_000_000, 1..=PLANAR_MAX_ELEMENTS)?, @@ -418,7 +418,7 @@ fn planar(s: &mut Session, p: &Value) -> Result { } let name = match p.get("name") { None => "Plane".to_string(), - Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`name` must be a body name".into()))?, + Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::param("name", "must be a body name"))?, }; GeometryRef::body(name.clone())?; let mut id = 0u32; @@ -438,7 +438,7 @@ fn planar(s: &mut Session, p: &Value) -> Result { (loops, name, "area".to_string(), names) } (None, Some(_)) => { - let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`face` must be a face name".into()))?; + let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::param("face", "must be a face name"))?; let (shape, mesh) = super::geometry::body_mesh(s, p)?; let face = shape.face_index(face_name).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{face_name}`", shape.name)))?; let b = face_boundary(&mesh.positions, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance)?; @@ -528,7 +528,7 @@ fn number(v: f64) -> Value { fn quality_report(s: &mut Session, p: &Value) -> Result { let bins = match p.get("bins") { None => 10, - Some(v) => v.as_u64().and_then(|b| usize::try_from(b).ok()).ok_or_else(|| EngineError::Params("`bins` must be a whole number".into()))?, + Some(v) => v.as_u64().and_then(|b| usize::try_from(b).ok()).ok_or_else(|| EngineError::param("bins", "must be a whole number"))?, }; let selection = match p.get("elements") { None => None, diff --git a/crates/engine/src/cmd/mirror.rs b/crates/engine/src/cmd/mirror.rs index f87fc71..e13a90a 100644 --- a/crates/engine/src/cmd/mirror.rs +++ b/crates/engine/src/cmd/mirror.rs @@ -43,7 +43,7 @@ fn plane(s: &Session, p: &Value) -> Result<([f64; 3], [f64; 3])> { .and_then(|d| d.plane()) .ok_or_else(|| EngineError::Params(format!("no datum plane `{}`", name.chars().take(64).collect::()))), Some(v @ Value::Object(_)) => Ok((super::xyz(v, "point")?, super::xyz(v, "normal")?)), - _ => Err(EngineError::Params("`plane` must be a datum plane's name or {\"point\", \"normal\"}".into())), + _ => Err(EngineError::param("plane", "must be a datum plane's name or {\"point\", \"normal\"}")), } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d7d8f9b..1634b7c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -145,10 +145,7 @@ pub static ALL: &[CommandSpec] = &[ /// A required label parameter (1..=u32::MAX; the model checks its own range). pub(crate) fn label(p: &Value, key: &str) -> Result { - p.get(key) - .and_then(Value::as_u64) - .and_then(|v| u32::try_from(v).ok()) - .ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive whole number"))) + p.get(key).and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| EngineError::param(key, "must be a positive whole number")) } /// The first node and element labels after the model's largest, for `nodes` new nodes and @@ -166,7 +163,7 @@ pub(crate) fn fresh_labels(model: &fieldcraft_model::Model, nodes: usize, elemen /// A required `[x, y, z]` parameter. pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> { - p.get(key).and_then(xyz_value).ok_or_else(|| EngineError::Params(format!("`{key}` must be [x, y, z]"))) + p.get(key).and_then(xyz_value).ok_or_else(|| EngineError::param(key, "must be [x, y, z]")) } /// A JSON `[x, y, z]`. @@ -179,7 +176,7 @@ fn xyz_value(v: &Value) -> Option<[f64; 3]> { /// A required list of `[x, y, z]` points, at most `max_len` long. pub(crate) fn points(p: &Value, key: &str, max_len: usize) -> Result> { - let bad = |what: String| EngineError::Params(format!("`{key}` must be a list of at most {max_len} [x, y, z]{what}")); + let bad = |what: String| EngineError::param(key, format!("must be a list of at most {max_len} [x, y, z]{what}")); let raw = p.get(key).and_then(Value::as_array).filter(|a| a.len() <= max_len).ok_or_else(|| bad(String::new()))?; raw.iter().enumerate().map(|(i, v)| xyz_value(v).ok_or_else(|| bad(format!(" (entry {i} is not)")))).collect() } @@ -190,11 +187,11 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result> { .get(key) .and_then(Value::as_array) .filter(|a| a.len() <= max_len) - .ok_or_else(|| EngineError::Params(format!("`{key}` must be a list of at most {max_len} labels")))?; + .ok_or_else(|| EngineError::param(key, format!("must be a list of at most {max_len} labels")))?; raw.iter() .map(|v| v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| (1..=MAX_LABEL).contains(n))) .collect::>>() - .ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}"))) + .ok_or_else(|| EngineError::param(key, format!("must be labels in 1..={MAX_LABEL}"))) } /// A required path parameter, as given. @@ -202,7 +199,7 @@ fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> { p.get(key) .and_then(Value::as_str) .filter(|s| !s.trim().is_empty() && s.len() <= fieldcraft_io::MAX_PATH_LEN && !s.contains('\0')) - .ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path"))) + .ok_or_else(|| EngineError::param(key, "must be a file path")) } /// A required path parameter naming a file (or folder) the command reads or writes: the path @@ -217,23 +214,23 @@ pub(crate) fn file_param(s: &Session, p: &Value, key: &str, access: Access) -> R pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result { match p.get(key) { None => Ok(default), - Some(v) => v.as_f64().ok_or_else(|| EngineError::Params(format!("`{key}` must be a number"))), + Some(v) => v.as_f64().ok_or_else(|| EngineError::param(key, "must be a number")), } } /// The `name` parameter. pub(crate) fn name(p: &Value) -> Result<&str> { - p.get("name").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`name` must be a name".into())) + p.get("name").and_then(Value::as_str).ok_or_else(|| EngineError::param("name", "must be a name")) } /// An optional parameter read as `T`'s JSON form; `None` when it is absent. pub(crate) fn opt<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result> { - p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::Params(format!("`{key}`: {e}")))).transpose() + p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::param(key, format!("is not valid: {e}")))).transpose() } /// A required parameter read as `T`'s JSON form. pub(crate) fn req<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result { - opt(p, key)?.ok_or_else(|| EngineError::Params(format!("`{key}` is required"))) + opt(p, key)?.ok_or_else(|| EngineError::param(key, "is required")) } /// Entries listed in a report: the first ones, and how many there are. diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index e8e2b56..92b9c05 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -173,7 +173,7 @@ fn deformed(s: &mut Session, p: &Value) -> Result { fn contour(s: &mut Session, p: &Value) -> Result { let step = step_for(s, p, Want::Any)?; - let name = opt::(p, "field")?.ok_or_else(|| EngineError::Params("`field` is required (a field id, or \"none\")".into()))?; + let name = opt::(p, "field")?.ok_or_else(|| EngineError::param("field", "is required (a field id, or \"none\")"))?; let field = match name.as_str() { "none" => None, id => Some(Field::parse(id).ok_or_else(|| { @@ -255,7 +255,7 @@ fn frame(s: &mut Session, p: &Value) -> Result { (None, Some(t)) if t.is_finite() => { times.iter().enumerate().min_by(|a, b| (a.1 - t).abs().total_cmp(&(b.1 - t).abs())).map_or(last, |(i, _)| i) } - (None, Some(_)) => return Err(EngineError::Params("`time` must be a number".into())), + (None, Some(_)) => return Err(EngineError::param("time", "must be a number")), (None, None) => last, }; let v = view_of(s, &step)?; @@ -272,7 +272,7 @@ fn reactions(s: &mut Session, p: &Value) -> Result { let step = step_for(s, p, Want::Any)?; let about = if p.get("about").is_some() { xyz(p, "about")? } else { [0.0; 3] }; if !about.iter().all(|c| c.is_finite()) { - return Err(EngineError::Params("`about` must be a finite [x, y, z]".into())); + return Err(EngineError::param("about", "must be a finite [x, y, z]")); } let Some((result, current)) = s.latest_result(&step) else { return Err(EngineError::Params(solved_list(s))) }; let Kind::Static(r) = result.kind() else { @@ -287,7 +287,7 @@ fn reactions(s: &mut Session, p: &Value) -> Result { .filter(|a| a.len() <= MAX_SETS) .ok_or_else(|| EngineError::Params(format!("`sets` must be a list of at most {MAX_SETS} set names")))?; list.iter() - .map(|n| n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`sets` must be set names".into()))) + .map(|n| n.as_str().map(str::to_string).ok_or_else(|| EngineError::param("sets", "must be set names"))) .collect::>()? } None => { diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index d7bb0dc..03887d7 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -134,7 +134,7 @@ fn cap + TryFrom + std::fmt::Display>(p: &Value, key .as_u64() .filter(|n| (1..=most).contains(n)) .and_then(|n| T::try_from(n).ok()) - .ok_or_else(|| EngineError::Params(format!("`{key}` must be a whole number from 1 to {default}"))), + .ok_or_else(|| EngineError::param(key, format!("must be a whole number from 1 to {default}"))), } } @@ -390,7 +390,7 @@ fn history(s: &mut Session, p: &Value) -> Result { let h = &r.history; let samples = h.time.len(); let every = match opt::(p, "every")? { - Some(0) => return Err(EngineError::Params("`every` must be at least 1".into())), + Some(0) => return Err(EngineError::param("every", "must be at least 1")), Some(k) => usize::try_from(k).unwrap_or(usize::MAX), None => samples.div_ceil(MAX_HISTORY_SAMPLES).max(1), }; @@ -541,7 +541,7 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result { } fn probe(s: &mut Session, p: &Value) -> Result { - let step = opt::(p, "step")?.ok_or_else(|| EngineError::Params("`step` is required".into()))?; + let step = opt::(p, "step")?.ok_or_else(|| EngineError::param("step", "is required"))?; let result = s.result(&step).ok_or_else(|| { EngineError::Params(if s.latest_result(&step).is_some() { format!("the result of step `{step}` is stale (the model changed since it was solved); solve it again") diff --git a/crates/engine/src/cmd/tags.rs b/crates/engine/src/cmd/tags.rs index 358c22f..f2426c5 100644 --- a/crates/engine/src/cmd/tags.rs +++ b/crates/engine/src/cmd/tags.rs @@ -38,7 +38,7 @@ struct ElementEdge { /// An optional list parameter of at most [`MAX_ELEMENTS`] entries. fn list<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result> { if p.get(key).and_then(Value::as_array).is_some_and(|a| a.len() > MAX_ELEMENTS) { - return Err(EngineError::Params(format!("`{key}` must be a list of at most {MAX_ELEMENTS} entries"))); + return Err(EngineError::param(key, format!("must be a list of at most {MAX_ELEMENTS} entries"))); } Ok(opt(p, key)?.unwrap_or_default()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 087ca3e..49d4ef7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -41,6 +41,11 @@ pub enum EngineError { UnknownCommand(String), #[error("{0}")] Params(String), + /// One parameter is missing or wrong: its key (a JSON path, `elastic/young`) and what is + /// wrong with it. The message names the key (agents need it); a form shows the problem + /// beside the field, by its label. + #[error("`{key}` {problem}")] + Param { key: String, problem: String }, #[error(transparent)] Model(#[from] fieldcraft_model::Error), #[error(transparent)] @@ -65,6 +70,13 @@ pub enum EngineError { Internal { command: String, message: String }, } +impl EngineError { + /// A problem with parameter `key`. + pub fn param(key: impl Into, problem: impl Into) -> Self { + Self::Param { key: key.into(), problem: problem.into() } + } +} + pub type Result = std::result::Result; /// The result of a solved step, from the implicit, the explicit or the lattice Boltzmann solver.