Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions crates/polyxml-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ pub enum Commands {

/// Validate XML schema syntax and structural invariants without generating code
Validate(ValidateArgs),

/// Bidirectionally transcode XML ↔ JSON with zero-copy streaming
Transcode(TranscodeArgs),
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -118,13 +121,45 @@ pub struct ValidateArgs {
pub schemas: Vec<PathBuf>,
}

#[derive(Debug, Args)]
pub struct TranscodeArgs {
/// Input file path (or '-' / omitted for stdin)
#[arg(value_name = "INPUT")]
pub input: Option<PathBuf>,

/// Output file path (or '-' / omitted for stdout)
#[arg(short = 'o', long = "out", value_name = "OUTPUT")]
pub output: Option<PathBuf>,

/// Input format ('xml' or 'json', auto-detected if omitted)
#[arg(long = "from", value_name = "FORMAT")]
pub from: Option<String>,

/// Output format ('xml' or 'json', auto-detected if omitted)
#[arg(long = "to", value_name = "FORMAT")]
pub to: Option<String>,

/// Optional XSD schema file for typed schema-directed transcoding
#[arg(short = 's', long = "schema", value_name = "SCHEMA")]
pub schema: Option<PathBuf>,

/// Root element name (used when transcoding JSON to XML)
#[arg(short = 'r', long = "root", value_name = "ROOT")]
pub root: Option<String>,

/// Format output with indentation and newlines
#[arg(long = "pretty")]
pub pretty: bool,
}

fn main() {
let cli = Cli::parse();

let result = match cli.command {
Commands::Generate(args) => run_generate(args),
Commands::Build(args) => run_build(args),
Commands::Validate(args) => run_validate(args),
Commands::Transcode(args) => run_transcode(args),
};

if let Err(err) = result {
Expand Down Expand Up @@ -392,6 +427,7 @@ fn emit_target_code(
emit_meta: true,
emit_root_aliases: true,
emit_codecs: opts.codecs.unwrap_or(true),
emit_json_metadata: true,
};

let codegen = PythonCodegen::new(options);
Expand Down Expand Up @@ -522,6 +558,7 @@ fn emit_target_code(
let options = GoOptions {
package_name: pkg.to_string(),
emit_xml_tags: true,
emit_json_tags: true,
validate_choice_exclusivity: true,
validate_facets: true,
emit_root_aliases: true,
Expand All @@ -546,6 +583,7 @@ fn emit_target_code(
let options = CSharpOptions {
namespace: ns.to_string(),
emit_xml_attributes: true,
emit_json_attributes: true,
emit_validation: true,
record_kind: CSharpRecordKind::Class,
use_file_scoped_namespaces: true,
Expand Down Expand Up @@ -685,3 +723,107 @@ fn run_language_formatter(lang: &str, dir: &Path) {
_ => {}
}
}

fn run_transcode(args: TranscodeArgs) -> Result<(), Box<dyn std::error::Error>> {
use std::io::{Read, Write};

// 1. Read input bytes
let input_bytes = if let Some(ref path) = args.input {
if path.as_os_str() == "-" {
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
buf
} else {
fs::read(path)?
}
} else {
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
buf
};

// 2. Determine from and to formats
let from_format = if let Some(ref f) = args.from {
f.to_lowercase()
} else if let Some(ref path) = args.input {
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
"xml" => "xml".to_string(),
"json" => "json".to_string(),
_ => {
if input_bytes.iter().find(|&&b| !b.is_ascii_whitespace()) == Some(&b'<') {
"xml".to_string()
} else {
"json".to_string()
}
}
}
} else if input_bytes.iter().find(|&&b| !b.is_ascii_whitespace()) == Some(&b'<') {
"xml".to_string()
} else {
"json".to_string()
};

let to_format = if let Some(ref t) = args.to {
t.to_lowercase()
} else if let Some(ref path) = args.output {
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
"xml" => "xml".to_string(),
"json" => "json".to_string(),
_ => {
if from_format == "xml" {
"json".to_string()
} else {
"xml".to_string()
}
}
}
} else if from_format == "xml" {
"json".to_string()
} else {
"xml".to_string()
};

// 3. Load optional ModelSchema
let model_schema = if let Some(ref schema_path) = args.schema {
let mut parser = XsdParser::new();
let ir = parser.parse_file(schema_path)?;
Some(polyxml::ModelSchema::from_ir(&ir, args.root.as_deref())?)
} else {
None
};

let indent = if args.pretty { Some(2) } else { None };

// 4. Perform transcoding
let output_bytes = match (from_format.as_str(), to_format.as_str()) {
("xml", "json") => polyxml::xml_to_json(&input_bytes, model_schema, indent, true)?,
("json", "xml") => polyxml::json_to_xml(
&input_bytes,
model_schema,
args.root.as_deref(),
indent,
None,
None,
)?,
_ => {
return Err(format!(
"Unsupported transcoding direction from '{}' to '{}'",
from_format, to_format
)
.into())
}
};

// 5. Write output bytes
if let Some(ref path) = args.output {
if path.as_os_str() == "-" {
std::io::stdout().write_all(&output_bytes)?;
} else {
fs::write(path, output_bytes)?;
}
} else {
std::io::stdout().write_all(&output_bytes)?;
}

Ok(())
}
111 changes: 95 additions & 16 deletions crates/polyxml-cli/tests/test_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,9 @@ fn test_cli_generate_python_backends() {
assert!(
pyd_py.contains("type InvoiceCode = Annotated[str, Field(min_length=5, max_length=10)]")
);
assert!(pyd_py.contains("code: InvoiceCode = Field(..., json_schema_extra={\"type\": \"Element\", \"name\": \"Code\", \"namespace\": \"https://example.com/invoice\"})"));
assert!(pyd_py.contains("total: Decimal = Field(..., json_schema_extra={\"type\": \"Element\", \"name\": \"Total\", \"namespace\": \"https://example.com/invoice\"})"));
assert!(pyd_py.contains("note: str | None = Field(default=None, json_schema_extra={\"type\": \"Element\", \"name\": \"Note\", \"namespace\": \"https://example.com/invoice\", \"nillable\": True})"));
assert!(pyd_py.contains("code: InvoiceCode = Field(..., alias=\"Code\", serialization_alias=\"Code\", json_schema_extra={\"type\": \"Element\", \"name\": \"Code\", \"json_name\": \"Code\", \"namespace\": \"https://example.com/invoice\"})"));
assert!(pyd_py.contains("total: Decimal = Field(..., alias=\"Total\", serialization_alias=\"Total\", json_schema_extra={\"type\": \"Element\", \"name\": \"Total\", \"json_name\": \"Total\", \"namespace\": \"https://example.com/invoice\"})"));
assert!(pyd_py.contains("note: str | None = Field(default=None, alias=\"Note\", serialization_alias=\"Note\", json_schema_extra={\"type\": \"Element\", \"name\": \"Note\", \"json_name\": \"Note\", \"namespace\": \"https://example.com/invoice\", \"nillable\": True})"));
}

#[test]
Expand Down Expand Up @@ -756,39 +756,49 @@ fn test_cli_go_generation() {
let generated_file = go_out.join("crm.go");
assert!(generated_file.exists(), "crm.go was not created");
let go_code = fs::read_to_string(&generated_file).unwrap();
println!("GO_CODE:\n{}", go_code);

assert!(go_code.contains("package crm"));
assert!(go_code.contains("type AccountTier string"));
assert!(go_code.contains("AccountTierStandard"));
assert!(go_code.contains("\"standard\""));
assert!(go_code.contains("func (e AccountTier) IsValid() bool"));
assert!(go_code.contains("type Account struct {"));
assert!(go_code.contains("XMLName xml.Name"));
assert!(go_code.contains("Name") && go_code.contains("`xml:\"name\"`"));
assert!(go_code.contains("XMLName xml.Name") && go_code.contains("`json:\"-\"`"));
assert!(
go_code.contains("Name")
&& go_code.contains("xml:\"name\"")
&& go_code.contains("json:\"name\"")
);
assert!(
go_code.contains("Tier")
&& go_code.contains("AccountTier")
&& go_code.contains("`xml:\"tier\"`")
&& go_code.contains("xml:\"tier\"")
&& go_code.contains("json:\"tier\"")
);
assert!(
go_code.contains("Balance")
&& go_code.contains("float64")
&& go_code.contains("`xml:\"balance\"`")
&& go_code.contains("xml:\"balance\"")
&& go_code.contains("json:\"balance\"")
);
assert!(
go_code.contains("Alias")
&& go_code.contains("*string")
&& go_code.contains("`xml:\"alias,omitempty\"`")
&& go_code.contains("xml:\"alias,omitempty\"")
&& go_code.contains("json:\"alias,omitempty\"")
);
assert!(
go_code.contains("Tag")
&& go_code.contains("[]string")
&& go_code.contains("`xml:\"tag\"`")
&& go_code.contains("xml:\"tag\"")
&& go_code.contains("json:\"tag\"")
);
assert!(
go_code.contains("ID")
&& go_code.contains("int32")
&& go_code.contains("`xml:\"id,attr\"`")
&& go_code.contains("xml:\"id,attr\"")
&& go_code.contains("json:\"id\"")
);
assert!(go_code.contains("func (s Account) Validate() error"));

Expand Down Expand Up @@ -934,23 +944,34 @@ fn test_cli_csharp_generation() {
assert!(cs_code.contains("[XmlEnum(\"standard\")]"));
assert!(cs_code.contains("public static bool IsValid(this AccountTier value)"));
assert!(cs_code.contains("public record Account("));
assert!(cs_code.contains("[property: XmlAttribute(\"id\")]") && cs_code.contains("int Id"));
assert!(
cs_code.contains("[property: XmlElement(\"name\")]") && cs_code.contains("string Name")
cs_code.contains("XmlAttribute(\"id\")")
&& cs_code.contains("JsonPropertyName(\"id\")")
&& cs_code.contains("int Id")
);
assert!(
cs_code.contains("XmlElement(\"name\")")
&& cs_code.contains("JsonPropertyName(\"name\")")
&& cs_code.contains("string Name")
);
assert!(
cs_code.contains("[property: XmlElement(\"tier\")]")
cs_code.contains("XmlElement(\"tier\")")
&& cs_code.contains("JsonPropertyName(\"tier\")")
&& cs_code.contains("AccountTier Tier")
);
assert!(
cs_code.contains("[property: XmlElement(\"balance\")]")
cs_code.contains("XmlElement(\"balance\")")
&& cs_code.contains("JsonPropertyName(\"balance\")")
&& cs_code.contains("decimal Balance")
);
assert!(
cs_code.contains("[property: XmlElement(\"alias\")]") && cs_code.contains("string? Alias")
cs_code.contains("XmlElement(\"alias\")")
&& cs_code.contains("JsonPropertyName(\"alias\")")
&& cs_code.contains("string? Alias")
);
assert!(
cs_code.contains("[property: XmlElement(\"tag\")]")
cs_code.contains("XmlElement(\"tag\")")
&& cs_code.contains("JsonPropertyName(\"tag\")")
&& cs_code.contains("List<string>? Tag")
);
assert!(cs_code.contains("public Account() : this("));
Expand Down Expand Up @@ -1050,3 +1071,61 @@ public class Program
"dotnet run failed on CLI generated C# files"
);
}

#[test]
fn test_cli_transcode_bidirectional() {
let dir = tempdir().unwrap();
let xml_file = dir.path().join("input.xml");
let json_file = dir.path().join("output.json");
let roundtrip_xml_file = dir.path().join("roundtrip.xml");

fs::write(
&xml_file,
r#"<service id="99" enabled="true"><name>API Gateway</name><port>8080</port><port>8443</port></service>"#,
)
.unwrap();

let exe = env!("CARGO_BIN_EXE_polyxml");

// 1. XML to JSON with --pretty
let status = Command::new(exe)
.args([
"transcode",
xml_file.to_str().unwrap(),
"-o",
json_file.to_str().unwrap(),
"--pretty",
])
.status()
.expect("failed to execute polyxml transcode");
assert!(status.success());

let json_str = fs::read_to_string(&json_file).unwrap();
assert!(json_str.contains("\"@id\": 99"));
assert!(json_str.contains("\"@enabled\": true"));
assert!(json_str.contains("\"name\": \"API Gateway\""));
assert!(json_str.contains("\"port\": [\n 8080,\n 8443\n ]"));

// 2. JSON to XML
let status2 = Command::new(exe)
.args([
"transcode",
json_file.to_str().unwrap(),
"-o",
roundtrip_xml_file.to_str().unwrap(),
"--pretty",
])
.status()
.expect("failed to execute polyxml transcode");
assert!(status2.success());

let roundtrip_str = fs::read_to_string(&roundtrip_xml_file).unwrap();
assert!(
roundtrip_str.contains("<service")
&& roundtrip_str.contains("id=\"99\"")
&& roundtrip_str.contains("enabled=\"true\"")
);
assert!(roundtrip_str.contains("<name>API Gateway</name>"));
assert!(roundtrip_str.contains("<port>8080</port>"));
assert!(roundtrip_str.contains("<port>8443</port>"));
}
Loading
Loading