config(mod): add package_groups resolution with cycle detection

This commit is contained in:
Red Bear OS
2026-07-26 05:44:47 +09:00
parent bf43c0a34f
commit bb243fb3e5
+122 -4
View File
@@ -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);
}
}