chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
pub mod ison;
|
||||
pub mod tonl;
|
||||
pub mod toon;
|
||||
pub mod zon;
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Shared ISON/ISONL v1 helpers for compact JSON-family records.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use serde_json::{Map, Number, Value};
|
||||
|
||||
use crate::CliError;
|
||||
|
||||
const RECORD_SEGMENT_ERROR: &str = "ISONL record expects 3 pipe-delimited segments";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FieldType {
|
||||
Int,
|
||||
Float,
|
||||
Bool,
|
||||
Str,
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Field {
|
||||
name: String,
|
||||
kind: FieldType,
|
||||
}
|
||||
|
||||
/// Encodes JSON object records into newline-delimited ISONL text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when any record is not an object or has no fields.
|
||||
pub fn encode_records(records: &[Value], record_name: &str) -> Result<String, CliError> {
|
||||
let mut output = String::with_capacity(records.len().saturating_mul(96));
|
||||
for record in records {
|
||||
output.push_str(&encode_record(record, record_name)?);
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Encodes one JSON object into an ISONL record line.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when `record` is not an object or has no fields.
|
||||
pub fn encode_record(record: &Value, record_name: &str) -> Result<String, CliError> {
|
||||
let object = record
|
||||
.as_object()
|
||||
.ok_or_else(|| CliError::usage("ISONL v1 expects each record to be a JSON object"))?;
|
||||
if object.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"ISONL v1 cannot infer fields from an empty object",
|
||||
));
|
||||
}
|
||||
let fields = infer_fields(object);
|
||||
let mut output =
|
||||
String::with_capacity(record_name.len() + fields.len().saturating_mul(24) + 16);
|
||||
output.push_str("object.");
|
||||
output.push_str(record_name);
|
||||
output.push('|');
|
||||
push_fields(&mut output, &fields);
|
||||
output.push('|');
|
||||
push_row(&mut output, object, &fields);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decodes one ISONL record line into a JSON object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when the line is malformed.
|
||||
pub fn decode_record_line(line: &str, line_number: usize) -> Result<Value, CliError> {
|
||||
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
|
||||
parse_header(header.trim(), line_number)?;
|
||||
let fields = parse_fields(fields_text.trim(), line_number)?;
|
||||
let row = parse_row(row_text.trim(), line_number, &fields)?;
|
||||
Ok(Value::Object(row))
|
||||
}
|
||||
|
||||
/// Decodes newline-delimited ISONL records into JSON objects.
|
||||
///
|
||||
/// Reuses the previous field schema when consecutive records share the same
|
||||
/// header and field definition, which is the common JSONL-style stream shape.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when any line is malformed.
|
||||
pub fn decode_records(input: &str) -> Result<Vec<Value>, CliError> {
|
||||
let mut records = Vec::new();
|
||||
let mut cached_header = "";
|
||||
let mut cached_fields_text = "";
|
||||
let mut cached_fields = Vec::new();
|
||||
|
||||
for (index, raw_line) in input.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
|
||||
if header != cached_header || fields_text != cached_fields_text {
|
||||
parse_header(header, line_number)?;
|
||||
cached_fields = parse_fields(fields_text, line_number)?;
|
||||
cached_header = header;
|
||||
cached_fields_text = fields_text;
|
||||
}
|
||||
|
||||
records.push(Value::Object(parse_row(
|
||||
row_text,
|
||||
line_number,
|
||||
&cached_fields,
|
||||
)?));
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn split_record_line(line: &str, line_number: usize) -> Result<(&str, &str, &str), CliError> {
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let Some(header) = parts.next() else {
|
||||
return Err(record_segment_error(line_number));
|
||||
};
|
||||
let Some(fields_text) = parts.next() else {
|
||||
return Err(record_segment_error(line_number));
|
||||
};
|
||||
let Some(row_text) = parts.next() else {
|
||||
return Err(record_segment_error(line_number));
|
||||
};
|
||||
Ok((header.trim(), fields_text.trim(), row_text.trim()))
|
||||
}
|
||||
|
||||
fn record_segment_error(line_number: usize) -> CliError {
|
||||
CliError::usage(format!("line {line_number}: {RECORD_SEGMENT_ERROR}"))
|
||||
}
|
||||
|
||||
fn infer_fields(object: &Map<String, Value>) -> Vec<Field> {
|
||||
object
|
||||
.iter()
|
||||
.map(|(name, value)| Field {
|
||||
name: name.clone(),
|
||||
kind: infer_type(value),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn infer_type(value: &Value) -> FieldType {
|
||||
match value {
|
||||
Value::Bool(_) => FieldType::Bool,
|
||||
Value::Number(number) if number.is_i64() || number.is_u64() => FieldType::Int,
|
||||
Value::Number(_) => FieldType::Float,
|
||||
Value::String(_) | Value::Array(_) | Value::Object(_) => FieldType::Str,
|
||||
Value::Null => FieldType::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_fields(output: &mut String, fields: &[Field]) {
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(' ');
|
||||
}
|
||||
output.push_str(&field.name);
|
||||
output.push(':');
|
||||
output.push_str(field.kind.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_row(output: &mut String, object: &Map<String, Value>, fields: &[Field]) {
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(' ');
|
||||
}
|
||||
push_value(output, object.get(&field.name).unwrap_or(&Value::Null));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_value(output: &mut String, value: &Value) {
|
||||
match value {
|
||||
Value::Null => output.push_str("null"),
|
||||
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => {
|
||||
let _ = write!(output, "{value}");
|
||||
}
|
||||
Value::String(value) => push_string(output, value),
|
||||
Value::Array(_) | Value::Object(_) => push_string(output, &value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_string(output: &mut String, value: &str) {
|
||||
if value.is_empty()
|
||||
|| value
|
||||
.chars()
|
||||
.any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '|'))
|
||||
{
|
||||
output.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()));
|
||||
} else {
|
||||
output.push_str(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header(line: &str, line_number: usize) -> Result<(), CliError> {
|
||||
let Some((kind, name)) = line.split_once('.') else {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: expected block header kind.name"
|
||||
)));
|
||||
};
|
||||
if kind != "object" || name.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: expected object.<name> header"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_fields(line: &str, line_number: usize) -> Result<Vec<Field>, CliError> {
|
||||
let fields = line
|
||||
.split_whitespace()
|
||||
.map(|token| parse_field(token, line_number))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if fields.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: field definition is empty"
|
||||
)));
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn parse_field(token: &str, line_number: usize) -> Result<Field, CliError> {
|
||||
let Some((name, kind)) = token.split_once(':') else {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: field '{token}' must be name:type"
|
||||
)));
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: field name must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(Field {
|
||||
name: name.to_string(),
|
||||
kind: parse_field_type(kind, line_number)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_field_type(kind: &str, line_number: usize) -> Result<FieldType, CliError> {
|
||||
match kind {
|
||||
"int" => Ok(FieldType::Int),
|
||||
"float" => Ok(FieldType::Float),
|
||||
"bool" => Ok(FieldType::Bool),
|
||||
"str" => Ok(FieldType::Str),
|
||||
"null" => Ok(FieldType::Null),
|
||||
other => Err(CliError::usage(format!(
|
||||
"line {line_number}: unknown field type '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_row(
|
||||
line: &str,
|
||||
line_number: usize,
|
||||
fields: &[Field],
|
||||
) -> Result<Map<String, Value>, CliError> {
|
||||
let values = split_row(line)
|
||||
.map_err(|message| CliError::usage(format!("line {line_number}: {message}")))?;
|
||||
if values.len() != fields.len() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: expected {} values, got {}",
|
||||
fields.len(),
|
||||
values.len()
|
||||
)));
|
||||
}
|
||||
fields
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(field, raw)| {
|
||||
parse_value(raw, field.kind, line_number).map(|value| (field.name.clone(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn split_row(line: &str) -> Result<Vec<&str>, String> {
|
||||
let mut values = Vec::new();
|
||||
let mut token_start = None;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
for (index, character) in line.char_indices() {
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if character == '\\' {
|
||||
escaped = true;
|
||||
} else if character == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if character == '"' {
|
||||
in_string = true;
|
||||
token_start.get_or_insert(index);
|
||||
} else if character.is_whitespace() {
|
||||
if let Some(start) = token_start.take() {
|
||||
values.push(&line[start..index]);
|
||||
}
|
||||
} else {
|
||||
token_start.get_or_insert(index);
|
||||
}
|
||||
}
|
||||
if in_string {
|
||||
return Err("unterminated quoted string".to_string());
|
||||
}
|
||||
if let Some(start) = token_start {
|
||||
values.push(&line[start..]);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn parse_value(raw: &str, kind: FieldType, line_number: usize) -> Result<Value, CliError> {
|
||||
match kind {
|
||||
FieldType::Int => parse_number_value(raw, line_number),
|
||||
FieldType::Float => raw
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.and_then(serde_json::Number::from_f64)
|
||||
.map(Value::Number)
|
||||
.ok_or_else(|| CliError::usage(format!("line {line_number}: invalid float '{raw}'"))),
|
||||
FieldType::Bool => raw.parse::<bool>().map(Value::Bool).map_err(|error| {
|
||||
CliError::usage(format!("line {line_number}: invalid bool '{raw}': {error}"))
|
||||
}),
|
||||
FieldType::Str => {
|
||||
if raw.starts_with('"') {
|
||||
serde_json::from_str::<String>(raw)
|
||||
.map(Value::String)
|
||||
.map_err(|error| {
|
||||
CliError::usage(format!("line {line_number}: invalid string: {error}"))
|
||||
})
|
||||
} else {
|
||||
Ok(Value::String(raw.to_string()))
|
||||
}
|
||||
}
|
||||
FieldType::Null => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_number_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
|
||||
if let Ok(value) = raw.parse::<i64>() {
|
||||
return Ok(Value::Number(Number::from(value)));
|
||||
}
|
||||
raw.parse::<u64>()
|
||||
.map(Number::from)
|
||||
.map(Value::Number)
|
||||
.map_err(|error| {
|
||||
CliError::usage(format!("line {line_number}: invalid int '{raw}': {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
impl FieldType {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Int => "int",
|
||||
Self::Float => "float",
|
||||
Self::Bool => "bool",
|
||||
Self::Str => "str",
|
||||
Self::Null => "null",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Shared TONL v1 helpers for JSON-backed key/value documents.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::CliError;
|
||||
|
||||
/// Encodes JSON records into TONL documents.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
|
||||
pub fn encode_documents(records: &[Value]) -> Result<String, CliError> {
|
||||
let mut output = String::with_capacity(records.len().saturating_mul(96));
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
if records.len() > 1 {
|
||||
output.push_str("---\n");
|
||||
} else if index > 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
push_document(&mut output, record)?;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decodes TONL documents into JSON records.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when a line is malformed or a value is not JSON.
|
||||
pub fn decode_documents(content: &str) -> Result<Vec<Value>, CliError> {
|
||||
let mut records = Vec::new();
|
||||
let mut current = Map::new();
|
||||
let mut root_value = None;
|
||||
for (index, line) in content.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "---" {
|
||||
finish_record(&mut records, &mut current, &mut root_value);
|
||||
continue;
|
||||
}
|
||||
let (key, raw_value) = trimmed.split_once('=').ok_or_else(|| {
|
||||
CliError::usage(format!(
|
||||
"line {line_number}: expected 'key = <valid JSON value>'"
|
||||
))
|
||||
})?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: key cannot be empty"
|
||||
)));
|
||||
}
|
||||
let value = serde_json::from_str::<Value>(raw_value.trim()).map_err(|error| {
|
||||
CliError::usage(format!(
|
||||
"line {line_number}: value must be a valid JSON value: {error}"
|
||||
))
|
||||
})?;
|
||||
if key == "$" {
|
||||
if !current.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: '$' root value cannot be mixed with object fields"
|
||||
)));
|
||||
}
|
||||
root_value = Some(value);
|
||||
} else {
|
||||
if root_value.is_some() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: object fields cannot be mixed with '$' root value"
|
||||
)));
|
||||
}
|
||||
current.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
finish_record(&mut records, &mut current, &mut root_value);
|
||||
if records.is_empty() {
|
||||
return Err(CliError::usage("no TONL records found"));
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn push_document(output: &mut String, record: &Value) -> Result<(), CliError> {
|
||||
match record {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
output.push_str(key);
|
||||
output.push_str(" = ");
|
||||
output.push_str(&compact_json(value)?);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
value => {
|
||||
output.push_str("$ = ");
|
||||
output.push_str(&compact_json(value)?);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_record(
|
||||
records: &mut Vec<Value>,
|
||||
current: &mut Map<String, Value>,
|
||||
root_value: &mut Option<Value>,
|
||||
) {
|
||||
if let Some(value) = root_value.take() {
|
||||
records.push(value);
|
||||
} else if !current.is_empty() {
|
||||
records.push(Value::Object(std::mem::take(current)));
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_json(value: &Value) -> Result<String, CliError> {
|
||||
serde_json::to_string(value)
|
||||
.map_err(|error| CliError::runtime(format!("failed to render json: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{decode_documents, encode_documents};
|
||||
|
||||
#[test]
|
||||
fn shared_tonl_roundtrips_objects_and_root_values() {
|
||||
let records = vec![json!({"id": "a", "ok": true}), json!([1, 2])];
|
||||
let encoded = encode_documents(&records).expect("encode tonl");
|
||||
|
||||
assert!(encoded.contains("---\nid = \"a\"\nok = true\n"));
|
||||
assert!(encoded.contains("---\n$ = [1,2]\n"));
|
||||
assert_eq!(decode_documents(&encoded).expect("decode tonl"), records);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_tonl_reports_malformed_inputs() {
|
||||
assert!(decode_documents("\n# comment\n").is_err());
|
||||
assert!(decode_documents("missing separator\n").is_err());
|
||||
assert!(decode_documents(" = 1\n").is_err());
|
||||
assert!(decode_documents("id = not-json\n").is_err());
|
||||
assert!(decode_documents("id = 1\n$ = 2\n").is_err());
|
||||
assert!(decode_documents("$ = 1\nid = 2\n").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
//! Shared Zero Overhead Notation (ZON) v1 helpers.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::CliError;
|
||||
|
||||
/// Encodes a JSON value into the Mercury ZON v1 subset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
|
||||
pub fn encode_value(value: &Value) -> Result<String, CliError> {
|
||||
let mut output = String::with_capacity(estimate_capacity(value));
|
||||
match value {
|
||||
Value::Object(object) => encode_object(object, &mut output)?,
|
||||
primitive => {
|
||||
output.push_str(&render_inline_value(primitive)?);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn estimate_capacity(value: &Value) -> usize {
|
||||
match value {
|
||||
Value::Object(object) => object.len().saturating_mul(64),
|
||||
Value::Array(items) => items.len().saturating_mul(48),
|
||||
Value::String(text) => text.len() + 8,
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the Mercury ZON v1 subset into JSON.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when a line is malformed.
|
||||
pub fn decode_str(input: &str) -> Result<Value, CliError> {
|
||||
let mut object = Map::new();
|
||||
let mut scalar = None;
|
||||
for (index, line) in input.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, raw_value)) = trimmed.split_once(':') {
|
||||
if key.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: key cannot be empty"
|
||||
)));
|
||||
}
|
||||
object.insert(
|
||||
key.to_string(),
|
||||
parse_inline_value(raw_value.trim(), line_number)?,
|
||||
);
|
||||
} else if scalar
|
||||
.replace(parse_inline_value(trimmed, line_number)?)
|
||||
.is_some()
|
||||
{
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: multiple scalar ZON values are not supported"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if object.is_empty() {
|
||||
scalar.ok_or_else(|| CliError::usage("ZON input is empty"))
|
||||
} else {
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_object(object: &Map<String, Value>, output: &mut String) -> Result<(), CliError> {
|
||||
for (key, value) in object {
|
||||
output.push_str(key);
|
||||
output.push(':');
|
||||
output.push_str(&render_inline_value(value)?);
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_inline_value(value: &Value) -> Result<String, CliError> {
|
||||
serde_json::to_string(value)
|
||||
.map_err(|error| CliError::runtime(format!("failed to render ZON inline value: {error}")))
|
||||
}
|
||||
|
||||
fn parse_inline_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
|
||||
if raw.is_empty() {
|
||||
return Ok(Value::String(String::new()));
|
||||
}
|
||||
if let Some(value) = parse_keyword_value(raw) {
|
||||
return Ok(value);
|
||||
}
|
||||
if raw.starts_with(['"', '[', '{']) || looks_like_number(raw) {
|
||||
return serde_json::from_str::<Value>(raw).map_err(|error| {
|
||||
CliError::usage(format!(
|
||||
"line {line_number}: invalid inline JSON value: {error}"
|
||||
))
|
||||
});
|
||||
}
|
||||
Ok(Value::String(raw.to_string()))
|
||||
}
|
||||
|
||||
fn parse_keyword_value(raw: &str) -> Option<Value> {
|
||||
match raw {
|
||||
"true" => Some(Value::Bool(true)),
|
||||
"false" => Some(Value::Bool(false)),
|
||||
"null" => Some(Value::Null),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_number(value: &str) -> bool {
|
||||
value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|byte| matches!(byte, b'-' | b'0'..=b'9'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encodes_and_decodes_object_and_scalar_values() {
|
||||
let object = json!({
|
||||
"active": true,
|
||||
"count": 3,
|
||||
"name": "mercury",
|
||||
"notes": "",
|
||||
"tags": ["fast", "portable"],
|
||||
});
|
||||
|
||||
let encoded = encode_value(&object).expect("encoded object");
|
||||
let decoded = decode_str(&encoded).expect("decoded object");
|
||||
|
||||
assert_eq!(decoded, object);
|
||||
assert_eq!(decode_str("null\n").expect("null scalar"), Value::Null);
|
||||
assert_eq!(
|
||||
decode_str("plain-text\n").expect("bare string"),
|
||||
json!("plain-text")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_malformed_zon_inputs() {
|
||||
let empty_key = decode_str(":true\n").expect_err("empty key");
|
||||
assert!(matches!(
|
||||
empty_key,
|
||||
CliError::Usage(message) if message.contains("key cannot be empty")
|
||||
));
|
||||
|
||||
let multiple_scalars = decode_str("1\n2\n").expect_err("multiple scalars");
|
||||
assert!(matches!(
|
||||
multiple_scalars,
|
||||
CliError::Usage(message) if message.contains("multiple scalar ZON values")
|
||||
));
|
||||
|
||||
let invalid_inline = decode_str("value:[1,,2]\n").expect_err("invalid inline JSON");
|
||||
assert!(matches!(
|
||||
invalid_inline,
|
||||
CliError::Usage(message) if message.contains("invalid inline JSON value")
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user