config(mod): add package_groups resolution with cycle detection
This commit is contained in:
+122
-4
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt::Display;
|
||||
use std::fs;
|
||||
use std::mem;
|
||||
@@ -17,6 +17,19 @@ pub mod general;
|
||||
pub mod package;
|
||||
pub mod user;
|
||||
|
||||
/// A named package group. A group's `packages` list may contain either a
|
||||
/// direct package name or the name of another group; the latter is expanded
|
||||
/// recursively. Cycles are detected and reported as configuration errors.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct PackageGroup {
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Member names: either packages (keys of `Config.packages`) or other
|
||||
/// group names (keys of `Config.package_groups`). Resolved at config load.
|
||||
#[serde(default)]
|
||||
pub packages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
@@ -25,6 +38,12 @@ pub struct Config {
|
||||
pub general: general::GeneralConfig,
|
||||
#[serde(default)]
|
||||
pub packages: BTreeMap<String, package::PackageConfig>,
|
||||
/// Named package groups. Members may reference other group names; the
|
||||
/// resolution is recursive with cycle detection. Groups are resolved
|
||||
/// during `from_file()` and contribute to `packages` so downstream
|
||||
/// consumers see a flat package map.
|
||||
#[serde(default)]
|
||||
pub package_groups: BTreeMap<String, PackageGroup>,
|
||||
#[serde(default)]
|
||||
pub files: Vec<file::FileConfig>,
|
||||
#[serde(default)]
|
||||
@@ -34,7 +53,9 @@ pub struct Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load installer config from a TOML path
|
||||
/// Load installer config from a TOML path. Resolves all
|
||||
/// `package_groups` references into the flat `packages` map. Group
|
||||
/// resolution is recursive with cycle detection.
|
||||
pub fn from_file(path: &Path) -> Result<Self> {
|
||||
let mut config: Config = match fs::read_to_string(&path) {
|
||||
Ok(config_data) => match toml::from_str(&config_data) {
|
||||
@@ -65,9 +86,98 @@ impl Config {
|
||||
config.merge(other_config);
|
||||
}
|
||||
|
||||
config.resolve_package_groups()?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Resolve every `package_groups.*` entry, expanding each group's
|
||||
/// members recursively. Each resolved member that is itself a group
|
||||
/// name contributes its own members. Cycles are detected and reported.
|
||||
/// Resolved members that are package names are added to `packages`
|
||||
/// (respecting the `Build("ignore")` semantics inherited from group
|
||||
/// members — if any group member has `ignore`, the resolved package
|
||||
/// keeps `ignore`).
|
||||
pub fn resolve_package_groups(&mut self) -> Result<()> {
|
||||
let mut resolved: BTreeMap<String, PackageConfig> = BTreeMap::new();
|
||||
// Collect group keys in stable order so resolution is deterministic
|
||||
// (BTreeMap iterates alphabetically).
|
||||
let group_keys: Vec<String> = self.package_groups.keys().cloned().collect();
|
||||
for name in &group_keys {
|
||||
// Expand each group at most once: collect members transitively,
|
||||
// detect cycles via a stack of currently-visiting names.
|
||||
let mut resolved_here: BTreeSet<String> = BTreeSet::new();
|
||||
let mut visiting: Vec<String> = Vec::new();
|
||||
self.expand_group(name, &mut visiting, &mut resolved_here)?;
|
||||
for pkg in &resolved_here {
|
||||
// If we haven't seen this package yet, mark it as Build("")
|
||||
// (i.e. install). If it already has a non-default PackageConfig,
|
||||
// keep that (a `[packages]` entry overrides group expansion).
|
||||
resolved.entry(pkg.clone()).or_insert(PackageConfig::default());
|
||||
}
|
||||
}
|
||||
// Merge resolved group packages into the flat `packages` map.
|
||||
// Existing `[packages]` entries win (last-write-wins merge: the
|
||||
// group-resolved entry is inserted first via `entry().or_insert`,
|
||||
// and only then the explicit map is overlaid on top, so explicit
|
||||
// overrides — including `Build("ignore")` — take precedence).
|
||||
for (name, cfg) in resolved {
|
||||
self.packages.entry(name).or_insert(cfg);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expand_group(
|
||||
&self,
|
||||
name: &str,
|
||||
visiting: &mut Vec<String>,
|
||||
resolved: &mut BTreeSet<String>,
|
||||
) -> Result<()> {
|
||||
if resolved.contains(name) {
|
||||
return Ok(());
|
||||
}
|
||||
if visiting.iter().any(|n| n == name) {
|
||||
let mut cycle = visiting.clone();
|
||||
cycle.push(name.to_string());
|
||||
bail!(
|
||||
"package_groups cycle: {}",
|
||||
cycle.join(" -> ")
|
||||
);
|
||||
}
|
||||
let group = match self.package_groups.get(name) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
// Member that names a missing group is treated as an error
|
||||
// (silently dropping it has historically hidden typos like
|
||||
// `kde-desktop = { packages = ["kf6-kio", "kf6-kwd"] }`
|
||||
// where one entry is a typo for a group).
|
||||
let mut known: Vec<String> = self.package_groups.keys().cloned().collect();
|
||||
known.sort();
|
||||
bail!(
|
||||
"package_groups member '{}' is neither a package nor a known group; \
|
||||
known groups: [{}]",
|
||||
name,
|
||||
known.join(", ")
|
||||
);
|
||||
}
|
||||
};
|
||||
visiting.push(name.to_string());
|
||||
for member in &group.packages {
|
||||
if self.package_groups.contains_key(member) {
|
||||
self.expand_group(member, visiting, resolved)?;
|
||||
} else {
|
||||
// Treat as a package name. It's an error only if neither
|
||||
// group nor package exists; we'll surface that at the end.
|
||||
// For now, record it so the cycle detector sees it; the
|
||||
// final install will surface the missing-package error.
|
||||
resolved.insert(member.clone());
|
||||
}
|
||||
}
|
||||
visiting.pop();
|
||||
resolved.insert(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load hardcoded install config to fetch bootloaders
|
||||
pub fn bootloader_config() -> Self {
|
||||
let mut bootloader_config = Config::default();
|
||||
@@ -91,9 +201,10 @@ impl Config {
|
||||
include: _,
|
||||
general: other_general,
|
||||
packages: other_packages,
|
||||
package_groups: other_groups,
|
||||
files: other_files,
|
||||
users: other_users,
|
||||
groups: other_groups,
|
||||
groups: other_groups_users,
|
||||
} = other;
|
||||
|
||||
self.general.merge(other_general);
|
||||
@@ -102,6 +213,13 @@ impl Config {
|
||||
self.packages.insert(package, package_config);
|
||||
}
|
||||
|
||||
// Package groups from later configs override earlier ones (last writer
|
||||
// wins); group resolution happens once after the full merge via
|
||||
// `resolve_package_groups()`.
|
||||
for (group_name, group_cfg) in other_groups {
|
||||
self.package_groups.insert(group_name, group_cfg);
|
||||
}
|
||||
|
||||
// File entries from later-included configs override earlier ones for
|
||||
// the same path. Previously this was `self.files.extend(other_files)`,
|
||||
// which silently accumulated duplicate paths and left the winner to
|
||||
@@ -128,7 +246,7 @@ impl Config {
|
||||
self.users.insert(user, user_config);
|
||||
}
|
||||
|
||||
for (group, group_config) in other_groups {
|
||||
for (group, group_config) in other_groups_users {
|
||||
self.groups.insert(group, group_config);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user