Compare commits

..
5 changed files with 247 additions and 519 deletions
+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 }}" > "${{ matrix.artifact_name }}.sha256")
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
- name: Upload workflow artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+4 -10
View File
@@ -8,16 +8,10 @@ unless a maintainer explicitly marks them as supported.
## Reporting a vulnerability
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.
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.
Include:
+6 -23
View File
@@ -110,30 +110,13 @@ Primary artifacts:
## Model Aliases
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`.
Short names resolve to the latest model versions:
| 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).
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags and Commands
-129
View File
@@ -927,135 +927,6 @@ 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);
+236 -356
View File
@@ -49,10 +49,10 @@ impl JsonValue {
.join(",");
format!("[{rendered}]")
}
Self::Object(map) => {
let rendered = map
Self::Object(entries) => {
let rendered = entries
.iter()
.map(|(k, v)| format!("{}:{}", render_string(k), v.render()))
.map(|(key, value)| format!("{}:{}", render_string(key), value.render()))
.collect::<Vec<_>>()
.join(",");
format!("{{{rendered}}}")
@@ -60,26 +60,21 @@ impl JsonValue {
}
}
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(b) => Some(*b),
_ => None,
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_i64(&self) -> Option<i64> {
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Number(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
Self::Object(value) => Some(value),
_ => None,
}
}
@@ -87,354 +82,273 @@ impl JsonValue {
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(a) => Some(a),
Self::Array(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Object(o) => Some(o),
Self::String(value) => Some(value),
_ => 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"));
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(value) => Some(*value),
_ => None,
}
}
#[must_use]
pub fn as_i64(&self) -> Option<i64> {
match self {
Self::Number(value) => Some(*value),
_ => 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,
}
}
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")),
}
}
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}"
)));
}
}
Ok(value)
}
}
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_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"))
}
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_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")),
}
}
}
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_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"))
}
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
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
} else {
break;
false
}
}
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 skip_whitespace(&mut self) {
while matches!(self.peek(), Some(' ' | '\n' | '\r' | '\t')) {
self.index += 1;
}
}
}
fn parse_object(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('{') {
return Err(JsonError::new("expected {"));
fn peek(&self) -> Option<char> {
self.chars.get(self.index).copied()
}
let mut map = BTreeMap::new();
skip_whitespace(chars);
if chars.peek() == Some(&'}') {
chars.next();
return Ok(JsonValue::Object(map));
fn next(&mut self) -> Option<char> {
let ch = self.peek()?;
self.index += 1;
Some(ch)
}
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 is_eof(&self) -> bool {
self.index >= self.chars.len()
}
}
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::*;
use super::{render_string, JsonValue};
use std::collections::BTreeMap;
#[test]
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(), "{}");
fn renders_and_parses_json_values() {
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}");
}
#[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("flag".to_string(), JsonValue::Bool(true));
object.insert(
"tags".to_string(),
JsonValue::Array(vec![JsonValue::Bool(true), JsonValue::Null]),
"items".to_string(),
JsonValue::Array(vec![
JsonValue::Number(4),
JsonValue::String("ok".to_string()),
]),
);
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("rendered json should re-parse");
let rendered = JsonValue::Object(object).render();
let parsed = JsonValue::parse(&rendered).expect("json should parse");
assert_eq!(reparsed, original);
assert_eq!(parsed.as_object().expect("object").len(), 2);
}
#[test]
@@ -519,7 +433,6 @@ 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);
}
@@ -558,37 +471,4 @@ 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");
}
}