use base64::Engine;
use chrono::{Duration, NaiveDate, NaiveTime};
use indexmap::IndexMap;
use nom::{
IResult, Parser,
branch::alt,
bytes::complete::{escaped_transform, is_not, tag, take_till1, take_until, take_while1},
character::complete::{alphanumeric1, char, multispace0, one_of},
combinator::{opt, recognize, value},
error::ParseError,
multi::{many0, separated_list0},
sequence::delimited,
};
use regex::Regex;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
String(String),
Integer(i64),
Float(f64),
Decimal(Decimal),
Boolean(bool),
Array(Vec<Value>),
Object(IndexMap<String, Value>),
Null,
Date(NaiveDate),
Time(NaiveTime),
Duration(Duration),
Binary(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ZokoFile {
pub entries: IndexMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SchemaType {
String,
Integer,
Float,
Decimal,
Boolean,
Null,
Array {
items: Box<SchemaType>,
},
Object {
properties: IndexMap<String, SchemaType>,
},
Date,
Time,
Duration,
Binary,
Any,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Schema {
pub properties: IndexMap<String, SchemaType>,
pub required: Vec<String>,
}
#[derive(Debug, Error)]
pub enum ValidationError {
#[error("Missing required field: {field}")]
MissingRequiredField { field: String },
#[error("Type mismatch for field '{field}': expected {expected}, found {found}")]
TypeMismatch {
field: String,
expected: String,
found: String,
},
#[error("Invalid value for field '{field}': {message}")]
InvalidValue { field: String, message: String },
}
pub fn validate_schema(zoko: &ZokoFile, schema: &Schema) -> Result<(), ValidationError> {
for field in &schema.required {
if !zoko.entries.contains_key(field) {
return Err(ValidationError::MissingRequiredField {
field: field.clone(),
});
}
}
for (field, value) in &zoko.entries {
if let Some(expected_type) = schema.properties.get(field) {
validate_value_type(field, value, expected_type)?;
}
}
Ok(())
}
fn validate_value_type(
field: &str,
value: &Value,
expected_type: &SchemaType,
) -> Result<(), ValidationError> {
match (value, expected_type) {
(Value::String(_), SchemaType::String) => Ok(()),
(Value::Integer(_), SchemaType::Integer) => Ok(()),
(Value::Float(_), SchemaType::Float) => Ok(()),
(Value::Decimal(_), SchemaType::Decimal) => Ok(()),
(Value::Boolean(_), SchemaType::Boolean) => Ok(()),
(Value::Null, SchemaType::Null) => Ok(()),
(Value::Array(arr), SchemaType::Array { items }) => {
for (i, item) in arr.iter().enumerate() {
validate_value_type(&format!("{}[{}]", field, i), item, items)?;
}
Ok(())
}
(Value::Object(obj), SchemaType::Object { properties }) => {
for (prop, val) in obj {
if let Some(prop_type) = properties.get(prop) {
validate_value_type(&format!("{}.{}", field, prop), val, prop_type)?;
}
}
Ok(())
}
(Value::Date(_), SchemaType::Date) => Ok(()),
(Value::Time(_), SchemaType::Time) => Ok(()),
(Value::Duration(_), SchemaType::Duration) => Ok(()),
(Value::Binary(_), SchemaType::Binary) => Ok(()),
(_, SchemaType::Any) => Ok(()),
(value, expected) => Err(ValidationError::TypeMismatch {
field: field.to_string(),
expected: format!("{:?}", expected),
found: format!("{:?}", value),
}),
}
}
#[derive(Debug, Error)]
pub enum ParseErrorKind {
#[error("Unexpected character: {0}")]
UnexpectedChar(char),
#[error("Unexpected end of input")]
UnexpectedEof,
#[error("Invalid number format: {0}")]
InvalidNumber(String),
#[error("Invalid escape sequence: {0}")]
InvalidEscape(String),
#[error("Expected {expected}, found {found}")]
Expected { expected: String, found: String },
#[error("Invalid date format: {0}")]
InvalidDate(String),
#[error("Invalid time format: {0}")]
InvalidTime(String),
#[error("Invalid duration format: {0}")]
InvalidDuration(String),
#[error("Invalid decimal format: {0}")]
InvalidDecimal(String),
#[error("Invalid binary data: {0}")]
InvalidBinary(String),
#[error("Environment variable not found: {0}")]
EnvVarNotFound(String),
#[error("Failed to read include file: {0}")]
IncludeFileError(String),
#[error("Circular include detected: {0}")]
CircularInclude(String),
}
impl<I> ParseError<I> for ParseErrorKind {
fn from_error_kind(_input: I, kind: nom::error::ErrorKind) -> Self {
ParseErrorKind::Expected {
expected: format!("{:?}", kind),
found: "unknown".to_string(),
}
}
fn append(_input: I, _kind: nom::error::ErrorKind, other: Self) -> Self {
other
}
}
pub type ParseResult<'a, T> = IResult<&'a str, T, ParseErrorKind>;
fn ws<'a, F, O, E: ParseError<&'a str>>(inner: F) -> impl Parser<&'a str, Output = O, Error = E>
where
F: Parser<&'a str, Output = O, Error = E>,
{
delimited(multispace0, inner, multispace0)
}
fn parse_identifier(input: &str) -> ParseResult<'_, String> {
let (input, ident) = recognize((
alt((alphanumeric1, tag("_"), tag("-"), tag("@"), tag("/"))),
many0(alt((
alphanumeric1,
tag("_"),
tag("-"),
tag("@"),
tag("/"),
tag("."),
))),
))
.parse(input)?;
Ok((input, ident.to_string()))
}
fn parse_string_single_quoted(input: &str) -> ParseResult<'_, String> {
let (input, _) = char('\'')(input)?;
let (input, content) = take_until("'")(input)?;
let (input, _) = char('\'')(input)?;
Ok((input, content.to_string()))
}
fn parse_string_double_quoted(input: &str) -> ParseResult<'_, String> {
let (input, _) = char('"')(input)?;
let (input, content) = escaped_transform(
is_not("\"\\"),
'\\',
alt((
value("\n", char('n')),
value("\r", char('r')),
value("\t", char('t')),
value("\\", char('\\')),
value("\"", char('"')),
value("'", char('\'')),
)),
)(input)?;
let (input, _) = char('"')(input)?;
Ok((input, content))
}
fn parse_string_backtick(input: &str) -> ParseResult<'_, String> {
let (input, _) = char('`')(input)?;
let (input, content) = take_until("`")(input)?;
let (input, _) = char('`')(input)?;
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
Ok((input, String::new()))
} else {
let min_whitespace = lines
.iter()
.filter(|line| !line.is_empty())
.map(|line| line.len() - line.trim_start().len())
.min()
.unwrap_or(0);
let stripped: Vec<String> = lines
.iter()
.map(|line| {
if line.is_empty() {
String::new()
} else {
line[min_whitespace..].to_string()
}
})
.collect();
let content = stripped.join("\n");
let substituted = substitute_env_vars(&content).map_err(nom::Err::Error)?;
Ok((input, substituted))
}
}
fn parse_string(input: &str) -> ParseResult<'_, String> {
alt((
parse_string_double_quoted,
parse_string_single_quoted,
parse_string_backtick,
))
.parse(input)
}
fn parse_integer(input: &str) -> ParseResult<'_, Value> {
let (input, num_str) =
recognize((opt(char('-')), take_while1(|c: char| c.is_ascii_digit()))).parse(input)?;
let num = num_str
.parse::<i64>()
.map_err(|_| nom::Err::Error(ParseErrorKind::InvalidNumber(num_str.to_string())))?;
Ok((input, Value::Integer(num)))
}
fn parse_float(input: &str) -> ParseResult<'_, Value> {
let (input, num_str) = recognize((
opt(char('-')),
take_while1(|c: char| c.is_ascii_digit()),
opt((char('.'), take_while1(|c: char| c.is_ascii_digit()))),
opt((
one_of("eE"),
opt(one_of("+-")),
take_while1(|c: char| c.is_ascii_digit()),
)),
))
.parse(input)?;
if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
return Err(nom::Err::Error(ParseErrorKind::InvalidNumber(
num_str.to_string(),
)));
}
let num = num_str
.parse::<f64>()
.map_err(|_| nom::Err::Error(ParseErrorKind::InvalidNumber(num_str.to_string())))?;
Ok((input, Value::Float(num)))
}
fn parse_boolean(input: &str) -> ParseResult<'_, Value> {
alt((
value(Value::Boolean(true), tag("true")),
value(Value::Boolean(false), tag("false")),
))
.parse(input)
}
fn parse_null(input: &str) -> ParseResult<'_, Value> {
value(Value::Null, tag("null")).parse(input)
}
fn parse_array(input: &str) -> ParseResult<'_, Value> {
let (input, values) = delimited(
ws(char('[')),
(
separated_list0(ws(char(',')), ws(parse_value)),
opt(ws(char(','))),
),
ws(char(']')),
)
.parse(input)?;
Ok((input, Value::Array(values.0)))
}
fn parse_object_entry(input: &str) -> ParseResult<'_, (String, Value)> {
let (input, key) = ws(parse_identifier).parse(input)?;
let (input, _) = ws(char(':')).parse(input)?;
let (input, value) = ws(parse_value).parse(input)?;
Ok((input, (key, value)))
}
fn parse_object(input: &str) -> ParseResult<'_, Value> {
let (input, entries) = delimited(
ws(char('{')),
(
separated_list0(ws(char(',')), parse_object_entry),
opt(ws(char(','))),
),
ws(char('}')),
)
.parse(input)?;
let mut map = IndexMap::new();
for (k, v) in entries.0 {
map.insert(k, v);
}
Ok((input, Value::Object(map)))
}
fn parse_value(input: &str) -> ParseResult<'_, Value> {
ws(alt((
parse_function_call,
parse_string.map(Value::String),
parse_float,
parse_integer,
parse_boolean,
parse_null,
parse_array,
parse_object,
)))
.parse(input)
}
fn parse_function_call(input: &str) -> ParseResult<'_, Value> {
alt((
parse_date_call,
parse_time_call,
parse_duration_call,
parse_decimal_call,
parse_binary_call,
))
.parse(input)
}
fn parse_date_call(input: &str) -> ParseResult<'_, Value> {
let (input, _) = tag("date")(input)?;
let (input, _) = ws(char('(')).parse(input)?;
let (input, date_str) = ws(parse_string).parse(input)?;
let (input, _) = ws(char(')')).parse(input)?;
let date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
.map_err(|_| nom::Err::Error(ParseErrorKind::InvalidDate(date_str.to_string())))?;
Ok((input, Value::Date(date)))
}
fn parse_time_call(input: &str) -> ParseResult<'_, Value> {
let (input, _) = tag("time")(input)?;
let (input, _) = ws(char('(')).parse(input)?;
let (input, time_str) = ws(parse_string).parse(input)?;
let (input, _) = ws(char(')')).parse(input)?;
let time = NaiveTime::parse_from_str(&time_str, "%H:%M:%S")
.map_err(|_| nom::Err::Error(ParseErrorKind::InvalidTime(time_str.to_string())))?;
Ok((input, Value::Time(time)))
}
fn parse_duration_call(input: &str) -> ParseResult<'_, Value> {
let (input, _) = tag("duration")(input)?;
let (input, _) = ws(char('(')).parse(input)?;
let (input, duration_str) = ws(parse_string).parse(input)?;
let (input, _) = ws(char(')')).parse(input)?;
let duration =
if duration_str.ends_with('h') {
let hours: f64 = duration_str.trim_end_matches('h').parse().map_err(|_| {
nom::Err::Error(ParseErrorKind::InvalidDuration(duration_str.clone()))
})?;
Duration::hours(hours as i64)
} else if duration_str.ends_with('m') {
let minutes: f64 = duration_str.trim_end_matches('m').parse().map_err(|_| {
nom::Err::Error(ParseErrorKind::InvalidDuration(duration_str.clone()))
})?;
Duration::minutes(minutes as i64)
} else if duration_str.ends_with('s') {
let seconds: f64 = duration_str.trim_end_matches('s').parse().map_err(|_| {
nom::Err::Error(ParseErrorKind::InvalidDuration(duration_str.clone()))
})?;
Duration::seconds(seconds as i64)
} else {
return Err(nom::Err::Error(ParseErrorKind::InvalidDuration(
duration_str,
)));
};
Ok((input, Value::Duration(duration)))
}
fn parse_decimal_call(input: &str) -> ParseResult<'_, Value> {
let (input, _) = tag("decimal")(input)?;
let (input, _) = ws(char('(')).parse(input)?;
let (input, decimal_str) = ws(parse_string).parse(input)?;
let (input, _) = ws(char(')')).parse(input)?;
let decimal = decimal_str
.parse::<Decimal>()
.map_err(|_| nom::Err::Error(ParseErrorKind::InvalidDecimal(decimal_str)))?;
Ok((input, Value::Decimal(decimal)))
}
fn parse_binary_call(input: &str) -> ParseResult<'_, Value> {
let (input, _) = tag("binary")(input)?;
let (input, _) = ws(char('(')).parse(input)?;
let (input, binary_str) = ws(parse_string).parse(input)?;
let (input, _) = ws(char(')')).parse(input)?;
let binary = base64::engine::general_purpose::STANDARD
.decode(&binary_str)
.map_err(|_| nom::Err::Error(ParseErrorKind::InvalidBinary(binary_str)))?;
Ok((input, Value::Binary(binary)))
}
fn substitute_env_vars(value: &str) -> Result<String, ParseErrorKind> {
let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
let mut result = value.to_string();
for cap in re.captures_iter(value) {
let var_name = &cap[1];
match std::env::var(var_name) {
Ok(val) => {
result = result.replace(&cap[0], &val);
}
Err(_) => {
return Err(ParseErrorKind::EnvVarNotFound(var_name.to_string()));
}
}
}
Ok(result)
}
fn parse_zoko_with_context(
input: &str,
base_path: &Path,
included_files: &mut HashSet<PathBuf>,
) -> Result<ZokoFile, ParseErrorKind> {
let (remaining, _) = parse_comments
.parse(input)
.map_err(|e| ParseErrorKind::Expected {
expected: "valid zoko input".to_string(),
found: format!("{:?}", e),
})?;
let (remaining, entries) =
many0(|input| parse_entry_or_include_flat(input, base_path, included_files))
.parse(remaining)
.map_err(|e| ParseErrorKind::Expected {
expected: "valid zoko entries".to_string(),
found: format!("{:?}", e),
})?;
let (_, _) = parse_comments
.parse(remaining)
.map_err(|e| ParseErrorKind::Expected {
expected: "end of input".to_string(),
found: format!("{:?}", e),
})?;
let mut map = IndexMap::new();
for entry_vec in entries {
for (k, v) in entry_vec {
map.insert(k, v);
}
}
Ok(ZokoFile { entries: map })
}
fn parse_entry_or_include_flat<'a>(
input: &'a str,
base_path: &Path,
included_files: &mut HashSet<PathBuf>,
) -> ParseResult<'a, Vec<(String, Value)>> {
let (input, _) = parse_comments(input)?;
if let Ok((input_after_tag, _)) = tag::<&str, &str, ParseErrorKind>("@include")(input) {
let (input, _) = ws(char('(')).parse(input_after_tag)?;
let (input, file_path) = ws(parse_string).parse(input)?;
let (input, _) = ws(char(')')).parse(input)?;
let (input, _) = opt(ws(char(','))).parse(input)?;
let full_path = if Path::new(&file_path).is_absolute() {
PathBuf::from(file_path)
} else {
base_path.join(&file_path)
};
if included_files.contains(&full_path) {
return Err(nom::Err::Error(ParseErrorKind::CircularInclude(
full_path.display().to_string(),
)));
}
included_files.insert(full_path.clone());
let content = std::fs::read_to_string(&full_path).map_err(|_| {
nom::Err::Error(ParseErrorKind::IncludeFileError(
full_path.display().to_string(),
))
})?;
let zoko_file = parse_zoko_with_context(
&content,
full_path.parent().unwrap_or(base_path),
included_files,
)
.map_err(|e| nom::Err::Error(ParseErrorKind::IncludeFileError(e.to_string())))?;
let entries: Vec<(String, Value)> = zoko_file.entries.into_iter().collect();
return Ok((input, entries));
}
let (input, key) = ws(parse_identifier).parse(input)?;
let (input, _) = ws(char(':')).parse(input)?;
let (input, value) = parse_value_with_context(input, base_path, included_files)?;
let (input, _) = parse_comments(input)?;
let (input, _) = opt(ws(char(','))).parse(input)?;
Ok((input, vec![(key, value)]))
}
fn parse_value_with_context<'a>(
input: &'a str,
_base_path: &Path,
_included_files: &mut HashSet<PathBuf>,
) -> ParseResult<'a, Value> {
parse_value(input)
}
fn parse_comment(input: &str) -> ParseResult<'_, ()> {
alt((
value((), (tag("//"), take_till1(|c| c == '\n'), multispace0)),
value((), (tag("/*"), take_until("*/"), tag("*/"), multispace0)),
))
.parse(input)
}
fn parse_comments(input: &str) -> ParseResult<'_, ()> {
let (input, _) = multispace0(input)?;
many0(parse_comment).parse(input).map(|(i, _)| (i, ()))
}
fn parse_entry(input: &str) -> ParseResult<'_, (String, Value)> {
let (input, _) = parse_comments(input)?;
let (input, key) = ws(parse_identifier).parse(input)?;
let (input, _) = ws(char(':')).parse(input)?;
let (input, value) = ws(parse_value).parse(input)?;
let (input, _) = parse_comments(input)?;
let (input, _) = opt(ws(char(','))).parse(input)?;
Ok((input, (key, value)))
}
pub fn parse_zoko(input: &str) -> Result<ZokoFile, ParseErrorKind> {
let (remaining, _) = parse_comments
.parse(input)
.map_err(|e| ParseErrorKind::Expected {
expected: "valid zoko input".to_string(),
found: format!("{:?}", e),
})?;
let (remaining, entries) =
many0(parse_entry)
.parse(remaining)
.map_err(|e| ParseErrorKind::Expected {
expected: "valid zoko entries".to_string(),
found: format!("{:?}", e),
})?;
let (_, _) = parse_comments
.parse(remaining)
.map_err(|e| ParseErrorKind::Expected {
expected: "end of input".to_string(),
found: format!("{:?}", e),
})?;
let mut map = IndexMap::new();
for (k, v) in entries {
map.insert(k, v);
}
Ok(ZokoFile { entries: map })
}
pub fn parse_zoko_with_includes(input: &str, base_path: &Path) -> Result<ZokoFile, ParseErrorKind> {
let mut included_files = HashSet::new();
parse_zoko_with_context(input, base_path, &mut included_files)
}
pub fn parse_zoko_to_json(input: &str) -> Result<String, ParseErrorKind> {
let zoko = parse_zoko(input)?;
serde_json::to_string_pretty(&zoko).map_err(|e| ParseErrorKind::Expected {
expected: "valid JSON serialization".to_string(),
found: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_object() {
let input = r#"name: "value""#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("name"),
Some(&Value::String("value".to_string()))
);
}
#[test]
fn test_parse_map() {
let input = r#"
map: {
id: "value",
id2: "value2",
}
"#;
let result = parse_zoko(input).unwrap();
let map = result.entries.get("map").unwrap();
if let Value::Object(obj) = map {
assert_eq!(obj.get("id"), Some(&Value::String("value".to_string())));
assert_eq!(obj.get("id2"), Some(&Value::String("value2".to_string())));
} else {
panic!("Expected object");
}
}
#[test]
fn test_parse_array() {
let input = r#"tags: ["Hello", "Zoil"]"#;
let result = parse_zoko(input).unwrap();
let arr = result.entries.get("tags").unwrap();
if let Value::Array(vec) = arr {
assert_eq!(vec.len(), 2);
assert_eq!(vec[0], Value::String("Hello".to_string()));
assert_eq!(vec[1], Value::String("Zoil".to_string()));
} else {
panic!("Expected array");
}
}
#[test]
fn test_parse_comments() {
let input = r#"
// Single line comment
name: "value"
/* Multi line
Comment */
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("name"),
Some(&Value::String("value".to_string()))
);
}
#[test]
fn test_parse_complex() {
let input = r#"
name: "@Main/Hello",
channel: "main",
branch: "Production",
status: "Release",
version: 1.0.0,
description: "Hello package for Zoil",
tags: [
"Hello",
"Zoil",
],
website: "https://hello.nel.co",
dependencies: [
"Hola": 1.0.2,
"@German/Hallo": {
channel: "main",
version: "latest",
},
],
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("name"),
Some(&Value::String("@Main/Hello".to_string()))
);
assert_eq!(
result.entries.get("channel"),
Some(&Value::String("main".to_string()))
);
assert_eq!(result.entries.get("version"), Some(&Value::Float(1.0)));
}
#[test]
fn test_parse_number_formats() {
let input = r#"
int: 42,
float: 3.14,
negative: -10,
scientific: 1.5e10,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.get("int"), Some(&Value::Integer(42)));
assert_eq!(result.entries.get("float"), Some(&Value::Float(3.14)));
assert_eq!(result.entries.get("negative"), Some(&Value::Integer(-10)));
assert_eq!(
result.entries.get("scientific"),
Some(&Value::Float(1.5e10))
);
}
#[test]
fn test_parse_boolean() {
let input = r#"
yes: true,
no: false,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.get("yes"), Some(&Value::Boolean(true)));
assert_eq!(result.entries.get("no"), Some(&Value::Boolean(false)));
}
#[test]
fn test_parse_null() {
let input = r#"value: null"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.get("value"), Some(&Value::Null));
}
#[test]
fn test_parse_different_string_types() {
let input = r#"
double: "hello",
single: 'world',
backtick: `multiline
string`,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("double"),
Some(&Value::String("hello".to_string()))
);
assert_eq!(
result.entries.get("single"),
Some(&Value::String("world".to_string()))
);
assert_eq!(
result.entries.get("backtick"),
Some(&Value::String("multiline\nstring".to_string()))
);
}
#[test]
fn test_trailing_comma() {
let input = r#"
a: 1,
b: 2,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.len(), 2);
}
#[test]
fn test_to_json() {
let input = r#"name: "test", value: 42"#;
let json = parse_zoko_to_json(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["entries"]["name"], "test");
assert_eq!(parsed["entries"]["value"], 42);
}
#[test]
fn test_array_with_objects() {
let input = r#"dependencies: [{name: "hola", version: "1.1.0"}, {name: "@german/hallo", version: "latest"}]"#;
let result = parse_zoko(input).unwrap();
let deps = result.entries.get("dependencies").unwrap();
if let Value::Array(vec) = deps {
assert_eq!(vec.len(), 2);
if let Value::Object(obj) = &vec[0] {
assert_eq!(obj.get("name"), Some(&Value::String("hola".to_string())));
assert_eq!(
obj.get("version"),
Some(&Value::String("1.1.0".to_string()))
);
} else {
panic!("Expected object for first dependency");
}
if let Value::Object(obj) = &vec[1] {
assert_eq!(
obj.get("name"),
Some(&Value::String("@german/hallo".to_string()))
);
assert_eq!(
obj.get("version"),
Some(&Value::String("latest".to_string()))
);
} else {
panic!("Expected object for second dependency");
}
} else {
panic!("Expected array");
}
}
#[test]
fn test_json_compatibility() {
let zoko_input = r#"dependencies: [{name: "hola", version: "1.1.0"}, {name: "@german/hallo", version: "latest"}]"#;
let json_output = parse_zoko_to_json(zoko_input).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json_output).unwrap();
assert!(json_value["entries"]["dependencies"].is_array());
assert_eq!(
json_value["entries"]["dependencies"]
.as_array()
.unwrap()
.len(),
2
);
}
#[test]
fn test_entry_order_preservation() {
let input = r#"first: "value1", second: "value2", third: "value3""#;
let result = parse_zoko(input).unwrap();
let keys: Vec<&String> = result.entries.keys().collect();
assert_eq!(keys, vec!["first", "second", "third"]);
}
#[test]
fn test_complex_file_parsing() {
let input = r#"name: "@Main/Hello",
channel: "main",
branch: "Production",
status: "Release",
version: "1.0.0",
description: "Hello package for Zoil",
tags: ["Hello", "Zoil"],
website: "https://hello.nel.co",
dependencies: [
{name: "Hola", version: "1.0.2"},
{name: "@German/Hallo", channel: "main", version: "latest"},
],
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.len(), 9);
assert!(result.entries.contains_key("name"));
assert!(result.entries.contains_key("channel"));
assert!(result.entries.contains_key("branch"));
assert!(result.entries.contains_key("status"));
assert!(result.entries.contains_key("version"));
assert!(result.entries.contains_key("description"));
assert!(result.entries.contains_key("tags"));
assert!(result.entries.contains_key("website"));
assert!(result.entries.contains_key("dependencies"));
}
#[test]
fn test_parse_date() {
let input = r#"release_date: date("2024-06-21")"#;
let result = parse_zoko(input).unwrap();
assert!(result.entries.get("release_date").is_some());
}
#[test]
fn test_parse_time() {
let input = r#"meeting_time: time("14:30:00")"#;
let result = parse_zoko(input).unwrap();
assert!(result.entries.get("meeting_time").is_some());
}
#[test]
fn test_parse_duration() {
let input = r#"timeout: duration("30s")"#;
let result = parse_zoko(input).unwrap();
assert!(result.entries.get("timeout").is_some());
}
#[test]
fn test_parse_decimal() {
let input = r#"price: decimal("19.99")"#;
let result = parse_zoko(input).unwrap();
assert!(result.entries.get("price").is_some());
}
#[test]
fn test_parse_binary() {
let input = r#"data: binary("SGVsbG8gV29ybGQ=")"#;
let result = parse_zoko(input).unwrap();
assert!(result.entries.get("data").is_some());
}
#[test]
fn test_parse_include() {
use std::fs;
let temp_dir = std::env::temp_dir();
let include_file = temp_dir.join("test_include.zo");
fs::write(&include_file, r#"included_key: "included_value""#).unwrap();
let base_path = temp_dir.as_path();
let input = r#"@include("test_include.zo")"#;
let result = parse_zoko_with_includes(input, base_path).unwrap();
assert!(result.entries.contains_key("included_key"));
fs::remove_file(&include_file).unwrap();
}
#[test]
fn test_env_var_substitution() {
unsafe {
std::env::set_var("TEST_VAR", "hello");
}
let input = r#"message: `${TEST_VAR}`"#;
let result = parse_zoko(input).unwrap();
if let Some(Value::String(msg)) = result.entries.get("message") {
assert_eq!(msg, "hello");
} else {
panic!("Expected string value");
}
unsafe {
std::env::remove_var("TEST_VAR");
}
}
#[test]
fn test_schema_validation() {
let input = r#"name: "test", age: 25, active: true"#;
let zoko = parse_zoko(input).unwrap();
let mut properties = IndexMap::new();
properties.insert("name".to_string(), SchemaType::String);
properties.insert("age".to_string(), SchemaType::Integer);
properties.insert("active".to_string(), SchemaType::Boolean);
let schema = Schema {
properties,
required: vec!["name".to_string(), "age".to_string()],
};
let result = validate_schema(&zoko, &schema);
assert!(result.is_ok());
}
#[test]
fn test_schema_validation_type_mismatch() {
let input = r#"name: "test", age: "25", active: true"#;
let zoko = parse_zoko(input).unwrap();
let mut properties = IndexMap::new();
properties.insert("name".to_string(), SchemaType::String);
properties.insert("age".to_string(), SchemaType::Integer);
properties.insert("active".to_string(), SchemaType::Boolean);
let schema = Schema {
properties,
required: vec!["name".to_string(), "age".to_string()],
};
let result = validate_schema(&zoko, &schema);
assert!(result.is_err());
}
#[test]
fn test_schema_validation_missing_required() {
let input = r#"name: "test", active: true"#;
let zoko = parse_zoko(input).unwrap();
let mut properties = IndexMap::new();
properties.insert("name".to_string(), SchemaType::String);
properties.insert("age".to_string(), SchemaType::Integer);
properties.insert("active".to_string(), SchemaType::Boolean);
let schema = Schema {
properties,
required: vec!["name".to_string(), "age".to_string()],
};
let result = validate_schema(&zoko, &schema);
assert!(result.is_err());
}
}