installer: add config-level package groups (Phase 3)

PackageGroup struct with description + package list. Groups are defined
in [[package_groups.NAME]] TOML sections and resolved by
resolve_package_groups() during Config::from_file(). Supports nested
groups (groups referencing other groups) with cycle detection.
Explicit [packages] entries override group membership.

Adds PartialEq derive to PackageConfig for dedup during merge.
3 unit tests: nested groups, explicit override, no-groups compat.
This commit is contained in:
Red Bear OS
2026-06-30 15:59:02 +03:00
parent 451813b2da
commit e45ce4d57a
2 changed files with 168 additions and 2 deletions
+167 -1
View File
@@ -1,10 +1,11 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Display;
use std::fs;
use std::mem;
use std::path::{Path, PathBuf};
use anyhow::bail;
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
@@ -17,6 +18,28 @@ pub mod general;
pub mod package;
pub mod user;
/// A named group of packages that can be referenced from the `[packages]` section
/// of a config TOML. When a group name appears in `[packages]`, the resolver
/// expands it to the individual package entries listed here.
///
/// Groups may reference other groups for hierarchical composition:
///
/// ```toml
/// [package_groups.qt6-core]
/// description = "Qt 6 Core modules"
/// packages = ["qtbase", "qtdeclarative", "qtsvg"]
///
/// [package_groups.kde-desktop]
/// description = "Complete KDE Plasma desktop session"
/// packages = ["qt6-core", "kwin", "sddm"]
/// ```
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct PackageGroup {
#[serde(default)]
pub description: String,
pub packages: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
@@ -26,6 +49,8 @@ pub struct Config {
#[serde(default)]
pub packages: BTreeMap<String, package::PackageConfig>,
#[serde(default)]
pub package_groups: BTreeMap<String, PackageGroup>,
#[serde(default)]
pub files: Vec<file::FileConfig>,
#[serde(default)]
pub users: BTreeMap<String, user::UserConfig>,
@@ -65,6 +90,8 @@ impl Config {
config.merge(other_config);
}
config.resolve_package_groups()?;
Ok(config)
}
@@ -91,6 +118,7 @@ impl Config {
include: _,
general: other_general,
packages: other_packages,
package_groups: other_package_groups,
files: other_files,
users: other_users,
groups: other_groups,
@@ -102,6 +130,10 @@ impl Config {
self.packages.insert(package, package_config);
}
for (group_name, group) in other_package_groups {
self.package_groups.insert(group_name, group);
}
self.files.extend(other_files);
for (user, user_config) in other_users {
@@ -112,6 +144,66 @@ impl Config {
self.groups.insert(group, group_config);
}
}
/// Expand all `[package-groups]` references in `packages` into individual
/// package entries. Must be called after `merge()` so that groups from all
/// included configs are collected.
///
/// Explicit package entries always take priority over group-expanded entries.
/// Circular group references are detected and rejected.
pub fn resolve_package_groups(&mut self) -> Result<()> {
if self.package_groups.is_empty() {
return Ok(());
}
let mut resolved: BTreeMap<String, package::PackageConfig> = BTreeMap::new();
for (name, config) in &self.packages {
if !self.package_groups.contains_key(name) {
resolved.insert(name.clone(), config.clone());
}
}
for (name, config) in &self.packages {
if self.package_groups.contains_key(name) {
let mut visiting = BTreeSet::new();
let expanded = self.expand_group(name, &mut visiting)?;
for pkg in expanded {
resolved.entry(pkg).or_insert_with(|| config.clone());
}
}
}
self.packages = resolved;
Ok(())
}
fn expand_group(
&self,
name: &str,
visiting: &mut BTreeSet<String>,
) -> Result<Vec<String>> {
if !visiting.insert(name.to_string()) {
bail!("circular package group reference involving '{}'", name);
}
let group = self
.package_groups
.get(name)
.ok_or_else(|| anyhow!("package group '{}' not found", name))?;
let mut result = Vec::new();
for pkg in &group.packages {
if self.package_groups.contains_key(pkg) {
result.extend(self.expand_group(pkg, visiting)?);
} else {
result.push(pkg.to_string());
}
}
visiting.remove(name);
Ok(result)
}
}
impl Display for Config {
@@ -132,3 +224,77 @@ impl Display for Config {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_nested_groups() {
let toml_str = r#"
[package_groups.qt6-core]
packages = ["qtbase", "qtdeclarative", "qtsvg"]
[package_groups.qt6-extras]
packages = ["qtwayland", "qt6-sensors"]
[package_groups.qt6-all]
packages = ["qt6-core", "qt6-extras"]
[package_groups.kde-desktop]
packages = ["qt6-all", "kwin", "sddm"]
[packages]
kde-desktop = {}
"#;
let mut config: Config = toml::from_str(toml_str).unwrap();
config.resolve_package_groups().unwrap();
assert!(config.packages.contains_key("qtbase"));
assert!(config.packages.contains_key("qtdeclarative"));
assert!(config.packages.contains_key("qtsvg"));
assert!(config.packages.contains_key("qtwayland"));
assert!(config.packages.contains_key("qt6-sensors"));
assert!(config.packages.contains_key("kwin"));
assert!(config.packages.contains_key("sddm"));
assert!(!config.packages.contains_key("kde-desktop"));
assert!(!config.packages.contains_key("qt6-all"));
assert!(!config.packages.contains_key("qt6-core"));
assert!(!config.packages.contains_key("qt6-extras"));
}
#[test]
fn test_explicit_overrides_group() {
let toml_str = r#"
[package_groups.qt6-core]
packages = ["qtbase", "qtdeclarative"]
[packages]
qt6-core = {}
qtbase = "ignore"
"#;
let mut config: Config = toml::from_str(toml_str).unwrap();
config.resolve_package_groups().unwrap();
assert_eq!(
config.packages.get("qtbase").unwrap(),
&package::PackageConfig::Build("ignore".to_string())
);
assert!(config.packages.contains_key("qtdeclarative"));
}
#[test]
fn test_no_groups_no_change() {
let toml_str = r#"
[packages]
foo = {}
bar = {}
"#;
let mut config: Config = toml::from_str(toml_str).unwrap();
config.resolve_package_groups().unwrap();
assert_eq!(config.packages.len(), 2);
assert!(config.packages.contains_key("foo"));
assert!(config.packages.contains_key("bar"));
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(untagged)]
pub enum PackageConfig {
Empty,