diff --git a/src/config/mod.rs b/src/config/mod.rs index b0c4b13d20..5487474519 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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, +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct Config { #[serde(default)] @@ -26,6 +49,8 @@ pub struct Config { #[serde(default)] pub packages: BTreeMap, #[serde(default)] + pub package_groups: BTreeMap, + #[serde(default)] pub files: Vec, #[serde(default)] pub users: BTreeMap, @@ -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 = 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, + ) -> Result> { + 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")); + } +} diff --git a/src/config/package.rs b/src/config/package.rs index b5b695cfdc..84a8ac50c5 100644 --- a/src/config/package.rs +++ b/src/config/package.rs @@ -1,4 +1,4 @@ -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(untagged)] pub enum PackageConfig { Empty,