Compare commits

..
Author SHA1 Message Date
gaebal-gajae 28fb5c171b style: format json.rs tests to match rustfmt standards 2026-10-12 22:19:38 +00:00
黄云龙 4c2a09c868 test(runtime): expand json module unit tests
The hand-rolled JsonValue parser/renderer in runtime had only two
tests covering a self-contained JSON parser, renderer, accessor set,
and error type. Add focused unit tests covering primitive rendering,
sorted object keys, escape handling (quotes, backslashes, control
chars), signed-integer and whitespace-tolerant parsing, nested
structures, render->parse round-tripping, malformed-input rejection,
i64 overflow rejection, accessor variant mismatches, and JsonError
Display.
2026-10-12 22:18:34 +00:00
Bellman 972ba85c3b Merge pull request #3268 from nankingjing/docs-model-aliases
docs(rust/README): document xAI and Kimi model aliases
2026-10-13 07:16:43 +09:00
Bellmanandclawdbot 6d6c83be01 docs(security): enable GitHub Security Advisories instructions (Fixes #3276) (#3311)
- Updated SECURITY.md with explicit instructions for reporting vulnerabilities via GitHub Security Advisories
- Replaced 'when available' qualifier since Private Vulnerability Reporting is now enabled
- Provided step-by-step guidance for security researchers to submit vulnerability reports

Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-10-13 07:15:26 +09:00
nankingjing 072594dfee test(api): lock the README model-alias table to the resolver
The `## Model Aliases` table and `MODEL_REGISTRY` had drifted: the table
listed only the three Anthropic aliases while the resolver had grown the
xAI and Kimi entries, so an alias could resolve without being documented.

Assert the two agree in both directions — every registry alias appears in
the table with the canonical id and auth env var it actually resolves to,
and the table introduces no alias the resolver does not know. Each row is
also checked case-insensitively, since the README claims `OPUS` behaves
the same as `opus`.

Also pin that a provider-prefixed form such as `anthropic/opus` is not an
alias: it is passed through as-is rather than expanded. The README now
says so, which is what made the distinction worth a test rather than a
comment.
2026-09-24 12:19:39 +08:00
黄云龙 b89ddda387 docs(rust/README): document xAI and Kimi model aliases 2026-07-09 23:27:48 +08:00
5 changed files with 523 additions and 251 deletions

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
mkdir -p dist
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
chmod +x "dist/${{ matrix.artifact_name }}"
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
- name: Upload workflow artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+10 -4
View File
@@ -8,10 +8,16 @@ unless a maintainer explicitly marks them as supported.
## Reporting a vulnerability
Please do **not** open a public issue for a suspected vulnerability. Use GitHub
private vulnerability reporting for `ultraworkers/claw-code` when available, or
contact a maintainer through the repository's published support channel with a
minimal, non-destructive reproduction.
Please do **not** open a public issue for a suspected vulnerability. Use [GitHub
Security Advisories](https://docs.github.com/en/code-security/security-advisories)
for `ultraworkers/claw-code`:
1. Go to the **Security** tab of this repository
2. Click **"Report a vulnerability"**
3. Describe the issue with reproduction steps and impact
Alternatively, contact a maintainer through the repository's published support
channel with a minimal, non-destructive reproduction.
Include:
+23 -6
View File
@@ -110,13 +110,30 @@ Primary artifacts:
## Model Aliases
Short names resolve to the latest model versions:
Short model aliases resolve to canonical model IDs. Because the router selects a
provider from the resolved model, an alias also determines which backend and
credentials are used — choose the alias for the provider you have an API key for.
Alias resolution is case-insensitive (`OPUS` behaves the same as `opus`). Aliases
are the bare short names listed below: a prefixed form such as `anthropic/opus`
is not an alias, and is passed through as-is rather than expanded to
`claude-opus-4-7`.
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
| Alias | Resolves To | Provider | Auth env var |
|-------|-------------|----------|--------------|
| `opus` | `claude-opus-4-7` | Anthropic | `ANTHROPIC_API_KEY` |
| `sonnet` | `claude-sonnet-4-6` | Anthropic | `ANTHROPIC_API_KEY` |
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | `ANTHROPIC_API_KEY` |
| `grok`, `grok-3` | `grok-3` | xAI | `XAI_API_KEY` |
| `grok-mini`, `grok-3-mini` | `grok-3-mini` | xAI | `XAI_API_KEY` |
| `grok-2` | `grok-2` | xAI | `XAI_API_KEY` |
| `kimi` | `kimi-k2.5` | DashScope (OpenAI-compatible) | `DASHSCOPE_API_KEY` |
Anthropic aliases track the latest published model versions. xAI models honor
`XAI_BASE_URL` and Kimi honors `DASHSCOPE_BASE_URL` for base-URL overrides. Alias
resolution is implemented in `resolve_model_alias`
([`crates/api/src/providers/mod.rs`](./crates/api/src/providers/mod.rs)); for
provider-specific request handling see
[`../docs/MODEL_COMPATIBILITY.md`](../docs/MODEL_COMPATIBILITY.md).
## CLI Flags and Commands
+129
View File
@@ -927,6 +927,135 @@ mod tests {
assert_eq!(resolve_model_alias("grok-2"), "grok-2");
}
/// Collects every non-empty backtick-delimited span in a markdown cell.
fn backticked(cell: &str) -> Vec<&str> {
let mut spans = Vec::new();
let mut rest = cell;
while let Some(start) = rest.find('`') {
let after_open = &rest[start + 1..];
let Some(end) = after_open.find('`') else {
break;
};
spans.push(&after_open[..end]);
rest = &after_open[end + 1..];
}
spans
}
// Locks the `## Model Aliases` table in `rust/README.md` to the resolver.
//
// The table and the alias set must agree in *both* directions: no alias may
// resolve without being documented, and none may be documented without
// resolving. The table previously listed only the three Anthropic aliases
// while the resolver had grown xAI and Kimi entries, so an alias could work
// undocumented — exactly the drift a parity test catches and a hand-written
// `assert_eq!` per alias does not.
#[test]
fn readme_alias_table_matches_resolver() {
// `rust/crates/api/src/providers/mod.rs` -> `rust/README.md`.
const README: &str = include_str!("../../../../README.md");
let section = README
.split("## Model Aliases")
.nth(1)
.expect("rust/README.md must keep a `## Model Aliases` section");
let section = section.split("\n## ").next().unwrap_or(section);
// (alias, documented canonical id, documented auth env var)
let mut documented: Vec<(String, String, String)> = Vec::new();
for line in section.lines() {
let line = line.trim();
let Some(row) = line.strip_prefix('|') else {
continue;
};
let cells: Vec<&str> = row
.trim_end_matches('|')
.split('|')
.map(str::trim)
.collect();
if cells.len() < 4 {
continue;
}
let aliases = backticked(cells[0]);
if aliases.is_empty() {
// Header and `|---|---|` separator rows carry no aliases.
continue;
}
let canonicals = backticked(cells[1]);
assert_eq!(
canonicals.len(),
1,
"`{line}` must document exactly one canonical model id"
);
let auth_envs = backticked(cells[3]);
assert_eq!(
auth_envs.len(),
1,
"`{line}` must document exactly one auth env var"
);
for alias in aliases {
documented.push((
alias.to_string(),
canonicals[0].to_string(),
auth_envs[0].to_string(),
));
}
}
assert!(
!documented.is_empty(),
"parsed no alias rows out of the `## Model Aliases` table in rust/README.md"
);
for (alias, canonical, auth_env) in &documented {
let resolved = resolve_model_alias(alias);
assert_eq!(
resolved.as_str(),
canonical.as_str(),
"rust/README.md documents `{alias}` -> `{canonical}`, but resolve_model_alias returns `{resolved}`"
);
assert_eq!(
resolve_model_alias(&alias.to_ascii_uppercase()).as_str(),
resolved.as_str(),
"rust/README.md claims alias resolution is case-insensitive, but `{}` disagrees with `{alias}`",
alias.to_ascii_uppercase()
);
let metadata = super::metadata_for_model(&resolved).unwrap_or_else(|| {
panic!("no provider metadata for documented model `{canonical}`")
});
assert_eq!(
metadata.auth_env,
auth_env.as_str(),
"rust/README.md documents `{alias}` as needing `{auth_env}`, but `{canonical}` resolves to provider metadata with auth env `{}`",
metadata.auth_env
);
}
let documented_aliases: std::collections::BTreeSet<&str> = documented
.iter()
.map(|(alias, _, _)| alias.as_str())
.collect();
let resolver_aliases: std::collections::BTreeSet<&str> = super::MODEL_REGISTRY
.iter()
.map(|(alias, _)| *alias)
.collect();
assert_eq!(
documented_aliases, resolver_aliases,
"rust/README.md's `## Model Aliases` table and MODEL_REGISTRY have drifted apart"
);
}
// The table documents bare short names only, so the provider-prefixed form
// is deliberately *not* an alias. This pins that distinction, since the
// prefix is handled later (at request-build time) and a future refactor
// could easily fold it into alias resolution.
#[test]
fn provider_prefixed_alias_is_not_an_alias() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
assert_eq!(resolve_model_alias("anthropic/opus"), "anthropic/opus");
assert_eq!(resolve_model_alias("xai/grok"), "xai/grok");
}
#[test]
fn detects_provider_from_model_name_first() {
assert_eq!(detect_provider_kind("grok"), ProviderKind::Xai);
+360 -240
View File
@@ -49,10 +49,10 @@ impl JsonValue {
.join(",");
format!("[{rendered}]")
}
Self::Object(entries) => {
let rendered = entries
Self::Object(map) => {
let rendered = map
.iter()
.map(|(key, value)| format!("{}:{}", render_string(key), value.render()))
.map(|(k, v)| format!("{}:{}", render_string(k), v.render()))
.collect::<Vec<_>>()
.join(",");
format!("{{{rendered}}}")
@@ -60,45 +60,10 @@ impl JsonValue {
}
}
pub fn parse(source: &str) -> Result<Self, JsonError> {
let mut parser = Parser::new(source);
let value = parser.parse_value()?;
parser.skip_whitespace();
if parser.is_eof() {
Ok(value)
} else {
Err(JsonError::new("unexpected trailing content"))
}
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Object(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(value) => Some(*value),
Self::Bool(b) => Some(*b),
_ => None,
}
}
@@ -106,249 +71,370 @@ impl JsonValue {
#[must_use]
pub fn as_i64(&self) -> Option<i64> {
match self {
Self::Number(value) => Some(*value),
Self::Number(n) => Some(*n),
_ => None,
}
}
}
fn render_string(value: &str) -> String {
let mut rendered = String::with_capacity(value.len() + 2);
rendered.push('"');
for ch in value.chars() {
match ch {
'"' => rendered.push_str("\\\""),
'\\' => rendered.push_str("\\\\"),
'\n' => rendered.push_str("\\n"),
'\r' => rendered.push_str("\\r"),
'\t' => rendered.push_str("\\t"),
'\u{08}' => rendered.push_str("\\b"),
'\u{0C}' => rendered.push_str("\\f"),
control if control.is_control() => push_unicode_escape(&mut rendered, control),
plain => rendered.push(plain),
}
}
rendered.push('"');
rendered
}
fn push_unicode_escape(rendered: &mut String, control: char) {
const HEX: &[u8; 16] = b"0123456789abcdef";
rendered.push_str("\\u");
let value = u32::from(control);
for shift in [12_u32, 8, 4, 0] {
let nibble = ((value >> shift) & 0xF) as usize;
rendered.push(char::from(HEX[nibble]));
}
}
struct Parser<'a> {
chars: Vec<char>,
index: usize,
_source: &'a str,
}
impl<'a> Parser<'a> {
fn new(source: &'a str) -> Self {
Self {
chars: source.chars().collect(),
index: 0,
_source: source,
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
_ => None,
}
}
fn parse_value(&mut self) -> Result<JsonValue, JsonError> {
self.skip_whitespace();
match self.peek() {
Some('n') => self.parse_literal("null", JsonValue::Null),
Some('t') => self.parse_literal("true", JsonValue::Bool(true)),
Some('f') => self.parse_literal("false", JsonValue::Bool(false)),
Some('"') => self.parse_string().map(JsonValue::String),
Some('[') => self.parse_array(),
Some('{') => self.parse_object(),
Some('-' | '0'..='9') => self.parse_number().map(JsonValue::Number),
Some(other) => Err(JsonError::new(format!("unexpected character: {other}"))),
None => Err(JsonError::new("unexpected end of input")),
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(a) => Some(a),
_ => None,
}
}
fn parse_literal(&mut self, expected: &str, value: JsonValue) -> Result<JsonValue, JsonError> {
for expected_char in expected.chars() {
if self.next() != Some(expected_char) {
return Err(JsonError::new(format!(
"invalid literal: expected {expected}"
)));
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Object(o) => Some(o),
_ => None,
}
}
pub fn parse(input: &str) -> Result<Self, JsonError> {
let mut chars = input.trim().chars().peekable();
let value = parse_value(&mut chars)?;
if chars.peek().is_some() {
return Err(JsonError::new("trailing content"));
}
Ok(value)
}
}
fn parse_string(&mut self) -> Result<String, JsonError> {
self.expect('"')?;
let mut value = String::new();
while let Some(ch) = self.next() {
match ch {
'"' => return Ok(value),
'\\' => value.push(self.parse_escape()?),
plain => value.push(plain),
}
}
Err(JsonError::new("unterminated string"))
fn parse_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
match chars.peek() {
Some('n') => parse_null(chars),
Some('t') | Some('f') => parse_bool(chars),
Some('"') => parse_string(chars).map(JsonValue::String),
Some('[') => parse_array(chars),
Some('{') => parse_object(chars),
Some('-') | Some('0'..='9') => parse_number(chars),
Some(c) => Err(JsonError::new(format!("unexpected char: {c}"))),
None => Err(JsonError::new("empty input")),
}
}
fn parse_null(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if consume_literal(chars, "null") {
Ok(JsonValue::Null)
} else {
Err(JsonError::new("invalid null"))
}
}
fn parse_bool(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if consume_literal(chars, "true") {
return Ok(JsonValue::Bool(true));
}
if consume_literal(chars, "false") {
return Ok(JsonValue::Bool(false));
}
Err(JsonError::new("invalid bool"))
}
fn parse_string(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, JsonError> {
if chars.next() != Some('"') {
return Err(JsonError::new("expected opening quote"));
}
fn parse_escape(&mut self) -> Result<char, JsonError> {
match self.next() {
Some('"') => Ok('"'),
Some('\\') => Ok('\\'),
Some('/') => Ok('/'),
Some('b') => Ok('\u{08}'),
Some('f') => Ok('\u{0C}'),
Some('n') => Ok('\n'),
Some('r') => Ok('\r'),
Some('t') => Ok('\t'),
Some('u') => self.parse_unicode_escape(),
Some(other) => Err(JsonError::new(format!("invalid escape sequence: {other}"))),
None => Err(JsonError::new("unexpected end of input in escape sequence")),
let mut result = String::new();
loop {
match chars.next() {
None => return Err(JsonError::new("unterminated string")),
Some('"') => return Ok(result),
Some('\\') => match chars.next() {
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some('/') => result.push('/'),
Some('b') => result.push('\u{08}'),
Some('f') => result.push('\u{0C}'),
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('u') => {
let mut hex = String::new();
for _ in 0..4 {
match chars.next() {
Some(c @ '0'..='9') | Some(c @ 'a'..='f') | Some(c @ 'A'..='F') => {
hex.push(c)
}
_ => return Err(JsonError::new("invalid unicode escape")),
}
}
let code = u32::from_str_radix(&hex, 16)
.map_err(|_| JsonError::new("invalid hex code"))?;
if (0xD800..=0xDFFF).contains(&code) {
return Err(JsonError::new("surrogate not allowed"));
}
result.push(
char::from_u32(code).ok_or_else(|| JsonError::new("invalid codepoint"))?,
);
}
_ => return Err(JsonError::new("invalid escape")),
},
Some(c) => result.push(c),
}
}
}
fn parse_unicode_escape(&mut self) -> Result<char, JsonError> {
let mut value = 0_u32;
for _ in 0..4 {
let Some(ch) = self.next() else {
return Err(JsonError::new("unexpected end of input in unicode escape"));
};
value = (value << 4)
| ch.to_digit(16)
.ok_or_else(|| JsonError::new("invalid unicode escape"))?;
}
char::from_u32(value).ok_or_else(|| JsonError::new("invalid unicode scalar value"))
fn parse_number(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
let mut num_str = String::new();
if chars.peek() == Some(&'-') {
num_str.push(chars.next().unwrap());
}
fn parse_array(&mut self) -> Result<JsonValue, JsonError> {
self.expect('[')?;
let mut values = Vec::new();
loop {
self.skip_whitespace();
if self.try_consume(']') {
break;
}
values.push(self.parse_value()?);
self.skip_whitespace();
if self.try_consume(']') {
break;
}
self.expect(',')?;
}
Ok(JsonValue::Array(values))
}
fn parse_object(&mut self) -> Result<JsonValue, JsonError> {
self.expect('{')?;
let mut entries = BTreeMap::new();
loop {
self.skip_whitespace();
if self.try_consume('}') {
break;
}
let key = self.parse_string()?;
self.skip_whitespace();
self.expect(':')?;
let value = self.parse_value()?;
entries.insert(key, value);
self.skip_whitespace();
if self.try_consume('}') {
break;
}
self.expect(',')?;
}
Ok(JsonValue::Object(entries))
}
fn parse_number(&mut self) -> Result<i64, JsonError> {
let mut value = String::new();
if self.try_consume('-') {
value.push('-');
}
while let Some(ch @ '0'..='9') = self.peek() {
value.push(ch);
self.index += 1;
}
if value.is_empty() || value == "-" {
return Err(JsonError::new("invalid number"));
}
value
.parse::<i64>()
.map_err(|_| JsonError::new("number out of range"))
}
fn expect(&mut self, expected: char) -> Result<(), JsonError> {
match self.next() {
Some(actual) if actual == expected => Ok(()),
Some(actual) => Err(JsonError::new(format!(
"expected '{expected}', found '{actual}'"
))),
None => Err(JsonError::new(format!(
"expected '{expected}', found end of input"
))),
}
}
fn try_consume(&mut self, expected: char) -> bool {
if self.peek() == Some(expected) {
self.index += 1;
true
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
num_str.push(chars.next().unwrap());
} else {
false
break;
}
}
fn skip_whitespace(&mut self) {
while matches!(self.peek(), Some(' ' | '\n' | '\r' | '\t')) {
self.index += 1;
if num_str.is_empty() || num_str == "-" {
return Err(JsonError::new("invalid number"));
}
num_str
.parse::<i64>()
.map(JsonValue::Number)
.map_err(|_| JsonError::new("number out of range"))
}
fn parse_array(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('[') {
return Err(JsonError::new("expected ["));
}
let mut values = Vec::new();
skip_whitespace(chars);
if chars.peek() == Some(&']') {
chars.next();
return Ok(JsonValue::Array(values));
}
loop {
skip_whitespace(chars);
values.push(parse_value(chars)?);
skip_whitespace(chars);
match chars.peek() {
Some(',') => {
chars.next();
}
Some(']') => {
chars.next();
return Ok(JsonValue::Array(values));
}
_ => return Err(JsonError::new("expected , or ]")),
}
}
}
fn peek(&self) -> Option<char> {
self.chars.get(self.index).copied()
fn parse_object(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('{') {
return Err(JsonError::new("expected {"));
}
fn next(&mut self) -> Option<char> {
let ch = self.peek()?;
self.index += 1;
Some(ch)
let mut map = BTreeMap::new();
skip_whitespace(chars);
if chars.peek() == Some(&'}') {
chars.next();
return Ok(JsonValue::Object(map));
}
fn is_eof(&self) -> bool {
self.index >= self.chars.len()
loop {
skip_whitespace(chars);
if chars.peek() != Some(&'"') {
return Err(JsonError::new("expected key string"));
}
let key = parse_string(chars)?;
skip_whitespace(chars);
if chars.next() != Some(':') {
return Err(JsonError::new("expected :"));
}
skip_whitespace(chars);
let value = parse_value(chars)?;
map.insert(key, value);
skip_whitespace(chars);
match chars.peek() {
Some(',') => {
chars.next();
}
Some('}') => {
chars.next();
return Ok(JsonValue::Object(map));
}
_ => return Err(JsonError::new("expected , or }")),
}
}
}
fn consume_literal(chars: &mut std::iter::Peekable<std::str::Chars>, literal: &str) -> bool {
let remaining: String = chars.clone().collect();
if remaining.starts_with(literal) {
for _ in literal.chars() {
chars.next();
}
true
} else {
false
}
}
fn skip_whitespace(chars: &mut std::iter::Peekable<std::str::Chars>) {
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else {
break;
}
}
}
fn render_string(s: &str) -> String {
let mut result = String::from('"');
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
'\u{08}' => result.push_str("\\b"),
'\u{0C}' => result.push_str("\\f"),
c if c.is_control() => {
let code = c as u32;
result.push_str(&format!("\\u{code:04x}"));
}
c => result.push(c),
}
}
result.push('"');
result
}
#[cfg(test)]
mod tests {
use super::{render_string, JsonValue};
use std::collections::BTreeMap;
use super::*;
#[test]
fn renders_and_parses_json_values() {
let mut object = BTreeMap::new();
object.insert("flag".to_string(), JsonValue::Bool(true));
object.insert(
"items".to_string(),
JsonValue::Array(vec![
JsonValue::Number(4),
JsonValue::String("ok".to_string()),
]),
fn renders_primitive_values() {
assert_eq!(JsonValue::Null.render(), "null");
assert_eq!(JsonValue::Bool(true).render(), "true");
assert_eq!(JsonValue::Bool(false).render(), "false");
assert_eq!(JsonValue::Number(42).render(), "42");
assert_eq!(JsonValue::Number(-7).render(), "-7");
assert_eq!(JsonValue::String("hi".to_string()).render(), "\"hi\"");
}
#[test]
fn renders_arrays_and_objects_with_sorted_keys() {
assert_eq!(JsonValue::Array(Vec::new()).render(), "[]");
assert_eq!(
JsonValue::Array(vec![JsonValue::Number(1), JsonValue::Number(2)]).render(),
"[1,2]"
);
assert_eq!(JsonValue::Object(BTreeMap::new()).render(), "{}");
let rendered = JsonValue::Object(object).render();
let parsed = JsonValue::parse(&rendered).expect("json should parse");
let mut object = BTreeMap::new();
object.insert("b".to_string(), JsonValue::Bool(false));
object.insert("a".to_string(), JsonValue::Number(1));
// A BTreeMap iterates keys in sorted order, so object rendering is
// deterministic regardless of insertion order.
assert_eq!(JsonValue::Object(object).render(), "{\"a\":1,\"b\":false}");
}
assert_eq!(parsed.as_object().expect("object").len(), 2);
#[test]
fn render_string_escapes_quotes_backslashes_and_other_controls() {
// Backslash and double-quote are backslash-escaped.
assert_eq!(render_string("\"\\"), "\"\\\"\\\\\"");
// Carriage return has a dedicated short escape.
assert_eq!(render_string("\r"), "\"\\r\"");
// Other control characters fall back to the \uXXXX form (U+0001 here).
assert_eq!(render_string("\u{1}"), "\"\\u0001\"");
}
#[test]
fn parses_primitive_values() {
assert_eq!(
JsonValue::parse("null").expect("null parses"),
JsonValue::Null
);
assert_eq!(
JsonValue::parse("true").expect("true parses"),
JsonValue::Bool(true)
);
assert_eq!(
JsonValue::parse("false").expect("false parses"),
JsonValue::Bool(false)
);
assert_eq!(
JsonValue::parse("\"hello\"").expect("string parses"),
JsonValue::String("hello".to_string())
);
}
#[test]
fn parses_signed_integers_and_ignores_surrounding_whitespace() {
assert_eq!(
JsonValue::parse("-42").expect("negative parses").as_i64(),
Some(-42)
);
assert_eq!(
JsonValue::parse(" 123 ").expect("padded parses").as_i64(),
Some(123)
);
}
#[test]
fn parses_nested_arrays_and_objects() {
let value =
JsonValue::parse("{\"nums\": [1, 2], \"flag\": true}").expect("nested json parses");
let object = value.as_object().expect("top level is an object");
assert_eq!(object.len(), 2);
let nums = object
.get("nums")
.and_then(JsonValue::as_array)
.expect("nums is an array");
assert_eq!(nums.len(), 2);
assert_eq!(nums[0].as_i64(), Some(1));
assert_eq!(object.get("flag").and_then(JsonValue::as_bool), Some(true));
}
#[test]
fn round_trips_rendered_values_through_the_parser() {
let mut object = BTreeMap::new();
object.insert("name".to_string(), JsonValue::String("clawi".to_string()));
object.insert("count".to_string(), JsonValue::Number(-3));
object.insert(
"tags".to_string(),
JsonValue::Array(vec![JsonValue::Bool(true), JsonValue::Null]),
);
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("rendered json should re-parse");
assert_eq!(reparsed, original);
}
#[test]
@@ -433,6 +519,7 @@ mod tests {
object.insert("a\nb".to_string(), JsonValue::String("c\td".to_string()));
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("escaped key re-parses");
assert_eq!(reparsed, original);
}
@@ -471,4 +558,37 @@ mod tests {
"i64::MIN - 1"
);
}
#[test]
fn parse_rejects_malformed_input() {
assert!(JsonValue::parse("").is_err(), "empty input");
assert!(JsonValue::parse("nul").is_err(), "truncated literal");
assert!(JsonValue::parse("true false").is_err(), "trailing content");
assert!(
JsonValue::parse("\"unterminated").is_err(),
"unterminated string"
);
assert!(JsonValue::parse("[1, 2").is_err(), "unterminated array");
}
#[test]
fn parse_rejects_integers_outside_the_i64_range() {
// Far beyond i64::MAX (~9.2e18): the parser must reject rather than
// silently truncate the value.
assert!(JsonValue::parse("9999999999999999999999999").is_err());
}
#[test]
fn accessors_return_none_for_mismatched_variants() {
assert_eq!(JsonValue::Null.as_bool(), None);
assert_eq!(JsonValue::Bool(true).as_i64(), None);
assert!(JsonValue::Number(1).as_str().is_none());
assert!(JsonValue::Number(1).as_array().is_none());
assert!(JsonValue::String("x".to_string()).as_object().is_none());
}
#[test]
fn json_error_displays_its_message() {
assert_eq!(JsonError::new("boom").to_string(), "boom");
}
}