fmtcnv 2.3.3

Cross-convert bson, csv, hjson, hocon, json, json5, jsonl, plist, ron, toml, toon, xml, yaml
Documentation
mod csv_value;
mod hocon_value;
mod jsonl_value;
mod xml_value;

use anyhow::Result;
use serde::Serialize;

use crate::{
	csv_value::{CsvWrapper, json_to_csv, load_csv},
	hocon_value::{HoconWrapper, load_hocon},
	jsonl_value::{JsonlWrapper, json_to_jsonl, load_jsonl},
	xml_value::{XmlWrapper, json_to_xml, load_xml},
};

#[derive(Debug, Copy, Clone, PartialEq, clap::ValueEnum)]
pub enum Format {
	Bson,
	Csv,
	Hjson,
	Hocon,
	Json,
	Json5,
	Jsonl,
	Plist,
	Ron,
	Toml,
	Toon,
	Xml,
	Yaml,
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum Value {
	Bson(bson::Bson),
	Csv(CsvWrapper),
	Hjson(serde_hjson::Value),
	Hocon(HoconWrapper),
	Json(serde_json::Value),
	Json5(serde_json::Value),
	Jsonl(JsonlWrapper),
	Plist(plist::Value),
	Ron(ron::Value),
	Toml(toml::Value),
	Toon(serde_json::Value),
	Xml(XmlWrapper),
	Yaml(serde_yaml::Value),
}

pub fn load_input(input: &[u8], format: Format) -> Result<Value> {
	let value = match format {
		Format::Bson => Value::Bson(bson::deserialize_from_slice(input)?),
		Format::Csv => Value::Csv(load_csv(input)?),
		Format::Hjson => Value::Hjson(serde_hjson::from_slice(input)?),
		Format::Hocon => Value::Hocon(load_hocon(input)?),
		Format::Json => Value::Json(serde_json::from_slice(input)?),
		Format::Json5 => Value::Json5(json5::from_str(str::from_utf8(input)?)?),
		Format::Jsonl => Value::Jsonl(load_jsonl(input)?),
		Format::Plist => Value::Plist(plist::from_bytes(input)?),
		Format::Ron => Value::Ron(ron::de::from_bytes(input)?),
		Format::Toml => {
			let s = std::str::from_utf8(input)?;
			Value::Toml(toml::from_str(s)?)
		}
		Format::Toon => {
			let s = std::str::from_utf8(input)?;
			Value::Toon(toon_format::decode_default(s)?)
		}
		Format::Xml => Value::Xml(load_xml(input)?),
		Format::Yaml => Value::Yaml(serde_yaml::from_slice(input)?),
	};
	Ok(value)
}

pub fn dump_value(value: &Value, format: Format, is_compact: bool) -> Result<Vec<u8>> {
	let dumped: Vec<u8> = match (format, is_compact) {
		(Format::Bson, _) => bson::serialize_to_vec(value)?,
		(Format::Csv, _) => {
			let json_dumped = serde_json::to_vec(value)?;
			json_to_csv(&json_dumped)?
		}
		(Format::Hjson, _) => serde_hjson::to_vec(value)?,
		(Format::Hocon, true) => serde_json::to_vec(value)?,
		(Format::Hocon, false) => serde_json::to_vec_pretty(value)?,
		(Format::Json, true) => serde_json::to_vec(value)?,
		(Format::Json, false) => serde_json::to_vec_pretty(value)?,
		(Format::Json5, _) => json5::to_string(value).map(|e| e.into_bytes())?,
		(Format::Jsonl, _) => {
			let json_dumped = serde_json::to_vec(value)?;
			json_to_jsonl(&json_dumped)?
		}
		(Format::Plist, _) => {
			let mut buffer = Vec::new();
			plist::to_writer_xml(&mut buffer, value)?;
			buffer
		}
		(Format::Ron, true) => ron::ser::to_string(value).map(|e| e.into_bytes())?,
		(Format::Ron, false) => ron::ser::to_string_pretty(value, ron::ser::PrettyConfig::default().new_line("\n".to_owned())).map(|e| e.into_bytes())?,
		(Format::Toml, true) => toml::to_string(value).map(|e| e.into_bytes())?,
		(Format::Toml, false) => toml::to_string_pretty(value).map(|e| e.into_bytes())?,
		(Format::Toon, _) => toon_format::encode_default(value)?.as_bytes().to_vec(),
		(Format::Xml, _) => {
			let json_dumped = serde_json::to_vec(value)?;
			json_to_xml(&json_dumped)?
		}
		(Format::Yaml, _) => serde_yaml::to_string(value).map(|e| e.into_bytes())?,
	};
	Ok(dumped)
}

#[cfg(test)]
mod tests {
	use rstest::rstest;

