From a6478c9bd4b6dc76639332fa24fb6c03bf342341 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Fri, 7 Aug 2026 12:42:15 +0700 Subject: [PATCH] feat(uci): derive UCI schema from settings Add uci-derive proc-macro crate with UciConfig and UciSection derive macros that generate UCI schema metadata from Settings structs. The schema maps UCI section types and option names to config-rs field paths. UciSource::from_schema() creates a source from the derived schema, mapping known UCI options to their Settings fields while preserving unknown options under raw UCI-derived paths. --- Cargo.lock | 99 ++++++++++++++ Cargo.toml | 5 + crates/uci-derive/Cargo.toml | 16 +++ crates/uci-derive/src/lib.rs | 252 +++++++++++++++++++++++++++++++++++ src/platform/unix/uci.rs | 56 +++++++- src/settings/mod.rs | 20 ++- src/settings/uci_schema.rs | 29 ++++ 7 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 crates/uci-derive/Cargo.toml create mode 100644 crates/uci-derive/src/lib.rs create mode 100644 src/settings/uci_schema.rs diff --git a/Cargo.lock b/Cargo.lock index 8cf7a74..5545a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -187,6 +193,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "icmp-socket2" version = "0.6.0" @@ -196,6 +208,16 @@ dependencies = [ "socket2", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itertools" version = "0.13.0" @@ -471,6 +493,19 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -529,6 +564,7 @@ dependencies = [ "rustix", "serde", "thiserror", + "uci-derive", ] [[package]] @@ -553,6 +589,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.19" @@ -579,10 +630,12 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ + "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -604,6 +657,37 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "trybuild" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + +[[package]] +name = "uci-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "trybuild", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -655,6 +739,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -678,3 +771,9 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index ec3eea2..1ed5008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ flume = { version = "0.12.0", default-features = false, features = ["eventual-f config = { version = "0.15.25", default-features = false } csv = "1.4.0" serde = { version = "1", features = ["derive"] } +uci-derive = { path = "crates/uci-derive" } [dependencies.log] features = ["std"] @@ -46,3 +47,7 @@ default = ["toml", "uci-vendored"] toml = ["config/toml"] uci = ["dep:rust-uci"] uci-vendored = ["uci", "rust-uci/vendored"] + +[workspace] +members = ["crates/uci-derive"] +resolver = "3" diff --git a/crates/uci-derive/Cargo.toml b/crates/uci-derive/Cargo.toml new file mode 100644 index 0000000..6e0abf1 --- /dev/null +++ b/crates/uci-derive/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "uci-derive" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + +[dev-dependencies] +trybuild = "1" diff --git a/crates/uci-derive/src/lib.rs b/crates/uci-derive/src/lib.rs new file mode 100644 index 0000000..f379b76 --- /dev/null +++ b/crates/uci-derive/src/lib.rs @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair) +// +// SPDX-License-Identifier: MPL-2.0 + +use proc_macro::TokenStream; +use quote::quote; +use syn::ext::IdentExt; +use syn::{Data, DeriveInput, Field, Fields, FieldsNamed, LitStr, Token, parse_macro_input}; + +/// Generates `UciSectionSchema` from a settings struct. +/// +/// Every named field is mapped one-to-one: +/// +/// ```text +/// Rust field name -> UCI option name +/// Rust field name -> config-rs field name +/// ``` +#[proc_macro_derive(UciSection)] +pub fn derive_uci_section(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + match expand_uci_section(&input) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + +/// Generates `UciConfigSchema` from the root settings struct. +/// +/// Required container attribute: +/// +/// ```ignore +/// #[uci(package = "sqm-autorate-rust")] +/// ``` +/// +/// Every field must specify its UCI section: +/// +/// ```ignore +/// #[uci(section)] +/// network: NetworkSettings, +/// +/// #[uci(section = "advanced_settings")] +/// advanced: AdvancedSettings, +/// ``` +#[proc_macro_derive(UciConfig, attributes(uci))] +pub fn derive_uci_config(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + match expand_uci_config(&input) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + +fn expand_uci_section(input: &DeriveInput) -> syn::Result { + let struct_name = &input.ident; + let fields = require_named_fields(input)?; + + let mappings = fields + .named + .iter() + .map(|field| { + let field_ident = field + .ident + .as_ref() + .expect("named fields always have identifiers"); + + let field_name = field_ident.unraw().to_string(); + let field_name = LitStr::new(&field_name, field_ident.span()); + + quote! { + crate::settings::uci_schema::UciOptionMapping { + uci_option: #field_name, + config_field: #field_name, + } + } + }) + .collect::>(); + + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + #[automatically_derived] + impl #impl_generics + crate::settings::uci_schema::UciSectionSchema + for #struct_name #type_generics + #where_clause + { + const UCI_OPTIONS: &'static [ + crate::settings::uci_schema::UciOptionMapping + ] = &[ + #(#mappings),* + ]; + } + }) +} + +fn expand_uci_config(input: &DeriveInput) -> syn::Result { + let struct_name = &input.ident; + let fields = require_named_fields(input)?; + let package = parse_package_name(input)?; + + let mut sections = Vec::with_capacity(fields.named.len()); + + for field in &fields.named { + let field_ident = field + .ident + .as_ref() + .expect("named fields always have identifiers"); + + let field_type = &field.ty; + + let config_section_name = field_ident.unraw().to_string(); + let config_section_name = LitStr::new(&config_section_name, field_ident.span()); + + let uci_section_name = parse_section_name(field)?; + + sections.push(quote! { + crate::settings::uci_schema::UciSectionMapping { + uci_section: #uci_section_name, + config_section: #config_section_name, + options: < + #field_type as + crate::settings::uci_schema::UciSectionSchema + >::UCI_OPTIONS, + } + }); + } + + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + #[automatically_derived] + impl #impl_generics + crate::settings::uci_schema::UciConfigSchema + for #struct_name #type_generics + #where_clause + { + const UCI_PACKAGE: &'static str = #package; + + const UCI_SECTIONS: &'static [ + crate::settings::uci_schema::UciSectionMapping + ] = &[ + #(#sections),* + ]; + } + }) +} + +fn require_named_fields(input: &DeriveInput) -> syn::Result<&FieldsNamed> { + match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => Ok(fields), + + Fields::Unnamed(_) | Fields::Unit => Err(syn::Error::new_spanned( + input, + "this derive only supports structs with named fields", + )), + }, + + Data::Enum(_) | Data::Union(_) => Err(syn::Error::new_spanned( + input, + "this derive only supports structs", + )), + } +} + +fn parse_package_name(input: &DeriveInput) -> syn::Result { + let mut package = None; + + for attribute in &input.attrs { + if !attribute.path().is_ident("uci") { + continue; + } + + attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("package") { + if package.is_some() { + return Err(meta.error("duplicate `package` attribute")); + } + + let value: LitStr = meta.value()?.parse()?; + + if value.value().is_empty() { + return Err(syn::Error::new(value.span(), "`package` cannot be empty")); + } + + package = Some(value); + return Ok(()); + } + + Err(meta.error("expected `package = \"...\"`")) + })?; + } + + package.ok_or_else(|| { + syn::Error::new_spanned( + &input.ident, + "UciConfig requires `#[uci(package = \"...\")]`", + ) + }) +} + +fn parse_section_name(field: &Field) -> syn::Result { + let field_ident = field + .ident + .as_ref() + .expect("named fields always have identifiers"); + + let default_name = field_ident.unraw().to_string(); + let default_name = LitStr::new(&default_name, field_ident.span()); + + let mut section = None; + + for attribute in &field.attrs { + if !attribute.path().is_ident("uci") { + continue; + } + + attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("section") { + if section.is_some() { + return Err(meta.error("duplicate `section` attribute")); + } + + if meta.input.peek(Token![=]) { + let value: LitStr = meta.value()?.parse()?; + + if value.value().is_empty() { + return Err(syn::Error::new(value.span(), "`section` cannot be empty")); + } + + section = Some(value); + } else { + section = Some(default_name.clone()); + } + + return Ok(()); + } + + Err(meta.error("expected `section` or `section = \"...\"`")) + })?; + } + + section.ok_or_else(|| { + syn::Error::new_spanned( + field, + "UciConfig fields require `#[uci(section)]` \ + or `#[uci(section = \"...\")]`", + ) + }) +} diff --git a/src/platform/unix/uci.rs b/src/platform/unix/uci.rs index 24f3737..cd48c44 100644 --- a/src/platform/unix/uci.rs +++ b/src/platform/unix/uci.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use crate::settings::uci_schema::{UciConfigSchema, UciSectionMapping}; use config::{ConfigError as SourceError, Map, Source, Value, ValueKind}; use rust_uci::config::{ @@ -16,6 +17,7 @@ enum PackageSelection { pub(crate) struct UciSource { packages: PackageSelection, required: bool, + schema: &'static [UciSectionMapping], config_dir: Option, save_dir: Option, } @@ -25,6 +27,7 @@ impl UciSource { Self { packages, required: true, + schema: &[], config_dir: None, save_dir: None, } @@ -50,6 +53,18 @@ impl UciSource { self } + pub(crate) fn from_schema() -> Self + where + T: UciConfigSchema, + { + Self::new([T::UCI_PACKAGE]).with_schema(T::UCI_SECTIONS) + } + + pub(crate) fn with_schema(mut self, schema: &'static [UciSectionMapping]) -> Self { + self.schema = schema; + self + } + #[allow(dead_code)] pub(crate) fn with_directories( mut self, @@ -84,6 +99,10 @@ impl UciSource { Ok(uci.into()) } + /// + /// Converts a [`SectionSelector`] into a string representation. + /// Anonymous sections are converted to `section_type[index]` format, + /// because config-rs does not allow @-prefixed section names. fn config_selector(selector: &SectionSelector<'_>) -> String { match selector { SectionSelector::Named(name) => (*name).to_owned(), @@ -95,6 +114,32 @@ impl UciSource { } } + fn mapped_config_key( + &self, + selector: &SectionSelector<'_>, + uci_option: &str, + ) -> Option { + let section_type = match selector { + SectionSelector::Anonymous { + section_type, + index, + } if *index == 0 => *section_type, + + SectionSelector::Anonymous { .. } | SectionSelector::Named(_) => return None, + }; + + self.schema + .iter() + .filter(|section| section.uci_section == section_type) + .find_map(|section| { + section + .options + .iter() + .find(|option| option.uci_option == uci_option) + .map(|option| format!("{}.{}", section.config_section, option.config_field,)) + }) + } + fn collect_package( &self, package_name: &str, @@ -127,14 +172,19 @@ impl UciSource { for option in options { let option_name = option.name(); + let uci_relative_key = format!("{selector}.{option_name}"); + let config_relative_key = format!("{}.{option_name}", Self::config_selector(&selector),); - let destination_key = format!("{package_name}.{config_relative_key}"); - - let uci_relative_key = format!("{selector}.{option_name}"); let uci_key = format!("{package_name}.{uci_relative_key}"); + let source_key = format!("{package_name}.{config_relative_key}"); + + let destination_key = self + .mapped_config_key(&selector, option_name) + .unwrap_or(source_key); + let Some(value) = option.get().map_err(|e| { SourceError::Message(format!( "failed to retrieve UCI option \ diff --git a/src/settings/mod.rs b/src/settings/mod.rs index a57ba34..adcc03d 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Deserializer, de}; use std::fmt::{self, Display}; use std::net::IpAddr; use std::str::FromStr; +use uci_derive::{UciConfig, UciSection}; + +pub(crate) mod uci_schema; #[cfg(all(feature = "uci", unix))] use crate::platform::unix::uci::UciSource; @@ -70,17 +73,22 @@ struct ReflectorRecord { description: String, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, UciConfig)] +#[uci(package = "sqm-autorate-rust")] pub(crate) struct Settings { + #[uci(section)] pub(crate) network: NetworkSettings, #[serde(default)] + #[uci(section)] pub(crate) output: OutputSettings, #[serde(default)] + #[uci(section)] pub(crate) observability: ObservabilitySettings, #[serde(default)] + #[uci(section)] pub(crate) advanced_settings: AdvancedSettings, } @@ -89,7 +97,7 @@ impl Settings { let builder = Config::builder(); #[cfg(all(feature = "uci", unix))] - let builder = builder.add_source(UciSource::new(["sqm-autorate-rust"]).required(false)); + let builder = builder.add_source(UciSource::from_schema::().required(false)); #[cfg(feature = "toml")] let builder = builder.add_source( @@ -138,7 +146,7 @@ impl Settings { } } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, UciSection)] pub(crate) struct NetworkSettings { pub(crate) download_interface: String, pub(crate) upload_interface: String, @@ -160,7 +168,7 @@ impl NetworkSettings { } } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, UciSection)] #[serde(default)] pub(crate) struct OutputSettings { #[serde(deserialize_with = "deserialize_from_str")] @@ -181,7 +189,7 @@ impl Default for OutputSettings { } } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, UciSection)] #[serde(default)] pub(crate) struct ObservabilitySettings { pub(crate) enabled: bool, @@ -216,7 +224,7 @@ impl Default for ObservabilitySettings { } } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, UciSection)] #[serde(default)] pub(crate) struct AdvancedSettings { pub(crate) download_delay_ms: f64, diff --git a/src/settings/uci_schema.rs b/src/settings/uci_schema.rs new file mode 100644 index 0000000..aedd51d --- /dev/null +++ b/src/settings/uci_schema.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair) +// +// SPDX-License-Identifier: MPL-2.0 + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct UciOptionMapping { + pub(crate) uci_option: &'static str, + pub(crate) config_field: &'static str, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct UciSectionMapping { + /// UCI section type, corresponding to `@section_type[0]`. + pub(crate) uci_section: &'static str, + + /// Top-level config-rs / Serde settings key. + pub(crate) config_section: &'static str, + + pub(crate) options: &'static [UciOptionMapping], +} + +pub(crate) trait UciSectionSchema { + const UCI_OPTIONS: &'static [UciOptionMapping]; +} + +pub(crate) trait UciConfigSchema { + const UCI_PACKAGE: &'static str; + const UCI_SECTIONS: &'static [UciSectionMapping]; +}