diff --git a/crates/eureka-state/src/bin/huginn/door.rs b/crates/eureka-state/src/bin/huginn/door.rs index ced8feb..67620b6 100644 --- a/crates/eureka-state/src/bin/huginn/door.rs +++ b/crates/eureka-state/src/bin/huginn/door.rs @@ -48,11 +48,15 @@ pub fn documents(text: &str) -> Result, Trouble> { object @ Value::Object(_) => vec![object], _ => return Err(Trouble::invalid("the documents must be a JSON array of {kind, value} objects")), }; - items - .into_iter() - .enumerate() - .map(|(index, item)| strict(item, &format!("documents[{index}]"))) - .collect() + let mut typed = Vec::with_capacity(items.len()); + let mut lost = Vec::new(); + for (index, item) in items.into_iter().enumerate() { + let (document, paths) = decode(item, &format!("documents[{index}]"))?; + typed.push(document); + lost.extend(paths); + } + refuse_lost(&lost)?; + Ok(typed) } pub fn selection(text: &str) -> Result { @@ -60,7 +64,9 @@ pub fn selection(text: &str) -> Result { } pub fn selection_value(value: Value) -> Result { - strict(value, "selection") + let (selection, lost) = decode(value, "selection")?; + refuse_lost(&lost)?; + Ok(selection) } /// The kind a pipeline id names in its middle segment (`::`). @@ -80,13 +86,23 @@ fn parse(text: &str) -> Result { serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("not JSON: {error}"))) } -fn strict(input: Value, at: &str) -> Result { +/// The typed value of one JSON object and the paths its decode lost. Serde +/// would also read a struct from an array, positionally, so only an object +/// is a document or a selection. +fn decode(input: Value, at: &str) -> Result<(T, Vec), Trouble> { + if !input.is_object() { + return Err(Trouble::invalid(format!("{at} must be a JSON object"))); + } let typed: T = serde_json::from_value(input.clone()).map_err(|error| Trouble::invalid(format!("{at} does not decode: {error}")))?; let kept = serde_json::to_value(&typed).map_err(|error| Trouble::Internal(format!("{at} does not re-serialise: {error}")))?; let mut paths = Vec::new(); dropped(&input, &kept, at, &mut paths); + Ok((typed, paths)) +} + +fn refuse_lost(paths: &[String]) -> Result<(), Trouble> { if paths.is_empty() { - Ok(typed) + Ok(()) } else { Err(Trouble::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", ")))) } diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index a92e8f4..0e2467a 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -394,6 +394,7 @@ fn a_document_that_does_not_decode_is_invalid_input() { let cases = [ (json!([{ "kind": "nonsense", "value": {} }]), "documents[0] does not decode"), (json!("text"), "JSON array"), + (json!([[]]), "documents[0] must be a JSON object"), (json!([]), "1 to 64"), (json!((0..65).map(|_| campaign_json()).collect::>()), "1 to 64"), ]; @@ -832,7 +833,7 @@ fn a_resolved_document_shows_its_closing_record() { let resolution = json!({ "kind": "resolution", "value": { - "subject": { "kind": "question", "id": format!("{CAMPAIGN}:question:Q1") }, + "subject": { "kind": "Question", "id": format!("{CAMPAIGN}:question:Q1") }, "sequence": 1, "outcome": { "Withdrawn": { "reason": "moot" } }, "rationale": "Resolved.",