From 192cdc1af2c32fb2e81dbc7e276c87327d92ca85 Mon Sep 17 00:00:00 2001 From: SFG545 Date: Wed, 25 Mar 2026 00:23:16 -0500 Subject: [PATCH] feat: add support for package groups in dependency management - Introduced a new `groups` field in the package specification to categorize packages. - Updated various functions to handle package groups, including parsing, registration, and querying. - Enhanced database schema to store package group information. - Modified the interactive package creation process to allow users to specify package groups. - Implemented tests to verify the correct handling of package groups in both source and binary repositories. - Updated the output formatting to include package group information where applicable. --- Cargo.lock | 2 +- Cargo.toml | 2 +- meson.build | 2 +- src/commands.rs | 299 ++++++++++++++++++++++++++++++++++++- src/db/mod.rs | 212 +++++++++++++++++++++++++- src/db/repo.rs | 199 ++++++++++++++++++++++++ src/deps.rs | 4 + src/package/interactive.rs | 43 ++++++ src/package/packager.rs | 11 ++ src/package/spec.rs | 37 +++++ src/package/starbuild.rs | 2 + src/planner.rs | 5 + 12 files changed, 806 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82bfcda..d081f92 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.33.0" +version = "0.33.1" dependencies = [ "anyhow", "ar", diff --git a/Cargo.toml b/Cargo.toml index 6d22b0a..869f369 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.33.0" +version = "0.33.1" edition = "2024" [lints.rust] diff --git a/meson.build b/meson.build index 349f1d6..e7651c7 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.33.0', + version: '0.33.1', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) diff --git a/src/commands.rs b/src/commands.rs index 9dcb626..be51a42 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -727,6 +727,7 @@ fn package_spec_from_archive_metadata(metadata: &toml::Value) -> package::Packag runtime: parse_dependency_list(metadata, "runtime"), test: Vec::new(), optional: parse_dependency_list(metadata, "optional"), + groups: parse_dependency_list(metadata, "groups"), lib32: None, }, package_alternatives: Default::default(), @@ -811,6 +812,7 @@ fn package_spec_from_repo_record( runtime: record.runtime_dependencies.clone(), test: Vec::new(), optional: record.optional_dependencies.clone(), + groups: Vec::new(), lib32: None, }, package_alternatives: Default::default(), @@ -2275,6 +2277,172 @@ fn scan_package_specs(dir: &Path) -> Result> { Ok(specs) } +fn spec_group_package_names(spec: &package::PackageSpec, group: &str) -> Vec { + let mut names = Vec::new(); + for output in spec.outputs() { + if spec + .dependencies_for_output(&output.name) + .groups + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(group)) + { + names.push(output.name); + } + } + if spec.builds_lib32_output() { + let lib32_name = spec.lib32_package_name(); + if spec + .dependencies_for_output(&lib32_name) + .groups + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(group)) + { + names.push(lib32_name); + } + } + names.sort(); + names.dedup(); + names +} + +fn collect_source_group_package_names(config: &config::Config, group: &str) -> Result> { + if !config.repo_clone_dir.exists() { + return Ok(Vec::new()); + } + + let mut packages = BTreeSet::new(); + for spec_path in scan_package_specs(&config.repo_clone_dir)? { + let spec = package::PackageSpec::from_file(&spec_path) + .with_context(|| format!("Failed to parse spec {}", spec_path.display()))?; + for package_name in spec_group_package_names(&spec, group) { + packages.insert(package_name); + } + } + Ok(packages.into_iter().collect()) +} + +fn collect_binary_group_package_names( + config: &config::Config, + rootfs: &Path, + group: &str, +) -> Result> { + let host_arch = std::env::consts::ARCH; + let mut binary_repos: Vec<_> = config + .binary_repos + .iter() + .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) + .collect(); + binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); + + let mut packages = BTreeSet::new(); + for (repo_name, repo_cfg) in binary_repos { + let matches = db::repo::find_binary_repo_packages_by_group( + repo_name, + repo_cfg, + rootfs, + &config.package_cache_dir, + group, + )?; + for record in matches { + packages.insert(record.name); + } + } + Ok(packages.into_iter().collect()) +} + +fn collect_install_group_package_names( + config: &config::Config, + rootfs: &Path, + group: &str, +) -> Result> { + let mut packages = BTreeSet::new(); + for package_name in collect_source_group_package_names(config, group)? { + packages.insert(package_name); + } + for package_name in collect_binary_group_package_names(config, rootfs, group)? { + packages.insert(package_name); + } + Ok(packages.into_iter().collect()) +} + +fn expand_install_requests_for_groups( + config: &config::Config, + rootfs: &Path, + requests: &[PathBuf], +) -> Result<(Vec, Vec)> { + let mut expanded = Vec::new(); + let mut explicit_groups = Vec::new(); + let mut seen = HashSet::new(); + + for request in requests { + if request.exists() { + let key = request.to_string_lossy().to_string(); + if seen.insert(key) { + expanded.push(request.clone()); + } + continue; + } + + let request_name = request.to_string_lossy().to_string(); + let group_packages = collect_install_group_package_names(config, rootfs, &request_name)?; + if group_packages.is_empty() { + if seen.insert(request_name.clone()) { + expanded.push(request.clone()); + } + continue; + } + + ui::info(format!( + "Expanding group '{}' ({} packages)...", + request_name, + group_packages.len() + )); + explicit_groups.push(request_name); + for package_name in group_packages { + if seen.insert(package_name.clone()) { + expanded.push(PathBuf::from(package_name)); + } + } + } + + Ok((expanded, explicit_groups)) +} + +fn expand_installed_group_targets( + db_path: &Path, + requests: &[String], +) -> Result<(Vec, Vec)> { + let mut expanded = Vec::new(); + let mut explicit_groups = Vec::new(); + let mut seen = HashSet::new(); + + for request in requests { + if db::is_installed_group(db_path, request)? { + let group_packages = db::get_packages_in_installed_group(db_path, request)?; + if !group_packages.is_empty() { + ui::info(format!( + "Expanding group '{}' ({} packages)...", + request, + group_packages.len() + )); + explicit_groups.push(request.clone()); + for package_name in group_packages { + if seen.insert(package_name.clone()) { + expanded.push(package_name); + } + } + continue; + } + } + + if seen.insert(request.clone()) { + expanded.push(request.clone()); + } + } + + Ok((expanded, explicit_groups)) +} + fn human_bytes(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; let mut value = bytes as f64; @@ -5094,6 +5262,8 @@ pub fn run(cli: Cli) -> Result<()> { // Load configuration early so we can use configured repos/paths. let config = config::Config::for_rootfs(&rootfs); ensure_depot_self_update_not_required(&config, &rootfs)?; + let (install_requests, explicit_groups) = + expand_install_requests_for_groups(&config, &rootfs, &install_requests)?; let install_test_deps = install_test_deps_enabled(cli_test_deps, &config); let mut planned_targets = Vec::new(); let mut planned_spec_paths = Vec::new(); @@ -5192,6 +5362,10 @@ pub fn run(cli: Cli) -> Result<()> { install::scripts::run_deferred_hooks_if_possible(&rootfs)?; } + if !dry_run && !explicit_groups.is_empty() { + db::record_installed_groups(&config.installed_db_path(&rootfs), &explicit_groups)?; + } + if clean && (ran_plan_mode || ran_direct_install) { clean_build_workspace(&config)?; } @@ -5207,11 +5381,18 @@ pub fn run(cli: Cli) -> Result<()> { let remove_lock_path = locking::lock_path(&config); let _remove_lock_guard = locking::try_write(&mut remove_lock, &remove_lock_path, "remove")?; - let removal_targets = vec![package.clone()]; + let db_path = config.installed_db_path(&rootfs); + let (removal_targets, explicit_groups) = + expand_installed_group_targets(&db_path, std::slice::from_ref(&package))?; if !ui::prompt_package_action("removal", &removal_targets, true)? { anyhow::bail!("Aborted"); } - remove_installed_package_with_hooks(&package, &rootfs, &config)?; + for target in &removal_targets { + remove_installed_package_with_hooks(target, &rootfs, &config)?; + } + for group in explicit_groups { + db::remove_installed_group(&db_path, &group)?; + } } Commands::Build(BuildArgs { rootfs_args, @@ -5764,11 +5945,16 @@ pub fn run(cli: Cli) -> Result<()> { let dry_run = build_exec_args.dry_run; let config = config::Config::for_rootfs(&rootfs); sync_source_repositories_for_update(&config)?; - if !is_explicit_depot_self_update_request(&packages) { + let expanded_packages = if packages.is_empty() { + Vec::new() + } else { + expand_installed_group_targets(&config.installed_db_path(&rootfs), &packages)?.0 + }; + if !is_explicit_depot_self_update_request(&expanded_packages) { ensure_depot_self_update_not_required(&config, &rootfs)?; } run_update_command( - &packages, + &expanded_packages, &config, UpdateCommandOptions { rootfs: &rootfs, @@ -6419,6 +6605,7 @@ mod tests { replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), } } @@ -6679,6 +6866,7 @@ optional = [] replaces: Vec::new(), runtime_dependencies: vec!["glibc".into()], optional_dependencies: vec!["manpages".into()], + groups: vec!["base".into()], }; let spec = package_spec_from_repo_record(&record); let installed = @@ -6981,6 +7169,7 @@ optional = [] replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), }; let spec = package_spec_from_repo_record(&record); let installed = @@ -7520,6 +7709,7 @@ optional = [] replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), }, ), )]); @@ -8818,6 +9008,7 @@ file = "missing.patch" runtime: Vec::new(), test: vec!["lib32-pytest".into()], optional: Vec::new(), + groups: Vec::new(), }); assert!(!should_install_test_deps( @@ -9118,6 +9309,7 @@ optional = [] runtime: vec!["lib32-zlib".into()], test: Vec::new(), optional: vec!["lib32-gtk-doc".into()], + groups: Vec::new(), }); let lib32 = make_lib32_package_spec(&base); @@ -9138,4 +9330,103 @@ optional = [] deps::RequestedOutputs::Lib32Only ); } + + #[test] + fn expand_install_requests_for_groups_uses_source_specs() -> Result<()> { + let temp = tempfile::tempdir()?; + let rootfs = temp.path().join("rootfs"); + let repo_root = temp.path().join("repos"); + let core = repo_root.join("core").join("foo"); + let desktop = repo_root.join("desktop").join("bar"); + fs::create_dir_all(&rootfs)?; + fs::create_dir_all(&core)?; + fs::create_dir_all(&desktop)?; + + let foo_spec = core.join("foo.toml"); + fs::write( + &foo_spec, + r#"[package] +name = "foo" +version = "1.0.0" +revision = 1 +description = "foo" +homepage = "https://example.test/foo" +license = "MIT" + +[[source]] +url = "https://example.test/foo-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "foo-1.0.0" + +[build] +type = "custom" + +[dependencies] +groups = ["base"] +runtime = [] +optional = [] +"#, + )?; + + let bar_spec = desktop.join("bar.toml"); + fs::write( + &bar_spec, + r#"[package] +name = "bar" +version = "1.0.0" +revision = 1 +description = "bar" +homepage = "https://example.test/bar" +license = "MIT" + +[[source]] +url = "https://example.test/bar-1.0.0.tar.gz" +sha256 = "skip" +extract_dir = "bar-1.0.0" + +[build] +type = "custom" + +[dependencies] +groups = ["base", "desktop"] +runtime = [] +optional = [] +"#, + )?; + + let mut config = config::Config::for_rootfs(&rootfs); + config.repo_clone_dir = repo_root; + config.binary_repos.clear(); + + let (expanded, groups) = + expand_install_requests_for_groups(&config, &rootfs, &[PathBuf::from("base")])?; + + assert_eq!(groups, vec!["base".to_string()]); + assert_eq!(expanded, vec![PathBuf::from("bar"), PathBuf::from("foo")]); + Ok(()) + } + + #[test] + fn expand_installed_group_targets_uses_installed_group_membership() -> Result<()> { + let temp = tempfile::tempdir()?; + let rootfs = temp.path().join("rootfs"); + fs::create_dir_all(&rootfs)?; + let config = config::Config::for_rootfs(&rootfs); + let db_path = config.installed_db_path(&rootfs); + + let dest = temp.path().join("dest"); + fs::create_dir_all(dest.join("usr/bin"))?; + fs::write(dest.join("usr/bin/foo"), "foo")?; + + let mut spec = test_package_spec(package::BuildType::Custom, None, &[]); + spec.package.name = "foo".into(); + spec.dependencies.groups = vec!["base".into()]; + db::register_package(&db_path, &spec, &dest)?; + db::record_installed_groups(&db_path, &[String::from("base")])?; + + let (expanded, groups) = expand_installed_group_targets(&db_path, &[String::from("base")])?; + assert_eq!(groups, vec!["base".to_string()]); + assert_eq!(expanded, vec!["foo".to_string()]); + Ok(()) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 1899b20..d2f31cd 100755 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -5,7 +5,7 @@ pub mod repo; use crate::package::PackageSpec; use crate::staging; use anyhow::{Context, Result}; -use rusqlite::{Connection, params}; +use rusqlite::{Connection, OptionalExtension, params}; use std::fs; use std::path::Path; use walkdir::WalkDir; @@ -174,6 +174,10 @@ pub fn register_package_with_replacement( "DELETE FROM directories WHERE package_id = ?1", params![pkg_id], )?; + tx.execute( + "DELETE FROM package_groups WHERE package_id = ?1", + params![pkg_id], + )?; // Insert provides for provides in &spec.alternatives.provides { @@ -189,6 +193,12 @@ pub fn register_package_with_replacement( params![pkg_id, replaces], )?; } + for group in &spec.dependencies.groups { + tx.execute( + "INSERT OR IGNORE INTO package_groups (package_id, group_name) VALUES (?1, ?2)", + params![pkg_id, group], + )?; + } // Detect ownership conflicts and separate into auto-removable vs fatal. let mut fatal_conflicts: Vec<(String, String)> = Vec::new(); @@ -461,6 +471,11 @@ pub fn show_package_info(db_path: &Path, name: &str) -> Result<()> { crate::log_info!("Homepage: {}", homepage); crate::log_info!("License: {}", license); + let groups = get_package_groups(db_path, name)?; + if !groups.is_empty() { + crate::log_info!("Groups: {}", groups.join(", ")); + } + // Count files let file_count: i64 = conn.query_row( "SELECT COUNT(*) FROM files f JOIN packages p ON f.package_id = p.id WHERE p.name = ?1", @@ -483,17 +498,46 @@ pub fn list_packages(db_path: &Path) -> Result<()> { let conn = Connection::open(db_path)?; init_db(&conn)?; - let mut stmt = conn.prepare("SELECT name, version FROM packages ORDER BY name")?; + let mut stmt = conn.prepare( + "SELECT + p.name, + p.version, + GROUP_CONCAT(pg.group_name, ',') + FROM packages p + LEFT JOIN package_groups pg ON pg.package_id = p.id + GROUP BY p.id + ORDER BY p.name", + )?; let packages = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) })?; crate::log_info!("{:<30} VERSION", "PACKAGE"); crate::log_info!("{}", "-".repeat(50)); for pkg in packages { - let (name, version) = pkg?; - crate::log_info!("{:<30} {}", name, version); + let (name, version, groups_csv) = pkg?; + let groups = groups_csv + .map(|csv| { + let mut values: Vec = csv + .split(',') + .filter(|value| !value.is_empty()) + .map(String::from) + .collect(); + values.sort(); + values.dedup(); + values + }) + .unwrap_or_default(); + if groups.is_empty() { + crate::log_info!("{:<30} {}", name, version); + } else { + crate::log_info!("{:<30} {} [groups: {}]", name, version, groups.join(", ")); + } } Ok(()) @@ -531,6 +575,18 @@ fn init_db(conn: &Connection) -> Result<()> { UNIQUE(package_id, replaces_name) ); + CREATE TABLE IF NOT EXISTS package_groups ( + id INTEGER PRIMARY KEY, + package_id INTEGER NOT NULL, + group_name TEXT NOT NULL, + FOREIGN KEY (package_id) REFERENCES packages(id), + UNIQUE(package_id, group_name) + ); + + CREATE TABLE IF NOT EXISTS installed_groups ( + group_name TEXT PRIMARY KEY NOT NULL + ); + CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY, package_id INTEGER NOT NULL, @@ -549,6 +605,7 @@ fn init_db(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_files_package ON files(package_id); CREATE INDEX IF NOT EXISTS idx_provides_name ON provides(provides_name); CREATE INDEX IF NOT EXISTS idx_replaces_name ON replaces(replaces_name); + CREATE INDEX IF NOT EXISTS idx_package_groups_name ON package_groups(group_name); CREATE INDEX IF NOT EXISTS idx_directories_package ON directories(package_id); CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path); ", @@ -630,6 +687,10 @@ fn delete_package_rows(conn: &Connection, pkg_id: i64) -> Result<()> { "DELETE FROM replaces WHERE package_id = ?1", params![pkg_id], )?; + conn.execute( + "DELETE FROM package_groups WHERE package_id = ?1", + params![pkg_id], + )?; conn.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?; Ok(()) } @@ -648,6 +709,10 @@ fn delete_package_rows_tx(tx: &rusqlite::Transaction<'_>, pkg_id: i64) -> Result "DELETE FROM replaces WHERE package_id = ?1", params![pkg_id], )?; + tx.execute( + "DELETE FROM package_groups WHERE package_id = ?1", + params![pkg_id], + )?; tx.execute("DELETE FROM packages WHERE id = ?1", params![pkg_id])?; Ok(()) } @@ -805,6 +870,101 @@ pub fn get_package_provides(db_path: &Path, name: &str) -> Result> { Ok(rows.filter_map(|row| row.ok()).collect()) } +/// Return the group memberships recorded for an installed package. +pub fn get_package_groups(db_path: &Path, name: &str) -> Result> { + if !db_path.exists() { + return Ok(Vec::new()); + } + + let conn = Connection::open(db_path)?; + init_db(&conn)?; + let mut stmt = conn.prepare( + "SELECT pg.group_name + FROM package_groups pg + JOIN packages p ON p.id = pg.package_id + WHERE p.name = ?1 + ORDER BY pg.group_name", + )?; + let rows = stmt.query_map(params![name], |row| row.get(0))?; + Ok(rows.filter_map(|row| row.ok()).collect()) +} + +/// Return the installed package names that belong to an explicitly recorded group. +pub fn get_packages_in_installed_group(db_path: &Path, group: &str) -> Result> { + if !db_path.exists() { + return Ok(Vec::new()); + } + + let conn = Connection::open(db_path)?; + init_db(&conn)?; + let mut stmt = conn.prepare( + "SELECT p.name + FROM package_groups pg + JOIN packages p ON p.id = pg.package_id + WHERE lower(pg.group_name) = lower(?1) + ORDER BY p.name", + )?; + let rows = stmt.query_map(params![group], |row| row.get(0))?; + Ok(rows.filter_map(|row| row.ok()).collect()) +} + +/// Return true when the named group was explicitly installed by the user. +pub fn is_installed_group(db_path: &Path, group: &str) -> Result { + if !db_path.exists() { + return Ok(false); + } + + let conn = Connection::open(db_path)?; + init_db(&conn)?; + let found = conn + .query_row( + "SELECT 1 FROM installed_groups WHERE lower(group_name) = lower(?1) LIMIT 1", + params![group], + |row| row.get::<_, i64>(0), + ) + .optional()? + .is_some(); + Ok(found) +} + +/// Record one or more explicit group installs. +pub fn record_installed_groups(db_path: &Path, groups: &[String]) -> Result<()> { + if groups.is_empty() { + return Ok(()); + } + + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent)?; + } + + let mut conn = Connection::open(db_path)?; + init_db(&conn)?; + let tx = conn.transaction()?; + for group in groups { + tx.execute( + "INSERT OR IGNORE INTO installed_groups (group_name) VALUES (?1)", + params![group], + )?; + } + tx.commit()?; + Ok(()) +} + +/// Remove an explicit installed-group marker. +pub fn remove_installed_group(db_path: &Path, group: &str) -> Result<()> { + if !db_path.exists() { + return Ok(()); + } + + let conn = Connection::open(db_path)?; + init_db(&conn)?; + conn.execute( + "DELETE FROM installed_groups WHERE lower(group_name) = lower(?1)", + params![group], + )?; + Ok(()) +} + /// Get set of all provided package names (alternatives) pub fn get_all_provides(db_path: &Path) -> Result> { use std::collections::HashSet; @@ -1235,4 +1395,46 @@ mod tests { assert!(replaces.contains("grep")); assert!(replaces.contains("patch")); } + + #[test] + fn register_package_persists_groups() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap(); + + let mut spec = mk_spec("foo", "1.0"); + spec.dependencies.groups = vec!["base".into(), "desktop".into()]; + + register_package(&db_path, &spec, &dest).unwrap(); + + assert_eq!( + get_package_groups(&db_path, "foo").unwrap(), + vec!["base".to_string(), "desktop".to_string()] + ); + } + + #[test] + fn installed_group_helpers_round_trip_membership() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("packages.db"); + let dest = tmp.path().join("dest"); + std::fs::create_dir_all(dest.join("usr/bin")).unwrap(); + std::fs::write(dest.join("usr/bin/foo"), "foo").unwrap(); + + let mut spec = mk_spec("foo", "1.0"); + spec.dependencies.groups = vec!["base".into()]; + register_package(&db_path, &spec, &dest).unwrap(); + + record_installed_groups(&db_path, &[String::from("base")]).unwrap(); + assert!(is_installed_group(&db_path, "base").unwrap()); + assert_eq!( + get_packages_in_installed_group(&db_path, "base").unwrap(), + vec!["foo".to_string()] + ); + + remove_installed_group(&db_path, "base").unwrap(); + assert!(!is_installed_group(&db_path, "base").unwrap()); + } } diff --git a/src/db/repo.rs b/src/db/repo.rs index f731ace..6561e71 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -90,6 +90,7 @@ struct IndexedPackage { replaces: Vec, runtime_dependencies: Vec, optional_dependencies: Vec, + groups: Vec, archive_files: Vec, } @@ -129,6 +130,7 @@ pub struct BinaryRepoPackageRecord { pub replaces: Vec, pub runtime_dependencies: Vec, pub optional_dependencies: Vec, + pub groups: Vec, } impl BinaryRepoPackageRecord { @@ -330,6 +332,11 @@ impl RepoManager { name TEXT NOT NULL, FOREIGN KEY(package_id) REFERENCES packages(id) ); + CREATE TABLE groups ( + package_id INTEGER, + name TEXT NOT NULL, + FOREIGN KEY(package_id) REFERENCES packages(id) + ); CREATE TABLE files ( package_id INTEGER, path TEXT NOT NULL, @@ -348,6 +355,7 @@ impl RepoManager { CREATE INDEX idx_replaces_name ON replaces(name); CREATE INDEX idx_dependencies_name ON dependencies(name); CREATE INDEX idx_dependencies_kind ON dependencies(kind); + CREATE INDEX idx_groups_name ON groups(name); CREATE INDEX idx_repo_files_path ON files(path);", ) .context("Failed to create repo DB indexes")?; @@ -380,6 +388,7 @@ impl RepoManager { let mut replaces = Vec::new(); let mut runtime_dependencies = Vec::new(); let mut optional_dependencies = Vec::new(); + let mut groups = Vec::new(); let mut archive_files = Vec::new(); { @@ -478,6 +487,17 @@ impl RepoManager { .map(String::from) .collect(); } + if let Some(groups_arr) = metadata + .get("dependencies") + .and_then(|v| v.get("groups")) + .and_then(|v| v.as_array()) + { + groups = groups_arr + .iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + } continue; } @@ -526,6 +546,7 @@ impl RepoManager { replaces, runtime_dependencies, optional_dependencies, + groups, archive_files, }) } @@ -550,6 +571,7 @@ impl RepoManager { replaces, runtime_dependencies, optional_dependencies, + groups, archive_files, } = indexed; @@ -608,6 +630,12 @@ impl RepoManager { params![package_id, dep], )?; } + for group in groups { + conn.execute( + "INSERT INTO groups (package_id, name) VALUES (?1, ?2)", + params![package_id, group], + )?; + } for file_path in archive_files { conn.execute( @@ -1681,6 +1709,26 @@ fn query_package_optional_deps(conn: &Connection, package_id: i64) -> Result Result> { + let has_groups_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='groups'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_groups_table { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare("SELECT name FROM groups WHERE package_id = ?1 ORDER BY name")?; + let rows = stmt.query_map(params![package_id], |row| row.get(0))?; + Ok(rows.filter_map(|r| r.ok()).collect()) +} + fn find_cached_binary_repo_packages( repo_name: &str, db_path: &Path, @@ -1770,6 +1818,7 @@ fn find_cached_binary_repo_packages( replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), }, )) })?; @@ -1782,6 +1831,113 @@ fn find_cached_binary_repo_packages( rec.replaces = query_package_replaces(&conn, package_id)?; rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; + rec.groups = query_package_groups(&conn, package_id)?; + out.push(rec); + } + Ok(out) +} + +fn find_cached_binary_repo_packages_by_group( + repo_name: &str, + db_path: &Path, + group: &str, +) -> Result> { + let conn = Connection::open(db_path) + .with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?; + + let has_groups_table: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='groups'", + [], + |r| { + let n: i64 = r.get(0)?; + Ok(n > 0) + }, + ) + .unwrap_or(false); + if !has_groups_table { + return Ok(Vec::new()); + } + + let completed_at_expr = if repo_packages_have_completed_at(&conn)? { + "p.completed_at" + } else { + "NULL" + }; + let real_name_expr = if repo_packages_have_real_name(&conn)? { + "p.real_name" + } else { + "NULL" + }; + let abi_breaking_expr = if repo_packages_have_abi_breaking(&conn)? { + "p.abi_breaking" + } else { + "0" + }; + let sql = format!( + "SELECT + p.id, + p.name, + {real_name_expr}, + p.version, + p.revision, + {abi_breaking_expr}, + {completed_at_expr}, + p.filename, + p.size, + p.sha256, + p.sha512, + p.description, + p.homepage, + p.license + FROM packages p + WHERE EXISTS ( + SELECT 1 FROM groups g + WHERE g.package_id = p.id + AND lower(g.name) = lower(?1) + ) + ORDER BY p.name ASC" + ); + let mut stmt = conn.prepare(&sql)?; + + let rows = stmt.query_map(params![group], |row| { + let package_id = row.get::<_, i64>(0)?; + Ok(( + package_id, + BinaryRepoPackageRecord { + repo_name: repo_name.to_string(), + name: row.get(1)?, + real_name: row.get(2)?, + version: row.get(3)?, + revision: row.get::<_, i64>(4)? as u32, + abi_breaking: row.get(5)?, + completed_at: row.get(6)?, + filename: row.get(7)?, + size: row.get::<_, i64>(8)? as u64, + sha256: row.get(9)?, + sha512: row.get(10)?, + description: row.get(11)?, + homepage: row.get(12)?, + license: row.get(13)?, + provides: Vec::new(), + conflicts: Vec::new(), + replaces: Vec::new(), + runtime_dependencies: Vec::new(), + optional_dependencies: Vec::new(), + groups: Vec::new(), + }, + )) + })?; + + let mut out = Vec::new(); + for row in rows { + let (package_id, mut rec) = row?; + rec.provides = query_package_provides(&conn, package_id)?; + rec.conflicts = query_package_conflicts(&conn, package_id)?; + rec.replaces = query_package_replaces(&conn, package_id)?; + rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; + rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; + rec.groups = query_package_groups(&conn, package_id)?; out.push(rec); } Ok(out) @@ -1854,6 +2010,7 @@ fn list_cached_binary_repo_packages( replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), }, )) })?; @@ -1866,6 +2023,7 @@ fn list_cached_binary_repo_packages( rec.replaces = query_package_replaces(&conn, package_id)?; rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?; rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?; + rec.groups = query_package_groups(&conn, package_id)?; out.push(rec); } Ok(out) @@ -1949,6 +2107,18 @@ pub fn find_binary_repo_packages( find_cached_binary_repo_packages(repo_name, &db_path, query) } +/// Resolve package records that belong to the named group from a binary repo. +pub fn find_binary_repo_packages_by_group( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rootfs: &Path, + package_cache_dir: &Path, + group: &str, +) -> Result> { + let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?; + find_cached_binary_repo_packages_by_group(repo_name, &db_path, group) +} + /// List all binary packages from a cached, verified repository database. pub fn list_binary_repo_packages( repo_name: &str, @@ -2716,6 +2886,33 @@ revision = 1 assert_eq!(provide_hits[0].name, "foo"); } + #[test] + fn test_find_cached_binary_repo_packages_by_group() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("repo.db"); + let mut conn = Connection::open(&db_path).unwrap(); + let manager = RepoManager::new(tmp.path().to_path_buf()); + manager.init_repo_schema(&mut conn).unwrap(); + + conn.execute( + "INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512) + VALUES (1, 'foo', '1.2.3', 1, 'Foo package', 'https://example.test', 'MIT', 'foo-1.2.3-1-x86_64.depot.pkg.tar.zst', 1234, 'a', 'b')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO groups (package_id, name) VALUES (1, 'base')", + [], + ) + .unwrap(); + drop(conn); + + let hits = find_cached_binary_repo_packages_by_group("testrepo", &db_path, "base").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "foo"); + assert_eq!(hits[0].groups, vec!["base".to_string()]); + } + #[test] #[cfg(unix)] fn test_repo_owns_query_candidates_follow_rootfs_symlink_targets() { @@ -2819,6 +3016,7 @@ revision = 1 replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), }; verify_binary_package_record_checksums(&pkg, &rec).unwrap(); @@ -2858,6 +3056,7 @@ revision = 1 replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), } } diff --git a/src/deps.rs b/src/deps.rs index dcb9144..ee17531 100755 --- a/src/deps.rs +++ b/src/deps.rs @@ -608,6 +608,7 @@ mod tests { runtime: Vec::new(), test: vec!["bats".into(), "python".into()], optional: Vec::new(), + groups: Vec::new(), lib32: None, }, package_alternatives: Default::default(), @@ -657,6 +658,7 @@ mod tests { runtime: vec!["python".into()], test: Vec::new(), optional: Vec::new(), + groups: Vec::new(), lib32: None, }, package_alternatives: Default::default(), @@ -715,6 +717,7 @@ mod tests { runtime: vec!["foo-libs".into(), "libfoo".into(), "python".into()], test: Vec::new(), optional: Vec::new(), + groups: Vec::new(), lib32: None, }, package_alternatives: std::collections::BTreeMap::from([( @@ -747,6 +750,7 @@ mod tests { runtime: vec!["foo".into()], test: Vec::new(), optional: Vec::new(), + groups: Vec::new(), }); let missing = check_runtime_deps_for_outputs( diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 48c7fed..6e6d116 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -655,6 +655,14 @@ pub fn create_interactive() -> Result { "Optional dependency", "Package that enables optional runtime functionality", )?; + let groups = if show_advanced { + prompt_repeating_list( + "Package group", + "Group name(s) this package belongs to, e.g. base, desktop, development", + )? + } else { + Vec::new() + }; let alternatives = if show_advanced { Alternatives { provides: prompt_repeating_list( @@ -696,6 +704,7 @@ pub fn create_interactive() -> Result { runtime: runtime_deps, test: test_deps, optional: optional_deps, + groups, lib32: None, }, package_alternatives: Default::default(), @@ -1504,6 +1513,7 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { || !spec.dependencies.runtime.is_empty() || !spec.dependencies.test.is_empty() || !spec.dependencies.optional.is_empty() + || !spec.dependencies.groups.is_empty() { let mut dep_tbl = Table::new(); if !spec.dependencies.build.is_empty() { @@ -1554,6 +1564,18 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !spec.dependencies.groups.is_empty() { + dep_tbl.insert( + "groups".into(), + Value::Array( + spec.dependencies + .groups + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } root.insert("dependencies".into(), Value::Table(dep_tbl)); } @@ -1600,6 +1622,17 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { ), ); } + if !deps.groups.is_empty() { + dep_tbl.insert( + "groups".into(), + Value::Array( + deps.groups + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } if !dep_tbl.is_empty() { outputs_tbl.insert(pkg_name.clone(), Value::Table(dep_tbl)); } @@ -1999,6 +2032,7 @@ mod tests { runtime: vec![], test: vec!["python".into(), "bats".into()], optional: vec!["gtk-doc".into()], + groups: vec!["base".into(), "devtools".into()], lib32: None, }, package_alternatives: Default::default(), @@ -2023,6 +2057,14 @@ mod tests { .expect("expected dependencies.optional array"); assert_eq!(optional_deps.len(), 1); assert_eq!(optional_deps[0].as_str(), Some("gtk-doc")); + let groups = val + .get("dependencies") + .and_then(|d| d.get("groups")) + .and_then(|t| t.as_array()) + .expect("expected dependencies.groups array"); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].as_str(), Some("base")); + assert_eq!(groups[1].as_str(), Some("devtools")); } #[test] @@ -2133,6 +2175,7 @@ mod tests { runtime: vec!["foo".into(), "bar".into()], test: Vec::new(), optional: Vec::new(), + groups: vec!["base".into()], lib32: None, }, package_alternatives: Default::default(), diff --git a/src/package/packager.rs b/src/package/packager.rs index 671acae..89a9b66 100644 --- a/src/package/packager.rs +++ b/src/package/packager.rs @@ -228,6 +228,17 @@ impl Packager { .collect(), ), ); + deps.insert( + "groups".to_string(), + toml::Value::Array( + self.spec + .dependencies + .groups + .iter() + .map(|s| toml::Value::String(s.clone())) + .collect(), + ), + ); map.insert("dependencies".to_string(), toml::Value::Table(deps)); if !self.spec.build.flags.keep.is_empty() { map.insert( diff --git a/src/package/spec.rs b/src/package/spec.rs index 5db92c6..efedcb5 100755 --- a/src/package/spec.rs +++ b/src/package/spec.rs @@ -1899,9 +1899,11 @@ type = "custom" [dependencies] runtime = ["base"] +groups = ["toolchain"] [package_dependencies.clang] runtime = ["llvm-libs", "llvm-libgcc"] +groups = ["compiler"] [package_dependencies.llvm-libs] runtime = ["llvm-libgcc", "zstd"] @@ -1914,14 +1916,26 @@ runtime = ["llvm-libgcc", "zstd"] spec.dependencies_for_output("llvm").runtime, vec!["base".to_string()] ); + assert_eq!( + spec.dependencies_for_output("llvm").groups, + vec!["toolchain".to_string()] + ); assert_eq!( spec.dependencies_for_output("clang").runtime, vec!["llvm-libs".to_string(), "llvm-libgcc".to_string()] ); + assert_eq!( + spec.dependencies_for_output("clang").groups, + vec!["compiler".to_string()] + ); assert_eq!( spec.dependencies_for_output("llvm-libs").runtime, vec!["llvm-libgcc".to_string(), "zstd".to_string()] ); + assert_eq!( + spec.dependencies_for_output("llvm-libs").groups, + Vec::::new() + ); } #[test] @@ -1949,11 +1963,13 @@ type = "custom" [dependencies] runtime = ["base"] +groups = ["toolchain"] [dependencies.lib32] build = ["gcc-multilib"] runtime = ["lib32-zlib"] test = ["bats"] +groups = ["lib32-toolchain"] "#, ) .unwrap(); @@ -1963,6 +1979,10 @@ test = ["bats"] spec.dependencies_for_output("llvm").runtime, vec!["base".to_string()] ); + assert_eq!( + spec.dependencies_for_output("llvm").groups, + vec!["toolchain".to_string()] + ); assert_eq!( spec.dependencies_for_output("lib32-llvm").build, vec!["gcc-multilib".to_string()] @@ -1975,6 +1995,10 @@ test = ["bats"] spec.dependencies_for_output("lib32-llvm").test, vec!["bats".to_string()] ); + assert_eq!( + spec.dependencies_for_output("lib32-llvm").groups, + vec!["lib32-toolchain".to_string()] + ); } #[test] @@ -2274,6 +2298,7 @@ type = "meta" [dependencies] runtime = ["foo", "bar"] +groups = ["base"] "#, ) .unwrap(); @@ -2283,6 +2308,7 @@ runtime = ["foo", "bar"] assert!(spec.manual_sources.is_empty()); assert!(spec.is_metapackage()); assert_eq!(spec.dependencies.runtime, vec!["foo", "bar"]); + assert_eq!(spec.dependencies.groups, vec!["base"]); } #[test] @@ -3803,6 +3829,9 @@ impl fmt::Display for PackageSpec { if !self.alternatives.replaces.is_empty() { writeln!(f, "Replaces: {}", self.alternatives.replaces.join(", "))?; } + if !self.dependencies.groups.is_empty() { + writeln!(f, "Groups: {}", self.dependencies.groups.join(", "))?; + } Ok(()) } } @@ -4786,6 +4815,9 @@ pub struct DependencyGroup { /// Optional runtime integrations that enhance functionality when installed. #[serde(default)] pub optional: Vec, + /// Package groups associated with this package output. + #[serde(default)] + pub groups: Vec, } impl DependencyGroup { @@ -4795,6 +4827,7 @@ impl DependencyGroup { runtime: self.runtime.clone(), test: self.test.clone(), optional: self.optional.clone(), + groups: self.groups.clone(), lib32: None, } } @@ -4815,6 +4848,9 @@ pub struct Dependencies { /// Optional runtime integrations that enhance functionality when installed. #[serde(default)] pub optional: Vec, + /// Package groups associated with this package. + #[serde(default)] + pub groups: Vec, /// Optional dependency overrides used only for the generated `lib32-*` companion package. #[serde(default, skip_serializing_if = "Option::is_none")] pub lib32: Option, @@ -4828,6 +4864,7 @@ impl Dependencies { runtime: self.runtime.clone(), test: self.test.clone(), optional: self.optional.clone(), + groups: self.groups.clone(), lib32: None, } } diff --git a/src/package/starbuild.rs b/src/package/starbuild.rs index 7119602..cbb951f 100644 --- a/src/package/starbuild.rs +++ b/src/package/starbuild.rs @@ -514,6 +514,7 @@ impl ParsedStarbuild { runtime: dedupe_preserve_order(self.dependencies.clone()), test: Vec::new(), optional: dedupe_preserve_order(self.optional_dependencies.clone()), + groups: Vec::new(), lib32: None, }; let mut alternatives = Alternatives { @@ -544,6 +545,7 @@ impl ParsedStarbuild { .cloned() .unwrap_or_default(), ), + groups: Vec::new(), lib32: None, }, ); diff --git a/src/planner.rs b/src/planner.rs index dfde9fc..6e5a7ce 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -942,6 +942,7 @@ mod tests { runtime: vec!["foo-libs".into(), "zlib".into()], test: vec!["bats".into()], optional: vec!["docs-viewer".into()], + groups: Vec::new(), lib32: None, }, package_alternatives: BTreeMap::from([( @@ -960,6 +961,7 @@ mod tests { runtime: vec!["foo-libs".into(), "libfoo".into(), "openssl".into()], test: Vec::new(), optional: Vec::new(), + groups: Vec::new(), lib32: None, }, )]), @@ -999,6 +1001,7 @@ mod tests { replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), + groups: Vec::new(), }), }, match_kind: MatchKind::Exact, @@ -1057,6 +1060,7 @@ mod tests { runtime: vec!["lib32-zlib".into()], test: vec!["lib32-bats".into()], optional: Vec::new(), + groups: Vec::new(), }); let deps = source_deps_for_install(&spec, true, true); @@ -1077,6 +1081,7 @@ mod tests { runtime: vec!["lib32-zlib".into()], test: vec!["lib32-bats".into()], optional: Vec::new(), + groups: Vec::new(), }); let deps = source_deps_for_install(&spec, true, false);