Some checks failed
Run Check Script / check (pull_request) Failing after 1m52s
1206 lines
37 KiB
Rust
1206 lines
37 KiB
Rust
// `XmlNode` currently has only one used variant (`Element`); the
|
|
// `let XmlNode::Element { ... } = node else { ... }` pattern is
|
|
// therefore irrefutable today, but the `else` arm is kept
|
|
// deliberately as defense-in-depth — when the IR grows a second
|
|
// variant (Text, Comment, …) the compiler will start enforcing it
|
|
// again. Suppress the warning at the file level so the structure
|
|
// of these defensive guards stays uniform across the parser.
|
|
#![allow(irrefutable_let_patterns)]
|
|
|
|
use heck::{ToPascalCase, ToSnakeCase};
|
|
use log::info;
|
|
use quick_xml::events::Event;
|
|
use quick_xml::reader::Reader;
|
|
use std::collections::HashMap;
|
|
use std::io::Cursor;
|
|
|
|
use crate::ir::{EnumIR, EnumVariantIR, FieldIR, ModelIR, StructIR, StructKind};
|
|
|
|
/// Ensure a string is a valid Rust identifier by prefixing digit-starting names
|
|
/// with `V` (e.g., `0NoClientHello` → `V0NoClientHello`, `8021q` → `V8021q`).
|
|
fn sanitize_rust_ident(name: &str) -> String {
|
|
if name.starts_with(|c: char| c.is_ascii_digit()) {
|
|
format!("V{name}")
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ParseError {
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("XML error: {0}")]
|
|
Xml(String),
|
|
#[error("Parse error: {0}")]
|
|
Custom(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum XmlNode {
|
|
Element {
|
|
name: String,
|
|
attributes: HashMap<String, String>,
|
|
children: Vec<XmlNode>,
|
|
text: Option<String>,
|
|
},
|
|
}
|
|
|
|
fn parse_xml_into_tree(xml_data: &[u8]) -> Result<XmlNode, ParseError> {
|
|
let mut reader = Reader::from_reader(Cursor::new(xml_data.to_vec()));
|
|
let mut buf = Vec::new();
|
|
let mut stack: Vec<XmlNode> = Vec::new();
|
|
let mut root: Option<XmlNode> = None;
|
|
|
|
loop {
|
|
buf.clear();
|
|
let event = reader
|
|
.read_event_into(&mut buf)
|
|
.map_err(|e| ParseError::Xml(e.to_string()))?;
|
|
|
|
match event {
|
|
Event::Start(e) => {
|
|
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
let attributes: HashMap<String, String> = e
|
|
.attributes()
|
|
.flatten()
|
|
.map(|a| {
|
|
(
|
|
String::from_utf8_lossy(a.key.as_ref()).to_string(),
|
|
String::from_utf8_lossy(a.value.as_ref()).to_string(),
|
|
)
|
|
})
|
|
.collect();
|
|
stack.push(XmlNode::Element {
|
|
name,
|
|
attributes,
|
|
children: Vec::new(),
|
|
text: None,
|
|
});
|
|
}
|
|
Event::Text(e) => {
|
|
let text = e.unescape().map_err(|e| ParseError::Xml(e.to_string()))?;
|
|
if !text.trim().is_empty()
|
|
&& let Some(XmlNode::Element { text: cur_text, .. }) = stack.last_mut()
|
|
&& cur_text.is_none()
|
|
{
|
|
*cur_text = Some(text.to_string());
|
|
}
|
|
}
|
|
Event::End(_) => {
|
|
if let Some(node) = stack.pop() {
|
|
let elem = match node {
|
|
XmlNode::Element {
|
|
name,
|
|
attributes,
|
|
children,
|
|
text,
|
|
} => XmlNode::Element {
|
|
name,
|
|
attributes,
|
|
children,
|
|
text,
|
|
},
|
|
};
|
|
if stack.is_empty() {
|
|
root = Some(elem);
|
|
} else if let Some(XmlNode::Element { children: sib, .. }) = stack.last_mut() {
|
|
sib.push(elem);
|
|
}
|
|
}
|
|
}
|
|
Event::Empty(e) => {
|
|
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
let attributes: HashMap<String, String> = e
|
|
.attributes()
|
|
.flatten()
|
|
.map(|a| {
|
|
(
|
|
String::from_utf8_lossy(a.key.as_ref()).to_string(),
|
|
String::from_utf8_lossy(a.value.as_ref()).to_string(),
|
|
)
|
|
})
|
|
.collect();
|
|
let elem = XmlNode::Element {
|
|
name,
|
|
attributes,
|
|
children: Vec::new(),
|
|
text: None,
|
|
};
|
|
if stack.is_empty() {
|
|
root = Some(elem);
|
|
} else if let Some(XmlNode::Element { children: sib, .. }) = stack.last_mut() {
|
|
sib.push(elem);
|
|
}
|
|
}
|
|
Event::Eof => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
root.ok_or_else(|| ParseError::Custom("no root element".to_string()))
|
|
}
|
|
|
|
pub fn parse_xml(xml_data: &[u8]) -> Result<ModelIR, ParseError> {
|
|
let root = parse_xml_into_tree(xml_data)?;
|
|
|
|
let XmlNode::Element {
|
|
name: root_name,
|
|
children,
|
|
..
|
|
} = root
|
|
else {
|
|
return Err(ParseError::Custom("root must be an element".to_string()));
|
|
};
|
|
|
|
if root_name != "model" {
|
|
return Err(ParseError::Custom(format!(
|
|
"expected <model> root, got <{}>",
|
|
root_name
|
|
)));
|
|
}
|
|
|
|
let mut model = ModelIR {
|
|
mount: String::new(),
|
|
description: String::new(),
|
|
version: String::new(),
|
|
api_key: String::new(),
|
|
root_struct_name: String::new(),
|
|
enums: Vec::new(),
|
|
structs: Vec::new(),
|
|
};
|
|
|
|
let mut items_children: Vec<XmlNode> = Vec::new();
|
|
for child in &children {
|
|
let XmlNode::Element {
|
|
name,
|
|
children: gc,
|
|
text,
|
|
..
|
|
} = child
|
|
else {
|
|
continue;
|
|
};
|
|
match name.as_str() {
|
|
"mount" => model.mount = text.clone().unwrap_or_default(),
|
|
"description" => model.description = text.clone().unwrap_or_default(),
|
|
"version" => model.version = text.clone().unwrap_or_default(),
|
|
"items" => items_children = gc.clone(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
model.root_struct_name = derive_root_struct_name(&model.mount);
|
|
model.api_key = derive_api_key(&model.mount);
|
|
|
|
if !items_children.is_empty() {
|
|
process_items(&items_children, &mut model)?;
|
|
}
|
|
|
|
Ok(model)
|
|
}
|
|
|
|
fn process_items(children: &[XmlNode], model: &mut ModelIR) -> Result<(), ParseError> {
|
|
let root_name = model.root_struct_name.clone();
|
|
let mut root_struct = StructIR {
|
|
name: root_name.clone(),
|
|
kind: StructKind::Root,
|
|
json_key: None,
|
|
fields: Vec::new(),
|
|
};
|
|
|
|
for child in children {
|
|
let XmlNode::Element {
|
|
name,
|
|
attributes,
|
|
children: gc,
|
|
text,
|
|
..
|
|
} = child
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if attributes.contains_key("type") {
|
|
let field = build_field(name, attributes, gc, text, root_name.clone(), model, None)?;
|
|
root_struct.fields.push(field);
|
|
} else {
|
|
let container_name = name.clone();
|
|
let child_struct = process_container(
|
|
name,
|
|
gc,
|
|
format!("{}{}", root_name, container_name.to_pascal_case()),
|
|
container_name.clone(),
|
|
model,
|
|
)?;
|
|
root_struct.fields.push(FieldIR {
|
|
name: container_name,
|
|
rust_type: child_struct.name.clone(),
|
|
serde_with: None,
|
|
opn_type: "Container".to_string(),
|
|
required: true,
|
|
default: None,
|
|
doc: None,
|
|
min: None,
|
|
max: None,
|
|
enum_ref: None,
|
|
as_list: None,
|
|
multiple: None,
|
|
mask: None,
|
|
struct_ref: Some(child_struct.name.clone()),
|
|
field_kind: Some("container".to_string()),
|
|
constraints: None,
|
|
relation: None,
|
|
});
|
|
model.structs.push(child_struct);
|
|
}
|
|
}
|
|
|
|
model.structs.insert(0, root_struct);
|
|
Ok(())
|
|
}
|
|
|
|
fn process_container(
|
|
_name: &str,
|
|
children: &[XmlNode],
|
|
struct_name: String,
|
|
json_key: String,
|
|
model: &mut ModelIR,
|
|
) -> Result<StructIR, ParseError> {
|
|
let mut struct_ir = StructIR {
|
|
name: struct_name,
|
|
kind: StructKind::Container,
|
|
json_key: Some(json_key),
|
|
fields: Vec::new(),
|
|
};
|
|
|
|
for child in children {
|
|
let XmlNode::Element {
|
|
name: cn,
|
|
attributes,
|
|
children: cc,
|
|
text,
|
|
..
|
|
} = child
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if attributes.contains_key("type") {
|
|
let f = build_field(
|
|
cn,
|
|
attributes,
|
|
cc,
|
|
text,
|
|
struct_ir.name.clone(),
|
|
model,
|
|
None,
|
|
)?;
|
|
struct_ir.fields.push(f);
|
|
} else {
|
|
let sub_name = cn.clone();
|
|
let child_struct = process_container(
|
|
cn,
|
|
cc,
|
|
format!("{}{}", struct_ir.name, cn.to_pascal_case()),
|
|
cn.clone(),
|
|
model,
|
|
)?;
|
|
struct_ir.fields.push(FieldIR {
|
|
name: sub_name,
|
|
rust_type: child_struct.name.clone(),
|
|
serde_with: None,
|
|
opn_type: "Container".to_string(),
|
|
required: true,
|
|
default: None,
|
|
doc: None,
|
|
min: None,
|
|
max: None,
|
|
enum_ref: None,
|
|
as_list: None,
|
|
multiple: None,
|
|
mask: None,
|
|
struct_ref: Some(child_struct.name.clone()),
|
|
field_kind: Some("container".to_string()),
|
|
constraints: None,
|
|
relation: None,
|
|
});
|
|
model.structs.push(child_struct);
|
|
}
|
|
}
|
|
|
|
Ok(struct_ir)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct FieldMetadata {
|
|
required: bool,
|
|
default: Option<String>,
|
|
min: Option<i64>,
|
|
max: Option<i64>,
|
|
as_list: bool,
|
|
multiple: bool,
|
|
mask: Option<String>,
|
|
constraints: Vec<crate::ir::ConstraintIR>,
|
|
relation: Option<crate::ir::RelationIR>,
|
|
}
|
|
|
|
fn extract_field_metadata(children: &[XmlNode]) -> FieldMetadata {
|
|
let mut meta = FieldMetadata::default();
|
|
for child in children {
|
|
let XmlNode::Element {
|
|
name,
|
|
children: gc,
|
|
text,
|
|
..
|
|
} = child
|
|
else {
|
|
continue;
|
|
};
|
|
match name.as_str() {
|
|
"Required" if text.as_deref() == Some("Y") => {
|
|
meta.required = true;
|
|
}
|
|
"Default" => {
|
|
meta.default = text.clone();
|
|
}
|
|
"MinimumValue" => {
|
|
meta.min = text.as_ref().and_then(|v| v.parse().ok());
|
|
}
|
|
"MaximumValue" => {
|
|
meta.max = text.as_ref().and_then(|v| v.parse().ok());
|
|
}
|
|
"AsList" if text.as_deref() == Some("Y") => {
|
|
meta.as_list = true;
|
|
}
|
|
"Multiple" if text.as_deref() == Some("Y") => {
|
|
meta.multiple = true;
|
|
}
|
|
"Mask" => {
|
|
meta.mask = text.clone();
|
|
}
|
|
"Constraints" => {
|
|
for constraint_node in gc {
|
|
let XmlNode::Element { children: cc, .. } = constraint_node else {
|
|
continue;
|
|
};
|
|
let mut constraint_type = None;
|
|
let mut message = String::new();
|
|
for inner in cc {
|
|
let XmlNode::Element {
|
|
name: iname,
|
|
text: itext,
|
|
..
|
|
} = inner
|
|
else {
|
|
continue;
|
|
};
|
|
match iname.as_str() {
|
|
"type" => constraint_type = itext.clone(),
|
|
"ValidationMessage" => message = itext.clone().unwrap_or_default(),
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some(ct) = constraint_type {
|
|
meta.constraints.push(crate::ir::ConstraintIR {
|
|
constraint_type: ct,
|
|
message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
"Model" => {
|
|
let mut source = None;
|
|
let mut items = None;
|
|
let mut display = None;
|
|
for model_child in gc {
|
|
let XmlNode::Element {
|
|
name: _mn,
|
|
children: mcc,
|
|
..
|
|
} = model_child
|
|
else {
|
|
continue;
|
|
};
|
|
for tag_info in mcc {
|
|
let XmlNode::Element {
|
|
name: tn,
|
|
children: tcc,
|
|
..
|
|
} = tag_info
|
|
else {
|
|
continue;
|
|
};
|
|
if tn == "tag" {
|
|
for attr in tcc {
|
|
let XmlNode::Element {
|
|
name: an,
|
|
text: atext,
|
|
..
|
|
} = attr
|
|
else {
|
|
continue;
|
|
};
|
|
match an.as_str() {
|
|
"source" => source = atext.clone(),
|
|
"items" => items = atext.clone(),
|
|
"display" => display = atext.clone(),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let (Some(source), Some(items), Some(display)) = (source, items, display) {
|
|
meta.relation = Some(crate::ir::RelationIR {
|
|
source,
|
|
items,
|
|
display,
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
meta
|
|
}
|
|
|
|
fn build_field(
|
|
name: &str,
|
|
attributes: &HashMap<String, String>,
|
|
children: &[XmlNode],
|
|
_text: &Option<String>,
|
|
parent_name: String,
|
|
model: &mut ModelIR,
|
|
enum_name_prefix: Option<&str>,
|
|
) -> Result<FieldIR, ParseError> {
|
|
let field_type = attributes
|
|
.get("type")
|
|
.cloned()
|
|
.unwrap_or_else(|| "TextField".to_string());
|
|
// Strip ".\" prefix used in OPNsense XML for local type references
|
|
let field_type = field_type.trim_start_matches(".\\").to_string();
|
|
|
|
let meta = extract_field_metadata(children);
|
|
|
|
if field_type == "ArrayField" {
|
|
let singular_name = singularize(name);
|
|
let item_struct_name = format!("{}{}", parent_name, singular_name.to_pascal_case());
|
|
let rust_type = format!("HashMap<String, {}>", item_struct_name);
|
|
|
|
let mut item_struct = StructIR {
|
|
name: item_struct_name.clone(),
|
|
kind: StructKind::ArrayItem,
|
|
json_key: Some(name.to_string()),
|
|
fields: Vec::new(),
|
|
};
|
|
|
|
let enum_prefix = singular_name.to_pascal_case();
|
|
|
|
for child in children {
|
|
let XmlNode::Element {
|
|
name: cn,
|
|
attributes: ca,
|
|
children: cc,
|
|
text: ct,
|
|
..
|
|
} = child
|
|
else {
|
|
continue;
|
|
};
|
|
if ca.contains_key("type") {
|
|
let f = build_field(
|
|
cn,
|
|
ca,
|
|
cc,
|
|
ct,
|
|
item_struct_name.clone(),
|
|
model,
|
|
Some(&enum_prefix),
|
|
)?;
|
|
item_struct.fields.push(f);
|
|
} else if !cc.is_empty() {
|
|
// Container element (e.g., <source>, <destination>) — generate nested struct
|
|
let container_struct = process_container(
|
|
cn,
|
|
cc,
|
|
format!("{}{}", item_struct_name, cn.to_pascal_case()),
|
|
cn.clone(),
|
|
model,
|
|
)?;
|
|
item_struct.fields.push(FieldIR {
|
|
name: cn.clone(),
|
|
rust_type: container_struct.name.clone(),
|
|
serde_with: None,
|
|
opn_type: "Container".to_string(),
|
|
required: false,
|
|
default: None,
|
|
doc: None,
|
|
min: None,
|
|
max: None,
|
|
enum_ref: None,
|
|
as_list: None,
|
|
multiple: None,
|
|
mask: None,
|
|
struct_ref: Some(container_struct.name.clone()),
|
|
field_kind: Some("container".to_string()),
|
|
constraints: None,
|
|
relation: None,
|
|
});
|
|
model.structs.push(container_struct);
|
|
}
|
|
}
|
|
|
|
model.structs.push(item_struct);
|
|
|
|
return Ok(FieldIR {
|
|
name: name.to_string(),
|
|
rust_type,
|
|
serde_with: None,
|
|
opn_type: field_type,
|
|
required: false,
|
|
default: None,
|
|
doc: None,
|
|
min: None,
|
|
max: None,
|
|
enum_ref: None,
|
|
as_list: None,
|
|
multiple: None,
|
|
mask: None,
|
|
struct_ref: Some(item_struct_name),
|
|
field_kind: Some("array_field".to_string()),
|
|
constraints: None,
|
|
relation: None,
|
|
});
|
|
}
|
|
|
|
// Custom *Field types (e.g. FilterRuleField, SourceNatRuleField) that have
|
|
// child elements with type attributes are ArrayField subclasses. Treat them
|
|
// the same as ArrayField — recursively parse children into struct fields.
|
|
let has_typed_children = children.iter().any(
|
|
|c| matches!(c, XmlNode::Element { attributes, .. } if attributes.contains_key("type")),
|
|
);
|
|
if field_type != "ArrayField"
|
|
&& field_type != "OptionField"
|
|
&& field_type.ends_with("Field")
|
|
&& has_typed_children
|
|
{
|
|
let singular_name = singularize(name);
|
|
let item_struct_name = format!("{}{}", parent_name, singular_name.to_pascal_case());
|
|
let rust_type = format!("HashMap<String, {}>", item_struct_name);
|
|
|
|
let mut item_struct = StructIR {
|
|
name: item_struct_name.clone(),
|
|
kind: StructKind::ArrayItem,
|
|
json_key: Some(name.to_string()),
|
|
fields: Vec::new(),
|
|
};
|
|
|
|
let enum_prefix = item_struct_name.clone();
|
|
|
|
for child in children {
|
|
let XmlNode::Element {
|
|
name: cn,
|
|
attributes: ca,
|
|
children: cc,
|
|
text: ct,
|
|
..
|
|
} = child
|
|
else {
|
|
continue;
|
|
};
|
|
if ca.contains_key("type") {
|
|
let f = build_field(
|
|
cn,
|
|
ca,
|
|
cc,
|
|
ct,
|
|
item_struct_name.clone(),
|
|
model,
|
|
Some(&enum_prefix),
|
|
)?;
|
|
item_struct.fields.push(f);
|
|
} else if !cc.is_empty() {
|
|
// Container element — generate nested struct
|
|
let container_struct = process_container(
|
|
cn,
|
|
cc,
|
|
format!("{}{}", item_struct_name, cn.to_pascal_case()),
|
|
cn.clone(),
|
|
model,
|
|
)?;
|
|
item_struct.fields.push(FieldIR {
|
|
name: cn.clone(),
|
|
rust_type: container_struct.name.clone(),
|
|
serde_with: None,
|
|
opn_type: "Container".to_string(),
|
|
required: false,
|
|
default: None,
|
|
doc: None,
|
|
min: None,
|
|
max: None,
|
|
enum_ref: None,
|
|
as_list: None,
|
|
multiple: None,
|
|
mask: None,
|
|
struct_ref: Some(container_struct.name.clone()),
|
|
field_kind: Some("container".to_string()),
|
|
constraints: None,
|
|
relation: None,
|
|
});
|
|
model.structs.push(container_struct);
|
|
}
|
|
}
|
|
|
|
model.structs.push(item_struct);
|
|
|
|
return Ok(FieldIR {
|
|
name: name.to_string(),
|
|
rust_type,
|
|
serde_with: None,
|
|
opn_type: field_type,
|
|
required: false,
|
|
default: None,
|
|
doc: Some(format!("{} (custom ArrayField subclass)", name)),
|
|
min: None,
|
|
max: None,
|
|
enum_ref: None,
|
|
as_list: None,
|
|
multiple: None,
|
|
mask: None,
|
|
struct_ref: Some(item_struct_name),
|
|
field_kind: Some("array_field".to_string()),
|
|
constraints: None,
|
|
relation: None,
|
|
});
|
|
}
|
|
|
|
if field_type == "OptionField" {
|
|
let enum_name = if let Some(prefix) = enum_name_prefix {
|
|
format!("{}{}", prefix, name.to_pascal_case())
|
|
} else {
|
|
name.to_pascal_case()
|
|
};
|
|
let mut variants = Vec::new();
|
|
let _default_variant: Option<String> = meta.default.clone();
|
|
|
|
for child in children {
|
|
let XmlNode::Element {
|
|
name: cn,
|
|
children: cc,
|
|
..
|
|
} = child
|
|
else {
|
|
continue;
|
|
};
|
|
if cn == "OptionValues" {
|
|
for variant_node in cc {
|
|
let XmlNode::Element {
|
|
name: vn,
|
|
text: vt,
|
|
attributes: va,
|
|
..
|
|
} = variant_node
|
|
else {
|
|
continue;
|
|
};
|
|
// Use the `value` attribute if present (e.g. <pcp0 value="0">),
|
|
// otherwise fall back to the element name.
|
|
let wire_value = va.get("value").cloned().unwrap_or_else(|| vn.clone());
|
|
let rust_name = vt
|
|
.as_ref()
|
|
.filter(|t| t != &vn)
|
|
.map(|t| t.to_pascal_case())
|
|
.unwrap_or_else(|| vn.to_pascal_case());
|
|
let rust_name = sanitize_rust_ident(&rust_name);
|
|
variants.push(EnumVariantIR {
|
|
rust_name,
|
|
wire_value,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if variants.is_empty()
|
|
&& let Some(t) = meta.default.clone()
|
|
&& !t.is_empty()
|
|
{
|
|
variants.push(EnumVariantIR {
|
|
rust_name: sanitize_rust_ident(&t.to_pascal_case()),
|
|
wire_value: t.clone(),
|
|
});
|
|
}
|
|
|
|
model.enums.push(EnumIR {
|
|
name: enum_name.clone(),
|
|
variants,
|
|
});
|
|
|
|
let doc = build_field_doc(
|
|
&field_type,
|
|
meta.required,
|
|
meta.default.clone(),
|
|
meta.min,
|
|
meta.max,
|
|
Some(enum_name.as_str()),
|
|
);
|
|
|
|
return Ok(FieldIR {
|
|
name: name.to_string(),
|
|
rust_type: format!("Option<{}>", enum_name),
|
|
serde_with: Some(format!("serde_{}", enum_name.to_snake_case())),
|
|
opn_type: field_type,
|
|
required: meta.required,
|
|
default: meta.default,
|
|
doc: Some(doc),
|
|
min: meta.min,
|
|
max: meta.max,
|
|
enum_ref: Some(enum_name),
|
|
as_list: None,
|
|
multiple: None,
|
|
mask: meta.mask,
|
|
struct_ref: None,
|
|
field_kind: None,
|
|
constraints: if meta.constraints.is_empty() {
|
|
None
|
|
} else {
|
|
Some(meta.constraints)
|
|
},
|
|
relation: meta.relation,
|
|
});
|
|
}
|
|
|
|
let rust_type = compute_rust_type(
|
|
&field_type,
|
|
meta.required,
|
|
meta.as_list,
|
|
meta.multiple,
|
|
meta.min,
|
|
meta.max,
|
|
);
|
|
let serde_with = derive_serde_with(
|
|
&field_type,
|
|
&rust_type,
|
|
meta.required,
|
|
meta.as_list,
|
|
meta.multiple,
|
|
);
|
|
let doc = build_field_doc(
|
|
&field_type,
|
|
meta.required,
|
|
meta.default.clone(),
|
|
meta.min,
|
|
meta.max,
|
|
None,
|
|
);
|
|
|
|
Ok(FieldIR {
|
|
name: name.to_string(),
|
|
rust_type,
|
|
serde_with,
|
|
opn_type: field_type,
|
|
required: meta.required,
|
|
default: meta.default,
|
|
doc: Some(doc),
|
|
min: meta.min,
|
|
max: meta.max,
|
|
enum_ref: None,
|
|
as_list: if meta.as_list { Some(true) } else { None },
|
|
multiple: if meta.multiple { Some(true) } else { None },
|
|
mask: meta.mask,
|
|
struct_ref: None,
|
|
field_kind: None,
|
|
constraints: if meta.constraints.is_empty() {
|
|
None
|
|
} else {
|
|
Some(meta.constraints)
|
|
},
|
|
relation: meta.relation,
|
|
})
|
|
}
|
|
|
|
fn compute_rust_type(
|
|
opn_type: &str,
|
|
required: bool,
|
|
as_list: bool,
|
|
multiple: bool,
|
|
min: Option<i64>,
|
|
max: Option<i64>,
|
|
) -> String {
|
|
match opn_type {
|
|
"BooleanField" => {
|
|
if required {
|
|
"bool".to_string()
|
|
} else {
|
|
"Option<bool>".to_string()
|
|
}
|
|
}
|
|
"IntegerField" | "AutoNumberField" => {
|
|
let use_u32 =
|
|
max.map(|m| m > 65535).unwrap_or(false) || min.is_none() || min == Some(0);
|
|
if use_u32 {
|
|
"Option<u32>".to_string()
|
|
} else {
|
|
"Option<u16>".to_string()
|
|
}
|
|
}
|
|
"NumericField" => "Option<String>".to_string(),
|
|
"TextField"
|
|
| "DescriptionField"
|
|
| "UpdateOnlyTextField"
|
|
| "Base64Field"
|
|
| "UniqueIdField"
|
|
| "HostnameField"
|
|
| "NetworkField"
|
|
| "EmailField"
|
|
| "UrlField"
|
|
| "MacAddressField"
|
|
| "IPPortField"
|
|
| "PortField"
|
|
| "NetworkAliasField"
|
|
| "VirtualIPField"
|
|
| "CertificateField"
|
|
| "AuthGroupField"
|
|
| "AuthenticationServerField"
|
|
| "CountryField"
|
|
| "ProtocolField"
|
|
| "ConfigdActionsField"
|
|
| "JsonKeyValueStoreField"
|
|
| "InterfaceField"
|
|
| "LegacyLinkField"
|
|
| "CSVListField" => {
|
|
if as_list || multiple {
|
|
"Option<Vec<String>>".to_string()
|
|
} else if required {
|
|
"String".to_string()
|
|
} else {
|
|
"Option<String>".to_string()
|
|
}
|
|
}
|
|
"OptionField" => "Option<Enum>".to_string(),
|
|
"ArrayField" => "HashMap<String, Item>".to_string(),
|
|
"ModelRelationField" => {
|
|
if multiple {
|
|
"Option<Vec<String>>".to_string()
|
|
} else {
|
|
"Option<String>".to_string()
|
|
}
|
|
}
|
|
other => {
|
|
// Unknown *Field types may return select widget objects from the
|
|
// API, so they go through opn_string/opn_csv which always return
|
|
// Option types.
|
|
if other.ends_with("Field") {
|
|
if as_list || multiple {
|
|
"Option<Vec<String>>".to_string()
|
|
} else {
|
|
"Option<String>".to_string()
|
|
}
|
|
} else if as_list || multiple {
|
|
"Option<Vec<String>>".to_string()
|
|
} else if required {
|
|
"String".to_string()
|
|
} else {
|
|
"Option<String>".to_string()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn derive_serde_with(
|
|
opn_type: &str,
|
|
rust_type: &str,
|
|
required: bool,
|
|
as_list: bool,
|
|
multiple: bool,
|
|
) -> Option<String> {
|
|
let uses_option = rust_type.starts_with("Option<") || rust_type.starts_with("HashMap");
|
|
if !(uses_option || required && opn_type == "BooleanField") {
|
|
return None;
|
|
}
|
|
|
|
match opn_type {
|
|
"BooleanField" => {
|
|
if required {
|
|
Some("opn_bool_req".to_string())
|
|
} else {
|
|
Some("opn_bool".to_string())
|
|
}
|
|
}
|
|
"IntegerField" | "AutoNumberField" => {
|
|
if rust_type.contains("u16") {
|
|
Some("opn_u16".to_string())
|
|
} else {
|
|
Some("opn_u32".to_string())
|
|
}
|
|
}
|
|
"TextField"
|
|
| "DescriptionField"
|
|
| "UpdateOnlyTextField"
|
|
| "Base64Field"
|
|
| "UniqueIdField"
|
|
| "HostnameField"
|
|
| "NetworkField"
|
|
| "EmailField"
|
|
| "UrlField"
|
|
| "MacAddressField"
|
|
| "IPPortField"
|
|
| "PortField"
|
|
| "NetworkAliasField"
|
|
| "VirtualIPField"
|
|
| "CertificateField"
|
|
| "AuthGroupField"
|
|
| "AuthenticationServerField"
|
|
| "CountryField"
|
|
| "ProtocolField"
|
|
| "ConfigdActionsField"
|
|
| "JsonKeyValueStoreField"
|
|
| "InterfaceField"
|
|
| "LegacyLinkField"
|
|
| "NumericField"
|
|
| "CSVListField"
|
|
| "ModelRelationField" => {
|
|
if as_list || multiple {
|
|
Some("opn_csv".to_string())
|
|
} else {
|
|
Some("opn_string".to_string())
|
|
}
|
|
}
|
|
other => {
|
|
// Any unrecognized *Field type might return a select widget
|
|
// object from the API, so default to opn_string/opn_csv.
|
|
if other.ends_with("Field") {
|
|
info!("Unknown field type {opn_type}, defaulting to opn_string/opn_csv");
|
|
if as_list || multiple {
|
|
Some("opn_csv".to_string())
|
|
} else {
|
|
Some("opn_string".to_string())
|
|
}
|
|
} else {
|
|
info!("Did not find type for serde derive {opn_type}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_field_doc(
|
|
opn_type: &str,
|
|
required: bool,
|
|
default: Option<String>,
|
|
min: Option<i64>,
|
|
max: Option<i64>,
|
|
enum_name: Option<&str>,
|
|
) -> String {
|
|
let mut parts = Vec::new();
|
|
parts.push(opn_type.to_string());
|
|
|
|
if required {
|
|
parts.push("required".to_string());
|
|
} else {
|
|
parts.push("optional".to_string());
|
|
}
|
|
|
|
if let Some(d) = default {
|
|
parts.push(format!("default={}", d));
|
|
}
|
|
|
|
if let (Some(m), Some(mx)) = (min, max) {
|
|
parts.push(format!("[{}-{}]", m, mx));
|
|
} else if let Some(m) = min {
|
|
parts.push(format!("[{}, ∞)", m));
|
|
}
|
|
|
|
if let Some(en) = enum_name {
|
|
parts.push(format!("enum={}", en));
|
|
}
|
|
|
|
parts.join(" | ")
|
|
}
|
|
|
|
fn derive_root_struct_name(mount: &str) -> String {
|
|
let parts: Vec<&str> = mount
|
|
.trim_start_matches('/')
|
|
.split('/')
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
if parts.len() >= 2 {
|
|
parts[parts.len() - 2..]
|
|
.iter()
|
|
.map(|s| (*s).to_pascal_case())
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
} else if let Some(last) = parts.last() {
|
|
last.to_pascal_case()
|
|
} else {
|
|
"Root".to_string()
|
|
}
|
|
}
|
|
|
|
fn derive_api_key(mount: &str) -> String {
|
|
let parts: Vec<&str> = mount
|
|
.trim_start_matches('/')
|
|
.split('/')
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
if parts.len() >= 2 {
|
|
parts[parts.len() - 2..]
|
|
.iter()
|
|
.map(|s| s.to_snake_case())
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
} else if let Some(last) = parts.last() {
|
|
last.to_snake_case()
|
|
} else {
|
|
"root".to_string()
|
|
}
|
|
}
|
|
|
|
fn singularize(s: &str) -> String {
|
|
static EXCEPTIONS: &[(&str, &str)] = &[
|
|
("addresses", "address"),
|
|
("aliases", "alias"),
|
|
("buses", "bus"),
|
|
("bases", "base"),
|
|
("caches", "cache"),
|
|
("cases", "case"),
|
|
("classes", "class"),
|
|
("codes", "code"),
|
|
("codes", "code"),
|
|
("courses", "course"),
|
|
("databases", "database"),
|
|
("dates", "date"),
|
|
("devices", "device"),
|
|
("directories", "directory"),
|
|
("domains", "domain"),
|
|
("entries", "entry"),
|
|
("errors", "error"),
|
|
("examples", "example"),
|
|
("fields", "field"),
|
|
("files", "file"),
|
|
("filters", "filter"),
|
|
("formats", "format"),
|
|
("functions", "function"),
|
|
("games", "game"),
|
|
("gateways", "gateway"),
|
|
("generics", "generic"),
|
|
("groups", "group"),
|
|
("histories", "history"),
|
|
("hosts", "host"),
|
|
("identifiers", "identifier"),
|
|
("images", "image"),
|
|
("indexes", "index"),
|
|
("indicies", "index"),
|
|
("instances", "instance"),
|
|
("interfaces", "interface"),
|
|
("keys", "key"),
|
|
("labels", "label"),
|
|
("languages", "language"),
|
|
("levels", "level"),
|
|
("links", "link"),
|
|
("lists", "list"),
|
|
("locations", "location"),
|
|
("logs", "log"),
|
|
("maps", "map"),
|
|
("maps", "map"),
|
|
("marks", "mark"),
|
|
("members", "member"),
|
|
("messages", "message"),
|
|
("modes", "mode"),
|
|
("models", "model"),
|
|
("names", "name"),
|
|
("networks", "network"),
|
|
("nodes", "node"),
|
|
("notifications", "notification"),
|
|
("numbers", "number"),
|
|
("objects", "object"),
|
|
("options", "option"),
|
|
("orders", "order"),
|
|
("packages", "package"),
|
|
("pages", "page"),
|
|
("pairs", "pair"),
|
|
("paths", "path"),
|
|
("policies", "policy"),
|
|
("pools", "pool"),
|
|
("ports", "port"),
|
|
("prefixes", "prefix"),
|
|
("principals", "principal"),
|
|
("profiles", "profile"),
|
|
("programs", "program"),
|
|
("projects", "project"),
|
|
("protocols", "protocol"),
|
|
("proxies", "proxy"),
|
|
("queries", "query"),
|
|
("queues", "queue"),
|
|
("ranges", "range"),
|
|
("records", "record"),
|
|
("references", "reference"),
|
|
("registries", "registry"),
|
|
("relations", "relation"),
|
|
("reports", "report"),
|
|
("requests", "request"),
|
|
("resources", "resource"),
|
|
("responses", "response"),
|
|
("rules", "rule"),
|
|
("schemas", "schema"),
|
|
("sections", "section"),
|
|
("security", "security"),
|
|
("sequences", "sequence"),
|
|
("servers", "server"),
|
|
("services", "service"),
|
|
("sessions", "session"),
|
|
("settings", "setting"),
|
|
("shares", "share"),
|
|
("signatures", "signature"),
|
|
("sizes", "size"),
|
|
("sources", "source"),
|
|
("spaces", "space"),
|
|
("specifications", "specification"),
|
|
("states", "state"),
|
|
("statistics", "statistic"),
|
|
("statuses", "status"),
|
|
("structures", "structure"),
|
|
("subnets", "subnet"),
|
|
("supports", "support"),
|
|
("symbols", "symbol"),
|
|
("syntaxes", "syntax"),
|
|
("system", "system"),
|
|
("systems", "system"),
|
|
("tables", "table"),
|
|
("tags", "tag"),
|
|
("targets", "target"),
|
|
("tasks", "task"),
|
|
("templates", "template"),
|
|
("tests", "test"),
|
|
("times", "time"),
|
|
("tokens", "token"),
|
|
("topics", "topic"),
|
|
("traces", "trace"),
|
|
("types", "type"),
|
|
("users", "user"),
|
|
("usages", "usage"),
|
|
("values", "value"),
|
|
("versions", "version"),
|
|
("views", "view"),
|
|
("volumes", "volume"),
|
|
("warnings", "warning"),
|
|
("zones", "zone"),
|
|
];
|
|
|
|
for (plural, singular) in EXCEPTIONS {
|
|
if *plural == s {
|
|
return singular.to_string();
|
|
}
|
|
}
|
|
|
|
if s.ends_with("ies") && s.len() > 3 {
|
|
format!("{}y", &s[..s.len() - 3])
|
|
} else if s.ends_with("es") && s.len() > 2 {
|
|
s[..s.len() - 2].to_string()
|
|
} else if s.ends_with("s") && s.len() > 1 {
|
|
s[..s.len() - 1].to_string()
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
}
|