Files
sqm-autorate-rust/src/settings/uci.rs
T
Nils Andreas Svee 76a7e14835 feat(uci): support repeated sections and skipped fields
Allow structured lists to share the existing UCI configuration adapter while keeping collection fields out of scalar section mappings.
2026-10-13 21:24:04 +07:00

376 lines
12 KiB
Rust

use std::path::{Path, PathBuf};
use crate::settings::uci_schema::{UciConfigSchema, UciRepeatedSectionMapping, UciSectionMapping};
use config::{ConfigError as SourceError, Map, Source, Value, ValueKind};
use rust_uci::config::{
Config as UciConfig, Package as UciPackage, SectionSelector, Value as UciValue,
};
#[derive(Clone, Debug)]
enum PackageSelection {
Selected(Vec<String>),
All,
}
#[derive(Clone, Debug)]
pub(crate) struct UciSource {
packages: PackageSelection,
required: bool,
schema: &'static [UciSectionMapping],
repeated_schema: &'static [UciRepeatedSectionMapping],
config_dir: Option<PathBuf>,
save_dir: Option<PathBuf>,
}
impl UciSource {
fn with_selection(packages: PackageSelection) -> Self {
Self {
packages,
required: true,
schema: &[],
repeated_schema: &[],
config_dir: None,
save_dir: None,
}
}
pub(crate) fn new<I, S>(packages: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::with_selection(PackageSelection::Selected(
packages.into_iter().map(Into::into).collect(),
))
}
#[allow(dead_code)]
pub(crate) fn all() -> Self {
Self::with_selection(PackageSelection::All)
}
pub(crate) fn required(mut self, required: bool) -> Self {
self.required = required;
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
}
/// Registers section types collected as arrays of tables at their config paths.
/// Absent section types leave the destination unset for Serde/source defaults.
#[allow(dead_code)]
pub(crate) fn with_repeated_schema(
mut self,
schema: &'static [UciRepeatedSectionMapping],
) -> Self {
self.repeated_schema = schema;
self
}
#[allow(dead_code)]
pub(crate) fn with_directories(
mut self,
config_dir: impl AsRef<Path>,
save_dir: impl AsRef<Path>,
) -> Self {
self.config_dir = Some(config_dir.as_ref().to_owned());
self.save_dir = Some(save_dir.as_ref().to_owned());
self
}
fn open(&self) -> Result<UciConfig, SourceError> {
let mut uci = rust_uci::Uci::new()
.map_err(|e| SourceError::Message(format!("failed to open UCI: {e}")))?;
if let Some(config_dir) = &self.config_dir {
uci.set_config_dir(config_dir).map_err(|e| {
SourceError::Message(format!(
"failed to set UCI config directory {config_dir:?}: {e}"
))
})?;
}
if let Some(save_dir) = &self.save_dir {
uci.set_save_dir(save_dir).map_err(|e| {
SourceError::Message(format!(
"failed to set UCI save directory {save_dir:?}: {e}"
))
})?;
}
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(),
SectionSelector::Anonymous {
section_type,
index,
} => format!("{section_type}[{index}]"),
}
}
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(
&self,
package_name: &str,
package: &UciPackage,
values: &mut Map<String, Value>,
repeated_values: &mut Map<String, Vec<Value>>,
) -> Result<(), SourceError> {
let sections = package.sections().map_err(|e| {
SourceError::Message(format!(
"failed to retrieve sections from UCI package \
{package_name:?}: {e}"
))
})?;
for section in sections {
let repeated_mapping = self
.repeated_schema
.iter()
.find(|mapping| mapping.uci_section == section.type_());
let mut section_values = Map::new();
let selector = section.selector().ok_or_else(|| {
SourceError::Message(format!(
"loaded UCI section of type {:?} in package \
{package_name:?} has no selector",
section.type_()
))
})?;
let options = section.options().map_err(|e| {
SourceError::Message(format!(
"failed to retrieve options from UCI section \
{package_name}.{selector}: {e}"
))
})?;
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 uci_key = format!("{package_name}.{uci_relative_key}");
let source_key = format!("{package_name}.{config_relative_key}");
let repeated_option = repeated_mapping.and_then(|mapping| {
mapping
.options
.iter()
.find(|option| option.uci_option == option_name)
});
let destination_key = if let Some(mapping) = repeated_option {
mapping.config_field.to_owned()
} else if repeated_mapping.is_some() {
option_name.to_owned()
} else {
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 \
{uci_key}: {e}"
))
})?
else {
continue;
};
let origin = format!("UCI option {uci_key}");
let value = match value {
UciValue::String(value) => Value::new(Some(&origin), ValueKind::String(value)),
UciValue::List(items) => {
let items = items
.into_iter()
.map(|item| Value::new(Some(&origin), ValueKind::String(item)))
.collect();
Value::new(Some(&origin), ValueKind::Array(items))
}
};
let destination = if repeated_mapping.is_some() {
&mut section_values
} else {
&mut *values
};
if destination.insert(destination_key.clone(), value).is_some() {
return Err(SourceError::Message(format!(
"duplicate UCI destination key \
{destination_key:?} while mapping {uci_key:?}"
)));
}
}
if let Some(mapping) = repeated_mapping {
let origin = format!("UCI section {package_name}.{selector}");
repeated_values
.entry(mapping.config_path.to_owned())
.or_default()
.push(Value::new(Some(&origin), ValueKind::Table(section_values)));
}
}
Ok(())
}
}
impl Source for UciSource {
fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
Box::new(self.clone())
}
fn collect(&self) -> Result<Map<String, Value>, SourceError> {
let resolved = (|| {
let uci = self.open()?;
let packages: Vec<UciPackage> = match &self.packages {
PackageSelection::Selected(package_names) => package_names
.iter()
.map(|package_name| {
uci.package(package_name)
.map_err(|e| {
SourceError::Message(format!(
"failed to open UCI package {package_name:?}: {e}"
))
})?
.ok_or_else(|| {
SourceError::NotFound(format!("UCI package {package_name:?}"))
})
})
.collect::<Result<_, SourceError>>()?,
PackageSelection::All => uci
.packages()
.map_err(|e| {
SourceError::Message(format!("failed to enumerate UCI packages: {e}"))
})?
.collect(),
};
Ok::<_, SourceError>((uci, packages))
})();
let (_uci, packages) = match resolved {
Ok(resolved) => resolved,
Err(SourceError::NotFound(_)) if !self.required => {
return Ok(Map::new());
}
Err(error) => {
return Err(error);
}
};
let mut values = Map::new();
let mut repeated_values = Map::new();
for package in packages {
let package_name = package
.name()
.map_err(|e| SourceError::Message(format!("failed to read UCI package name: {e}")))?
.to_owned();
self.collect_package(&package_name, &package, &mut values, &mut repeated_values)?;
}
for (config_path, sections) in repeated_values {
let origin = "UCI repeated sections".to_owned();
if values
.insert(
config_path.clone(),
Value::new(Some(&origin), ValueKind::Array(sections)),
)
.is_some()
{
return Err(SourceError::Message(format!(
"duplicate UCI destination key {config_path:?} while mapping repeated sections"
)));
}
}
Ok(values)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::Settings;
use std::fs;
#[test]
fn malformed_optional_package_is_an_error() {
let root = std::env::temp_dir().join(format!("sqma-uci-{}", std::process::id()));
let config_dir = root.join("config");
let save_dir = root.join("save");
fs::create_dir_all(&config_dir).unwrap();
fs::create_dir_all(&save_dir).unwrap();
let config_file = config_dir.join("sqm-autorate-rust");
fs::write(&config_file, "config network 'unterminated\n").unwrap();
let source = UciSource::from_schema::<Settings>()
.with_directories(&config_dir, &save_dir)
.required(false);
let result = source.collect();
fs::remove_dir_all(root).unwrap();
assert!(result.is_err());
}
}