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:
+167
-1
@@ -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,4 +1,4 @@
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum PackageConfig {
|
||||
Empty,
|
||||
|
||||
Reference in New Issue
Block a user