use anyhow::{Context, Result};
use fast_yaml_core::{Emitter, Parser, Value};
use serde_json;
use crate::cli::ConvertFormat;
use crate::config::CommonConfig;
use crate::io::{InputSource, OutputWriter};
pub struct ConvertCommand {
config: CommonConfig,
target_format: ConvertFormat,
pretty: bool,
}
impl ConvertCommand {
pub const fn new(config: CommonConfig, target_format: ConvertFormat, pretty: bool) -> Self {
Self {
config,
target_format,
pretty,
}
}
pub fn execute(&self, input: &InputSource, output: &OutputWriter) -> Result<()> {
match self.target_format {
ConvertFormat::Json => self.yaml_to_json(input, output),
ConvertFormat::Yaml => self.json_to_yaml(input, output),
}
}
fn yaml_to_json(&self, input: &InputSource, output: &OutputWriter) -> Result<()> {
let docs = Parser::parse_all(input.as_str()).context("Failed to parse YAML")?;
if docs.is_empty() {
return Err(anyhow::anyhow!("Empty YAML document"));
}
let json_value = if docs.len() == 1 {
value_to_json(&docs[0])?
} else {
let arr: Result<Vec<_>> = docs.iter().map(value_to_json).collect();
serde_json::Value::Array(arr?)
};
let mut json_string = if self.pretty {
serde_json::to_string_pretty(&json_value).context("Failed to serialize JSON")?
} else {
serde_json::to_string(&json_value).context("Failed to serialize JSON")?
};
json_string.push('\n');
output.write(&json_string)?;
Ok(())
}
#[allow(clippy::unused_self)]
fn json_to_yaml(&self, input: &InputSource, output: &OutputWriter) -> Result<()> {
let json_value: serde_json::Value =
serde_json::from_str(input.as_str()).context("Failed to parse JSON")?;
let yaml_value = json_to_value(&json_value)?;
let yaml_string = Emitter::emit_str(&yaml_value).context("Failed to emit YAML")?;
output.write(&yaml_string)?;
Ok(())
}
}
fn yaml_key_to_string(key: &Value) -> Result<String> {
use Value as YValue;
use fast_yaml_core::value::ScalarOwned;
match key {
YValue::Value(scalar) => Ok(match scalar {
ScalarOwned::Null => "null".to_string(),
ScalarOwned::Boolean(b) => b.to_string(),
ScalarOwned::Integer(i) => i.to_string(),
ScalarOwned::FloatingPoint(f) => f.to_string(),
ScalarOwned::String(s) => s.clone(),
}),
_ => Err(anyhow::anyhow!(
"Unsupported YAML map key type: only scalar keys (string, number, boolean, null) \
can be converted to JSON"
)),
}
}
fn value_to_json(value: &Value) -> Result<serde_json::Value> {
use Value as YValue;
use fast_yaml_core::value::ScalarOwned;
use serde_json::Value as JValue;
Ok(match value {
YValue::Value(scalar) => match scalar {
ScalarOwned::Null => JValue::Null,
ScalarOwned::Boolean(b) => JValue::Bool(*b),
ScalarOwned::Integer(i) => JValue::Number((*i).into()),
ScalarOwned::FloatingPoint(f) => serde_json::Number::from_f64(f.0)
.map(JValue::Number)
.ok_or_else(|| {
anyhow::anyhow!(
"YAML value '{f}' cannot be represented in JSON \
(JSON does not support infinity/NaN). \
Consider replacing with a numeric sentinel value."
)
})?,
ScalarOwned::String(s) => JValue::String(s.clone()),
},
YValue::Sequence(arr) => {
let json_arr: Result<Vec<_>> = arr.iter().map(value_to_json).collect();
JValue::Array(json_arr?)
}
YValue::Mapping(map) => {
let mut json_map = serde_json::Map::new();
for (k, v) in map {
let key = yaml_key_to_string(k)?;
json_map.insert(key, value_to_json(v)?);
}
JValue::Object(json_map)
}
YValue::Alias(_) => {
anyhow::bail!("YAML aliases are not supported in JSON conversion");
}
YValue::BadValue => {
anyhow::bail!("Invalid YAML value encountered");
}
YValue::Representation(s, _, _) => {
JValue::String(s.clone())
}
YValue::Tagged(_, inner) => {
value_to_json(inner)?
}
})
}
fn json_to_value(json: &serde_json::Value) -> Result<Value> {
use Value as YValue;
use fast_yaml_core::Map;
use fast_yaml_core::value::ScalarOwned;
use serde_json::Value as JValue;
Ok(match json {
JValue::Null => YValue::Value(ScalarOwned::Null),
JValue::Bool(b) => YValue::Value(ScalarOwned::Boolean(*b)),
JValue::Number(n) => {
use ordered_float::OrderedFloat;
use saphyr_parser::ScalarStyle;
let raw = n.as_str();
let is_float = raw.contains('.') || raw.contains('e') || raw.contains('E');
if is_float {
let _ = n.as_f64().ok_or_else(|| {
anyhow::anyhow!("Float value out of representable range: {n}")
})?;
YValue::Representation(raw.to_string(), ScalarStyle::Plain, None)
} else if let Some(i) = n.as_i64() {
YValue::Value(ScalarOwned::Integer(i))
} else if let Some(f) = n.as_f64() {
YValue::Value(ScalarOwned::FloatingPoint(OrderedFloat(f)))
} else {
anyhow::bail!("Unsupported number type: {n}");
}
}
JValue::String(s) => YValue::Value(ScalarOwned::String(s.clone())),
JValue::Array(arr) => {
let yaml_arr: Result<Vec<_>> = arr.iter().map(json_to_value).collect();
YValue::Sequence(yaml_arr?)
}
JValue::Object(map) => {
let mut yaml_map = Map::new();
for (k, v) in map {
yaml_map.insert(
YValue::Value(ScalarOwned::String(k.clone())),
json_to_value(v)?,
);
}
YValue::Mapping(yaml_map)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CommonConfig;
use crate::io::input::InputOrigin;
#[test]
fn test_yaml_to_json() {
let input = InputSource {
content: "name: test\nvalue: 123".to_string(),
origin: InputOrigin::Stdin,
};
let temp_dir = tempfile::tempdir().unwrap();
let temp_path = temp_dir.path().join("output.json");
let output = OutputWriter::from_args(Some(temp_path.clone()), false, None).unwrap();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Json, true);
let result = cmd.execute(&input, &output);
if let Err(e) = &result {
eprintln!("Execute error: {e}");
}
assert!(result.is_ok());
let json_str = std::fs::read_to_string(&temp_path)
.unwrap_or_else(|e| panic!("Failed to read {temp_path:?}: {e}"));
assert!(!json_str.is_empty(), "Output file is empty!");
let json: serde_json::Value = serde_json::from_str(&json_str)
.unwrap_or_else(|e| panic!("Failed to parse JSON from '{json_str}': {e}"));
assert_eq!(json["name"], "test");
assert_eq!(json["value"], 123);
}
#[test]
fn test_json_to_yaml() {
let input = InputSource {
content: r#"{"name": "test", "value": 123}"#.to_string(),
origin: InputOrigin::Stdin,
};
let temp_dir = tempfile::tempdir().unwrap();
let temp_path = temp_dir.path().join("output.yaml");
let output = OutputWriter::from_args(Some(temp_path.clone()), false, None).unwrap();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Yaml, true);
assert!(cmd.execute(&input, &output).is_ok());
let yaml_str = std::fs::read_to_string(&temp_path).unwrap();
assert!(yaml_str.contains("name:"));
assert!(yaml_str.contains("value:"));
}
#[test]
fn test_value_to_json_simple() {
let yaml = "name: test";
let value = Parser::parse_str(yaml).unwrap().unwrap();
let json = value_to_json(&value).unwrap();
assert_eq!(json["name"], "test");
}
#[test]
fn test_json_to_value_simple() {
let json_str = r#"{"name": "test"}"#;
let json: serde_json::Value = serde_json::from_str(json_str).unwrap();
let yaml = json_to_value(&json).unwrap();
match yaml {
Value::Mapping(map) => {
assert_eq!(map.len(), 1);
}
_ => panic!("Expected Mapping"),
}
}
#[test]
fn test_invalid_yaml_to_json() {
let input = InputSource {
content: "invalid: [".to_string(),
origin: InputOrigin::Stdin,
};
let output = OutputWriter::stdout();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Json, true);
assert!(cmd.execute(&input, &output).is_err());
}
#[test]
fn test_invalid_json_to_yaml() {
let input = InputSource {
content: "{invalid json}".to_string(),
origin: InputOrigin::Stdin,
};
let output = OutputWriter::stdout();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Yaml, true);
assert!(cmd.execute(&input, &output).is_err());
}
#[test]
fn test_multi_document_yaml_to_json() {
let input = InputSource {
content: "---\nfoo: 1\n---\nbar: 2\n---\nbaz: 3\n".to_string(),
origin: InputOrigin::Stdin,
};
let temp_dir = tempfile::tempdir().unwrap();
let temp_path = temp_dir.path().join("output.json");
let output = OutputWriter::from_args(Some(temp_path.clone()), false, None).unwrap();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Json, false);
assert!(cmd.execute(&input, &output).is_ok());
let json_str = std::fs::read_to_string(&temp_path).unwrap();
let json: serde_json::Value = serde_json::from_str(json_str.trim()).unwrap();
assert!(
json.is_array(),
"Expected JSON array for multi-document stream"
);
let arr = json.as_array().unwrap();
assert_eq!(arr.len(), 3);
assert_eq!(arr[0]["foo"], 1);
assert_eq!(arr[1]["bar"], 2);
assert_eq!(arr[2]["baz"], 3);
}
#[test]
fn test_yaml_inf_nan_to_json_gives_clear_error() {
for yaml in &["val: .inf", "val: -.inf", "val: .nan"] {
let input = InputSource {
content: (*yaml).to_string(),
origin: InputOrigin::Stdin,
};
let output = OutputWriter::stdout();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Json, false);
let err = cmd.execute(&input, &output).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("cannot be represented in JSON"),
"expected descriptive error, got: {msg}"
);
}
}
#[test]
fn test_json_float_preserves_type() {
let input = InputSource {
content: r#"{"whole_float": 1.0, "sci": 1.23e10, "integer": 42}"#.to_string(),
origin: InputOrigin::Stdin,
};
let temp_dir = tempfile::tempdir().unwrap();
let temp_path = temp_dir.path().join("output.yaml");
let output = OutputWriter::from_args(Some(temp_path.clone()), false, None).unwrap();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Yaml, false);
assert!(cmd.execute(&input, &output).is_ok());
let yaml_str = std::fs::read_to_string(&temp_path).unwrap();
assert!(
yaml_str.contains("whole_float: 1.0"),
"expected 'whole_float: 1.0' in: {yaml_str}"
);
assert!(
yaml_str.contains("integer: 42"),
"expected 'integer: 42' in: {yaml_str}"
);
}
#[test]
fn test_explicit_int_tag_float_to_json() {
let input = InputSource {
content: "val: !!int 3.14".to_string(),
origin: InputOrigin::Stdin,
};
let temp_dir = tempfile::tempdir().unwrap();
let temp_path = temp_dir.path().join("output.json");
let output = OutputWriter::from_args(Some(temp_path.clone()), false, None).unwrap();
let config = CommonConfig::new();
let cmd = ConvertCommand::new(config, ConvertFormat::Json, false);
assert!(cmd.execute(&input, &output).is_ok());
let json_str = std::fs::read_to_string(&temp_path).unwrap();
let json: serde_json::Value = serde_json::from_str(json_str.trim()).unwrap();
assert_eq!(json["val"], 3, "!!int 3.14 should truncate to integer 3");
}
#[test]
fn test_value_to_json_null_key() {
let yaml = "null: value";
let value = Parser::parse_str(yaml).unwrap().unwrap();
let json = value_to_json(&value).unwrap();
assert_eq!(json["null"], "value");
}
#[test]
fn test_value_to_json_bool_key() {
let yaml = "true: yes_value";
let value = Parser::parse_str(yaml).unwrap().unwrap();
let json = value_to_json(&value).unwrap();
assert_eq!(json["true"], "yes_value");
}
#[test]
fn test_value_to_json_integer_key() {
let yaml = "42: answer";
let value = Parser::parse_str(yaml).unwrap().unwrap();
let json = value_to_json(&value).unwrap();
assert_eq!(json["42"], "answer");
}
}