From bb243fb3e56717b7d953236633edfdbe90892e1d Mon Sep 17 00:00:00 2001 From: Red Bear OS Date: Sun, 26 Jul 2026 05:44:47 +0900 Subject: [PATCH] config(mod): add package_groups resolution with cycle detection --- src/config/mod.rs | 126 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 4 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 21613e3a29..a5b40c0ac9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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, +} + #[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, + /// 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, #[serde(default)] pub files: Vec, #[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 { 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 = BTreeMap::new(); + // Collect group keys in stable order so resolution is deterministic + // (BTreeMap iterates alphabetically). + let group_keys: Vec = 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 = BTreeSet::new(); + let mut visiting: Vec = 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, + resolved: &mut BTreeSet, + ) -> 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 = 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); } }