pub mod folding;
pub mod primitives;
pub mod writer;
use indexmap::IndexMap;
use crate::{
constants::MAX_DEPTH,
types::{
EncodeOptions,
IntoJsonValue,
JsonValue as Value,
KeyFoldingMode,
ToonError,
ToonResult,
},
utils::{
format_canonical_number,
normalize,
validation::validate_depth,
QuotingContext,
},
};
pub fn encode<T: serde::Serialize>(value: &T, options: &EncodeOptions) -> ToonResult<String> {
let json_value =
serde_json::to_value(value).map_err(|e| ToonError::SerializationError(e.to_string()))?;
let json_value: Value = json_value.into();
encode_impl(&json_value, options)
}
fn encode_impl(value: &Value, options: &EncodeOptions) -> ToonResult<String> {
let normalized: Value = normalize(value.clone());
let mut writer = writer::Writer::new(options.clone());
match &normalized {
Value::Array(arr) => {
write_array(&mut writer, None, arr, 0)?;
}
Value::Object(obj) => {
write_object(&mut writer, obj, 0)?;
}
_ => {
write_primitive_value(&mut writer, &normalized, QuotingContext::ObjectValue)?;
}
}
Ok(writer.finish())
}
pub fn encode_default<T: serde::Serialize>(value: &T) -> ToonResult<String> {
encode(value, &EncodeOptions::default())
}
pub fn encode_object<V: IntoJsonValue>(value: V, options: &EncodeOptions) -> ToonResult<String> {
let json_value = value.into_json_value();
if !json_value.is_object() {
return Err(ToonError::TypeMismatch {
expected: "object".to_string(),
found: value_type_name(&json_value).to_string(),
});
}
encode_impl(&json_value, options)
}
pub fn encode_array<V: IntoJsonValue>(value: V, options: &EncodeOptions) -> ToonResult<String> {
let json_value = value.into_json_value();
if !json_value.is_array() {
return Err(ToonError::TypeMismatch {
expected: "array".to_string(),
found: value_type_name(&json_value).to_string(),
});
}
encode_impl(&json_value, options)
}
fn value_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn write_object(
writer: &mut writer::Writer,
obj: &IndexMap<String, Value>,
depth: usize,
) -> ToonResult<()> {
write_object_impl(writer, obj, depth, false)
}
fn write_object_impl(
writer: &mut writer::Writer,
obj: &IndexMap<String, Value>,
depth: usize,
disable_folding: bool,
) -> ToonResult<()> {
validate_depth(depth, MAX_DEPTH)?;
let keys: Vec<&String> = obj.keys().collect();
for (i, key) in keys.iter().enumerate() {
if i > 0 {
writer.write_newline()?;
}
let value = &obj[*key];
let has_conflicting_sibling = keys
.iter()
.any(|k| k.starts_with(&format!("{key}.")) || (k.contains('.') && k == key));
let folded = if !disable_folding
&& writer.options.key_folding == KeyFoldingMode::Safe
&& !has_conflicting_sibling
{
folding::analyze_foldable_chain(key, value, writer.options.flatten_depth, &keys)
} else {
None
};
if let Some(chain) = folded {
if depth > 0 {
writer.write_indent(depth)?;
}
match &chain.leaf_value {
Value::Array(arr) => {
write_array(writer, Some(&chain.folded_key), arr, 0)?;
}
Value::Object(nested_obj) => {
writer.write_key(&chain.folded_key)?;
writer.write_char(':')?;
if !nested_obj.is_empty() {
writer.write_newline()?;
write_object_impl(writer, nested_obj, depth + 1, true)?;
}
}
_ => {
writer.write_key(&chain.folded_key)?;
writer.write_char(':')?;
writer.write_char(' ')?;
write_primitive_value(writer, &chain.leaf_value, QuotingContext::ObjectValue)?;
}
}
} else {
match value {
Value::Array(arr) => {
write_array(writer, Some(key), arr, depth)?;
}
Value::Object(nested_obj) => {
if depth > 0 {
writer.write_indent(depth)?;
}
writer.write_key(key)?;
writer.write_char(':')?;
if !nested_obj.is_empty() {
writer.write_newline()?;
let nested_disable_folding = disable_folding || has_conflicting_sibling;
write_object_impl(writer, nested_obj, depth + 1, nested_disable_folding)?;
}
}
_ => {
if depth > 0 {
writer.write_indent(depth)?;
}
writer.write_key(key)?;
writer.write_char(':')?;
writer.write_char(' ')?;
write_primitive_value(writer, value, QuotingContext::ObjectValue)?;
}
}
}
}
Ok(())
}
fn write_array(
writer: &mut writer::Writer,
key: Option<&str>,
arr: &[Value],
depth: usize,
) -> ToonResult<()> {
validate_depth(depth, MAX_DEPTH)?;
if arr.is_empty() {
writer.write_empty_array_with_key(key, depth)?;
return Ok(());
}
if let Some(keys) = is_tabular_array(arr) {
encode_tabular_array(writer, key, arr, &keys, depth)?;
} else if is_primitive_array(arr) {
encode_primitive_array(writer, key, arr, depth)?;
} else {
encode_nested_array(writer, key, arr, depth)?;
}
Ok(())
}
fn is_tabular_array(arr: &[Value]) -> Option<Vec<String>> {
if arr.is_empty() {
return None;
}
let first = arr.first()?;
if !first.is_object() {
return None;
}
let first_obj = first.as_object()?;
let keys: Vec<String> = first_obj.keys().cloned().collect();
for value in first_obj.values() {
if !is_primitive(value) {
return None;
}
}
for val in arr.iter().skip(1) {
if let Some(obj) = val.as_object() {
if obj.len() != keys.len() {
return None;
}
for key in &keys {
if !obj.contains_key(key) {
return None;
}
}
for value in obj.values() {
if !is_primitive(value) {
return None;
}
}
} else {
return None;
}
}
Some(keys)
}
fn is_primitive(value: &Value) -> bool {
matches!(
value,
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
)
}
fn is_primitive_array(arr: &[Value]) -> bool {
arr.iter().all(is_primitive)
}
fn encode_primitive_array(
writer: &mut writer::Writer,
key: Option<&str>,
arr: &[Value],
depth: usize,
) -> ToonResult<()> {
writer.write_array_header(key, arr.len(), None, depth)?;
writer.write_char(' ')?;
writer.push_active_delimiter(writer.options.delimiter);
for (i, val) in arr.iter().enumerate() {
if i > 0 {
writer.write_delimiter()?;
}
write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
}
writer.pop_active_delimiter();
Ok(())
}
fn write_primitive_value(
writer: &mut writer::Writer,
value: &Value,
context: QuotingContext,
) -> ToonResult<()> {
match value {
Value::Null => writer.write_str("null"),
Value::Bool(b) => writer.write_str(&b.to_string()),
Value::Number(n) => {
let num_str = format_canonical_number(n);
writer.write_str(&num_str)
}
Value::String(s) => {
if writer.needs_quoting(s, context) {
writer.write_quoted_string(s)
} else {
writer.write_str(s)
}
}
_ => Err(ToonError::InvalidInput(
"Expected primitive value".to_string(),
)),
}
}
fn encode_tabular_array(
writer: &mut writer::Writer,
key: Option<&str>,
arr: &[Value],
keys: &[String],
depth: usize,
) -> ToonResult<()> {
writer.write_array_header(key, arr.len(), Some(keys), depth)?;
writer.write_newline()?;
writer.push_active_delimiter(writer.options.delimiter);
for (row_index, obj_val) in arr.iter().enumerate() {
if let Some(obj) = obj_val.as_object() {
writer.write_indent(depth + 1)?;
for (i, key) in keys.iter().enumerate() {
if i > 0 {
writer.write_delimiter()?;
}
if let Some(val) = obj.get(key) {
write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
} else {
writer.write_str("null")?;
}
}
if row_index < arr.len() - 1 {
writer.write_newline()?;
}
}
}
Ok(())
}
fn encode_list_item_tabular_array(
writer: &mut writer::Writer,
arr: &[Value],
keys: &[String],
depth: usize,
) -> ToonResult<()> {
writer.write_char('[')?;
writer.write_str(&arr.len().to_string())?;
if writer.options.delimiter != crate::types::Delimiter::Comma {
writer.write_char(writer.options.delimiter.as_char())?;
}
writer.write_char(']')?;
writer.write_char('{')?;
for (i, field) in keys.iter().enumerate() {
if i > 0 {
writer.write_char(writer.options.delimiter.as_char())?;
}
writer.write_key(field)?;
}
writer.write_char('}')?;
writer.write_char(':')?;
writer.write_newline()?;
writer.push_active_delimiter(writer.options.delimiter);
for (row_index, obj_val) in arr.iter().enumerate() {
if let Some(obj) = obj_val.as_object() {
writer.write_indent(depth + 2)?;
for (i, key) in keys.iter().enumerate() {
if i > 0 {
writer.write_delimiter()?;
}
if let Some(val) = obj.get(key) {
write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
} else {
writer.write_str("null")?;
}
}
if row_index < arr.len() - 1 {
writer.write_newline()?;
}
}
}
writer.pop_active_delimiter();
Ok(())
}
fn encode_nested_array(
writer: &mut writer::Writer,
key: Option<&str>,
arr: &[Value],
depth: usize,
) -> ToonResult<()> {
writer.write_array_header(key, arr.len(), None, depth)?;
writer.write_newline()?;
writer.push_active_delimiter(writer.options.delimiter);
for (i, val) in arr.iter().enumerate() {
writer.write_indent(depth + 1)?;
writer.write_char('-')?;
match val {
Value::Array(inner_arr) => {
writer.write_char(' ')?;
write_array(writer, None, inner_arr, depth + 1)?;
}
Value::Object(obj) => {
let keys: Vec<&String> = obj.keys().collect();
if let Some(first_key) = keys.first() {
writer.write_char(' ')?;
let first_val = &obj[*first_key];
match first_val {
Value::Array(arr) => {
writer.write_key(first_key)?;
if let Some(keys) = is_tabular_array(arr) {
encode_list_item_tabular_array(writer, arr, &keys, depth + 1)?;
} else {
write_array(writer, None, arr, depth + 2)?;
}
}
Value::Object(nested_obj) => {
writer.write_key(first_key)?;
writer.write_char(':')?;
if !nested_obj.is_empty() {
writer.write_newline()?;
write_object(writer, nested_obj, depth + 3)?;
}
}
_ => {
writer.write_key(first_key)?;
writer.write_char(':')?;
writer.write_char(' ')?;
write_primitive_value(writer, first_val, QuotingContext::ObjectValue)?;
}
}
for key in keys.iter().skip(1) {
writer.write_newline()?;
writer.write_indent(depth + 2)?;
let value = &obj[*key];
match value {
Value::Array(arr) => {
writer.write_key(key)?;
write_array(writer, None, arr, depth + 2)?;
}
Value::Object(nested_obj) => {
writer.write_key(key)?;
writer.write_char(':')?;
if !nested_obj.is_empty() {
writer.write_newline()?;
write_object(writer, nested_obj, depth + 3)?;
}
}
_ => {
writer.write_key(key)?;
writer.write_char(':')?;
writer.write_char(' ')?;
write_primitive_value(writer, value, QuotingContext::ObjectValue)?;
}
}
}
}
}
_ => {
writer.write_char(' ')?;
write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
}
}
if i < arr.len() - 1 {
writer.write_newline()?;
}
}
writer.pop_active_delimiter();
Ok(())
}
#[cfg(test)]
mod tests {
use core::f64;
use serde_json::json;
use super::*;
#[test]
fn test_encode_null() {
let value = json!(null);
assert_eq!(encode_default(&value).unwrap(), "null");
}
#[test]
fn test_encode_bool() {
assert_eq!(encode_default(&json!(true)).unwrap(), "true");
assert_eq!(encode_default(&json!(false)).unwrap(), "false");
}
#[test]
fn test_encode_number() {
assert_eq!(encode_default(&json!(42)).unwrap(), "42");
assert_eq!(
encode_default(&json!(f64::consts::PI)).unwrap(),
"3.141592653589793"
);
assert_eq!(encode_default(&json!(-5)).unwrap(), "-5");
}
#[test]
fn test_encode_string() {
assert_eq!(encode_default(&json!("hello")).unwrap(), "hello");
assert_eq!(
encode_default(&json!("hello world")).unwrap(),
"hello world"
);
}
#[test]
fn test_encode_simple_object() {
let obj = json!({"name": "Alice", "age": 30});
let result = encode_default(&obj).unwrap();
assert!(result.contains("name: Alice"));
assert!(result.contains("age: 30"));
}
#[test]
fn test_encode_primitive_array() {
let obj = json!({"tags": ["reading", "gaming", "coding"]});
let result = encode_default(&obj).unwrap();
assert_eq!(result, "tags[3]: reading,gaming,coding");
}
#[test]
fn test_encode_tabular_array() {
let obj = json!({
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
]
});
let result = encode_default(&obj).unwrap();
assert!(result.contains("users[2]{id,name}:"));
assert!(result.contains("1,Alice"));
assert!(result.contains("2,Bob"));
}
#[test]
fn test_encode_empty_array() {
let obj = json!({"items": []});
let result = encode_default(&obj).unwrap();
assert_eq!(result, "items[0]:");
}
#[test]
fn test_encode_nested_object() {
let obj = json!({
"user": {
"name": "Alice",
"age": 30
}
});
let result = encode_default(&obj).unwrap();
assert!(result.contains("user:"));
assert!(result.contains("name: Alice"));
assert!(result.contains("age: 30"));
}
#[test]
fn test_encode_list_item_tabular_array_v3() {
let obj = json!({
"items": [
{
"users": [
{"id": 1, "name": "Ada"},
{"id": 2, "name": "Bob"}
],
"status": "active"
}
]
});
let result = encode_default(&obj).unwrap();
assert!(
result.contains(" - users[2]{id,name}:"),
"Header should be on hyphen line"
);
assert!(
result.contains(" 1,Ada"),
"First row should be at 6 spaces (depth +2 from hyphen). Got:\n{}",
result
);
assert!(
result.contains(" 2,Bob"),
"Second row should be at 6 spaces (depth +2 from hyphen). Got:\n{}",
result
);
assert!(
result.contains(" status: active"),
"Sibling field should be at 4 spaces (depth +1 from hyphen). Got:\n{}",
result
);
}
#[test]
fn test_encode_list_item_tabular_array_multiple_items() {
let obj = json!({
"data": [
{
"records": [
{"id": 1, "val": "x"}
],
"count": 1
},
{
"records": [
{"id": 2, "val": "y"}
],
"count": 1
}
]
});
let result = encode_default(&obj).unwrap();
let lines: Vec<&str> = result.lines().collect();
let row_lines: Vec<&str> = lines
.iter()
.filter(|line| line.trim().starts_with(char::is_numeric))
.copied()
.collect();
for row in row_lines {
let spaces = row.len() - row.trim_start().len();
assert_eq!(
spaces, 6,
"Tabular rows should be at 6 spaces. Found {} spaces in: {}",
spaces, row
);
}
}
#[test]
fn test_encode_list_item_non_tabular_array_unchanged() {
let obj = json!({
"items": [
{
"tags": ["a", "b", "c"],
"name": "test"
}
]
});
let result = encode_default(&obj).unwrap();
assert!(
result.contains(" - tags[3]: a,b,c"),
"Inline array should be on hyphen line. Got:\n{}",
result
);
assert!(
result.contains(" name: test"),
"Sibling field should be at 4 spaces. Got:\n{}",
result
);
}
#[test]
fn test_encode_list_item_tabular_array_with_nested_fields() {
let obj = json!({
"entries": [
{
"people": [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
],
"total": 2,
"category": "staff"
}
]
});
let result = encode_default(&obj).unwrap();
assert!(result.contains(" - people[2]{name,age}:"));
assert!(result.contains(" Alice,30"));
assert!(result.contains(" Bob,25"));
assert!(result.contains(" total: 2"));
assert!(result.contains(" category: staff"));
}
}