chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
pub mod ison;
pub mod tonl;
pub mod toon;
pub mod zon;
+365
View File
@@ -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",
}
}
}
+144
View File
@@ -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
+168
View File
@@ -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