	use super::*;

	fn get_test_value(format: Format, is_compact: bool) -> String {
		let value = match (format, is_compact) {
			(Format::Bson, _) => {
				"A\0\0\0\u{4}array\0\u{17}\0\0\0\u{2}0\0\u{2}\0\0\0a\0\u{2}1\0\u{2}\0\0\0b\0\0\u{8}boolean\0\0\u{12}the_answer\0*\0\0\0\0\0\0\0\0"
			}
			(Format::Csv, _) => unimplemented!("use raw data for tests"),
			(Format::Hjson, _) => {
				r#"{
  array:
  [
    a
    b
  ]
  boolean: false
  the_answer: 42
}"#
			}
			(Format::Hocon, _) => {
				r#"
array: [a,b]
boolean: false
the_answer: 42
"#
			}
			(Format::Json, true) => r#"{"array":["a","b"],"boolean":false,"the_answer":42}"#,
			(Format::Json, false) => {
				r#"{
  "array": [
    "a",
    "b"
  ],
  "boolean": false,
  "the_answer": 42
}"#
			}
			(Format::Json5, _) => {
				r#"{
  array: [
    "a",
    "b",
  ],
  boolean: false,
  the_answer: 42,
}"#
			}
			(Format::Jsonl, _) => unimplemented!("use raw data for tests"),
			(Format::Plist, _) => {
				r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>array</key>
	<array>
		<string>a</string>
		<string>b</string>
	</array>
	<key>boolean</key>
	<false/>
	<key>the_answer</key>
	<integer>42</integer>
</dict>
</plist>"#
			}
			(Format::Ron, true) => r#"{"array":["a","b"],"boolean":false,"the_answer":42}"#,
			(Format::Ron, false) => {
				r#"{
    "array": [
        "a",
        "b",
    ],
    "boolean": false,
    "the_answer": 42,
}"#
			}
			(Format::Toml, true) => {
				r#"array = ["a", "b"]
boolean = false
the_answer = 42
"#
			}
			(Format::Toml, false) => {
				r#"array = [
    "a",
    "b",
]
boolean = false
the_answer = 42
"#
			}
			(Format::Toon, _) => {
				r#"array[2]: a,b
boolean: false
the_answer: 42"#
			}
			(Format::Xml, _) => r#"<root><array>a</array><array>b</array><boolean>false</boolean><the_answer>42</the_answer></root>"#,
			(Format::Yaml, _) => {
				r#"array:
- a
- b
boolean: false
the_answer: 42
"#
			}
		};
		value.to_string()
	}

	#[rstest]
	#[case(Format::Json, Format::Yaml, false)]
	#[case(Format::Json, Format::Toml, true)]
	#[case(Format::Json, Format::Toml, false)]
	#[case(Format::Yaml, Format::Json, false)]
	#[case(Format::Yaml, Format::Json, true)]
	#[case(Format::Yaml, Format::Toml, true)]
	#[case(Format::Toml, Format::Yaml, false)]
	#[case(Format::Toml, Format::Json, true)]
	#[case(Format::Json, Format::Ron, true)]
	#[case(Format::Json, Format::Ron, false)]
	#[case(Format::Ron, Format::Json, true)]
	#[case(Format::Json5, Format::Json, true)]
	#[case(Format::Json, Format::Json5, true)]
	#[case(Format::Json, Format::Json5, false)]
	#[case(Format::Json5, Format::Json, false)]
	#[case(Format::Json, Format::Bson, false)]
	#[case(Format::Bson, Format::Json5, true)]
	#[case(Format::Hocon, Format::Json, false)]
	#[case(Format::Xml, Format::Yaml, false)]
	#[case(Format::Toml, Format::Xml, true)]
	#[case(Format::Toml, Format::Hjson, true)]
	#[case(Format::Hjson, Format::Json, false)]
	#[case(Format::Toon, Format::Yaml, false)]
	#[case(Format::Toon, Format::Json, true)]
	#[case(Format::Yaml, Format::Toon, false)]
	#[case(Format::Yaml, Format::Toon, true)]
	#[case(Format::Json, Format::Plist, true)]
	#[case(Format::Plist, Format::Yaml, true)]
	fn test_convert_formats(#[case] from_format: Format, #[case] to_format: Format, #[case] is_compact: bool) {
		println!("{from_format:?} -> {to_format:?}. is_compact: {is_compact}");

		let input = get_test_value(from_format, is_compact);
		let expected_output = get_test_value(to_format, is_compact);

		let value = load_input(input.as_bytes(), from_format).unwrap();
		let output = String::from_utf8(dump_value(&value, to_format, is_compact).unwrap()).unwrap();

		assert_eq!(output, expected_output);
	}

	#[rstest]
	#[case(
		Format::Csv,
		Format::Json,
		r#"age,immortal,name,power
