Engine: EngineError::Param carries the schema Problem at its field's path

The ~40 parameter checks give a ProblemKind instead of text; Session::execute looks the problem
up in the command's form (a group's missing field becomes that field's own path). Replies still
name keys; forms phrase the same problem by label. A test refuses every field of every form and
checks each refusal names a field of that form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 22:42:19 -04:00
1 parent de8b7b6321
commit 65fb1112ab
16 files changed
+172 -87

No files matched your search

+25 -12
View File
@@ -23,6 +23,7 @@ pub(crate) mod solve;
mod symmetry;
mod tags;
use crate::schema::ProblemKind;
use fieldcraft_model::MAX_LABEL;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -143,9 +144,18 @@ pub static ALL: &[CommandSpec] = &[
results::CLEAR,
];
/// The problem with required parameter `key`: [`ProblemKind::Required`] when it is left out,
/// else `kind`.
pub(crate) fn wrong(p: &Value, key: &str, kind: ProblemKind) -> EngineError {
EngineError::param(key, if p.get(key).is_none_or(Value::is_null) { ProblemKind::Required } else { kind })
}
/// A required label parameter (1..=u32::MAX; the model checks its own range).
pub(crate) fn label(p: &Value, key: &str) -> Result<u32> {
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"))
p.get(key)
.and_then(Value::as_u64)
.and_then(|v| u32::try_from(v).ok())
.ok_or_else(|| wrong(p, key, ProblemKind::NotAWholeNumber { min: 1, max: i64::from(u32::MAX) }))
}
/// The first node and element labels after the model's largest, for `nodes` new nodes and
@@ -163,7 +173,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::param(key, "must be [x, y, z]"))
p.get(key).and_then(xyz_value).ok_or_else(|| wrong(p, key, ProblemKind::NotAVector { len: 3 }))
}
/// A JSON `[x, y, z]`.
@@ -176,9 +186,12 @@ 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<Vec<[f64; 3]>> {
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()
let raw = p.get(key).and_then(Value::as_array).filter(|a| a.len() <= max_len);
let raw = raw.ok_or_else(|| wrong(p, key, ProblemKind::NotAList { max: max_len, what: "[x, y, z]" }))?;
raw.iter()
.enumerate()
.map(|(i, v)| xyz_value(v).ok_or_else(|| EngineError::invalid(key, format!("must be a list of [x, y, z] (entry {i} is not)"))))
.collect()
}
/// A required list of labels (each in 1..=MAX_LABEL), at most `max_len` long.
@@ -187,11 +200,11 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
.get(key)
.and_then(Value::as_array)
.filter(|a| a.len() <= max_len)
.ok_or_else(|| EngineError::param(key, format!("must be a list of at most {max_len} labels")))?;
.ok_or_else(|| wrong(p, key, ProblemKind::NotAList { max: max_len, what: "labels" }))?;
raw.iter()
.map(|v| v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| (1..=MAX_LABEL).contains(n)))
.collect::<Option<Vec<u32>>>()
.ok_or_else(|| EngineError::param(key, format!("must be labels in 1..={MAX_LABEL}")))
.ok_or_else(|| EngineError::invalid(key, format!("must be labels in 1..={MAX_LABEL}")))
}
/// A required path parameter, as given.
@@ -199,7 +212,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::param(key, "must be a file path"))
.ok_or_else(|| wrong(p, key, ProblemKind::NotAPath))
}
/// A required path parameter naming a file (or folder) the command reads or writes: the path
@@ -214,23 +227,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<f64> {
match p.get(key) {
None => Ok(default),
Some(v) => v.as_f64().ok_or_else(|| EngineError::param(key, "must be a number")),
Some(v) => v.as_f64().ok_or_else(|| EngineError::param(key, ProblemKind::NotANumber)),
}
}
/// The `name` parameter.
pub(crate) fn name(p: &Value) -> Result<&str> {
p.get("name").and_then(Value::as_str).ok_or_else(|| EngineError::param("name", "must be a name"))
p.get("name").and_then(Value::as_str).ok_or_else(|| wrong(p, "name", ProblemKind::NotAName))
}
/// 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<Option<T>> {
p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::param(key, format!("is not valid: {e}")))).transpose()
p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::invalid(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<T> {
opt(p, key)?.ok_or_else(|| EngineError::param(key, "is required"))
opt(p, key)?.ok_or_else(|| EngineError::param(key, ProblemKind::Required))
}
/// Entries listed in a report: the first ones, and how many there are.