diff --git a/src/cli.rs b/src/cli.rs index 18f7f58..e1f7117 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,23 +1,25 @@ -use crate::convert::ConversionError; +use crate::convert::{ConversionError, KdlVersion}; use crate::convert::{convert_and_write_file_content, convert_file_content}; use std::env; -use std::path::Path; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; const HELP_TEXT: &str = "\ -Usage: jsonkdl [options] +Usage: jsonkdl [options] [--] [] Converts JSON to KDL. -By default, KDL spec v2 is used. - -Arguments: - Path to input JSON file - Path to output KDL file Options: -1, --kdl-v1 Convert to KDL v1 - -2, --kdl-v2 Convert to KDL v2 + -2, --kdl-v2 Convert to KDL v2 (default) -f, --force Overwrite output if it exists -v, --verbose Print extra information during conversion -h, --help Show this help message + +Arguments: + Path to input JSON file + Path to output KDL file (optional) + +If no output file is provided, the resulting KDL document is printed to standard output. "; #[derive(Debug)] @@ -25,9 +27,12 @@ pub enum CliError { MissingInput, HelpRequested, MultipleKdlVersion, - InvalidInputPath(String), - FileExists(String), - InputNotFound(String), + UnknownOption(String), + TooManyPositionals, + NotUnicode(OsString), + InvalidInputPath(PathBuf), + FileExists(PathBuf), + InputNotFound(PathBuf), Conversion(ConversionError), } @@ -35,11 +40,11 @@ pub type Result = std::result::Result; #[derive(Debug)] pub struct Args { - pub input: String, - pub output: Option, + pub input: PathBuf, + pub output: Option, pub force: bool, pub verbose: bool, - pub kdl_version: i32, + pub kdl_version: KdlVersion, } impl std::fmt::Display for CliError { @@ -48,11 +53,24 @@ impl std::fmt::Display for CliError { CliError::MissingInput => writeln!(f, "missing input path"), CliError::HelpRequested => writeln!(f, "help requested"), CliError::MultipleKdlVersion => writeln!(f, "specify only one of --kdl-v1 or --kdl-v2"), - CliError::InvalidInputPath(path) => writeln!(f, "not a file: {}", path), + CliError::UnknownOption(opt) => writeln!( + f, + "unknown command-line option {opt} (use `--` to pass arbitrary filenames)" + ), + CliError::TooManyPositionals => writeln!(f, "too many positional arguments"), + CliError::NotUnicode(arg) => writeln!( + f, + "the argument {arg:?} was not valid Unicode (use `--` to pass arbitrary filenames)" + ), + CliError::InvalidInputPath(path) => writeln!(f, "not a file: {}", path.display()), CliError::FileExists(path) => { - writeln!(f, "file exists: {} (use --force to overwrite)", path) + writeln!( + f, + "file exists: {} (use --force to overwrite)", + path.display() + ) } - CliError::InputNotFound(path) => writeln!(f, "no such file: {}", path), + CliError::InputNotFound(path) => writeln!(f, "no such file: {}", path.display()), CliError::Conversion(e) => writeln!(f, "conversion error: {}", e), } } @@ -75,47 +93,71 @@ impl From for CliError { impl Args { fn parse() -> Result { - let args: Vec = env::args().collect(); - let mut positional: Vec = vec![]; - let mut force = false; - let mut verbose = false; - let mut kdl_version = 0; + let args = env::args_os(); if args.len() == 1 { return Err(CliError::HelpRequested); } - for arg in args.iter().skip(1) { - if !arg.starts_with('-') { - positional.push(arg.into()); - } else if arg == "-f" || arg == "--force" { - force = true; - } else if arg == "-v" || arg == "--verbose" { - verbose = true; - } else if arg == "-1" || arg == "--kdl-v1" { - if kdl_version != 0 { - return Err(CliError::MultipleKdlVersion); - } + let args = args.skip(1); - kdl_version = 1; - } else if arg == "-2" || arg == "--kdl-v2" { - if kdl_version != 0 { - return Err(CliError::MultipleKdlVersion); + let mut force = false; + let mut verbose = false; + let mut kdl_version = None; + + let mut positionals_only = false; + + let mut input = None; + let mut output = None; + + for arg in args { + let is_positional; + + if positionals_only { + is_positional = true; + } else { + let Some(arg) = arg.to_str() else { + return Err(CliError::NotUnicode(arg)); + }; + + if arg.starts_with("-") { + is_positional = false; + match arg { + "--" => positionals_only = true, + "-f" | "--force" => force = true, + "-v" | "--verbose" => verbose = true, + "-1" | "--kdl-v1" => { + if kdl_version.replace(KdlVersion::V1).is_some() { + return Err(CliError::MultipleKdlVersion); + } + } + "-2" | "--kdl-v2" => { + if kdl_version.replace(KdlVersion::V2).is_some() { + return Err(CliError::MultipleKdlVersion); + } + } + "-h" | "--help" => return Err(CliError::HelpRequested), + _ => return Err(CliError::UnknownOption(arg.to_string())), + } + } else { + is_positional = true; } + } - kdl_version = 2; - } else if arg == "-h" || arg == "--help" { - return Err(CliError::HelpRequested); + if is_positional { + if input.is_none() { + input = Some(PathBuf::from(arg)); + } else if output.is_none() { + output = Some(PathBuf::from(arg)); + } else { + return Err(CliError::TooManyPositionals); + } } } - if kdl_version == 0 { - kdl_version = 2; - }; - - let input = positional.get(0).ok_or(CliError::MissingInput)?.to_string(); + let kdl_version = kdl_version.unwrap_or_default(); - let output = positional.get(1).map(|s| s.to_string()); + let input = input.ok_or(CliError::MissingInput)?; let result = Self { input, @@ -143,13 +185,11 @@ pub fn run() -> Result<()> { Err(e) => return Err(e), }; - let input_path = Path::new(&args.input); - - if !input_path.exists() { + if !args.input.exists() { return Err(CliError::InputNotFound(args.input)); } - if !input_path.is_file() { + if !args.input.is_file() { return Err(CliError::InvalidInputPath(args.input)); } @@ -160,9 +200,9 @@ pub fn run() -> Result<()> { return Err(CliError::FileExists(output)); } - convert_and_write_file_content(input_path, output_path, args.verbose, args.kdl_version)?; + convert_and_write_file_content(&args.input, output_path, args.verbose, args.kdl_version)?; } else { - let kdl_content = convert_file_content(input_path, args.kdl_version)?; + let kdl_content = convert_file_content(&args.input, args.kdl_version)?; println!("{}", kdl_content); } diff --git a/src/convert.rs b/src/convert.rs index c55befc..11d7771 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -1,5 +1,5 @@ -use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue, NodeKey}; -use serde_json::Value; +use kdl::{KdlDocument, KdlEntry, KdlIdentifier, KdlNode, KdlValue}; +use serde_json::Value as JsonValue; use std::{fmt, fs, path::Path}; #[derive(Debug)] @@ -43,30 +43,37 @@ impl From for ConversionError { pub type Result = std::result::Result; -pub fn convert_file_content(input: &Path, kdl_version: i32) -> Result { +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +pub enum KdlVersion { + V1, + #[default] + V2, +} + +pub fn convert_file_content(input: &Path, version: KdlVersion) -> Result { let json_content = fs::read_to_string(input)?; - let json_value: Value = serde_json::from_str(&json_content)?; - let mut kdl_doc = json_to_kdl(json_value)?; + let json_value: JsonValue = serde_json::from_str(&json_content)?; + + let mut document = convert_document(&json_value)?; // For some reason, you MUST autoformat before ensuring version. - kdl_doc.autoformat(); + document.autoformat(); - if kdl_version == 2 { - kdl_doc.ensure_v2(); - } else { - kdl_doc.ensure_v1(); + match version { + KdlVersion::V1 => document.ensure_v1(), + KdlVersion::V2 => document.ensure_v2(), } - Ok(kdl_doc.to_string()) + Ok(document.to_string()) } pub fn convert_and_write_file_content( input: &Path, output: &Path, verbose: bool, - kdl_version: i32, + version: KdlVersion, ) -> Result<()> { - let kdl_doc_content = convert_file_content(input, kdl_version)?; + let kdl_doc_content = convert_file_content(input, version)?; fs::write(output, kdl_doc_content)?; @@ -77,108 +84,121 @@ pub fn convert_and_write_file_content( Ok(()) } -pub fn json_to_kdl(json: Value) -> Result { - let array = json.as_array().ok_or_else(|| { - ConversionError::InvalidStructure("document root must be json array".to_string()) +pub fn convert_document(json: &JsonValue) -> Result { + let json = json.as_array().ok_or_else(|| { + ConversionError::InvalidStructure("document root must be an array".to_string()) })?; let mut document = KdlDocument::new(); - for value in array { - let node = json_value_to_node(value)?; + for value in json { + let node = convert_node(value)?; document.nodes_mut().push(node); } Ok(document) } -fn json_value_to_node(value: &Value) -> Result { - let name = value.get("name").and_then(|n| n.as_str()).ok_or_else(|| { - ConversionError::InvalidStructure("name must be non-empty string".to_string()) - })?; +fn convert_node(json: &JsonValue) -> Result { + let json = json + .as_object() + .ok_or_else(|| ConversionError::InvalidStructure("node must be an object".to_string()))?; + + let name = match json.get("name") { + Some(JsonValue::String(name)) => Ok(name.as_str()), + Some(_) => Err(ConversionError::InvalidStructure( + "name must be a string".to_string(), + )), + None => Err(ConversionError::InvalidStructure( + "node must have a name".to_string(), + )), + }?; let mut node = KdlNode::new(name); // Handle arguments - if let Some(arguments) = value.get("arguments") { - let args = arguments.as_array().ok_or_else(|| { + if let Some(arguments) = json.get("arguments") { + let arguments = arguments.as_array().ok_or_else(|| { ConversionError::InvalidStructure("arguments must be an array".to_string()) })?; - for arg in args { - let entry = json_value_to_entry(arg)?; + for arg in arguments { + let entry = convert_entry(arg)?; node.push(entry); } } // Handle properties - if let Some(properties) = value.get("properties") { - let props = properties.as_object().ok_or_else(|| { + if let Some(properties) = json.get("properties") { + let properties = properties.as_object().ok_or_else(|| { ConversionError::InvalidStructure("properties must be an object".to_string()) })?; - for (key, prop_value) in props { - let entry = json_value_to_entry(prop_value)?; - node.insert(NodeKey::from(key.clone()), entry); + for (key, prop_value) in properties { + let mut entry = convert_entry(prop_value)?; + entry.set_name(Some(key.as_str())); + node.push(entry); } } // Handle children - if let Some(children) = value.get("children") { - let child_doc = json_to_kdl(children.clone())?; - node.set_children(child_doc); + if let Some(children) = json.get("children") { + let children = convert_document(children)?; + node.set_children(children); } // Handle type annotation - if let Some(type_value) = value.get("type") { - if !type_value.is_null() { - if let Some(type_str) = type_value.as_str() { - node.set_ty(type_str); - } + if let Some(ty) = json.get("type") { + if let Some(ty) = convert_type(ty)? { + node.set_ty(ty); } } Ok(node) } -fn json_value_to_entry(value: &Value) -> Result { - let (actual_value, type_annotation) = if let Some(obj) = value.as_object() { - let val = obj.get("value").unwrap_or(value); - let ty = obj - .get("type") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()); - (val, ty) - } else { - (value, None) - }; - - let kdl_value = match actual_value { - Value::Null => KdlValue::Null, - Value::Bool(b) => KdlValue::Bool(*b), - Value::Number(n) => { +fn convert_entry(json: &JsonValue) -> Result { + let value = convert_value(json.get("value").unwrap_or(json))?; + + let mut entry = KdlEntry::new(value); + + if let Some(ty) = json.get("type") { + if let Some(ty) = convert_type(ty)? { + entry.set_ty(ty); + } + } + + Ok(entry) +} + +fn convert_value(json: &JsonValue) -> Result { + match json { + JsonValue::Null => Ok(KdlValue::Null), + JsonValue::Bool(b) => Ok(KdlValue::Bool(*b)), + JsonValue::Number(n) => { if let Some(i) = n.as_i64() { - KdlValue::Integer(i as i128) + Ok(KdlValue::Integer(i as i128)) } else if let Some(f) = n.as_f64() { - KdlValue::Float(f) + Ok(KdlValue::Float(f)) } else { - return Err(ConversionError::InvalidStructure( + Err(ConversionError::InvalidStructure( "invalid number value".to_string(), - )); + )) } } - Value::String(s) => KdlValue::String(s.clone()), - _ => { - return Err(ConversionError::InvalidStructure( - "unsupported json value type for kdl conversion".to_string(), - )); - } - }; - - let mut entry = KdlEntry::new(kdl_value); - if let Some(ty) = type_annotation { - entry.set_ty(ty); + JsonValue::String(s) => Ok(KdlValue::String(s.clone())), + _ => Err(ConversionError::InvalidStructure( + "unsupported json value type for kdl conversion".to_string(), + )), } +} - Ok(entry) +fn convert_type(json: &JsonValue) -> Result> { + match json { + JsonValue::String(ty) => Ok(Some(KdlIdentifier::from(ty.as_str()))), + JsonValue::Null => Ok(None), + _ => Err(ConversionError::InvalidStructure( + "type must be a string or null".to_string(), + )), + } }