Skip to content

Commit 746d600

Browse files
committed
Refactor TagType structure and add round-trip serialization test for L5X files
- Removed the TagContent enum and replaced it with individual fields in the Tag struct for better clarity and structure. - Updated the LocalizedCommentWide struct to follow the same pattern as Tag, replacing the mixed content vector with specific fields. - Introduced a new example for round-trip serialization testing of L5X files, ensuring that parsing and serializing maintains data integrity. - Implemented comparison logic to identify discrepancies between original and serialized projects.
1 parent 79e3a01 commit 746d600

25 files changed

Lines changed: 3525 additions & 3422 deletions

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

l5x/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## 0.6.0 (2026-03-09)
4+
- l5x serialization
5+
36
## 0.5.0 (2025-12-09)
47
- fixed security module
58

l5x/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "l5x"
3-
version = "0.5.1"
3+
version = "0.6.0"
44
edition = "2021"
55
description = "Parser for Rockwell Automation L5X files (Studio 5000 Logix Designer)"
66
license = "MIT"

l5x/README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,44 @@ A Rust library for parsing L5X files exported from Studio 5000 Logix Designer.
44

55
## Features
66

7+
- **Parsing** - fast, type-safe parsing via `quick-xml` and `serde`
8+
- **Serialization** - serialize back to L5X XML for round-trip editing
79
- **RLL (Relay Ladder Logic) parsing** - parse ladder logic instructions into AST
810
- **Tag reference extraction** - find all tag references in rungs
9-
- **Security/Safety features** - protection against certan type of badly formed XML
11+
- **Security/Safety features** - protection against certain types of badly formed XML
1012

1113
## Installation
1214

1315
Add to your `Cargo.toml`:
1416

1517
```toml
1618
[dependencies]
17-
l5x = "0.5"
19+
l5x = "0.6"
1820
```
1921

2022
## Usage
2123

24+
### Modify and write an L5X file
25+
26+
```rust
27+
use l5x::{from_str, to_string, Project};
28+
29+
let xml = std::fs::read_to_string("project.L5X")?;
30+
let mut project: Project = l5x::from_str(&xml)?;
31+
32+
// Modify the project
33+
if let Some(ref mut ctrl) = project.controller {
34+
ctrl.description = Some("Updated by tool".to_string());
35+
}
36+
37+
// Serialize back to XML
38+
let output = format!(
39+
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n{}",
40+
l5x::to_string(&project)?
41+
);
42+
std::fs::write("modified.L5X", output)?;
43+
```
44+
2245
### Parse an L5X file
2346

