///|
struct CliResult {
exit_code : Int
output : String
}
///|
fn usage() -> String {
"moon-avro - schema, Avro binary and OCF inspector\n\nUsage:\n moon run cmd/moon_avro -- help\n moon run cmd/moon_avro -- schema inspect ''\n moon run cmd/moon_avro -- encode '' ''\n moon run cmd/moon_avro -- decode '' ''\n moon run cmd/moon_avro -- ocf-info ''\n moon run cmd/moon_avro -- roundtrip\n\nCommands:\n schema inspect Parse an inline Avro schema and print canonical form.\n encode Convert schema-defined JSON datum to Avro binary hex.\n decode Convert Avro binary hex to schema-defined JSON datum.\n ocf-info Inspect a complete OCF byte sequence supplied as hex.\n roundtrip Encode then decode a built-in record datum.\n\nThe CLI intentionally accepts inline data only; it never reads or writes user files."
}
///|
fn command_index(args : Array[String]) -> Int? {
for index in 0.. return Some(index)
_ => ()
}
}
None
}
///|
fn hex(bytes : Bytes) -> String {
let output = StringBuilder()
for byte in bytes {
output.write_string(byte.to_hex())
}
output.to_string().to_lower()
}
///|
fn bytes_from_hex(input : String) -> Bytes? {
let source = if input.has_prefix("0x") || input.has_prefix("0X") {
input[2:]
} else {
input
}
if source.length() % 2 != 0 {
return None
}
let result : Array[Byte] = []
for index = 0; index < source.length(); index = index + 2 {
let value = @string.parse_int(source[index:index + 2], base=16) catch {
_ => return None
}
if value < 0 || value > 255 {
return None
}
result.push(value.to_byte())
}
Some(Bytes::from_array(result))
}
///|
fn schema_error_message(error : @schema.SchemaError) -> String {
match error {
DuplicateName(name) => "duplicate name: \{name}"
EmptySchema => "empty schema"
InvalidDefault(field~, reason~) => "invalid default for \{field}: \{reason}"
InvalidJson(message) => "invalid JSON: \{message}"
InvalidName(name) => "invalid name: \{name}"
InvalidSchema(message) => "invalid schema: \{message}"
UnknownName(name) => "unknown name: \{name}"
UnsupportedSchema(message) => "unsupported schema: \{message}"
}
}
///|
fn codec_error_message(error : @codec.CodecError) -> String {
match error {
TypeMismatch(expected~, actual~) =>
"type mismatch: expected \{expected}, got \{actual}"
UnexpectedEof(offset~) => "unexpected end of input at byte \{offset}"
InvalidEncoding(offset~, message~) =>
"invalid encoding at byte \{offset}: \{message}"
InvalidLength(offset~, length~) =>
"invalid length \{length} at byte \{offset}"
LimitExceeded(offset~, limit~) =>
"limit exceeded at byte \{offset}: \{limit}"
MissingField(field~) => "missing record field: \{field}"
TrailingData(offset~) => "trailing data at byte \{offset}"
}
}
///|
fn encode_json(schema_source : String, datum_source : String) -> CliResult {
let schema = @schema.parse(schema_source) catch {
error =>
return {
exit_code: 2,
output: "encode schema failed: \{schema_error_message(error)}\n",
}
}
let datum = @avro_json.parse(schema, datum_source) catch {
_ =>
return {
exit_code: 2,
output: "encode datum failed: invalid schema-defined JSON\n",
}
}
let encoded = @codec.encode(schema, datum) catch {
error =>
return {
exit_code: 1,
output: "binary encode failed: \{codec_error_message(error)}\n",
}
}
{ exit_code: 0, output: hex(encoded) }
}
///|
fn decode_json(schema_source : String, encoded_hex : String) -> CliResult {
let schema = @schema.parse(schema_source) catch {
error =>
return {
exit_code: 2,
output: "decode schema failed: \{schema_error_message(error)}\n",
}
}
let encoded = match bytes_from_hex(encoded_hex) {
Some(bytes) => bytes
None =>
return {
exit_code: 2,
output: "decode input must be even-length hexadecimal\n",
}
}
let datum = @codec.decode(schema, encoded) catch {
error =>
return {
exit_code: 2,
output: "binary decode failed: \{codec_error_message(error)}\n",
}
}
let rendered = @avro_json.stringify(schema, datum) catch {
_ => return { exit_code: 1, output: "JSON rendering failed\n" }
}
{ exit_code: 0, output: rendered }
}
///|
fn inspect_ocf(encoded_hex : String) -> CliResult {
let input = match bytes_from_hex(encoded_hex) {
Some(bytes) => bytes
None =>
return {
exit_code: 2,
output: "ocf-info input must be even-length hexadecimal\n",
}
}
let (header, records) = @ocf.decode_container(input) catch {
_ =>
return {
exit_code: 2,
output: "ocf-info failed: invalid or unsupported OCF data\n",
}
}
{
exit_code: 0,
output: "schema_kind: \{header.schema().kind_name()}\ncodec: \{header.codec().name()}\nrecords: \{records.length()}",
}
}
///|
fn inspect_schema(source : String) -> CliResult {
let parsed = @schema.parse(source) catch {
error =>
return {
exit_code: 2,
output: "schema inspect failed: \{schema_error_message(error)}\n",
}
}
let names = parsed.definition_names()
let definitions = if names.is_empty() { "(none)" } else { names.join(", ") }
{
exit_code: 0,
output: "kind: \{parsed.kind_name()}\ncanonical: \{parsed.canonical_form()}\nfingerprint64: 0x\{parsed.fingerprint64().to_string(radix=16)}\ndefinitions: \{definitions}",
}
}
///|
fn roundtrip_demo() -> CliResult {
let source = "{\"type\":\"record\",\"name\":\"Greeting\",\"fields\":[{\"name\":\"message\",\"type\":\"string\"},{\"name\":\"count\",\"type\":\"long\"}]}"
let parsed = @schema.parse(source) catch {
error =>
return {
exit_code: 1,
output: "built-in demo schema is invalid: \{schema_error_message(error)}\n",
}
}
let datum = @codec.Record([("message", String("hello")), ("count", Long(42L))])
let encoded = @codec.encode(parsed, datum) catch {
error =>
return {
exit_code: 1,
output: "binary encode failed: \{codec_error_message(error)}\n",
}
}
let decoded = @codec.decode(parsed, encoded) catch {
error =>
return {
exit_code: 1,
output: "binary decode failed: \{codec_error_message(error)}\n",
}
}
if decoded != datum {
return { exit_code: 1, output: "binary roundtrip changed the datum\n" }
}
{
exit_code: 0,
output: "schema: \{parsed.kind_name()}\nencoded_hex: \{hex(encoded)}\ndecoded: \{repr(decoded)}\nroundtrip: ok",
}
}
///|
fn run_cli(args : Array[String]) -> CliResult {
match command_index(args) {
Some(index) =>
match args[index] {
"help" | "--help" | "-h" => { exit_code: 0, output: usage() }
"inspect" =>
match args.get(index + 1) {
Some(source) => inspect_schema(source)
None =>
{
exit_code: 2,
output: "inspect requires an inline schema JSON\n\n\{usage()}",
}
}
"schema" =>
match (args.get(index + 1), args.get(index + 2)) {
(Some("inspect"), Some(source)) => inspect_schema(source)
_ =>
{
exit_code: 2,
output: "schema supports only the inspect subcommand\n\n\{usage()}",
}
}
"encode" =>
match (args.get(index + 1), args.get(index + 2)) {
(Some(schema), Some(datum)) => encode_json(schema, datum)
_ =>
{
exit_code: 2,
output: "encode requires schema JSON and datum JSON\n\n\{usage()}",
}
}
"decode" =>
match (args.get(index + 1), args.get(index + 2)) {
(Some(schema), Some(encoded)) => decode_json(schema, encoded)
_ =>
{
exit_code: 2,
output: "decode requires schema JSON and binary hex\n\n\{usage()}",
}
}
"ocf-info" =>
match args.get(index + 1) {
Some(encoded) => inspect_ocf(encoded)
None =>
{
exit_code: 2,
output: "ocf-info requires OCF hex\n\n\{usage()}",
}
}
"roundtrip" => roundtrip_demo()
_ => { exit_code: 2, output: usage() }
}
None => { exit_code: 2, output: usage() }
}
}
///|
fn main {
let result = run_cli(@env.args())
println(result.output)
cli_process_exit(result.exit_code)
}