Skip to content

Commit 135dd07

Browse files
authored
feat(canonical): typed Error::DuplicateKey + non-exhaustive Error enum (#1741)
parse_strict already rejects duplicate object keys at every depth, but folded that rejection into Error::Parse alongside ordinary malformed JSON, so consumers mapping the two to distinct reason classes had to string-match the "duplicate object key" message. Add a distinct Error::DuplicateKey(String) carrying the offending key. The duplicate-key signal originates as a serde custom error in visit_map; parse_strict now classifies it at the parse boundary via a shared marker constant, so the message is interpreted in exactly one place and the public API exposes a clean variant. Other JSON syntax errors still map to Error::Parse. Mark Error #[non_exhaustive] in the same change. The crate is publishable but not yet released, so this is the free window to do it: once a version is on crates.io, adding a variant (or non_exhaustive itself) to the public enum would be a breaking API change. With it, this variant and future parse/canonicalization failure modes stay additive. Additive within the workspace: no caller matches Error exhaustively. Digests and goldens are untouched; duplicate keys are still rejected. Signed-off-by: Rul1an <roelschuurkes@gmail.com>
1 parent ccd66af commit 135dd07

2 files changed

Lines changed: 63 additions & 6 deletions

File tree

crates/assay-canonical/src/lib.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,24 @@ pub use parse::parse_strict;
4141
pub use profile::{ensure_supported_profile, semantic_digest, PROFILE};
4242

4343
/// An error from canonicalizing, parsing, or profiling a value.
44+
///
45+
/// Non-exhaustive: new parse or canonicalization failure modes can be added without breaking
46+
/// downstream consumers, so callers should include a wildcard match arm.
4447
#[derive(Debug, thiserror::Error)]
48+
#[non_exhaustive]
4549
pub enum Error {
4650
/// The value could not be serialized to canonical (RFC 8785) JSON.
4751
#[error("canonicalization failed: {0}")]
4852
Canonicalize(String),
49-
/// Raw JSON could not be parsed under the strict (duplicate-key-rejecting) rules.
53+
/// Raw JSON could not be parsed as JSON (malformed syntax). Duplicate object keys are rejected
54+
/// too, but surface as the distinct [`Error::DuplicateKey`] so the two can be told apart.
5055
#[error("strict JSON parse failed: {0}")]
5156
Parse(String),
57+
/// Raw JSON contained a duplicate object key, rejected at any nesting depth; carries the offending
58+
/// key. Split out from [`Error::Parse`] so a consumer can distinguish a duplicate-key rejection
59+
/// from ordinary malformed JSON without string-matching the error message.
60+
#[error("duplicate object key: {0}")]
61+
DuplicateKey(String),
5262
/// A record was produced under a `canonicalization_profile` this build does not implement; a
5363
/// consumer must fail closed rather than recompute it under the current rules.
5464
#[error("unknown canonicalization profile: {0}")]

crates/assay-canonical/src/parse.rs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ use serde_json::{Map, Value};
1313

1414
use crate::Error;
1515

16+
/// Marker prefix the strict map visitor stamps onto its custom serde error for a duplicate key.
17+
/// [`parse_strict`] matches this once, at the parse boundary, to promote the failure to a typed
18+
/// [`Error::DuplicateKey`] — the only place the marker string is interpreted, so callers never have
19+
/// to. Producer ([`StrictVisitor::visit_map`]) and consumer ([`parse_strict`]) share this constant
20+
/// so they cannot drift apart.
21+
const DUPLICATE_KEY_MARKER: &str = "duplicate object key: ";
22+
1623
/// A [`serde_json::Value`] parsed with duplicate object keys rejected at every depth.
1724
struct StrictValue(Value);
1825

@@ -82,7 +89,7 @@ impl<'de> Visitor<'de> for StrictVisitor {
8289
while let Some(key) = map.next_key::<String>()? {
8390
let StrictValue(val) = map.next_value()?;
8491
if out.insert(key.clone(), val).is_some() {
85-
return Err(de::Error::custom(format!("duplicate object key: {key}")));
92+
return Err(de::Error::custom(format!("{DUPLICATE_KEY_MARKER}{key}")));
8693
}
8794
}
8895
Ok(Value::Object(out))
@@ -101,7 +108,32 @@ impl<'de> Visitor<'de> for StrictVisitor {
101108
pub fn parse_strict(raw: &str) -> Result<Value, Error> {
102109
serde_json::from_str::<StrictValue>(raw)
103110
.map(|s| s.0)
104-
.map_err(|e| Error::Parse(e.to_string()))
111+
.map_err(classify_parse_error)
112+
}
113+
114+
/// Map a `serde_json` failure to the typed [`Error`], promoting the duplicate-key marker stamped by
115+
/// [`StrictVisitor::visit_map`] to [`Error::DuplicateKey`]; everything else is generic
116+
/// [`Error::Parse`]. Matching the marker here, once, keeps the message-sniffing at the boundary so
117+
/// the public API exposes a clean variant instead of a string a caller has to parse.
118+
fn classify_parse_error(e: serde_json::Error) -> Error {
119+
let msg = e.to_string();
120+
match duplicate_key(&msg) {
121+
Some(key) => Error::DuplicateKey(key),
122+
None => Error::Parse(msg),
123+
}
124+
}
125+
126+
/// Recover the offending key from a duplicate-key marker message, or `None` for an ordinary parse
127+
/// error. `serde_json` appends ` at line L column C` to custom errors; the last such suffix is
128+
/// trimmed so the variant carries just the key. Classification stays correct even if that suffix
129+
/// format ever changes — only the trailing trim depends on it.
130+
fn duplicate_key(msg: &str) -> Option<String> {
131+
let rest = msg.strip_prefix(DUPLICATE_KEY_MARKER)?;
132+
let key = match rest.rfind(" at line ") {
133+
Some(pos) => &rest[..pos],
134+
None => rest,
135+
};
136+
Some(key.to_owned())
105137
}
106138

107139
#[cfg(test)]
@@ -113,14 +145,29 @@ mod tests {
113145
fn rejects_top_level_duplicate_keys() {
114146
assert!(matches!(
115147
parse_strict(r#"{"a":1,"a":2}"#),
116-
Err(Error::Parse(_))
148+
Err(Error::DuplicateKey(k)) if k == "a"
117149
));
118150
}
119151

120152
#[test]
121153
fn rejects_nested_duplicate_keys() {
122-
assert!(parse_strict(r#"{"outer":{"k":1,"k":2}}"#).is_err());
123-
assert!(parse_strict(r#"{"xs":[{"k":1,"k":2}]}"#).is_err());
154+
assert!(matches!(
155+
parse_strict(r#"{"outer":{"k":1,"k":2}}"#),
156+
Err(Error::DuplicateKey(k)) if k == "k"
157+
));
158+
assert!(matches!(
159+
parse_strict(r#"{"xs":[{"k":1,"k":2}]}"#),
160+
Err(Error::DuplicateKey(k)) if k == "k"
161+
));
162+
}
163+
164+
#[test]
165+
fn malformed_json_stays_a_parse_error() {
166+
// Ordinary syntax errors must not be reclassified as duplicate-key rejections: the two map
167+
// to distinct reason classes downstream.
168+
assert!(matches!(parse_strict("{not json"), Err(Error::Parse(_))));
169+
assert!(matches!(parse_strict(""), Err(Error::Parse(_))));
170+
assert!(matches!(parse_strict(r#"{"a":}"#), Err(Error::Parse(_))));
124171
}
125172

126173
#[test]

0 commit comments

Comments
 (0)