Files
harmony/opnsense-codegen/src/codegen.rs
Jean-Gabriel Gill-Couture a032be6ce7 fix: codegen handles container elements in ArrayField and adds opn_map serializer
The XML parser silently skipped container elements (like <source>,
<destination>) nested inside ArrayField nodes because it only processed
children with a type attribute. This caused generated structs to be
missing nested fields, forcing opnsense-config to use json!() macros
instead of typed structs.

- Add container handling in ArrayField and custom *Field child loops
- Add serialize function to opn_map serde helper (was deserialize-only)
- Change opn_map serde attribute from deserialize_with to with
- Regenerate all 9 model files with the fixes

NatRuleRule now correctly has source/destination/created/updated
container structs with all child fields.
2026-04-06 11:14:46 -04:00

1090 lines
40 KiB
Rust

use crate::ir::{EnumIR, FieldIR, ModelIR, StructIR, StructKind};
use std::collections::HashSet;
use std::fmt::{Result as FmtResult, Write};
/// Rust keywords that must be escaped with `r#` when used as field names.
const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
"unsafe", "use", "where", "while", "abstract", "become", "box", "do", "final", "macro",
"override", "priv", "typeof", "unsized", "virtual", "yield", "try", "union",
];
fn is_rust_keyword(name: &str) -> bool {
RUST_KEYWORDS.contains(&name)
}
pub struct CodeGenerator {
output: String,
/// Full module path prefix for `serde(with)` attributes,
/// e.g. `crate::generated::dnsmasq`
module_path: String,
}
impl CodeGenerator {
pub fn new(module_path: String) -> Self {
Self {
output: String::new(),
module_path,
}
}
pub fn generate(&mut self, model: &ModelIR) -> FmtResult {
writeln!(self.output, "//! Auto-generated from OPNsense model XML")?;
writeln!(
self.output,
"//! Mount: `{}` — Version: `{}`",
model.mount, model.version
)?;
writeln!(self.output, "//!")?;
writeln!(
self.output,
"//! **DO NOT EDIT** — produced by opnsense-codegen"
)?;
writeln!(self.output)?;
writeln!(self.output, "use serde::{{Deserialize, Serialize}};")?;
// Only import HashMap if any field uses it
let needs_hashmap = model.structs.iter().any(|s| {
s.fields
.iter()
.any(|f| f.field_kind.as_deref() == Some("array_field"))
});
if needs_hashmap {
writeln!(self.output, "use std::collections::HashMap;")?;
}
writeln!(self.output)?;
// Emit serde_helpers module with only the helpers actually used
self.emit_serde_helpers(model)?;
writeln!(
self.output,
"// ═══════════════════════════════════════════════════════════════════════════"
)?;
writeln!(self.output, "// Enums")?;
writeln!(
self.output,
"// ═══════════════════════════════════════════════════════════════════════════"
)?;
writeln!(self.output)?;
for enum_ir in &model.enums {
self.generate_enum(enum_ir)?;
}
writeln!(self.output)?;
writeln!(
self.output,
"// ═══════════════════════════════════════════════════════════════════════════"
)?;
writeln!(self.output, "// Structs")?;
writeln!(
self.output,
"// ═══════════════════════════════════════════════════════════════════════════"
)?;
writeln!(self.output)?;
for struct_ir in &model.structs {
self.generate_struct(struct_ir, model)?;
}
writeln!(self.output)?;
writeln!(
self.output,
"// ═══════════════════════════════════════════════════════════════════════════"
)?;
writeln!(self.output, "// API Wrapper")?;
writeln!(
self.output,
"// ═══════════════════════════════════════════════════════════════════════════"
)?;
writeln!(self.output)?;
let response_name = format!("{}Response", model.root_struct_name);
let api_key = if model.api_key.is_empty() {
model.mount.trim_start_matches('/').replace('/', "")
} else {
model.api_key.clone()
};
writeln!(
self.output,
"/// Wrapper matching the OPNsense GET response envelope."
)?;
writeln!(
self.output,
"/// `GET /api/{}/get` returns {{ \"{}\": {{ ... }} }}",
api_key, api_key
)?;
writeln!(
self.output,
"#[derive(Default, Debug, Clone, Serialize, Deserialize)]"
)?;
writeln!(self.output, "pub struct {} {{", response_name)?;
writeln!(
self.output,
" pub {}: {},",
api_key, model.root_struct_name
)?;
writeln!(self.output, "}}")?;
Ok(())
}
/// Scan all fields in the model to find which serde helpers are actually used,
/// then emit only those.
fn emit_serde_helpers(&mut self, model: &ModelIR) -> FmtResult {
let mut used: HashSet<&str> = HashSet::new();
for struct_ir in &model.structs {
for field in &struct_ir.fields {
if let Some(ref sw) = field.serde_with {
// Only collect the bare helper names (opn_*)
if sw.starts_with("opn_") {
used.insert(sw.as_str());
}
}
}
}
if used.is_empty() {
return Ok(());
}
writeln!(self.output, "pub mod serde_helpers {{")?;
if used.contains("opn_bool") {
self.emit_opn_bool()?;
}
if used.contains("opn_bool_req") {
self.emit_opn_bool_req()?;
}
if used.contains("opn_u16") {
self.emit_opn_u16()?;
}
if used.contains("opn_u32") {
self.emit_opn_u32()?;
}
if used.contains("opn_string") {
self.emit_opn_string()?;
}
if used.contains("opn_csv") {
self.emit_opn_csv()?;
}
// Always check if any array fields exist and emit opn_map
let has_array_fields = model.structs.iter().any(|s| {
s.fields
.iter()
.any(|f| f.field_kind.as_deref() == Some("array_field"))
});
if has_array_fields {
self.emit_opn_map()?;
}
writeln!(self.output, "}}")?;
writeln!(self.output)?;
Ok(())
}
fn emit_opn_bool(&mut self) -> FmtResult {
writeln!(self.output, " pub mod opn_bool {{")?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serializer}};"
)?;
writeln!(self.output, " pub fn serialize<S: Serializer>(")?;
writeln!(self.output, " value: &Option<bool>,")?;
writeln!(self.output, " serializer: S,")?;
writeln!(self.output, " ) -> Result<S::Ok, S::Error> {{")?;
writeln!(self.output, " match value {{")?;
writeln!(
self.output,
" Some(true) => serializer.serialize_str(\"1\"),"
)?;
writeln!(
self.output,
" Some(false) => serializer.serialize_str(\"0\"),"
)?;
writeln!(
self.output,
" None => serializer.serialize_str(\"\"),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" pub fn deserialize<'de, D: Deserializer<'de>>("
)?;
writeln!(self.output, " deserializer: D,")?;
writeln!(
self.output,
" ) -> Result<Option<bool>, D::Error> {{"
)?;
writeln!(
self.output,
" let v = serde_json::Value::deserialize(deserializer)?;"
)?;
writeln!(self.output, " match &v {{")?;
writeln!(
self.output,
" serde_json::Value::String(s) => match s.as_str() {{"
)?;
writeln!(
self.output,
" \"1\" | \"true\" => Ok(Some(true)),"
)?;
writeln!(
self.output,
" \"0\" | \"false\" => Ok(Some(false)),"
)?;
writeln!(self.output, " \"\" => Ok(None),")?;
writeln!(
self.output,
" other => Err(serde::de::Error::custom(format!(\"invalid bool string: {{other}}\"))),"
)?;
writeln!(self.output, " }},")?;
writeln!(
self.output,
" serde_json::Value::Bool(b) => Ok(Some(*b)),"
)?;
writeln!(
self.output,
" serde_json::Value::Number(n) => match n.as_u64() {{"
)?;
writeln!(
self.output,
" Some(1) => Ok(Some(true)),"
)?;
writeln!(
self.output,
" Some(0) => Ok(Some(false)),"
)?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(format!(\"invalid bool number: {{n}}\"))),"
)?;
writeln!(self.output, " }},")?;
writeln!(
self.output,
" serde_json::Value::Null => Ok(None),"
)?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(\"expected string, bool, or number for bool field\")),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
Ok(())
}
fn emit_opn_bool_req(&mut self) -> FmtResult {
writeln!(self.output, " pub mod opn_bool_req {{")?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serializer}};"
)?;
writeln!(
self.output,
" pub fn serialize<S: Serializer>(value: &bool, serializer: S) -> Result<S::Ok, S::Error> {{"
)?;
writeln!(
self.output,
" serializer.serialize_str(if *value {{ \"1\" }} else {{ \"0\" }})"
)?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {{"
)?;
writeln!(
self.output,
" let v = serde_json::Value::deserialize(deserializer)?;"
)?;
writeln!(self.output, " match &v {{")?;
writeln!(
self.output,
" serde_json::Value::String(s) => match s.as_str() {{"
)?;
writeln!(
self.output,
" \"1\" | \"true\" => Ok(true),"
)?;
writeln!(
self.output,
" \"0\" | \"false\" => Ok(false),"
)?;
writeln!(
self.output,
" other => Err(serde::de::Error::custom(format!(\"invalid required bool: {{other}}\"))),"
)?;
writeln!(self.output, " }},")?;
writeln!(
self.output,
" serde_json::Value::Bool(b) => Ok(*b),"
)?;
writeln!(
self.output,
" serde_json::Value::Number(n) => match n.as_u64() {{"
)?;
writeln!(self.output, " Some(1) => Ok(true),")?;
writeln!(self.output, " Some(0) => Ok(false),")?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(format!(\"invalid required bool number: {{n}}\"))),"
)?;
writeln!(self.output, " }},")?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(\"expected string, bool, or number for required bool\")),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
Ok(())
}
fn emit_opn_u16(&mut self) -> FmtResult {
writeln!(self.output, " pub mod opn_u16 {{")?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serializer}};"
)?;
writeln!(self.output, " pub fn serialize<S: Serializer>(")?;
writeln!(self.output, " value: &Option<u16>,")?;
writeln!(self.output, " serializer: S,")?;
writeln!(self.output, " ) -> Result<S::Ok, S::Error> {{")?;
writeln!(self.output, " match value {{")?;
writeln!(
self.output,
" Some(v) => serializer.serialize_str(&v.to_string()),"
)?;
writeln!(
self.output,
" None => serializer.serialize_str(\"\"),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" pub fn deserialize<'de, D: Deserializer<'de>>("
)?;
writeln!(self.output, " deserializer: D,")?;
writeln!(self.output, " ) -> Result<Option<u16>, D::Error> {{")?;
writeln!(
self.output,
" let v = serde_json::Value::deserialize(deserializer)?;"
)?;
writeln!(self.output, " match &v {{")?;
writeln!(
self.output,
" serde_json::Value::String(s) if s.is_empty() => Ok(None),"
)?;
writeln!(
self.output,
" serde_json::Value::String(s) => s.parse::<u16>().map(Some).map_err(serde::de::Error::custom),"
)?;
writeln!(
self.output,
" serde_json::Value::Number(n) => n.as_u64().and_then(|n| u16::try_from(n).ok()).map(Some).ok_or_else(|| serde::de::Error::custom(\"number out of u16 range\")),"
)?;
writeln!(
self.output,
" serde_json::Value::Null => Ok(None),"
)?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(\"expected string or number for u16\")),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
Ok(())
}
fn emit_opn_u32(&mut self) -> FmtResult {
writeln!(self.output, " pub mod opn_u32 {{")?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serializer}};"
)?;
writeln!(self.output, " pub fn serialize<S: Serializer>(")?;
writeln!(self.output, " value: &Option<u32>,")?;
writeln!(self.output, " serializer: S,")?;
writeln!(self.output, " ) -> Result<S::Ok, S::Error> {{")?;
writeln!(self.output, " match value {{")?;
writeln!(
self.output,
" Some(v) => serializer.serialize_str(&v.to_string()),"
)?;
writeln!(
self.output,
" None => serializer.serialize_str(\"\"),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" pub fn deserialize<'de, D: Deserializer<'de>>("
)?;
writeln!(self.output, " deserializer: D,")?;
writeln!(self.output, " ) -> Result<Option<u32>, D::Error> {{")?;
writeln!(
self.output,
" let v = serde_json::Value::deserialize(deserializer)?;"
)?;
writeln!(self.output, " match &v {{")?;
writeln!(
self.output,
" serde_json::Value::String(s) if s.is_empty() => Ok(None),"
)?;
writeln!(
self.output,
" serde_json::Value::String(s) => s.parse::<u32>().map(Some).map_err(serde::de::Error::custom),"
)?;
writeln!(
self.output,
" serde_json::Value::Number(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()).map(Some).ok_or_else(|| serde::de::Error::custom(\"number out of u32 range\")),"
)?;
writeln!(
self.output,
" serde_json::Value::Null => Ok(None),"
)?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(\"expected string or number for u32\")),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
Ok(())
}
fn emit_opn_string(&mut self) -> FmtResult {
writeln!(self.output, " pub mod opn_string {{")?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serializer}};"
)?;
writeln!(self.output, " pub fn serialize<S: Serializer>(")?;
writeln!(self.output, " value: &Option<String>,")?;
writeln!(self.output, " serializer: S,")?;
writeln!(self.output, " ) -> Result<S::Ok, S::Error> {{")?;
writeln!(self.output, " match value {{")?;
writeln!(
self.output,
" Some(v) => serializer.serialize_str(v),"
)?;
writeln!(
self.output,
" None => serializer.serialize_str(\"\"),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" pub fn deserialize<'de, D: Deserializer<'de>>("
)?;
writeln!(self.output, " deserializer: D,")?;
writeln!(
self.output,
" ) -> Result<Option<String>, D::Error> {{"
)?;
writeln!(
self.output,
" let v = serde_json::Value::deserialize(deserializer)?;"
)?;
writeln!(self.output, " match v {{")?;
writeln!(
self.output,
" serde_json::Value::String(s) if s.is_empty() => Ok(None),"
)?;
writeln!(
self.output,
" serde_json::Value::String(s) => Ok(Some(s)),"
)?;
writeln!(
self.output,
" serde_json::Value::Object(map) => {{"
)?;
writeln!(
self.output,
" // OPNsense select widget: extract selected key"
)?;
writeln!(self.output, " let selected = map.iter()")?;
writeln!(
self.output,
" .find(|(_, v)| v.get(\"selected\").and_then(|s| s.as_i64()).unwrap_or(0) == 1)"
)?;
writeln!(
self.output,
" .map(|(k, _)| k.clone())"
)?;
writeln!(
self.output,
" .filter(|k| !k.is_empty());"
)?;
writeln!(self.output, " Ok(selected)")?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" serde_json::Value::Null => Ok(None),"
)?;
writeln!(
self.output,
" serde_json::Value::Array(_) => Ok(None),"
)?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(\"expected string, object, or null\")),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
Ok(())
}
fn emit_opn_csv(&mut self) -> FmtResult {
writeln!(self.output, " pub mod opn_csv {{")?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serializer}};"
)?;
writeln!(self.output, " pub fn serialize<S: Serializer>(")?;
writeln!(self.output, " value: &Option<Vec<String>>,")?;
writeln!(self.output, " serializer: S,")?;
writeln!(self.output, " ) -> Result<S::Ok, S::Error> {{")?;
writeln!(self.output, " match value {{")?;
writeln!(
self.output,
" Some(v) if !v.is_empty() => serializer.serialize_str(&v.join(\",\")),"
)?;
writeln!(
self.output,
" _ => serializer.serialize_str(\"\"),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" pub fn deserialize<'de, D: Deserializer<'de>>("
)?;
writeln!(self.output, " deserializer: D,")?;
writeln!(
self.output,
" ) -> Result<Option<Vec<String>>, D::Error> {{"
)?;
writeln!(
self.output,
" let v = serde_json::Value::deserialize(deserializer)?;"
)?;
writeln!(self.output, " match v {{")?;
writeln!(
self.output,
" serde_json::Value::String(s) if s.is_empty() => Ok(None),"
)?;
writeln!(
self.output,
" serde_json::Value::String(s) => Ok(Some(s.split(',').map(|item| item.trim().to_string()).collect())),"
)?;
writeln!(
self.output,
" serde_json::Value::Array(arr) => {{"
)?;
writeln!(
self.output,
" let items: Result<Vec<String>, _> = arr.into_iter().map(|v| match v {{"
)?;
writeln!(
self.output,
" serde_json::Value::String(s) => Ok(s),"
)?;
writeln!(
self.output,
" other => Err(serde::de::Error::custom(format!(\"expected string in array, got: {{other}}\"))),"
)?;
writeln!(self.output, " }}).collect();")?;
writeln!(self.output, " let items = items?;")?;
writeln!(
self.output,
" if items.is_empty() {{ Ok(None) }} else {{ Ok(Some(items)) }}"
)?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" serde_json::Value::Object(map) => {{"
)?;
writeln!(
self.output,
" // OPNsense select widget: {{\"key\": {{\"value\": \"...\", \"selected\": 1}}}}"
)?;
writeln!(
self.output,
" let selected: Vec<String> = map.into_iter()"
)?;
writeln!(
self.output,
" .filter(|(_, v)| v.get(\"selected\").and_then(|s| s.as_i64()).unwrap_or(0) == 1)"
)?;
writeln!(self.output, " .map(|(k, _)| k)")?;
writeln!(
self.output,
" .filter(|k| !k.is_empty())"
)?;
writeln!(self.output, " .collect();")?;
writeln!(
self.output,
" if selected.is_empty() {{ Ok(None) }} else {{ Ok(Some(selected)) }}"
)?;
writeln!(self.output, " }}")?;
writeln!(
self.output,
" serde_json::Value::Null => Ok(None),"
)?;
writeln!(
self.output,
" _ => Err(serde::de::Error::custom(\"expected string, array, or object for csv field\")),"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
Ok(())
}
/// Emit `opn_map` — serializes/deserializes HashMap fields that OPNsense may return
/// as either `{}` (object) or `[]` (empty array).
fn emit_opn_map(&mut self) -> FmtResult {
writeln!(self.output, " pub mod opn_map {{")?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serialize, Serializer}};"
)?;
writeln!(self.output, " use std::collections::HashMap;")?;
writeln!(self.output, " use std::fmt;")?;
writeln!(self.output, " use std::marker::PhantomData;")?;
writeln!(self.output)?;
writeln!(
self.output,
" pub fn deserialize<'de, D, V>(deserializer: D) -> Result<HashMap<String, V>, D::Error>"
)?;
writeln!(self.output, " where")?;
writeln!(self.output, " D: Deserializer<'de>,")?;
writeln!(self.output, " V: Deserialize<'de>,")?;
writeln!(self.output, " {{")?;
writeln!(
self.output,
" struct MapOrArray<V>(PhantomData<V>);"
)?;
writeln!(self.output)?;
writeln!(
self.output,
" impl<'de, V: Deserialize<'de>> serde::de::Visitor<'de> for MapOrArray<V> {{"
)?;
writeln!(
self.output,
" type Value = HashMap<String, V>;"
)?;
writeln!(self.output)?;
writeln!(
self.output,
" fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {{"
)?;
writeln!(
self.output,
" f.write_str(\"a map or an empty array\")"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
writeln!(
self.output,
" fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {{"
)?;
writeln!(
self.output,
" let mut result = HashMap::new();"
)?;
writeln!(
self.output,
" while let Some((k, v)) = map.next_entry()? {{"
)?;
writeln!(self.output, " result.insert(k, v);")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " Ok(result)")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
writeln!(
self.output,
" fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {{"
)?;
writeln!(
self.output,
" // Accept empty arrays as empty maps"
)?;
writeln!(
self.output,
" while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {{}}"
)?;
writeln!(self.output, " Ok(HashMap::new())")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
writeln!(
self.output,
" deserializer.deserialize_any(MapOrArray(PhantomData))"
)?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
// serialize: HashMap<String, V> → JSON object
writeln!(
self.output,
" pub fn serialize<S, V>(map: &HashMap<String, V>, serializer: S) -> Result<S::Ok, S::Error>"
)?;
writeln!(self.output, " where")?;
writeln!(self.output, " S: Serializer,")?;
writeln!(self.output, " V: Serialize,")?;
writeln!(self.output, " {{")?;
writeln!(self.output, " map.serialize(serializer)")?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
Ok(())
}
fn generate_enum(&mut self, enum_ir: &EnumIR) -> FmtResult {
let snake_name = to_snake_case(&enum_ir.name);
writeln!(self.output, "/// {}", enum_ir.name)?;
writeln!(self.output, "#[derive(Debug, Clone, PartialEq, Eq, Hash)]")?;
writeln!(self.output, "pub enum {} {{", enum_ir.name)?;
for variant in &enum_ir.variants {
writeln!(self.output, " {},", variant.rust_name)?;
}
writeln!(
self.output,
" /// Preserves unrecognized wire values for safe round-tripping."
)?;
writeln!(self.output, " Other(String),")?;
writeln!(self.output, "}}")?;
writeln!(self.output)?;
writeln!(self.output, "pub(crate) mod serde_{} {{", snake_name)?;
writeln!(self.output, " use super::{};", enum_ir.name)?;
writeln!(
self.output,
" use serde::{{Deserialize, Deserializer, Serializer}};"
)?;
writeln!(self.output)?;
writeln!(self.output, " pub fn serialize<S: Serializer>(")?;
writeln!(self.output, " value: &Option<{}>,", enum_ir.name)?;
writeln!(self.output, " serializer: S,")?;
writeln!(self.output, " ) -> Result<S::Ok, S::Error> {{")?;
writeln!(
self.output,
" serializer.serialize_str(match value {{"
)?;
for variant in &enum_ir.variants {
writeln!(
self.output,
" Some({}::{}) => \"{}\",",
enum_ir.name, variant.rust_name, variant.wire_value
)?;
}
writeln!(
self.output,
" Some({}::Other(s)) => s.as_str(),",
enum_ir.name
)?;
writeln!(self.output, " None => \"\",")?;
writeln!(self.output, " }})")?;
writeln!(self.output, " }}")?;
writeln!(self.output)?;
writeln!(
self.output,
" pub fn deserialize<'de, D: Deserializer<'de>>("
)?;
writeln!(self.output, " deserializer: D,")?;
writeln!(
self.output,
" ) -> Result<Option<{}>, D::Error> {{",
enum_ir.name
)?;
writeln!(
self.output,
" let v = serde_json::Value::deserialize(deserializer)?;"
)?;
writeln!(self.output, " match v {{")?;
writeln!(
self.output,
" serde_json::Value::String(s) => match s.as_str() {{"
)?;
for variant in &enum_ir.variants {
writeln!(
self.output,
" \"{}\" => Ok(Some({}::{})),",
variant.wire_value, enum_ir.name, variant.rust_name
)?;
}
writeln!(self.output, " \"\" => Ok(None),")?;
writeln!(
self.output,
" other => Ok(Some({}::Other(other.to_string()))),",
enum_ir.name
)?;
writeln!(self.output, " }},")?;
writeln!(
self.output,
" serde_json::Value::Object(map) => {{"
)?;
writeln!(
self.output,
" // OPNsense select widget: {{\"key\": {{\"value\": \"...\", \"selected\": 1}}}}"
)?;
writeln!(self.output, " let selected_key = map.iter()")?;
writeln!(
self.output,
" .find(|(_, v)| v.get(\"selected\").and_then(|s| s.as_i64()).unwrap_or(0) == 1)"
)?;
writeln!(
self.output,
" .map(|(k, _)| k.as_str());"
)?;
writeln!(self.output, " match selected_key {{")?;
for variant in &enum_ir.variants {
writeln!(
self.output,
" Some(\"{}\") => Ok(Some({}::{})),",
variant.wire_value, enum_ir.name, variant.rust_name
)?;
}
writeln!(
self.output,
" Some(\"\") | None => Ok(None),"
)?;
writeln!(
self.output,
" Some(other) => Ok(Some({}::Other(other.to_string()))),",
enum_ir.name
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }},")?;
writeln!(
self.output,
" serde_json::Value::Null => Ok(None),"
)?;
// Array-style select widget: [{value, selected}, ...]
writeln!(
self.output,
" serde_json::Value::Array(arr) => {{"
)?;
writeln!(self.output, " let selected = arr.iter()")?;
writeln!(
self.output,
" .find(|v| v.get(\"selected\").and_then(|s| s.as_i64()).unwrap_or(0) == 1)"
)?;
writeln!(
self.output,
" .and_then(|v| v.get(\"value\").and_then(|s| s.as_str()));"
)?;
writeln!(self.output, " match selected {{")?;
for variant in &enum_ir.variants {
writeln!(
self.output,
" Some(\"{}\") => Ok(Some({}::{})),",
variant.wire_value, enum_ir.name, variant.rust_name
)?;
}
writeln!(
self.output,
" Some(\"\") | None => Ok(None),"
)?;
writeln!(
self.output,
" Some(other) => Ok(Some({}::Other(other.to_string()))),",
enum_ir.name
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }},")?;
writeln!(
self.output,
" other => Err(serde::de::Error::custom(format!(\"unexpected type for {}: {{:?}}\", other))),",
enum_ir.name
)?;
writeln!(self.output, " }}")?;
writeln!(self.output, " }}")?;
writeln!(self.output, "}}")?;
writeln!(self.output)?;
Ok(())
}
fn generate_struct(&mut self, struct_ir: &StructIR, model: &ModelIR) -> FmtResult {
match struct_ir.kind {
StructKind::Root => {
writeln!(self.output, "/// Root model for `{}`", model.mount)?;
}
StructKind::Container => {
let doc = struct_ir
.json_key
.as_ref()
.map(|k| format!("Container for `{}`", k))
.unwrap_or_else(|| "Container".to_string());
writeln!(self.output, "/// {}", doc)?;
}
StructKind::ArrayItem => {
writeln!(
self.output,
"/// Array item for `{}`",
struct_ir.json_key.as_deref().unwrap_or("items")
)?;
}
}
writeln!(
self.output,
"#[derive(Default, Debug, Clone, Serialize, Deserialize)]"
)?;
writeln!(self.output, "pub struct {} {{", struct_ir.name)?;
for field in &struct_ir.fields {
self.generate_field(field)?;
}
writeln!(self.output, "}}")?;
writeln!(self.output)?;
Ok(())
}
fn generate_field(&mut self, field: &FieldIR) -> FmtResult {
if let Some(ref doc) = field.doc {
writeln!(self.output, " /// {}", doc)?;
}
let is_keyword = is_rust_keyword(&field.name);
let has_invalid_chars = field.name.contains('-') || field.name.contains('.');
let needs_rename = is_keyword || has_invalid_chars;
let field_ident = if is_keyword {
format!("r#{}", field.name)
} else if has_invalid_chars {
field.name.replace('-', "_").replace('.', "_")
} else {
field.name.clone()
};
// Build the serde attribute parts
let mut serde_parts: Vec<String> = Vec::new();
if needs_rename {
serde_parts.push(format!("rename = \"{}\"", field.name));
}
// Always add default — OPNsense API responses may omit fields
// even if the XML model marks them as required
serde_parts.push("default".to_string());
if field.field_kind.as_deref() == Some("array_field") {
// ArrayField: use opn_map to handle both {} and [] from the API
let map_path = format!("{}::serde_helpers::opn_map", self.module_path);
serde_parts.push(format!("with = \"{}\"", map_path));
} else if let Some(ref serde_with) = field.serde_with {
let full_path = self.resolve_serde_path(serde_with);
serde_parts.push(format!("with = \"{}\"", full_path));
}
if !serde_parts.is_empty() {
writeln!(self.output, " #[serde({})]", serde_parts.join(", "))?;
}
writeln!(self.output, " pub {}: {},", field_ident, field.rust_type)?;
writeln!(self.output)?;
Ok(())
}
/// Resolve a bare serde_with name to a full path.
/// - `opn_bool` → `{module_path}::serde_helpers::opn_bool`
/// - `serde_add_mac` → `{module_path}::serde_add_mac`
fn resolve_serde_path(&self, serde_with: &str) -> String {
if serde_with.starts_with("opn_") {
format!("{}::serde_helpers::{}", self.module_path, serde_with)
} else {
// Enum serde modules (e.g. serde_add_mac)
format!("{}::{}", self.module_path, serde_with)
}
}
pub fn into_output(self) -> String {
self.output
}
}
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_ascii_lowercase());
}
result
}
pub fn derive_module_name(struct_name: &str) -> String {
to_snake_case(struct_name)
}
/// Generate Rust code from a ModelIR.
///
/// `module_path` is the full crate path prefix for `serde(with)` attributes,
/// e.g. `crate::generated::dnsmasq`. If None, it is auto-derived from the
/// root struct name as `crate::generated::{module_name}`.
pub fn generate(model: &ModelIR, module_path: Option<&str>) -> String {
let module_name = derive_module_name(&model.root_struct_name);
let path = module_path
.map(|s| s.to_string())
.unwrap_or_else(|| format!("crate::generated::{}", module_name));
let mut generator = CodeGenerator::new(path);
generator
.generate(model)
.expect("generation should not fail");
generator.into_output()
}
/// Scan a directory for generated `.rs` files and write a `mod.rs` that
/// re-exports all of them.
pub fn write_mod_rs(dir: &std::path::Path) -> std::io::Result<()> {
let mut modules: Vec<String> = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("rs") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if stem != "mod" {
modules.push(stem.to_string());
}
}
}
}
modules.sort();
let mut content = String::new();
content.push_str("//! Auto-generated module index — DO NOT EDIT\n");
content.push_str("//!\n");
content.push_str("//! Produced by `opnsense-codegen`.\n\n");
for m in &modules {
content.push_str(&format!("pub mod {};\n", m));
}
std::fs::write(dir.join("mod.rs"), content)?;
Ok(())
}