55000,true,Gendalf,50.0
50,false,Frodo,5.0
"#,
		r#"[
  {
    "age": 55000,
    "immortal": true,
    "name": "Gendalf",
    "power": 50.0
  },
  {
    "age": 50,
    "immortal": false,
    "name": "Frodo",
    "power": 5.0
  }
]"#,
		false
	)]
	#[case(
        Format::Csv,
        Format::Json,
        r#"age,immortal,name,power,test_empty
55000, true, Gendalf, 50.0,
50, false, Frodo, 5.0,
"#,
        r#"[{"age":55000,"immortal":true,"name":"Gendalf","power":50.0,"test_empty":null},{"age":50,"immortal":false,"name":"Frodo","power":5.0,"test_empty":null}]"#,
        true
    )]
	#[case(
		Format::Json,
		Format::Csv,
		r#"[{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0},{"age":50,"immortal":false,"name":"Frodo","power":5.0}]"#,
		r#"age,immortal,name,power
55000,true,"Gendalf the \"White\"",50.0
50,false,"Frodo",5.0
"#,
		true
	)]
	#[case(
		Format::Hocon,
		Format::Json,
		r#"{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0}"#,
		r#"{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0}"#,
		true
	)]
	#[case(
		Format::Json,
		Format::Json,
		r#"[{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0},{"age":50,"immortal":false,"name":"Frodo","power":5.0}]"#,
		r#"[{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0},{"age":50,"immortal":false,"name":"Frodo","power":5.0}]"#,
		true
	)]
	#[case(
		Format::Jsonl,
		Format::Xml,
		r#"{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0}
{"age":50,"immortal":false,"name":"Frodo","power":5.0}
"#,
		r#"<root><age>55000</age><immortal>true</immortal><name>Gendalf the &quot;White&quot;</name><power>50.0</power></root>
<root><age>50</age><immortal>false</immortal><name>Frodo</name><power>5.0</power></root>
"#,
		false
	)]
	#[case(
		Format::Json,
		Format::Jsonl,
		r#"[{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0},{"age":50,"immortal":false,"name":"Frodo","power":5.0}]"#,
		r#"{"age":55000,"immortal":true,"name":"Gendalf the \"White\"","power":50.0}
{"age":50,"immortal":false,"name":"Frodo","power":5.0}
"#,
		false
	)]
	fn test_raw_convert(#[case] from_format: Format, #[case] to_format: Format, #[case] input: &str, #[case] expected_output: &str, #[case] is_compact: bool) {
		let value = load_input(input.as_bytes(), from_format).unwrap();
		let output = String::from_utf8(dump_value(&value, to_format, is_compact).unwrap()).unwrap();

		assert_eq!(output, expected_output);
	}
}