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.
This commit is contained in:
Nils Andreas Svee committed 2026-08-07 16:04:59 +07:00
1 parent f51d90bff6
commit a6478c9bd4
7 files changed
+468 -9

No files matched your search

Generated
+99
View File
@@ -133,6 +133,12 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -187,6 +193,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "icmp-socket2" name = "icmp-socket2"
version = "0.6.0" version = "0.6.0"
@@ -196,6 +208,16 @@ dependencies = [
"socket2", "socket2",
] ]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -471,6 +493,19 @@ dependencies = [
"syn 3.0.2", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@@ -529,6 +564,7 @@ dependencies = [
"rustix", "rustix",
"serde", "serde",
"thiserror", "thiserror",
"uci-derive",
] ]
[[package]] [[package]]
@@ -553,6 +589,21 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.19" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [ dependencies = [
"indexmap",
"serde_core", "serde_core",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
"toml_parser", "toml_parser",
"toml_writer",
"winnow", "winnow",
] ]
@@ -604,6 +657,37 @@ dependencies = [
"winnow", "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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -655,6 +739,15 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -678,3 +771,9 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+5
View File
@@ -29,6 +29,7 @@ flume = { version = "0.12.0", default-features = false, features = ["eventual-f
config = { version = "0.15.25", default-features = false } config = { version = "0.15.25", default-features = false }
csv = "1.4.0" csv = "1.4.0"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
uci-derive = { path = "crates/uci-derive" }
[dependencies.log] [dependencies.log]
features = ["std"] features = ["std"]
@@ -46,3 +47,7 @@ default = ["toml", "uci-vendored"]
toml = ["config/toml"] toml = ["config/toml"]
uci = ["dep:rust-uci"] uci = ["dep:rust-uci"]
uci-vendored = ["uci", "rust-uci/vendored"] uci-vendored = ["uci", "rust-uci/vendored"]
[workspace]
members = ["crates/uci-derive"]
resolver = "3"
+16
View File
@@ -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"
+252
View File
@@ -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<proc_macro2::TokenStream> {
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::<Vec<_>>();
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<proc_macro2::TokenStream> {
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<LitStr> {
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<LitStr> {
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 = \"...\")]`",
)
})
}
+53 -3
View File
@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::settings::uci_schema::{UciConfigSchema, UciSectionMapping};
use config::{ConfigError as SourceError, Map, Source, Value, ValueKind}; use config::{ConfigError as SourceError, Map, Source, Value, ValueKind};
use rust_uci::config::{ use rust_uci::config::{
@@ -16,6 +17,7 @@ enum PackageSelection {
pub(crate) struct UciSource { pub(crate) struct UciSource {
packages: PackageSelection, packages: PackageSelection,
required: bool, required: bool,
schema: &'static [UciSectionMapping],
config_dir: Option<PathBuf>, config_dir: Option<PathBuf>,
save_dir: Option<PathBuf>, save_dir: Option<PathBuf>,
} }
@@ -25,6 +27,7 @@ impl UciSource {
Self { Self {
packages, packages,
required: true, required: true,
schema: &[],
config_dir: None, config_dir: None,
save_dir: None, save_dir: None,
} }
@@ -50,6 +53,18 @@ impl UciSource {
self self
} }
pub(crate) fn from_schema<T>() -> 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)] #[allow(dead_code)]
pub(crate) fn with_directories( pub(crate) fn with_directories(
mut self, mut self,
@@ -84,6 +99,10 @@ impl UciSource {
Ok(uci.into()) 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 { fn config_selector(selector: &SectionSelector<'_>) -> String {
match selector { match selector {
SectionSelector::Named(name) => (*name).to_owned(), SectionSelector::Named(name) => (*name).to_owned(),
@@ -95,6 +114,32 @@ impl UciSource {
} }
} }
fn mapped_config_key(
&self,
selector: &SectionSelector<'_>,
uci_option: &str,
) -> Option<String> {
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( fn collect_package(
&self, &self,
package_name: &str, package_name: &str,
@@ -127,14 +172,19 @@ impl UciSource {
for option in options { for option in options {
let option_name = option.name(); let option_name = option.name();
let uci_relative_key = format!("{selector}.{option_name}");
let config_relative_key = let config_relative_key =
format!("{}.{option_name}", Self::config_selector(&selector),); 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 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| { let Some(value) = option.get().map_err(|e| {
SourceError::Message(format!( SourceError::Message(format!(
"failed to retrieve UCI option \ "failed to retrieve UCI option \
+14 -6
View File
@@ -9,6 +9,9 @@ use serde::{Deserialize, Deserializer, de};
use std::fmt::{self, Display}; use std::fmt::{self, Display};
use std::net::IpAddr; use std::net::IpAddr;
use std::str::FromStr; use std::str::FromStr;
use uci_derive::{UciConfig, UciSection};
pub(crate) mod uci_schema;
#[cfg(all(feature = "uci", unix))] #[cfg(all(feature = "uci", unix))]
use crate::platform::unix::uci::UciSource; use crate::platform::unix::uci::UciSource;
@@ -70,17 +73,22 @@ struct ReflectorRecord {
description: String, description: String,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, UciConfig)]
#[uci(package = "sqm-autorate-rust")]
pub(crate) struct Settings { pub(crate) struct Settings {
#[uci(section)]
pub(crate) network: NetworkSettings, pub(crate) network: NetworkSettings,
#[serde(default)] #[serde(default)]
#[uci(section)]
pub(crate) output: OutputSettings, pub(crate) output: OutputSettings,
#[serde(default)] #[serde(default)]
#[uci(section)]
pub(crate) observability: ObservabilitySettings, pub(crate) observability: ObservabilitySettings,
#[serde(default)] #[serde(default)]
#[uci(section)]
pub(crate) advanced_settings: AdvancedSettings, pub(crate) advanced_settings: AdvancedSettings,
} }
@@ -89,7 +97,7 @@ impl Settings {
let builder = Config::builder(); let builder = Config::builder();
#[cfg(all(feature = "uci", unix))] #[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::<Self>().required(false));
#[cfg(feature = "toml")] #[cfg(feature = "toml")]
let builder = builder.add_source( 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) struct NetworkSettings {
pub(crate) download_interface: String, pub(crate) download_interface: String,
pub(crate) upload_interface: String, pub(crate) upload_interface: String,
@@ -160,7 +168,7 @@ impl NetworkSettings {
} }
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, UciSection)]
#[serde(default)] #[serde(default)]
pub(crate) struct OutputSettings { pub(crate) struct OutputSettings {
#[serde(deserialize_with = "deserialize_from_str")] #[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)] #[serde(default)]
pub(crate) struct ObservabilitySettings { pub(crate) struct ObservabilitySettings {
pub(crate) enabled: bool, pub(crate) enabled: bool,
@@ -216,7 +224,7 @@ impl Default for ObservabilitySettings {
} }
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, UciSection)]
#[serde(default)] #[serde(default)]
pub(crate) struct AdvancedSettings { pub(crate) struct AdvancedSettings {
pub(crate) download_delay_ms: f64, pub(crate) download_delay_ms: f64,
+29
View File
@@ -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];
}