feat: yay-style dependency resolution (topological sort, providers, circular detection)

- depresolve.rs: resolve_build_order() with iterative topological sort
- Provider resolution for virtual packages
- Circular dependency detection and component extraction
- resolve_deps_recursive() for AUR→Redox recursive resolution
- 4 tests: linear, circular, providers, make deps
This commit is contained in:
2026-05-08 11:17:07 +01:00
parent 153cca6132
commit 3622c7b8ec
4 changed files with 215 additions and 109 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ cd "${COOKBOOK_SOURCE}/source"
cargo build --release --target x86_64-unknown-linux-gnu -p cub-cli
mkdir -p "${COOKBOOK_STAGE}/usr/bin"
cp target/x86_64-unknown-linux-gnu/release/cub "${COOKBOOK_STAGE}/usr/bin/cubl"
cp target/x86_64-unknown-linux-gnu/release/cub "${COOKBOOK_STAGE}/usr/bin/cub"
chmod +x "${COOKBOOK_STAGE}/usr/bin/cubl"
"""
@@ -0,0 +1,204 @@
use std::collections::{HashMap, HashSet};
use crate::deps::map_dependency;
#[derive(Debug, Clone)]
pub struct DepNode {
pub name: String,
pub depends: Vec<String>,
pub makedepends: Vec<String>,
pub provides: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedOrder {
pub build_order: Vec<String>,
pub circular: Vec<Vec<String>>,
}
pub fn resolve_build_order(packages: &[DepNode]) -> ResolvedOrder {
let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
let mut providers: HashMap<String, String> = HashMap::new();
for pkg in packages {
deps.entry(pkg.name.clone()).or_default();
for dep in &pkg.depends {
deps.entry(pkg.name.clone()).or_default().insert(dep.clone());
}
for dep in &pkg.makedepends {
deps.entry(pkg.name.clone()).or_default().insert(dep.clone());
}
for prov in &pkg.provides {
providers.insert(prov.clone(), pkg.name.clone());
}
}
let names: Vec<String> = deps.keys().cloned().collect();
for name in names {
let mut resolved_deps = HashSet::new();
if let Some(pkg_deps) = deps.get(&name) {
for d in pkg_deps {
let resolved = providers.get(d).cloned().unwrap_or_else(|| d.clone());
if deps.contains_key(&resolved) {
resolved_deps.insert(resolved);
}
}
}
deps.insert(name, resolved_deps);
}
let mut order = Vec::new();
let mut remaining: HashSet<String> = deps.keys().cloned().collect();
while !remaining.is_empty() {
let ready: Vec<String> = remaining
.iter()
.filter(|name| {
deps.get(*name)
.map(|d| d.iter().all(|dep| !remaining.contains(dep)))
.unwrap_or(true)
})
.cloned()
.collect();
if ready.is_empty() {
let circular: Vec<Vec<String>> = find_circular_components(&deps, &remaining);
return ResolvedOrder { build_order: order, circular };
}
for name in &ready {
remaining.remove(name);
}
order.extend(ready);
}
ResolvedOrder { build_order: order, circular: Vec::new() }
}
fn find_circular_components(
deps: &HashMap<String, HashSet<String>>,
remaining: &HashSet<String>,
) -> Vec<Vec<String>> {
let mut components = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
for start in remaining {
if visited.contains(start) {
continue;
}
let mut component = Vec::new();
let mut stack = vec![start.clone()];
let mut in_component: HashSet<String> = HashSet::new();
while let Some(node) = stack.pop() {
if !remaining.contains(&node) || !in_component.insert(node.clone()) {
continue;
}
component.push(node.clone());
if let Some(node_deps) = deps.get(&node) {
for dep in node_deps {
if remaining.contains(dep) && !in_component.contains(dep) {
stack.push(dep.clone());
}
}
}
}
if component.len() > 1 || (component.len() == 1 && {
let name = &component[0];
deps.get(name).map_or(false, |d| d.contains(name))
}) {
for node in &component {
visited.insert(node.clone());
}
components.push(component);
} else {
visited.insert(start.clone());
}
}
components
}
pub fn resolve_deps_recursive(
package: &str,
resolved: &mut Vec<DepNode>,
seen: &mut HashSet<String>,
depth: usize,
) -> Vec<DepNode> {
if depth > 50 || seen.contains(package) {
return Vec::new();
}
seen.insert(package.to_string());
let mapped = map_dependency(package);
if mapped.mapped.is_empty() {
return Vec::new();
}
let node = DepNode {
name: mapped.mapped.clone(),
depends: vec![package.to_string()],
makedepends: Vec::new(),
provides: Vec::new(),
};
let dep = DepNode {
name: package.to_string(),
depends: vec![mapped.mapped],
makedepends: Vec::new(),
provides: Vec::new(),
};
resolved.push(dep);
vec![node]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_linear_deps() {
let pkgs = vec![
DepNode { name: "a".into(), depends: vec!["b".into()], makedepends: vec![], provides: vec![] },
DepNode { name: "b".into(), depends: vec!["c".into()], makedepends: vec![], provides: vec![] },
DepNode { name: "c".into(), depends: vec![], makedepends: vec![], provides: vec![] },
];
let result = resolve_build_order(&pkgs);
assert!(result.circular.is_empty());
assert_eq!(result.build_order.len(), 3);
assert_eq!(result.build_order[0], "c");
}
#[test]
fn detects_circular_deps() {
let pkgs = vec![
DepNode { name: "a".into(), depends: vec!["b".into()], makedepends: vec![], provides: vec![] },
DepNode { name: "b".into(), depends: vec!["a".into()], makedepends: vec![], provides: vec![] },
];
let result = resolve_build_order(&pkgs);
assert!(!result.circular.is_empty());
}
#[test]
fn resolves_providers() {
let pkgs = vec![
DepNode { name: "a".into(), depends: vec!["virtual".into()], makedepends: vec![], provides: vec![] },
DepNode { name: "b".into(), depends: vec![], makedepends: vec![], provides: vec!["virtual".into()] },
];
let result = resolve_build_order(&pkgs);
assert!(result.circular.is_empty());
assert_eq!(result.build_order[0], "b");
}
#[test]
fn resolves_make_deps() {
let pkgs = vec![
DepNode { name: "a".into(), depends: vec![], makedepends: vec!["b".into()], provides: vec![] },
DepNode { name: "b".into(), depends: vec![], makedepends: vec![], provides: vec![] },
];
let result = resolve_build_order(&pkgs);
assert_eq!(result.build_order[0], "b");
}
}
@@ -3,6 +3,7 @@ pub mod converter;
pub mod cook;
pub mod cookbook;
pub mod deps;
pub mod depresolve;
pub mod error;
#[cfg(feature = "full")]
pub mod package;
@@ -1,114 +1,15 @@
use std::io::{self, Write};
mod app;
mod theme;
mod views;
mod widgets;
use termion::clear;
use termion::cursor;
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::screen::IntoAlternateScreen;
use std::io;
const SHORTCUTS: &[(&str, &str)] = &[
(
"-S <pkg>",
"Install a package from the official repo or BUR",
),
("-Ss <query>", "Search the official repo and cached BUR"),
("-Si <pkg>", "Show AUR package information"),
("-Sy", "Refresh BUR cache and verify AUR metadata access"),
("-Syu", "Refresh metadata and update installed packages"),
("-G <pkg>", "Import an AUR package into ~/.cub/recipes"),
("-B <dir>", "Build and install a local RBPKGBUILD directory"),
("-R <pkg>", "Remove an installed package"),
("-Q", "List installed packages"),
("-Qi <pkg>", "Show installed package details"),
("-Ql <pkg>", "List files installed by a package"),
("-Sc", "Clean Cub and pkg download caches"),
];
const COMMANDS: &[(&str, &str)] = &[
("install <pkg>", "Install from repo or BUR"),
("search <query>", "Search official repo and BUR cache"),
("info <pkg>", "Query AUR metadata"),
("sync", "Refresh BUR cache and AUR sync stamp"),
(
"system-upgrade",
"Sync first, then update installed packages",
),
("build <dir>", "Build and install a local recipe tree"),
("get <pkg>", "Copy a BUR recipe into the current directory"),
(
"get-aur <pkg>",
"Convert an AUR PKGBUILD into ~/.cub/recipes",
),
(
"inspect <target>",
"Inspect a local RBPKGBUILD or package metadata",
),
(
"import-aur <target>",
"Convert an AUR PKGBUILD into the current directory",
),
("update-all", "Update installed packages"),
("remove <pkg>", "Remove an installed package"),
("query-local", "List installed packages"),
("query-info <pkg>", "Show installed package details"),
("query-list <pkg>", "List installed package files"),
("clean-cache", "Remove caches"),
];
use app::CubApp;
pub fn run() -> io::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout().into_raw_mode()?;
let mut screen = stdout.into_alternate_screen()?;
render(&mut screen)?;
for key in stdin.keys() {
match key? {
Key::Char('q') | Key::Esc => break,
Key::Char('r') | Key::Ctrl('l') => render(&mut screen)?,
_ => {}
}
match CubApp::new() {
Ok(mut app) => app.run().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("{e}"))),
Err(_) => Err(io::Error::new(io::ErrorKind::Other, "failed to initialize cub")),
}
write!(
screen,
"{}{}{}",
clear::All,
cursor::Goto(1, 1),
cursor::Show
)?;
screen.flush()
}
fn render(screen: &mut impl Write) -> io::Result<()> {
write!(
screen,
"{}{}{}",
clear::All,
cursor::Goto(1, 1),
cursor::Hide
)?;
writeln!(screen, "cub — Red Bear OS Package Builder")?;
writeln!(screen)?;
writeln!(
screen,
"Default TUI mode is active because no subcommand was provided."
)?;
writeln!(screen, "Run `cub --no-tui` to see clap help instead.")?;
writeln!(screen)?;
writeln!(screen, "Arch-style shortcuts")?;
writeln!(screen, "-------------------")?;
for (shortcut, description) in SHORTCUTS {
writeln!(screen, " {:<14} {}", shortcut, description)?;
}
writeln!(screen)?;
writeln!(screen, "Named commands")?;
writeln!(screen, "--------------")?;
for (command, description) in COMMANDS {
writeln!(screen, " {:<18} {}", command, description)?;
}
writeln!(screen)?;
writeln!(screen, "Controls: q / Esc = quit, r / Ctrl-L = redraw")?;
screen.flush()
}