2447
```rust

l5x/examples/roundtrip.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//! Round-trip serialization test for L5X files.
2+
//!
3+
//! Parses an L5X file, serializes it back to XML, then re-parses
4+
//! to verify correctness. Reports any differences or issues.
5+
//!
6+
//! Usage:
7+
//! cargo run --example roundtrip -- path/to/file.L5X
8+
//!
9+
10+
use l5x::{from_str, to_string, Project};
11+
use std::{env, fs, path::Path};
12+
use walkdir::WalkDir;
13+
14+
const XML_HEADER: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#;
15+
16+
fn test_file(path: &Path) -> Result<RoundtripResult, String> {
17+
let original = fs::read_to_string(path)
18+
.map_err(|e| format!("read error: {e}"))?;
19+
20+
// Step 1: parse
21+
let project: Project = from_str(&original)
22+
.map_err(|e| format!("parse error: {e}"))?;
23+
24+
// Step 2: serialize
25+
let serialized = to_string(&project)
26+
.map_err(|e| format!("serialize error: {e}"))?;
27+
28+
// Prepend XML declaration (quick-xml se doesn't add it automatically)
29+
let with_header = format!("{XML_HEADER}\n{serialized}");
30+
31+
// Step 3: re-parse the serialized output
32+
let reparsed: Project = from_str(&with_header)
33+
.map_err(|e| format!("re-parse error: {e}\n--- serialized output (first 2000 chars) ---\n{}", &serialized[..serialized.len().min(2000)]))?;
34+
35+
// Step 4: compare key fields
36+
let issues = compare_projects(&project, &reparsed);
37+
38+
Ok(RoundtripResult {
39+
serialized_len: serialized.len(),
40+
original_len: original.len(),
41+
issues,
42+
})
43+
}
44+
45+
struct RoundtripResult {
46+
original_len: usize,
47+
serialized_len: usize,
48+
issues: Vec<String>,
49+
}
50+
51+
fn compare_projects(a: &Project, b: &Project) -> Vec<String> {
52+
let mut issues = Vec::new();
53+
54+
if a.schema_revision != b.schema_revision {
55+
issues.push(format!(
56+
"schema_revision mismatch: {:?} vs {:?}",
57+
a.schema_revision, b.schema_revision
58+
));
59+
}
60+
if a.software_revision != b.software_revision {
61+
issues.push(format!(
62+
"software_revision mismatch: {:?} vs {:?}",
63+
a.software_revision, b.software_revision
64+
));
65+
}
66+
if a.target_name != b.target_name {
67+
issues.push(format!(
68+
"target_name mismatch: {:?} vs {:?}",
69+
a.target_name, b.target_name
70+
));
71+
}
72+
73+
// Check controller name if present
74+
if let (Some(ca), Some(cb)) = (&a.controller, &b.controller) {
75+
if ca.name != cb.name {
76+
issues.push(format!(
77+
"controller.name mismatch: {:?} vs {:?}",
78+
ca.name, cb.name
79+
));
80+
}
81+
} else if a.controller.is_some() != b.controller.is_some() {
82+
issues.push("controller presence mismatch".to_string());
83+
}
84+
85+
issues
86+
}
87+
88+
fn main() {
89+
let args: Vec<String> = env::args().collect();
90+
91+
if args.len() >= 2 {
92+
// Single file mode
93+
let path = Path::new(&args[1]);
94+
print!("{} ... ", path.file_name().unwrap().to_string_lossy());
95+
match test_file(path) {
96+
Ok(r) => print_result(&r),
97+
Err(e) => println!("[FAIL] {e}"),
98+
}
99+
return;
100+
}
101+
102+
// Corpus mode
103+
let corpus_dir = env::var("DATAPLC_DIR").unwrap_or_else(|_| {
104+
eprintln!("Set DATAPLC_DIR to a directory containing .L5X files.");
105+
std::process::exit(1);
106+
});
107+
108+
println!("Round-trip serialization test on: {corpus_dir}\n");
109+
110+
let mut total = 0;
111+
let mut passed = 0;
112+
let mut failed = 0;
113+
let mut warned = 0;
114+
let mut total_original_bytes: u64 = 0;
115+
let mut total_serialized_bytes: u64 = 0;
116+
117+
for entry in WalkDir::new(&corpus_dir)
118+
.into_iter()
119+
.filter_map(|e| e.ok())
120+
.filter(|e| {
121+
e.path()
122+
.extension()
123+
.and_then(|s| s.to_str())
124+
.map(|s| s.eq_ignore_ascii_case("l5x"))
125+
.unwrap_or(false)
126+
})
127+
{
128+
total += 1;
129+
let path = entry.path();
130+
print!("{} ... ", path.file_name().unwrap().to_string_lossy());
131+
132+
match test_file(path) {
133+
Ok(r) => {
134+
total_original_bytes += r.original_len as u64;
135+
total_serialized_bytes += r.serialized_len as u64;
136+
if r.issues.is_empty() {
137+
passed += 1;
138+
} else {
139+
warned += 1;
140+
}
141+
print_result(&r);
142+
}
143+
Err(e) => {
144+
failed += 1;
145+
println!("[FAIL] {e}");
146+
}
147+
}
148+
}
149+
150+
println!();
151+
println!("Results: {total} files — {passed} passed, {warned} warned, {failed} failed");
152+
if total_original_bytes > 0 {
153+
let ratio = total_serialized_bytes as f64 / total_original_bytes as f64;
154+
println!(
155+
"Total size: {} KB -> {} KB ({:.1}%)",
156+
total_original_bytes / 1024,
157+
total_serialized_bytes / 1024,
158+
ratio * 100.0
159+
);
160+
}
161+
}
162+
163+
fn print_result(r: &RoundtripResult) {
164+
let ratio = r.serialized_len as f64 / r.original_len as f64;
165+
if r.issues.is_empty() {
166+
println!("[OK] {:.1}% size ({} -> {} bytes)", ratio * 100.0, r.original_len, r.serialized_len);
167+
} else {
168+
println!("[WARN] {:.1}% size — {} field issue(s):", ratio * 100.0, r.issues.len());
169+
for issue in &r.issues {
170+
println!(" - {issue}");
171+
}
172+
}
173+
}

l5x/generated/generated.rs

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,57 @@
1-
// Auto-generated L5X types from XSD schema
2-
// DO NOT EDIT MANUALLY
1+
// Auto-generated L5X types from RSLogix5000 XSD schema
2+
// DO NOT EDIT - Generated by build script
33

4-
#![allow(clippy::large_enum_variant)]
4+
use serde::{Serialize, Deserialize};
55

6-
use serde::{Deserialize, Serialize};
7-
8-
#[path = "generated_programs.rs"]
9-
mod programs;
10-
#[path = "generated_core.rs"]
11-
mod core;
126
#[path = "generated_devices.rs"]
137
mod devices;
14-
#[path = "generated_network.rs"]
15-
mod network;
16-
#[path = "generated_alarms.rs"]
17-
mod alarms;
18-
#[path = "generated_instructions.rs"]
19-
mod instructions;
20-
#[path = "generated_children.rs"]
21-
mod children;
228
#[path = "generated_trends.rs"]
239
mod trends;
10+
#[path = "generated_core.rs"]
11+
mod core;
2412
#[path = "generated_tags.rs"]
2513
mod tags;
26-
#[path = "generated_security.rs"]
27-
mod security;
28-
#[path = "generated_motion.rs"]
29-
mod motion;
3014
#[path = "generated_elements.rs"]
3115
mod elements;
32-
#[path = "generated_datatypes.rs"]
33-
mod datatypes;
16+
#[path = "generated_children.rs"]
17+
mod children;
18+
#[path = "generated_data.rs"]
19+
mod data;
20+
#[path = "generated_alarms.rs"]
21+
mod alarms;
22+
#[path = "generated_security.rs"]
23+
mod security;
24+
#[path = "generated_network.rs"]
25+
mod network;
26+
#[path = "generated_programs.rs"]
27+
mod programs;
3428
#[path = "generated_misc.rs"]
3529
mod misc;
3630
#[path = "generated_tasks.rs"]
3731
mod tasks;
38-
#[path = "generated_data.rs"]
39-
mod data;
32+
#[path = "generated_datatypes.rs"]
33+
mod datatypes;
34+
#[path = "generated_motion.rs"]
35+
mod motion;
36+
#[path = "generated_instructions.rs"]
37+
mod instructions;
4038
pub use self::{
41-
programs::*,
42-
core::*,
4339
devices::*,
44-
network::*,
45-
alarms::*,
46-
instructions::*,
47-
children::*,
4840
trends::*,
41+
core::*,
4942
tags::*,
50-
security::*,
51-
motion::*,
5243
elements::*,
53-
datatypes::*,
44+
children::*,
45+
data::*,
46+
alarms::*,
47+
security::*,
48+
network::*,
49+
programs::*,
5450
misc::*,
5551
tasks::*,
56-
data::*
52+
datatypes::*,
53+
motion::*,
54+
instructions::*
5755
};
5856

5957
/// Placeholder for xs:any wildcard elements

0 commit comments

Comments
 (0)