Commit Diff


commit - e678f0f29294a33d774f58298da8658035b1e6cb
commit + a9801d55e62952ed925510ec98eda85d55316cc8
blob - 9d32ecd98d729027e4a3f7f2a8693ad58aae8d4d
blob + f2c3c2ff2b75548c0e4cc41bf3255d4957d09767
--- .gitignore
+++ .gitignore
@@ -1,3 +1,2 @@
 build
-vendor
 channels
blob - 7dd31e538016cf5c7d220ffe8a2e9628a79da650
blob + b58a2ff4c350c616efb5a4b616c1d751ce575bbc
--- Makefile
+++ Makefile
@@ -23,6 +23,8 @@ MANDIR ?= $(PREFIX)/share/man
 BUILD = build
 BIN = $(BUILD)/sm
 MAIN = sm.rs
+JACKSON = vendor/jackson.rs
+JACKSON_LIB = $(BUILD)/libjackson.rlib
 
 CLIPPY ?= $(shell rustup which clippy-driver 2>/dev/null)
 RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null)
@@ -31,11 +33,18 @@ RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null)
 
 all: $(BIN)
 
-$(BIN): $(MAIN)
+$(JACKSON_LIB): $(JACKSON)
 	mkdir -p $(BUILD)
+	$(RUSTC) --edition 2024 \
+		--crate-type rlib --crate-name jackson $(RUSTFLAGS) \
+		-o $@ $<
+
+$(BIN): $(MAIN) $(JACKSON_LIB)
+	mkdir -p $(BUILD)
 	SM_VERSION=$(VERSION) TMPDIR=/tmp $(RUSTC) --edition 2024 \
 		--crate-type bin --crate-name sm $(RUSTFLAGS) \
 		-C link-arg=-ltls \
+		--extern jackson=$(JACKSON_LIB) \
 		-o $@ $<
 
 clean:
@@ -49,10 +58,11 @@ install: $(BIN)
 fmt-check:
 	$(RUSTFMT) --edition 2024 --check $(MAIN)
 
-clippy:
+clippy: $(JACKSON_LIB)
 	SM_VERSION=$(VERSION) TMPDIR=/tmp $(CLIPPY) --edition 2024 \
 		--crate-type bin --crate-name sm \
 		-C link-arg=-ltls \
+		--extern jackson=$(JACKSON_LIB) \
 		-W clippy::all -o /tmp/sm.clippy $(MAIN)
 	@rm -f /tmp/sm.clippy
 
blob - a33466e063fdc3fa28fece729888b6dae3b32507
blob + daa5961a41a66a88aebb2ff37c335b3473a9e282
--- sm.rs
+++ sm.rs
@@ -26,6 +26,24 @@ use std::{
     time::Duration,
 };
 
+use jackson::{FromJson, ToJson, json_struct};
+
+json_struct! {
+    struct PostMessage {
+        channel: String,
+        text: String,
+    }
+}
+
+json_struct! {
+    struct PostResponse {
+        ok: bool,
+        error: Option<String>,
+        needed: Option<String>,
+        provided: Option<String>,
+    }
+}
+
 const HOST: &str = "slack.com";
 const PORT: u16 = 443;
 const PATH: &str = "/api/chat.postMessage";
@@ -84,12 +102,17 @@ fn run() -> i32 {
         }
     };
 
-    let mut body = String::with_capacity(channel.len() + message.len() + 32);
-    body.push_str("{\"channel\":\"");
-    json_escape(&mut body, channel);
-    body.push_str("\",\"text\":\"");
-    json_escape(&mut body, &message);
-    body.push_str("\"}");
+    let req = PostMessage {
+        channel: channel.clone(),
+        text: message,
+    };
+    let body = match req.to_json().stringify() {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("sm: encode body: {e}");
+            return 2;
+        }
+    };
 
     let resp = match post(&token, &body) {
         Ok(r) => r,
@@ -147,28 +170,6 @@ fn validate_message(s: &str) -> Result<(), String> {
 }
 
 //////////////////////////////////////////////////////////////////////////////
-// JSON
-//////////////////////////////////////////////////////////////////////////////
-
-fn json_escape(out: &mut String, s: &str) {
-    for c in s.chars() {
-        match c {
-            '"' => out.push_str("\\\""),
-            '\\' => out.push_str("\\\\"),
-            '\n' => out.push_str("\\n"),
-            '\r' => out.push_str("\\r"),
-            '\t' => out.push_str("\\t"),
-            '\x08' => out.push_str("\\b"),
-            '\x0c' => out.push_str("\\f"),
-            c if (c as u32) < 0x20 => {
-                let _ = write!(out, "\\u{:04x}", c as u32);
-            }
-            c => out.push(c),
-        }
-    }
-}
-
-//////////////////////////////////////////////////////////////////////////////
 // libtls FFI
 //////////////////////////////////////////////////////////////////////////////
 
@@ -399,22 +400,20 @@ fn post(token: &str, body: &str) -> Result<Vec<u8>, St
 fn parse_ok(resp: &[u8]) -> Result<(), String> {
     let sep =
         find(resp, b"\r\n\r\n").ok_or("no header terminator in response")?;
-    let body = &resp[sep + 4..];
-    // TODO(ijanc): substring-based matching is enough for Slack's flat
-    // envelope but would misread nested strings containing "ok":true/false.
-    // Replace with a minimal JSON scanner when a second caller appears.
-    if find(body, b"\"ok\":true").is_some() {
+    let body = std::str::from_utf8(&resp[sep + 4..])
+        .map_err(|e| format!("non-utf8 body: {e}"))?;
+    let v: jackson::Value =
+        body.parse().map_err(|e: jackson::Error| format!("json: {e}"))?;
+    let r = PostResponse::from_json(&v)
+        .map_err(|e| format!("decode body: {e}"))?;
+    if r.ok {
         return Ok(());
     }
-    if find(body, b"\"ok\":false").is_none() {
-        return Err("malformed or truncated response".into());
-    }
-    let mut msg = extract_str(body, b"\"error\":\"")
-        .unwrap_or_else(|| "unknown error".into());
-    if let Some(needed) = extract_str(body, b"\"needed\":\"") {
+    let mut msg = r.error.unwrap_or_else(|| "unknown error".into());
+    if let Some(needed) = r.needed {
         let _ = write!(msg, " (needed: {needed})");
     }
-    if let Some(provided) = extract_str(body, b"\"provided\":\"") {
+    if let Some(provided) = r.provided {
         let _ = write!(msg, " (provided: {provided})");
     }
     Err(msg)
@@ -423,34 +422,3 @@ fn parse_ok(resp: &[u8]) -> Result<(), String> {
 fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
     hay.windows(needle.len()).position(|w| w == needle)
 }
-
-fn extract_str(body: &[u8], key: &[u8]) -> Option<String> {
-    let start = find(body, key)? + key.len();
-    let mut out = String::new();
-    let mut i = start;
-    while i < body.len() {
-        let b = body[i];
-        if b == b'"' {
-            return Some(out);
-        }
-        if b == b'\\' && i + 1 < body.len() {
-            match body[i + 1] {
-                b'n' => out.push('\n'),
-                b'r' => out.push('\r'),
-                b't' => out.push('\t'),
-                b'"' => out.push('"'),
-                b'\\' => out.push('\\'),
-                b'/' => out.push('/'),
-                o => {
-                    out.push('\\');
-                    out.push(o as char);
-                }
-            }
-            i += 2;
-        } else {
-            out.push(b as char);
-            i += 1;
-        }
-    }
-    None
-}
blob - /dev/null
blob + e876bc9ed124135ffec1f6d07ead838168f7c2b5 (mode 644)
--- /dev/null
+++ vendor/VENDOR
@@ -0,0 +1,6 @@
+jackson.rs
+	origin:	ssh://ijanc@ijanc.org/json/jackson
+	commit:	a4e84f6d2f21d8a5f28d17a38ed9968251325714
+	date:	2026-04-18
+
+To update: copy jackson.rs from upstream, then update commit/date above.
blob - /dev/null
blob + 89d9655a02f2cd1a90920cd514f0428a57739453 (mode 644)
--- /dev/null
+++ vendor/jackson.rs
@@ -0,0 +1,1171 @@
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+//! Parse and generate JSON documents.
+
+use std::fmt;
+use std::fmt::Write as _;
+use std::str::FromStr;
+
+/// A JSON value.
+pub enum Value {
+    Null,
+    Bool(bool),
+    Number(f64),
+    String(String),
+    Array(Vec<Value>),
+    Object(Vec<(String, Value)>),
+}
+
+/// A parse or serialise error.
+pub struct Error {
+    msg: &'static str,
+    pos: usize,
+}
+
+impl Error {
+    pub const fn new(msg: &'static str, pos: usize) -> Self {
+        Self { msg, pos }
+    }
+
+    /// Static error message.
+    pub const fn message(&self) -> &'static str {
+        self.msg
+    }
+
+    /// Byte offset into the input where the error was detected.
+    pub const fn position(&self) -> usize {
+        self.pos
+    }
+}
+
+impl fmt::Display for Error {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{} at byte {}", self.msg, self.pos)
+    }
+}
+
+impl fmt::Debug for Error {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Display::fmt(self, f)
+    }
+}
+
+impl std::error::Error for Error {}
+
+impl FromStr for Value {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Parser::new(s).parse_root()
+    }
+}
+
+impl Value {
+    /// Serialise this value as a JSON document.
+    pub fn stringify(&self) -> Result<String, Error> {
+        let mut out = String::new();
+        write_value(self, &mut out)?;
+        Ok(out)
+    }
+
+    pub fn as_bool(&self) -> Option<bool> {
+        match self {
+            Value::Bool(b) => Some(*b),
+            _ => None,
+        }
+    }
+
+    pub fn as_number(&self) -> Option<f64> {
+        match self {
+            Value::Number(n) => Some(*n),
+            _ => None,
+        }
+    }
+
+    pub fn as_str(&self) -> Option<&str> {
+        match self {
+            Value::String(s) => Some(s.as_str()),
+            _ => None,
+        }
+    }
+
+    pub fn as_array(&self) -> Option<&[Value]> {
+        match self {
+            Value::Array(a) => Some(a.as_slice()),
+            _ => None,
+        }
+    }
+
+    pub fn as_object(&self) -> Option<&[(String, Value)]> {
+        match self {
+            Value::Object(o) => Some(o.as_slice()),
+            _ => None,
+        }
+    }
+}
+
+impl From<bool> for Value {
+    fn from(b: bool) -> Self {
+        Value::Bool(b)
+    }
+}
+
+impl From<f64> for Value {
+    fn from(n: f64) -> Self {
+        Value::Number(n)
+    }
+}
+
+impl From<&str> for Value {
+    fn from(s: &str) -> Self {
+        Value::String(s.to_string())
+    }
+}
+
+impl From<String> for Value {
+    fn from(s: String) -> Self {
+        Value::String(s)
+    }
+}
+
+fn write_value(v: &Value, out: &mut String) -> Result<(), Error> {
+    match v {
+        Value::Null => out.push_str("null"),
+        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
+        Value::Number(n) => {
+            if !n.is_finite() {
+                return Err(Error::new("non-finite number", 0));
+            }
+            if *n != 0.0 && n.fract() == 0.0 && n.abs() < (1_i64 << 53) as f64 {
+                write!(out, "{}", *n as i64).unwrap();
+            } else {
+                write!(out, "{n}").unwrap();
+            }
+        }
+        Value::String(s) => write_string(s, out),
+        Value::Array(a) => {
+            out.push('[');
+            for (i, item) in a.iter().enumerate() {
+                if i > 0 {
+                    out.push(',');
+                }
+                write_value(item, out)?;
+            }
+            out.push(']');
+        }
+        Value::Object(o) => {
+            out.push('{');
+            for (i, (k, item)) in o.iter().enumerate() {
+                if i > 0 {
+                    out.push(',');
+                }
+                write_string(k, out);
+                out.push(':');
+                write_value(item, out)?;
+            }
+            out.push('}');
+        }
+    }
+    Ok(())
+}
+
+fn write_string(s: &str, out: &mut String) {
+    out.push('"');
+    let bytes = s.as_bytes();
+    let mut run_start = 0;
+    for (i, &b) in bytes.iter().enumerate() {
+        let esc: &str = match b {
+            b'"' => "\\\"",
+            b'\\' => "\\\\",
+            b'\n' => "\\n",
+            b'\r' => "\\r",
+            b'\t' => "\\t",
+            0x08 => "\\b",
+            0x0C => "\\f",
+            0..=0x1F => {
+                out.push_str(&s[run_start..i]);
+                write!(out, "\\u{:04x}", b).unwrap();
+                run_start = i + 1;
+                continue;
+            }
+            _ => continue,
+        };
+        out.push_str(&s[run_start..i]);
+        out.push_str(esc);
+        run_start = i + 1;
+    }
+    out.push_str(&s[run_start..]);
+    out.push('"');
+}
+
+const MAX_DEPTH: usize = 128;
+
+struct Parser<'a> {
+    src: &'a [u8],
+    pos: usize,
+    depth: usize,
+}
+
+impl<'a> Parser<'a> {
+    fn new(s: &'a str) -> Self {
+        Self {
+            src: s.as_bytes(),
+            pos: 0,
+            depth: 0,
+        }
+    }
+
+    fn err(&self, msg: &'static str) -> Error {
+        Error::new(msg, self.pos)
+    }
+
+    fn peek(&self) -> Option<u8> {
+        self.src.get(self.pos).copied()
+    }
+
+    fn bump(&mut self) -> Option<u8> {
+        let b = self.peek()?;
+        self.pos += 1;
+        Some(b)
+    }
+
+    fn skip_ws(&mut self) {
+        while let Some(b) = self.peek() {
+            if matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
+                self.pos += 1;
+            } else {
+                break;
+            }
+        }
+    }
+
+    fn expect(&mut self, b: u8, msg: &'static str) -> Result<(), Error> {
+        if self.peek() == Some(b) {
+            self.pos += 1;
+            Ok(())
+        } else {
+            Err(self.err(msg))
+        }
+    }
+
+    fn expect_keyword(&mut self, kw: &[u8]) -> Result<(), Error> {
+        let end = self.pos + kw.len();
+        if end > self.src.len() || &self.src[self.pos..end] != kw {
+            return Err(self.err("expected keyword"));
+        }
+        self.pos = end;
+        Ok(())
+    }
+
+    fn enter(&mut self) -> Result<(), Error> {
+        if self.depth >= MAX_DEPTH {
+            return Err(self.err("max nesting depth exceeded"));
+        }
+        self.depth += 1;
+        Ok(())
+    }
+
+    fn parse_root(&mut self) -> Result<Value, Error> {
+        let v = self.parse_value()?;
+        self.skip_ws();
+        if self.pos < self.src.len() {
+            return Err(self.err("trailing garbage"));
+        }
+        Ok(v)
+    }
+
+    fn parse_value(&mut self) -> Result<Value, Error> {
+        self.skip_ws();
+        let b = self
+            .peek()
+            .ok_or_else(|| self.err("unexpected end of input"))?;
+        match b {
+            b'n' => {
+                self.expect_keyword(b"null")?;
+                Ok(Value::Null)
+            }
+            b't' => {
+                self.expect_keyword(b"true")?;
+                Ok(Value::Bool(true))
+            }
+            b'f' => {
+                self.expect_keyword(b"false")?;
+                Ok(Value::Bool(false))
+            }
+            b'"' => Ok(Value::String(self.parse_string()?)),
+            b'[' => self.parse_array(),
+            b'{' => self.parse_object(),
+            b'-' | b'0'..=b'9' => Ok(Value::Number(self.parse_number()?)),
+            _ => Err(self.err("unexpected character")),
+        }
+    }
+
+    fn parse_string(&mut self) -> Result<String, Error> {
+        self.pos += 1;
+        let mut out = String::new();
+        loop {
+            let run_start = self.pos;
+            while let Some(&b) = self.src.get(self.pos) {
+                if matches!(b, b'"' | b'\\') || b < 0x20 {
+                    break;
+                }
+                self.pos += 1;
+            }
+            let run = &self.src[run_start..self.pos];
+            // SAFETY: src is the byte view of a &str input; the scan
+            // only breaks on ASCII bytes ("", \\, < 0x20), so
+            // run_start..self.pos is always a valid UTF-8 substring.
+            let run_str = unsafe { std::str::from_utf8_unchecked(run) };
+            out.push_str(run_str);
+            match self.peek() {
+                None => return Err(self.err("unterminated string")),
+                Some(b'"') => {
+                    self.pos += 1;
+                    return Ok(out);
+                }
+                Some(b'\\') => {
+                    self.pos += 1;
+                    let esc =
+                        self.bump().ok_or_else(|| self.err("bad escape"))?;
+                    match esc {
+                        b'"' => out.push('"'),
+                        b'\\' => out.push('\\'),
+                        b'/' => out.push('/'),
+                        b'b' => out.push('\u{08}'),
+                        b'f' => out.push('\u{0C}'),
+                        b'n' => out.push('\n'),
+                        b'r' => out.push('\r'),
+                        b't' => out.push('\t'),
+                        b'u' => out.push(self.parse_u_escape()?),
+                        _ => return Err(self.err("invalid escape")),
+                    }
+                }
+                Some(_) => {
+                    return Err(self.err("control character in string"));
+                }
+            }
+        }
+    }
+
+    fn parse_u_escape(&mut self) -> Result<char, Error> {
+        let hi = self.parse_hex4()?;
+        if (0xD800..=0xDBFF).contains(&hi) {
+            if self.bump() != Some(b'\\') || self.bump() != Some(b'u') {
+                return Err(self.err("expected low surrogate"));
+            }
+            let lo = self.parse_hex4()?;
+            if !(0xDC00..=0xDFFF).contains(&lo) {
+                return Err(self.err("invalid low surrogate"));
+            }
+            let code = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
+            char::from_u32(code).ok_or_else(|| self.err("invalid codepoint"))
+        } else {
+            char::from_u32(hi).ok_or_else(|| self.err("invalid codepoint"))
+        }
+    }
+
+    fn parse_hex4(&mut self) -> Result<u32, Error> {
+        let mut v: u32 = 0;
+        for _ in 0..4 {
+            let b =
+                self.bump().ok_or_else(|| self.err("bad unicode escape"))?;
+            let d = match b {
+                b'0'..=b'9' => b - b'0',
+                b'a'..=b'f' => b - b'a' + 10,
+                b'A'..=b'F' => b - b'A' + 10,
+                _ => return Err(self.err("bad hex digit")),
+            };
+            v = v * 16 + d as u32;
+        }
+        Ok(v)
+    }
+
+    fn parse_number(&mut self) -> Result<f64, Error> {
+        let start = self.pos;
+        if self.peek() == Some(b'-') {
+            self.pos += 1;
+        }
+        match self.peek() {
+            Some(b'0') => self.pos += 1,
+            Some(b'1'..=b'9') => {
+                self.pos += 1;
+                while matches!(self.peek(), Some(b'0'..=b'9')) {
+                    self.pos += 1;
+                }
+            }
+            _ => return Err(self.err("expected digit")),
+        }
+        if self.peek() == Some(b'.') {
+            self.pos += 1;
+            if !matches!(self.peek(), Some(b'0'..=b'9')) {
+                return Err(self.err("expected digit after decimal point"));
+            }
+            while matches!(self.peek(), Some(b'0'..=b'9')) {
+                self.pos += 1;
+            }
+        }
+        if matches!(self.peek(), Some(b'e' | b'E')) {
+            self.pos += 1;
+            if matches!(self.peek(), Some(b'+' | b'-')) {
+                self.pos += 1;
+            }
+            if !matches!(self.peek(), Some(b'0'..=b'9')) {
+                return Err(self.err("expected digit in exponent"));
+            }
+            while matches!(self.peek(), Some(b'0'..=b'9')) {
+                self.pos += 1;
+            }
+        }
+        let slice = &self.src[start..self.pos];
+        let s = std::str::from_utf8(slice).unwrap();
+        s.parse::<f64>()
+            .map_err(|_| Error::new("invalid number", start))
+    }
+
+    fn parse_array(&mut self) -> Result<Value, Error> {
+        self.pos += 1;
+        self.enter()?;
+        let mut items = Vec::new();
+        self.skip_ws();
+        if self.peek() == Some(b']') {
+            self.pos += 1;
+            self.depth -= 1;
+            return Ok(Value::Array(items));
+        }
+        loop {
+            items.push(self.parse_value()?);
+            self.skip_ws();
+            match self.peek() {
+                Some(b',') => self.pos += 1,
+                Some(b']') => {
+                    self.pos += 1;
+                    self.depth -= 1;
+                    return Ok(Value::Array(items));
+                }
+                _ => return Err(self.err("expected ',' or ']'")),
+            }
+        }
+    }
+
+    fn parse_object(&mut self) -> Result<Value, Error> {
+        self.pos += 1;
+        self.enter()?;
+        let mut items = Vec::new();
+        self.skip_ws();
+        if self.peek() == Some(b'}') {
+            self.pos += 1;
+            self.depth -= 1;
+            return Ok(Value::Object(items));
+        }
+        loop {
+            if self.peek() != Some(b'"') {
+                return Err(self.err("expected string key"));
+            }
+            let key = self.parse_string()?;
+            self.skip_ws();
+            self.expect(b':', "expected ':'")?;
+            let v = self.parse_value()?;
+            items.push((key, v));
+            self.skip_ws();
+            match self.peek() {
+                Some(b',') => {
+                    self.pos += 1;
+                    self.skip_ws();
+                }
+                Some(b'}') => {
+                    self.pos += 1;
+                    self.depth -= 1;
+                    return Ok(Value::Object(items));
+                }
+                _ => return Err(self.err("expected ',' or '}'")),
+            }
+        }
+    }
+}
+
+/// Trait implemented by types that can be written to a [`Value`].
+pub trait ToJson {
+    fn to_json(&self) -> Value;
+}
+
+/// Trait implemented by types that can be read from a [`Value`].
+pub trait FromJson: Sized {
+    fn from_json(v: &Value) -> Result<Self, Error>;
+
+    /// Produce a value when the corresponding field is absent from the
+    /// parent object.  The default rejects; `Option<T>` overrides to
+    /// return `None`.
+    fn missing_field() -> Result<Self, Error> {
+        Err(Error::new("missing field", 0))
+    }
+}
+
+impl ToJson for bool {
+    fn to_json(&self) -> Value {
+        Value::Bool(*self)
+    }
+}
+
+impl FromJson for bool {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_bool().ok_or_else(|| Error::new("expected bool", 0))
+    }
+}
+
+impl ToJson for String {
+    fn to_json(&self) -> Value {
+        Value::String(self.clone())
+    }
+}
+
+impl FromJson for String {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_str()
+            .map(str::to_string)
+            .ok_or_else(|| Error::new("expected string", 0))
+    }
+}
+
+impl ToJson for f64 {
+    fn to_json(&self) -> Value {
+        Value::Number(*self)
+    }
+}
+
+impl FromJson for f64 {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_number()
+            .ok_or_else(|| Error::new("expected number", 0))
+    }
+}
+
+impl ToJson for f32 {
+    fn to_json(&self) -> Value {
+        Value::Number(*self as f64)
+    }
+}
+
+impl FromJson for f32 {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        v.as_number()
+            .map(|n| n as f32)
+            .ok_or_else(|| Error::new("expected number", 0))
+    }
+}
+
+macro_rules! jackson_int_impls {
+    ($($t:ty),* $(,)?) => { $(
+        impl ToJson for $t {
+            fn to_json(&self) -> Value {
+                Value::Number(*self as f64)
+            }
+        }
+
+        impl FromJson for $t {
+            fn from_json(v: &Value) -> Result<Self, Error> {
+                let n = v
+                    .as_number()
+                    .ok_or_else(|| Error::new("expected number", 0))?;
+                if n.is_finite()
+                    && n.fract() == 0.0
+                    && n >= <$t>::MIN as f64
+                    && n <= <$t>::MAX as f64
+                {
+                    Ok(n as $t)
+                } else {
+                    Err(Error::new("integer out of range", 0))
+                }
+            }
+        }
+    )* };
+}
+
+jackson_int_impls!(i8, i16, i32, i64, u8, u16, u32, u64);
+
+impl<T: ToJson> ToJson for Option<T> {
+    fn to_json(&self) -> Value {
+        match self {
+            Some(v) => v.to_json(),
+            None => Value::Null,
+        }
+    }
+}
+
+impl<T: FromJson> FromJson for Option<T> {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        match v {
+            Value::Null => Ok(None),
+            _ => T::from_json(v).map(Some),
+        }
+    }
+
+    fn missing_field() -> Result<Self, Error> {
+        Ok(None)
+    }
+}
+
+impl<T: ToJson> ToJson for Vec<T> {
+    fn to_json(&self) -> Value {
+        Value::Array(self.iter().map(T::to_json).collect())
+    }
+}
+
+impl<T: FromJson> FromJson for Vec<T> {
+    fn from_json(v: &Value) -> Result<Self, Error> {
+        let a = v
+            .as_array()
+            .ok_or_else(|| Error::new("expected array", 0))?;
+        a.iter().map(T::from_json).collect()
+    }
+}
+
+/// Define a struct together with [`ToJson`] and [`FromJson`] impls.
+#[macro_export]
+macro_rules! json_struct {
+    (
+        $(#[$meta:meta])*
+        $vis:vis struct $name:ident {
+            $(
+                $(#[$fmeta:meta])*
+                $fvis:vis $field:ident : $ty:ty
+            ),* $(,)?
+        }
+    ) => {
+        $(#[$meta])*
+        $vis struct $name {
+            $(
+                $(#[$fmeta])*
+                $fvis $field: $ty,
+            )*
+        }
+
+        impl $crate::ToJson for $name {
+            fn to_json(&self) -> $crate::Value {
+                $crate::Value::Object(vec![
+                    $(
+                        (
+                            stringify!($field).to_string(),
+                            <$ty as $crate::ToJson>::to_json(&self.$field),
+                        ),
+                    )*
+                ])
+            }
+        }
+
+        impl $crate::FromJson for $name {
+            fn from_json(
+                v: &$crate::Value,
+            ) -> ::std::result::Result<Self, $crate::Error> {
+                let obj = v.as_object().ok_or_else(|| {
+                    $crate::Error::new("expected object", 0)
+                })?;
+                $( let mut $field: Option<&$crate::Value> = None; )*
+                for (k, val) in obj {
+                    match k.as_str() {
+                        $( stringify!($field) => $field = Some(val), )*
+                        _ => {}
+                    }
+                }
+                Ok(Self {
+                    $(
+                        $field: match $field {
+                            Some(val) => {
+                                <$ty as $crate::FromJson>::from_json(val)?
+                            }
+                            None => <$ty as $crate::FromJson>::missing_field()
+                                .map_err(|_| $crate::Error::new(
+                                    concat!(
+                                        "missing field: ",
+                                        stringify!($field),
+                                    ),
+                                    0,
+                                ))?,
+                        },
+                    )*
+                })
+            }
+        }
+    };
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn value_accessors() {
+        assert_eq!(Value::Bool(true).as_bool(), Some(true));
+        assert_eq!(Value::Null.as_bool(), None);
+        assert_eq!(Value::Number(1.5).as_number(), Some(1.5));
+        assert_eq!(Value::Null.as_number(), None);
+        assert_eq!(Value::String("hi".into()).as_str(), Some("hi"));
+        assert_eq!(Value::Null.as_str(), None);
+        assert!(Value::Array(Vec::new()).as_array().is_some());
+        assert!(Value::Null.as_array().is_none());
+        assert!(Value::Object(Vec::new()).as_object().is_some());
+        assert!(Value::Null.as_object().is_none());
+    }
+
+    #[test]
+    fn value_from_impls() {
+        assert!(matches!(Value::from(true), Value::Bool(true)));
+        assert!(matches!(Value::from(1.5), Value::Number(n) if n == 1.5));
+        match Value::from("hello") {
+            Value::String(s) => assert_eq!(s, "hello"),
+            _ => panic!(),
+        }
+        match Value::from(String::from("world")) {
+            Value::String(s) => assert_eq!(s, "world"),
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn error_accessors() {
+        let e = Error::new("boom", 42);
+        assert_eq!(e.message(), "boom");
+        assert_eq!(e.position(), 42);
+    }
+
+    #[test]
+    fn value_variants_construct() {
+        let _ = Value::Null;
+        let _ = Value::Bool(true);
+        let _ = Value::Number(0.0);
+        let _ = Value::String(String::new());
+        let _ = Value::Array(Vec::new());
+        let _ = Value::Object(Vec::new());
+    }
+
+    fn parse(s: &str) -> Value {
+        s.parse::<Value>().unwrap()
+    }
+
+    fn parse_err(s: &str) {
+        assert!(s.parse::<Value>().is_err(), "expected error for {s:?}");
+    }
+
+    #[test]
+    fn parse_primitives() {
+        assert!(matches!(parse("null"), Value::Null));
+        assert!(matches!(parse("true"), Value::Bool(true)));
+        assert!(matches!(parse("false"), Value::Bool(false)));
+        assert!(matches!(parse("  null  "), Value::Null));
+    }
+
+    #[test]
+    fn parse_numbers() {
+        let cases: &[(&str, f64)] = &[
+            ("0", 0.0),
+            ("-0", 0.0),
+            ("42", 42.0),
+            ("-42", -42.0),
+            ("3.14", 3.14),
+            ("-2.5", -2.5),
+            ("1e10", 1e10),
+            ("1E10", 1e10),
+            ("1.5e2", 150.0),
+            ("1.5e+2", 150.0),
+            ("2.5e-1", 0.25),
+        ];
+        for (s, v) in cases {
+            match parse(s) {
+                Value::Number(n) => assert_eq!(n, *v, "{s}"),
+                _ => panic!("not a number: {s}"),
+            }
+        }
+    }
+
+    #[test]
+    fn parse_number_errors() {
+        parse_err("01");
+        parse_err("+1");
+        parse_err(".5");
+        parse_err("1.");
+        parse_err("1e");
+        parse_err("1e+");
+        parse_err("-");
+    }
+
+    #[test]
+    fn parse_strings() {
+        match parse(r#""hello""#) {
+            Value::String(s) => assert_eq!(s, "hello"),
+            _ => panic!(),
+        }
+        match parse(r#""a\"b\\c\/d""#) {
+            Value::String(s) => assert_eq!(s, "a\"b\\c/d"),
+            _ => panic!(),
+        }
+        match parse(r#""\n\r\t\b\f""#) {
+            Value::String(s) => assert_eq!(s, "\n\r\t\u{08}\u{0C}"),
+            _ => panic!(),
+        }
+        match parse(r#""\u0041""#) {
+            Value::String(s) => assert_eq!(s, "A"),
+            _ => panic!(),
+        }
+        match parse(r#""\uD834\uDD1E""#) {
+            Value::String(s) => assert_eq!(s, "\u{1D11E}"),
+            _ => panic!(),
+        }
+        match parse("\"é\"") {
+            Value::String(s) => assert_eq!(s, "é"),
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_string_errors() {
+        parse_err(r#""unterminated"#);
+        parse_err("\"embedded\nnewline\"");
+        parse_err(r#""\x""#);
+        parse_err(r#""\uD800""#);
+        parse_err(r#""\uDC00""#);
+        parse_err(r#""\uD800\u0041""#);
+    }
+
+    #[test]
+    fn parse_arrays() {
+        assert!(matches!(parse("[]"), Value::Array(a) if a.is_empty()));
+        match parse("[1, 2, 3]") {
+            Value::Array(a) => {
+                assert_eq!(a.len(), 3);
+                assert!(matches!(a[0], Value::Number(n) if n == 1.0));
+            }
+            _ => panic!(),
+        }
+        match parse("[[1], [2, 3]]") {
+            Value::Array(a) => assert_eq!(a.len(), 2),
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_array_errors() {
+        parse_err("[");
+        parse_err("[1,");
+        parse_err("[1 2]");
+        parse_err("[,]");
+    }
+
+    #[test]
+    fn parse_objects() {
+        assert!(matches!(parse("{}"), Value::Object(m) if m.is_empty()));
+        match parse(r#"{"a": 1, "b": true}"#) {
+            Value::Object(m) => {
+                assert_eq!(m.len(), 2);
+                assert_eq!(m[0].0, "a");
+                assert!(matches!(m[0].1, Value::Number(n) if n == 1.0));
+                assert_eq!(m[1].0, "b");
+                assert!(matches!(m[1].1, Value::Bool(true)));
+            }
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_object_errors() {
+        parse_err("{");
+        parse_err(r#"{"a""#);
+        parse_err(r#"{"a":}"#);
+        parse_err(r#"{"a":1"#);
+        parse_err(r#"{a:1}"#);
+        parse_err(r#"{"a":1,}"#);
+    }
+
+    #[test]
+    fn parse_duplicate_keys_kept() {
+        match parse(r#"{"a":1,"a":2}"#) {
+            Value::Object(m) => {
+                assert_eq!(m.len(), 2);
+                assert_eq!(m[0].0, "a");
+                assert!(matches!(m[0].1, Value::Number(n) if n == 1.0));
+                assert_eq!(m[1].0, "a");
+                assert!(matches!(m[1].1, Value::Number(n) if n == 2.0));
+            }
+            _ => panic!(),
+        }
+    }
+
+    #[test]
+    fn parse_trailing_garbage_fails() {
+        parse_err("null null");
+        parse_err("1 2");
+        parse_err("[] x");
+    }
+
+    #[test]
+    fn parse_empty_fails() {
+        parse_err("");
+        parse_err("   ");
+    }
+
+    #[test]
+    fn round_trip() {
+        let cases = &[
+            "null",
+            "true",
+            "false",
+            "0",
+            "-1.5",
+            r#""hello""#,
+            "[]",
+            "[1,2,3]",
+            "{}",
+            r#"{"a":1,"b":[true,null]}"#,
+        ];
+        for s in cases {
+            let v: Value = s.parse().unwrap();
+            assert_eq!(&v.stringify().unwrap(), s, "round trip {s}");
+        }
+    }
+
+    #[test]
+    fn parse_rejects_deep_nesting() {
+        let s: String = "[".repeat(200);
+        parse_err(&s);
+    }
+
+    #[test]
+    fn stringify_primitives() {
+        assert_eq!(Value::Null.stringify().unwrap(), "null");
+        assert_eq!(Value::Bool(true).stringify().unwrap(), "true");
+        assert_eq!(Value::Bool(false).stringify().unwrap(), "false");
+        assert_eq!(Value::Number(0.0).stringify().unwrap(), "0");
+        assert_eq!(Value::Number(-42.5).stringify().unwrap(), "-42.5");
+    }
+
+    #[test]
+    fn stringify_integer_values() {
+        assert_eq!(Value::Number(42.0).stringify().unwrap(), "42");
+        assert_eq!(Value::Number(-1000.0).stringify().unwrap(), "-1000");
+        assert_eq!(Value::Number(1.5).stringify().unwrap(), "1.5");
+        // Beyond 2^53 falls back to the f64 formatter.
+        let big = (1_i64 << 54) as f64;
+        let s = Value::Number(big).stringify().unwrap();
+        assert_eq!(s.parse::<f64>().unwrap(), big);
+    }
+
+    #[test]
+    fn stringify_non_finite_errors() {
+        assert!(Value::Number(f64::NAN).stringify().is_err());
+        assert!(Value::Number(f64::INFINITY).stringify().is_err());
+        assert!(Value::Number(f64::NEG_INFINITY).stringify().is_err());
+    }
+
+    #[test]
+    fn stringify_string_escapes() {
+        assert_eq!(Value::String("hi".into()).stringify().unwrap(), "\"hi\"");
+        assert_eq!(
+            Value::String("a\"b\\c".into()).stringify().unwrap(),
+            "\"a\\\"b\\\\c\""
+        );
+        assert_eq!(
+            Value::String("\n\r\t\u{08}\u{0C}".into())
+                .stringify()
+                .unwrap(),
+            "\"\\n\\r\\t\\b\\f\""
+        );
+        assert_eq!(
+            Value::String("\x01\x1f".into()).stringify().unwrap(),
+            "\"\\u0001\\u001f\""
+        );
+        assert_eq!(Value::String("é".into()).stringify().unwrap(), "\"é\"");
+    }
+
+    #[test]
+    fn stringify_array() {
+        assert_eq!(Value::Array(Vec::new()).stringify().unwrap(), "[]");
+        let a = Value::Array(vec![
+            Value::Number(1.0),
+            Value::Null,
+            Value::Bool(true),
+        ]);
+        assert_eq!(a.stringify().unwrap(), "[1,null,true]");
+    }
+
+    #[test]
+    fn stringify_object_preserves_insertion_order() {
+        let o = vec![
+            ("b".into(), Value::Number(2.0)),
+            ("a".into(), Value::Number(1.0)),
+        ];
+        assert_eq!(Value::Object(o).stringify().unwrap(), r#"{"b":2,"a":1}"#);
+    }
+
+    #[test]
+    fn stringify_nested() {
+        let inner = vec![("x".into(), Value::Array(vec![Value::Number(3.0)]))];
+        let outer = vec![("obj".into(), Value::Object(inner))];
+        assert_eq!(
+            Value::Object(outer).stringify().unwrap(),
+            r#"{"obj":{"x":[3]}}"#
+        );
+    }
+
+    #[test]
+    fn stringify_array_propagates_error() {
+        let a = Value::Array(vec![Value::Number(f64::NAN)]);
+        assert!(a.stringify().is_err());
+    }
+
+    json_struct! {
+        struct SimpleUser {
+            name: String,
+            age: i64,
+            active: bool,
+        }
+    }
+
+    #[test]
+    fn derive_simple_round_trip() {
+        let u = SimpleUser {
+            name: "ada".into(),
+            age: 36,
+            active: true,
+        };
+        let s = u.to_json().stringify().unwrap();
+        let v: Value = s.parse().unwrap();
+        let back = SimpleUser::from_json(&v).unwrap();
+        assert_eq!(back.name, "ada");
+        assert_eq!(back.age, 36);
+        assert!(back.active);
+    }
+
+    json_struct! {
+        struct OptionalFields {
+            required: String,
+            maybe: Option<i64>,
+        }
+    }
+
+    #[test]
+    fn derive_option_none_serialises_as_null() {
+        let u = OptionalFields {
+            required: "x".into(),
+            maybe: None,
+        };
+        let s = u.to_json().stringify().unwrap();
+        assert_eq!(s, r#"{"required":"x","maybe":null}"#);
+        let back = OptionalFields::from_json(&s.parse().unwrap()).unwrap();
+        assert!(back.maybe.is_none());
+    }
+
+    #[test]
+    fn derive_absent_option_is_none() {
+        let v: Value = r#"{"required":"x"}"#.parse().unwrap();
+        let back = OptionalFields::from_json(&v).unwrap();
+        assert_eq!(back.required, "x");
+        assert!(back.maybe.is_none());
+    }
+
+    #[test]
+    fn derive_option_some_round_trips() {
+        let u = OptionalFields {
+            required: "x".into(),
+            maybe: Some(42),
+        };
+        let s = u.to_json().stringify().unwrap();
+        let back = OptionalFields::from_json(&s.parse().unwrap()).unwrap();
+        assert_eq!(back.maybe, Some(42));
+    }
+
+    json_struct! {
+        struct Container {
+            tags: Vec<String>,
+            counts: Vec<i32>,
+        }
+    }
+
+    #[test]
+    fn derive_vec_round_trip() {
+        let c = Container {
+            tags: vec!["a".into(), "b".into()],
+            counts: vec![1, 2, 3],
+        };
+        let s = c.to_json().stringify().unwrap();
+        let back = Container::from_json(&s.parse().unwrap()).unwrap();
+        assert_eq!(back.tags, vec!["a".to_string(), "b".to_string()]);
+        assert_eq!(back.counts, vec![1, 2, 3]);
+    }
+
+    json_struct! {
+        struct Inner {
+            value: i32,
+        }
+    }
+
+    json_struct! {
+        struct Outer {
+            name: String,
+            inner: Inner,
+        }
+    }
+
+    #[test]
+    fn derive_nested_struct() {
+        let o = Outer {
+            name: "n".into(),
+            inner: Inner { value: 7 },
+        };
+        let s = o.to_json().stringify().unwrap();
+        assert_eq!(s, r#"{"name":"n","inner":{"value":7}}"#);
+        let back = Outer::from_json(&s.parse().unwrap()).unwrap();
+        assert_eq!(back.name, "n");
+        assert_eq!(back.inner.value, 7);
+    }
+
+    #[test]
+    fn derive_missing_field_errors() {
+        let v: Value = r#"{"name":"ada","active":true}"#.parse().unwrap();
+        let e = SimpleUser::from_json(&v).err().unwrap();
+        assert_eq!(e.message(), "missing field: age");
+    }
+
+    #[test]
+    fn derive_wrong_type_errors() {
+        let v: Value =
+            r#"{"name":"ada","age":"old","active":true}"#.parse().unwrap();
+        assert!(SimpleUser::from_json(&v).is_err());
+    }
+
+    #[test]
+    fn derive_expects_object() {
+        let v = Value::Array(Vec::new());
+        assert!(SimpleUser::from_json(&v).is_err());
+    }
+
+    #[test]
+    fn derive_integer_out_of_range_errors() {
+        assert!(i32::from_json(&Value::Number(1e20)).is_err());
+    }
+
+    #[test]
+    fn derive_non_integer_errors() {
+        assert!(i64::from_json(&Value::Number(1.5)).is_err());
+    }
+
+    #[test]
+    fn derive_duplicate_keys_last_wins() {
+        let v: Value =
+            r#"{"name":"a","age":1,"active":true,"age":2}"#.parse().unwrap();
+        let back = SimpleUser::from_json(&v).unwrap();
+        assert_eq!(back.age, 2);
+    }
+}