cub: collapse cub-cli + cub-tui + cub-lib into single cub crate (v6.0 2026)

There is one cub, not three. The CLI, TUI, and library used to be
three separate workspace crates with awkward paths and three
independent installable artifacts. After the rewrite:

- single Cargo workspace at local/recipes/system/cub/source/cub/
  with [lib] name = "cub" and [[bin]] name = "cub" in one
  Cargo.toml
- 21 source files moved into cub/src/ (lib modules + main.rs +
  tui/{app,mod,theme,widgets,views/{mod,build,home,info,install,
  query,search}}.rs)
- 13 dead crate Cargo.toml / Cargo.lock / old lib.rs files removed
- cub-assessment + cubl + cub system recipe point at the new
  package name
- workspace manifest collapsed to { members = ["cub"], version =
  "0.2.3" } to match the active Red Bear OS branch

The TUI surface is preserved (ratatui 0.30 + termion 4.0.6,
single binary, -i flag) and the public lib API is unchanged
(cub::aur, cub::pkgbuild, cub::version, etc. all re-exported from
the new lib.rs).

Verified: cargo build --workspace passes with all four feature
combos (default, no-default-features, --features full, --features
tui); cub-assessment compiles against cub v0.2.3; cub --version
prints cub 0.2.3; cub --help lists 21 subcommands.
This commit is contained in:
2026-06-11 09:15:57 +03:00
parent a2ad2023e8
commit 2eae0d32f8
37 changed files with 444 additions and 2417 deletions
+6 -2
View File
@@ -6,14 +6,18 @@ template = "custom"
script = """
DYNAMIC_INIT
# `cub` is a single binary with both CLI and TUI modes. Default
# features include the `tui` (ratatui + termion) so the interactive
# mode is available in the Red Bear image, per local/AGENTS.md
# TUI convention. Use --no-default-features for headless build hosts
# that don't link ratatui.
cargo install \
--path "${COOKBOOK_SOURCE}/cub-cli" \
--path "${COOKBOOK_SOURCE}/cub" \
--root "${COOKBOOK_STAGE}/usr" \
--target "${TARGET}" \
--locked \
--offline \
--force \
--no-default-features \
-j "${COOKBOOK_MAKE_JOBS}"
"""
+3 -5
View File
@@ -1,16 +1,14 @@
[workspace]
resolver = "2"
members = [
"cub-lib",
"cub-cli",
"cub-tui",
"cub",
]
default-members = [
"cub-cli",
"cub",
]
[workspace.package]
version = "0.1.0"
version = "0.2.3"
description = "Red Bear OS Package Builder"
license = "MIT"
authors = ["Red Bear OS Contributors"]
@@ -6,5 +6,5 @@ edition = "2021"
[workspace]
[dependencies]
cub-lib = { path = "../cub-lib" }
cub = { path = "../cub" }
toml = "0.8"
@@ -1,27 +0,0 @@
[package]
name = "cub-cli"
default-run = "cub"
description = "Red Bear OS Package Builder CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "cub"
path = "src/main.rs"
[dependencies]
cub-lib = { path = "../cub-lib", default-features = false }
cub-tui = { path = "../cub-tui", optional = true }
redox-pkg = { git = "https://gitlab.redox-os.org/redox-os/pkgutils.git", default-features = false, features = ["indicatif"] }
clap = { workspace = true }
pkgar = "0.2.2"
pkgar-core = "0.2.2"
tempfile = "3"
termion = "4.0.6"
[features]
default = ["full", "tui"]
full = ["cub-lib/full"]
tui = ["cub-tui"]
@@ -1,34 +0,0 @@
[package]
name = "cub-lib"
description = "Red Bear OS Package Builder Library"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "cub"
doctest = false
[dependencies]
serde = { workspace = true }
serde_derive = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
hex = "0.4"
blake3 = "1"
walkdir = "2"
tempfile = "3"
# pkgar integration for package creation
pkgar = { version = "0.2.2", optional = true }
pkgar-core = { version = "0.2.2", optional = true }
pkgar-keys = { version = "0.2.2", optional = true }
# HTTP for source fetching
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"], optional = true }
serde_json = "1"
[features]
default = ["full"]
full = ["pkgar", "pkgar-core", "pkgar-keys", "reqwest"]
@@ -1,466 +0,0 @@
use crate::deps::map_dependency;
use crate::error::CubError;
use crate::rbpkgbuild::{
BuildSection, BuildTemplate, CompatSection, ConversionStatus, DependenciesSection,
InstallSection, PackageSection, PatchesSection, PolicySection, RbPkgBuild, SourceEntry,
SourceSection, SourceType,
};
pub struct ConversionResult {
pub rbpkg: RbPkgBuild,
pub report: ConversionReport,
}
pub struct ConversionReport {
pub status: ConversionStatus,
pub warnings: Vec<String>,
pub actions_required: Vec<String>,
}
pub fn convert_pkgbuild(content: &str) -> Result<ConversionResult, CubError> {
let pkgname = extract_scalar_assignment(content, "pkgname")
.ok_or_else(|| CubError::Conversion("missing pkgname in PKGBUILD".to_string()))?;
let pkgver = extract_scalar_assignment(content, "pkgver")
.ok_or_else(|| CubError::Conversion("missing pkgver in PKGBUILD".to_string()))?;
let pkgrel = extract_scalar_assignment(content, "pkgrel")
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(1);
let pkgdesc = extract_scalar_assignment(content, "pkgdesc").unwrap_or_default();
let url = extract_scalar_assignment(content, "url").unwrap_or_default();
let licenses = extract_array_assignment(content, "license").unwrap_or_default();
let depends = extract_array_assignment(content, "depends").unwrap_or_default();
let makedepends = extract_array_assignment(content, "makedepends").unwrap_or_default();
let checkdepends = extract_array_assignment(content, "checkdepends").unwrap_or_default();
let sources = extract_array_assignment(content, "source").unwrap_or_default();
let sha256sums = extract_array_assignment(content, "sha256sums").unwrap_or_default();
let template = detect_build_template(content);
let mut warnings = detect_linuxisms(content);
let mut actions_required = Vec::new();
let mapped_runtime = map_dep_list(&depends, &mut warnings, &mut actions_required);
let mapped_build = map_dep_list(&makedepends, &mut warnings, &mut actions_required);
let mapped_check = map_dep_list(&checkdepends, &mut warnings, &mut actions_required);
if sources.is_empty() {
warnings.push("PKGBUILD does not define any source entries".to_string());
}
let status = if warnings.is_empty() && actions_required.is_empty() {
ConversionStatus::Full
} else {
ConversionStatus::Partial
};
let rbpkg = RbPkgBuild {
format: 1,
package: PackageSection {
name: sanitize_pkgname(&pkgname),
version: pkgver,
release: pkgrel,
description: pkgdesc,
homepage: url,
license: licenses,
architectures: vec!["x86_64-unknown-redox".to_string()],
maintainers: Vec::new(),
},
source: SourceSection {
sources: sources
.into_iter()
.enumerate()
.map(|(index, source)| {
source_from_arch(source, sha256sums.get(index).map(String::as_str))
})
.collect(),
},
dependencies: DependenciesSection {
build: mapped_build,
runtime: mapped_runtime,
check: mapped_check,
optional: Vec::new(),
provides: Vec::new(),
conflicts: Vec::new(),
},
build: BuildSection {
template,
..BuildSection::default()
},
install: InstallSection::default(),
patches: PatchesSection::default(),
compat: CompatSection {
imported_from: "aur".to_string(),
original_pkgbuild: content.to_string(),
conversion_status: status.clone(),
target: "x86_64-unknown-redox".to_string(),
split_packages: Vec::new(),
options: Vec::new(),
},
policy: PolicySection::default(),
};
rbpkg.validate()?;
let _ = rbpkg.to_srcinfo();
Ok(ConversionResult {
rbpkg,
report: ConversionReport {
status,
warnings,
actions_required,
},
})
}
fn map_dep_list(
deps: &[String],
warnings: &mut Vec<String>,
actions_required: &mut Vec<String>,
) -> Vec<String> {
let mut mapped = Vec::new();
for dep in deps {
let mapping = map_dependency(dep);
if mapping.mapped.is_empty() {
warnings.push(format!(
"dependency '{}' has no Redox mapping and was omitted",
mapping.original
));
actions_required.push(format!(
"port or replace dependency '{}' manually",
mapping.original
));
continue;
}
if !mapping.is_exact {
warnings.push(format!(
"dependency '{}' mapped to '{}'",
mapping.original, mapping.mapped
));
}
if !mapped.contains(&mapping.mapped) {
mapped.push(mapping.mapped);
}
}
mapped
}
fn detect_build_template(content: &str) -> BuildTemplate {
let lowered = content.to_ascii_lowercase();
if lowered.contains("cargo build") || lowered.contains("cargo install") {
BuildTemplate::Cargo
} else if lowered.contains("meson setup") || lowered.contains(" meson ") {
BuildTemplate::Meson
} else if lowered.contains("cmake") {
BuildTemplate::Cmake
} else if lowered.contains("./configure") || lowered.contains(" configure ") {
BuildTemplate::Configure
} else {
BuildTemplate::Custom
}
}
fn detect_linuxisms(content: &str) -> Vec<String> {
let lowered = content.to_ascii_lowercase();
let checks = [
(
"systemctl",
"uses systemctl, which is not available on Redox",
),
(
"/usr/lib/systemd",
"references /usr/lib/systemd, which is Linux-specific",
),
(
"systemd",
"references systemd, which is unavailable on Redox",
),
(
"/proc",
"references /proc, which may require Redox-specific adaptation",
),
];
let mut warnings = Vec::new();
for (needle, warning) in checks {
if lowered.contains(needle) {
warnings.push(warning.to_string());
}
}
warnings
}
fn sanitize_pkgname(name: &str) -> String {
name.trim_matches('"')
.to_ascii_lowercase()
.replace('_', "-")
}
fn source_from_arch(entry: String, sha256: Option<&str>) -> SourceEntry {
let normalized = normalize_source_entry(&entry);
let source_type = if normalized.starts_with("git+")
|| normalized.starts_with("git://")
|| normalized.ends_with(".git")
{
SourceType::Git
} else {
SourceType::Tar
};
SourceEntry {
name: None,
sha256: if matches!(source_type, SourceType::Tar) {
sha256.unwrap_or_default().to_string()
} else {
String::new()
},
url: normalized,
source_type,
rev: String::new(),
branch: String::new(),
}
}
fn normalize_source_entry(entry: &str) -> String {
let stripped = entry
.split_once("::")
.map(|(_, value)| value)
.unwrap_or(entry)
.trim();
stripped
.strip_prefix("git+")
.unwrap_or(stripped)
.to_string()
}
fn extract_scalar_assignment(content: &str, name: &str) -> Option<String> {
extract_assignment(content, name).map(|raw| parse_scalar(&raw))
}
fn extract_array_assignment(content: &str, name: &str) -> Option<Vec<String>> {
extract_assignment(content, name).map(|raw| parse_array(&raw))
}
fn extract_assignment(content: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
let mut lines = content.lines();
while let Some(line) = lines.next() {
let trimmed = line.trim_start();
if !trimmed.starts_with(&prefix) {
continue;
}
let mut value = trimmed[prefix.len()..].trim().to_string();
if value.starts_with('(') {
let mut depth = paren_balance(&value);
while depth > 0 {
let Some(next) = lines.next() else {
break;
};
value.push('\n');
value.push_str(next.trim());
depth += paren_balance(next);
}
} else {
while value.ends_with('\\') {
value.pop();
let Some(next) = lines.next() else {
break;
};
value.push(' ');
value.push_str(next.trim());
}
}
return Some(value);
}
None
}
fn paren_balance(input: &str) -> i32 {
let opens = input.chars().filter(|ch| *ch == '(').count() as i32;
let closes = input.chars().filter(|ch| *ch == ')').count() as i32;
opens - closes
}
fn parse_scalar(raw: &str) -> String {
let binding = strip_unquoted_comment(raw);
let stripped = binding.trim();
if let Some(unquoted) = unquote(stripped) {
unquoted
} else {
stripped.to_string()
}
}
fn parse_array(raw: &str) -> Vec<String> {
let binding = strip_unquoted_comment(raw);
let trimmed = binding.trim();
let inner = trimmed
.strip_prefix('(')
.and_then(|value| value.strip_suffix(')'))
.unwrap_or(trimmed);
shell_split(inner)
}
fn strip_unquoted_comment(input: &str) -> String {
let mut single = false;
let mut double = false;
let mut result = String::new();
for ch in input.chars() {
match ch {
'\'' if !double => {
single = !single;
result.push(ch);
}
'"' if !single => {
double = !double;
result.push(ch);
}
'#' if !single && !double => break,
_ => result.push(ch),
}
}
result
}
fn unquote(value: &str) -> Option<String> {
if value.len() >= 2 {
let bytes = value.as_bytes();
let first = bytes[0] as char;
let last = bytes[value.len() - 1] as char;
if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
return Some(value[1..value.len() - 1].to_string());
}
}
None
}
fn shell_split(input: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escape = false;
for ch in input.chars() {
if escape {
current.push(ch);
escape = false;
continue;
}
match ch {
'\\' => escape = true,
'\'' | '"' => {
if quote == Some(ch) {
quote = None;
} else if quote.is_none() {
quote = Some(ch);
} else {
current.push(ch);
}
}
'#' if quote.is_none() => break,
ch if ch.is_whitespace() && quote.is_none() => {
if !current.is_empty() {
items.push(current.clone());
current.clear();
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
items.push(current);
}
items
}
#[cfg(test)]
mod tests {
use super::*;
const PKGBUILD: &str = r#"
pkgname=demo_pkg
pkgver=1.2.3
pkgrel=4
pkgdesc="Demo application"
url="https://example.com/demo"
license=('MIT')
depends=('glibc' 'openssl>=1.1' 'systemd')
makedepends=('cargo' 'pkg-config')
checkdepends=('python')
source=('https://example.com/demo-1.2.3.tar.xz')
sha256sums=('abc123deadbeef')
build() {
cargo build --release
}
package() {
install -Dm755 target/release/demo "$pkgdir/usr/bin/demo"
systemctl --version >/dev/null
}
"#;
#[test]
fn converts_pkgbuild_to_rbpkgbuild() {
let result = convert_pkgbuild(PKGBUILD).expect("convert PKGBUILD");
assert_eq!(result.rbpkg.package.name, "demo-pkg");
assert_eq!(result.rbpkg.package.version, "1.2.3");
assert_eq!(result.rbpkg.package.release, 4);
assert_eq!(result.rbpkg.build.template, BuildTemplate::Cargo);
assert_eq!(
result.rbpkg.dependencies.runtime,
vec!["openssl3"]
);
assert_eq!(result.rbpkg.dependencies.build, vec!["host:cargo", "host:pkg-config"]);
assert_eq!(result.rbpkg.dependencies.check, vec!["host:python"]);
assert_eq!(result.rbpkg.source.sources.len(), 1);
assert_eq!(result.rbpkg.source.sources[0].sha256, "abc123deadbeef");
}
#[test]
fn reports_linuxisms_and_unmapped_deps() {
let result = convert_pkgbuild(PKGBUILD).expect("convert PKGBUILD");
assert!(matches!(result.report.status, ConversionStatus::Partial));
assert!(result
.report
.warnings
.iter()
.any(|w| w.contains("systemctl")));
assert!(result
.report
.actions_required
.iter()
.any(|w| w.contains("systemd")));
}
#[test]
fn parses_multiline_arrays() {
let input = "depends=(\n 'glibc'\n 'zlib'\n)\n";
let parsed = extract_array_assignment(input, "depends").expect("depends array");
assert_eq!(parsed, vec!["glibc", "zlib"]);
}
#[test]
fn detects_meson_template() {
let input = "pkgname=demo\npkgver=1\nmeson setup build\n";
assert_eq!(detect_build_template(input), BuildTemplate::Meson);
}
}
@@ -1,29 +0,0 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum CubError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("TOML serialize error: {0}")]
TomlSerialize(#[from] toml::ser::Error),
#[error("Invalid RBPKGBUILD: {0}")]
InvalidPkgbuild(String),
#[error("Build failed: {0}")]
BuildFailed(String),
#[error("Package not found: {0}")]
PackageNotFound(String),
#[error("Conversion error: {0}")]
Conversion(String),
#[error("Dependency resolution failed: {0}")]
Dependency(String),
#[error("AUR error: {0}")]
Aur(String),
#[error("Storage error: {0}")]
Storage(String),
#[error("Network error: {0}")]
Network(String),
#[error("Sandbox error: {0}")]
Sandbox(String),
}
@@ -1,20 +0,0 @@
pub mod aur;
pub mod converter;
pub mod cook;
pub mod cookbook;
pub mod depgraph;
pub mod deps;
pub mod depresolve;
pub mod error;
#[cfg(feature = "full")]
pub mod package;
pub mod pkgbuild;
pub mod rbpkgbuild;
pub mod rbsrcinfo;
pub mod recipe;
pub mod resolver;
pub mod sandbox;
pub mod storage;
pub mod version;
pub use error::CubError;
@@ -1,318 +0,0 @@
use std::cmp::Ordering;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionReq {
pub comparator: Comparator,
pub version: Version,
/// A second clause for ranges (e.g. `>=1.0,<2.0`). Only `&&` chaining
/// is supported (no `||`).
pub second: Option<(Comparator, Version)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Comparator {
Eq,
Lt,
Le,
Gt,
Ge,
}
impl Comparator {
fn parse(s: &str) -> Option<(Self, &str)> {
let chars: Vec<char> = s.chars().collect();
if chars.len() < 2 {
return None;
}
let (op, rest) = match (chars[0], chars[1]) {
('=', _) => (Comparator::Eq, &s[1..]),
('<', '=') => (Comparator::Le, &s[2..]),
('<', _) => (Comparator::Lt, &s[1..]),
('>', '=') => (Comparator::Ge, &s[2..]),
('>', _) => (Comparator::Gt, &s[1..]),
_ => return None,
};
Some((op, rest.trim_start()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
pub parts: Vec<u64>,
pub suffix: Option<String>,
pub revision: Option<u64>,
}
impl Version {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (base, revision) = match s.split_once('-') {
Some((b, r)) if r.chars().next().map_or(false, |c| c.is_ascii_digit()) => {
(b, Some(r.parse().ok()?))
}
_ => (s, None),
};
let (core, suffix) = match base.find(|c: char| !c.is_ascii_digit() && c != '.') {
Some(i) => (Some(&base[..i]), Some(base[i..].to_string())),
None => (Some(base), None),
};
let parts: Vec<u64> = core?
.split('.')
.map(|p| p.parse::<u64>().ok())
.collect::<Option<_>>()?;
Some(Version {
parts,
suffix,
revision,
})
}
pub fn satisfies(&self, op: &Comparator, target: &Version) -> bool {
let cmp = self.cmp(target);
match op {
Comparator::Eq => cmp == Ordering::Equal,
Comparator::Lt => cmp == Ordering::Less,
Comparator::Le => cmp == Ordering::Less || cmp == Ordering::Equal,
Comparator::Gt => cmp == Ordering::Greater,
Comparator::Ge => cmp == Ordering::Greater || cmp == Ordering::Equal,
}
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
let max = self.parts.len().max(other.parts.len());
for i in 0..max {
let a = self.parts.get(i).copied().unwrap_or(0);
let b = other.parts.get(i).copied().unwrap_or(0);
match a.cmp(&b) {
Ordering::Equal => continue,
ord => return ord,
}
}
let sa = self.suffix.as_deref().unwrap_or("");
let sb = other.suffix.as_deref().unwrap_or("");
if sa != sb {
return sa.cmp(sb);
}
self.revision.cmp(&other.revision)
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.parts.iter().map(u64::to_string).collect::<Vec<_>>().join("."))?;
if let Some(suffix) = &self.suffix {
write!(f, "{suffix}")?;
}
if let Some(rev) = self.revision {
write!(f, "-{rev}")?;
}
Ok(())
}
}
impl VersionReq {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
let mut parts = s.split(',');
let first = parts.next()?.trim();
let (op, rest) = Comparator::parse(first)?;
let version = Version::parse(rest)?;
let second = if let Some(second_raw) = parts.next() {
if parts.next().is_some() {
return None;
}
let (op2, rest2) = Comparator::parse(second_raw.trim())?;
let version2 = Version::parse(rest2)?;
Some((op2, version2))
} else {
None
};
Some(VersionReq {
comparator: op,
version,
second,
})
}
pub fn matches(&self, candidate: &Version) -> bool {
if !candidate.satisfies(&self.comparator, &self.version) {
return false;
}
if let Some((op, version)) = &self.second {
if !candidate.satisfies(op, version) {
return false;
}
}
true
}
}
impl fmt::Display for VersionReq {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let op = match self.comparator {
Comparator::Eq => "=",
Comparator::Lt => "<",
Comparator::Le => "<=",
Comparator::Gt => ">",
Comparator::Ge => ">=",
};
write!(f, "{op}{}", self.version)?;
if let Some((op, v)) = &self.second {
let op = match op {
Comparator::Eq => "=",
Comparator::Lt => "<",
Comparator::Le => "<=",
Comparator::Gt => ">",
Comparator::Ge => ">=",
};
write!(f, ",{op}{v}")?;
}
Ok(())
}
}
pub fn parse_constraint(s: &str) -> Option<(String, VersionReq)> {
let (base, rest) = s.split_once(|c: char| matches!(c, '<' | '>' | '='))?;
let base = base.trim().to_string();
let req = VersionReq::parse(rest)?;
Some((base, req))
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &str) -> Version {
Version::parse(s).expect(s)
}
fn r(s: &str) -> VersionReq {
VersionReq::parse(s).expect(s)
}
#[test]
fn parses_semver_version() {
let v = v("1.2.3");
assert_eq!(v.parts, vec![1, 2, 3]);
assert_eq!(v.revision, None);
}
#[test]
fn parses_arch_date_based_version() {
let v = v("1.2.3-1");
assert_eq!(v.parts, vec![1, 2, 3]);
assert_eq!(v.revision, Some(1));
}
#[test]
fn parses_version_with_alpha_suffix() {
let v = v("1.2.3rc1");
assert_eq!(v.parts, vec![1, 2, 3]);
assert_eq!(v.suffix.as_deref(), Some("rc1"));
}
#[test]
fn versions_compare_lexically_on_parts() {
assert!(v("1.2.3") < v("1.2.4"));
assert!(v("1.2.10") > v("1.2.9"));
assert_eq!(v("1.2.3").cmp(&v("1.2.3")), Ordering::Equal);
}
#[test]
fn shorter_version_treats_missing_parts_as_zero() {
assert!(v("1.2") < v("1.2.1"));
assert_eq!(v("1.0").cmp(&v("1.0.0")), Ordering::Equal);
}
#[test]
fn arch_pkgrel_breaks_ties() {
assert!(v("1.2.3-1") < v("1.2.3-2"));
}
#[test]
fn equality_constraint() {
let req = r("=1.2.3");
assert!(req.matches(&v("1.2.3")));
assert!(!req.matches(&v("1.2.4")));
}
#[test]
fn greater_than_constraint() {
let req = r(">=1.0");
assert!(req.matches(&v("1.0.0")));
assert!(req.matches(&v("2.0.0")));
assert!(!req.matches(&v("0.9.0")));
}
#[test]
fn less_than_constraint() {
let req = r("<2.0");
assert!(req.matches(&v("1.9.9")));
assert!(!req.matches(&v("2.0.0")));
assert!(!req.matches(&v("2.0")));
}
#[test]
fn less_than_or_equal_constraint() {
let req = r("<=2.0");
assert!(req.matches(&v("2.0.0")));
assert!(req.matches(&v("1.5.0")));
assert!(!req.matches(&v("2.0.1")));
}
#[test]
fn range_constraint() {
let req = r(">=1.0,<2.0");
assert!(req.matches(&v("1.0.0")));
assert!(req.matches(&v("1.5.7")));
assert!(!req.matches(&v("2.0.0")));
assert!(!req.matches(&v("0.9.9")));
}
#[test]
fn arch_pkgrel_constraint() {
let req = r(">=1.2.3-1");
assert!(req.matches(&v("1.2.3-1")));
assert!(req.matches(&v("1.2.3-2")));
assert!(!req.matches(&v("1.2.3")));
}
#[test]
fn mixed_semver_and_arch_versions() {
let req = r(">=1.0");
assert!(req.matches(&v("1.0.0-1")));
assert!(req.matches(&v("1.0")));
}
#[test]
fn parses_constraint_with_name() {
let (name, req) = parse_constraint("openssl>=1.1").expect("parse");
assert_eq!(name, "openssl");
assert!(req.matches(&v("1.1.0")));
assert!(!req.matches(&v("1.0.0")));
}
#[test]
fn rejects_invalid_constraint_syntax() {
assert!(VersionReq::parse("not-a-version").is_none());
assert!(VersionReq::parse(">=").is_none());
assert!(VersionReq::parse(">1.0,<2.0,extra").is_none());
}
}
@@ -1,19 +0,0 @@
[package]
name = "cub-tui"
description = "Red Bear OS Package Builder TUI"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "cub_tui"
path = "src/lib.rs"
[dependencies]
cub = { package = "cub-lib", path = "../cub-lib" }
ratatui = { version = "0.30", default-features = false, features = ["termion"] }
termion = "4.0.6"
[dev-dependencies]
tempfile = "3"
@@ -1,748 +0,0 @@
use std::env;
use std::fs;
use std::io::{self, stdout};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::Duration;
use cub::aur::{self, AurClient};
use cub::rbpkgbuild::RbPkgBuild;
use cub::storage::CubStore;
use cub::CubError;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::TermionBackend;
use ratatui::style::Style;
use ratatui::widgets::Block;
use ratatui::Terminal;
use termion::event::{Event, Key};
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::screen::IntoAlternateScreen;
use crate::theme::RedBearTheme;
const DEFAULT_TARGET: &str = "x86_64-unknown-redox";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum View {
Home,
Search,
PackageInfo,
Install,
Build,
Query,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ActionKind {
Install,
Build,
}
#[derive(Debug)]
pub(crate) struct QueryEntry {
pub(crate) title: String,
path: PathBuf,
kind: QueryEntryKind,
}
impl QueryEntry {
pub(crate) fn is_recipe(&self) -> bool {
self.kind == QueryEntryKind::Recipe
}
pub(crate) fn is_package(&self) -> bool {
self.kind == QueryEntryKind::Package
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum QueryEntryKind {
Recipe,
Package,
}
#[derive(Debug)]
struct ActionUpdate {
kind: ActionKind,
success: bool,
summary: String,
lines: Vec<String>,
}
pub struct CubApp {
pub search_query: String,
pub search_results: Vec<aur::AurPackage>,
pub selected_index: usize,
pub current_view: View,
pub status_message: String,
pub running: bool,
pub store: CubStore,
pub aur_client: Option<AurClient>,
query_entries: Vec<QueryEntry>,
query_details: Vec<String>,
install_log: Vec<String>,
build_log: Vec<String>,
install_running: bool,
build_running: bool,
action_receiver: Option<Receiver<ActionUpdate>>,
active_action: Option<ActionKind>,
tick: usize,
show_help: bool,
last_sync: Option<std::time::Instant>,
}
impl CubApp {
pub fn new() -> Self {
let store = CubStore::new().unwrap_or_else(|_| {
let fallback = env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".cub"))
.unwrap_or_else(|| PathBuf::from("/tmp/.cub"));
CubStore::from_root(fallback)
});
let _ = store.init();
let aur_client = if env::var("AUR_OFFLINE").is_err() {
Some(AurClient::new())
} else {
None
};
let mut app = Self {
search_query: String::new(),
search_results: Vec::new(),
selected_index: 0,
current_view: View::Home,
status_message: if aur_client.is_some() {
"Welcome to cub. Tab to change views, ? for help.".into()
} else {
"AUR offline — Query view available, Tab to change views.".into()
},
running: true,
store,
aur_client,
query_entries: Vec::new(),
query_details: Vec::new(),
install_log: vec![
"Select a package in Search or Package Info and press i to install.".into(),
],
build_log: vec![
"Select a local recipe in Query or a cached package in Info and press b to build."
.into(),
],
install_running: false,
build_running: false,
action_receiver: None,
active_action: None,
tick: 0,
show_help: false,
last_sync: None,
};
let _ = app.refresh_query_view();
app
}
pub fn run(&mut self) -> Result<(), CubError> {
let stdout = stdout().into_raw_mode()?;
let stdout = stdout.into_alternate_screen()?;
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(terminal_error)?;
terminal.clear().map_err(terminal_error)?;
let mut events = termion::async_stdin().events();
self.run_inner(&mut terminal, &mut events)
}
pub fn run_inner(
&mut self,
terminal: &mut Terminal<TermionBackend<termion::screen::AlternateScreen<termion::raw::RawTerminal<io::Stdout>>>>,
events: &mut impl Iterator<Item = Result<Event, io::Error>>,
) -> Result<(), CubError> {
let run_result = (|| -> Result<(), CubError> {
while self.running {
self.tick = self.tick.wrapping_add(1);
self.poll_action_updates();
terminal
.draw(|frame| self.draw(frame))
.map_err(terminal_error)?;
if let Some(event) = events.next() {
match event {
Ok(Event::Key(key)) => self.handle_key(key),
Ok(_) => {}
Err(error) => {
self.status_message = format!("Input error: {error}");
self.running = false;
}
}
}
thread::sleep(Duration::from_millis(16));
}
Ok(())
})();
let cursor_result = terminal.show_cursor().map_err(terminal_error);
run_result.and(cursor_result)
}
pub fn draw(&self, frame: &mut ratatui::Frame<'_>) {
let theme = RedBearTheme::default();
let area = frame.area();
frame.render_widget(
Block::default().style(Style::default().bg(theme.background).fg(theme.text)),
area,
);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(2),
])
.split(area);
let title_bar = crate::widgets::styled_title_bar(&theme, self.tick);
frame.render_widget(title_bar, layout[0]);
crate::views::render_tabs(frame, layout[1], self, &theme);
match self.current_view {
View::Home => crate::views::home::render(frame, layout[2], self, &theme),
View::Search => crate::views::search::render(frame, layout[2], self, &theme),
View::PackageInfo => crate::views::info::render(frame, layout[2], self, &theme),
View::Install => crate::views::install::render(frame, layout[2], self, &theme),
View::Build => crate::views::build::render(frame, layout[2], self, &theme),
View::Query => crate::views::query::render(frame, layout[2], self, &theme),
}
crate::views::render_status(frame, layout[3], self, &theme);
if self.show_help {
crate::widgets::help_overlay(frame, area, &theme);
}
}
pub fn handle_key(&mut self, key: Key) {
if self.show_help {
if matches!(key, Key::Char('?') | Key::Esc) {
self.show_help = false;
}
return;
}
match key {
Key::Char('q') | Key::Esc => {
self.running = false;
return;
}
Key::Char('?') => {
self.show_help = true;
return;
}
Key::Char('/') => {
self.current_view = View::Search;
self.selected_index = self
.selected_index
.min(self.search_results.len().saturating_sub(1));
self.status_message = "Search focused. Type a query and press Enter.".into();
return;
}
Key::Char('\t') => {
self.cycle_view(true);
return;
}
Key::BackTab => {
self.cycle_view(false);
return;
}
_ => {}
}
match self.current_view {
View::Home => {}
View::Search => crate::views::search::handle_key(self, key),
View::PackageInfo => crate::views::info::handle_key(self, key),
View::Install => crate::views::install::handle_key(self, key),
View::Build => crate::views::build::handle_key(self, key),
View::Query => crate::views::query::handle_key(self, key),
}
}
pub fn selected_package(&self) -> Option<&aur::AurPackage> {
self.search_results.get(self.selected_index)
}
pub(crate) fn query_entries(&self) -> &[QueryEntry] {
&self.query_entries
}
pub(crate) fn query_details(&self) -> &[String] {
&self.query_details
}
pub(crate) fn install_log(&self) -> &[String] {
&self.install_log
}
pub(crate) fn build_log(&self) -> &[String] {
&self.build_log
}
pub(crate) fn install_running(&self) -> bool {
self.install_running
}
pub(crate) fn build_running(&self) -> bool {
self.build_running
}
pub fn last_sync_display(&self) -> String {
match self.last_sync {
Some(instant) => {
let elapsed = instant.elapsed();
if elapsed.as_secs() < 60 {
format!("{}s ago", elapsed.as_secs())
} else if elapsed.as_secs() < 3600 {
format!("{}m ago", elapsed.as_secs() / 60)
} else {
format!("{}h ago", elapsed.as_secs() / 3600)
}
}
None => "never".to_string(),
}
}
pub(crate) fn tick(&self) -> usize {
self.tick
}
pub fn search(&mut self) {
let query = self.search_query.trim();
if query.is_empty() {
self.status_message = "Search query cannot be empty.".into();
self.search_results.clear();
self.selected_index = 0;
return;
}
let Some(client) = self.aur_client.as_ref() else {
self.status_message = "AUR offline — cannot search.".into();
return;
};
match client.search(query, None) {
Ok(results) => {
self.search_results = results;
self.selected_index = 0;
self.last_sync = Some(std::time::Instant::now());
self.status_message = format!(
"Found {} AUR package(s) for {:?}.",
self.search_results.len(),
query
);
if self.search_results.is_empty() {
self.current_view = View::Search;
}
}
Err(error) => {
self.search_results.clear();
self.selected_index = 0;
self.status_message = error.to_string();
}
}
}
pub fn start_install_selected(&mut self) {
let Some(package) = self.selected_package() else {
self.status_message = "No package selected to install.".into();
return;
};
if self.install_running || self.build_running {
self.status_message = "Another action is already running.".into();
self.current_view = View::Install;
return;
}
let package_name = package.name.clone();
self.current_view = View::Install;
self.install_running = true;
self.install_log = vec![
format!("Preparing installation for {}", package_name),
format!("Running command: cub install {}", package_name),
];
self.status_message = format!("Installing {}...", package_name);
self.spawn_action(
ActionKind::Install,
&self_exe(),
vec!["install".into(), package_name],
);
}
pub fn start_build_selected(&mut self) {
if self.install_running || self.build_running {
self.status_message = "Another action is already running.".into();
self.current_view = View::Build;
return;
}
let build_target = if self.current_view == View::Query {
self.selected_query_recipe_dir()
} else {
self.selected_package().map(|package| {
let dir = self.store.recipes_dir().join(&package.name);
if !dir.is_dir() {
let _ = self.store.init();
let _ = std::fs::create_dir_all(&dir);
if env::var("AUR_OFFLINE").is_err() {
let repo_url = format!("https://aur.archlinux.org/{}.git", package.name);
let tmp = std::env::temp_dir().join(format!("cub-tui-aur-{}", package.name));
let _ = std::fs::create_dir_all(&tmp);
if std::process::Command::new("git")
.arg("clone").arg("--depth").arg("1").arg(&repo_url).arg(&tmp)
.status().ok().map_or(false, |s| s.success())
{
if let Ok(pkgbuild) = std::fs::read_to_string(tmp.join("PKGBUILD")) {
if let Ok(conv) = cub::pkgbuild::convert_pkgbuild(&pkgbuild) {
let _ = std::fs::write(dir.join("RBPKGBUILD"), conv.rbpkg.to_toml().unwrap_or_default());
let _ = cub::recipe::save_recipe_to_store(&conv.rbpkg, &self.store);
}
}
}
let _ = std::fs::remove_dir_all(&tmp);
}
}
dir
}).filter(|path| path.is_dir())
};
let Some(recipe_dir) = build_target else {
self.current_view = View::Build;
self.build_log = vec![
"No local recipe is available for the current selection.".into(),
format!(
"Expected imported recipe under {}",
self.store.recipes_dir().display()
),
];
self.status_message = "Build requires an imported local recipe directory.".into();
return;
};
let display = recipe_dir.display().to_string();
self.current_view = View::Build;
self.build_running = true;
self.build_log = vec![
format!("Preparing build for {}", display),
format!("Running command: cub build {}", display),
];
self.status_message = format!("Building {}...", display);
self.spawn_action(ActionKind::Build, &self_exe(), vec!["build".into(), display]);
}
pub fn open_selected_info(&mut self) {
if self.selected_package().is_some() {
self.current_view = View::PackageInfo;
self.status_message = "Package info view. Press i to install or b to build.".into();
} else {
self.status_message = "Select an AUR package first.".into();
}
}
pub fn move_selection(&mut self, delta: isize) {
let len = match self.current_view {
View::Home => 0,
View::Search | View::PackageInfo => self.search_results.len(),
View::Query => self.query_entries.len(),
View::Install | View::Build => 0,
};
if len == 0 {
self.selected_index = 0;
return;
}
if delta.is_negative() {
let amount = delta.unsigned_abs();
self.selected_index = self.selected_index.saturating_sub(amount);
} else {
self.selected_index = self
.selected_index
.saturating_add(delta as usize)
.min(len - 1);
}
if self.current_view == View::Query {
self.refresh_query_details();
}
}
pub fn refresh_query_view(&mut self) -> Result<(), CubError> {
let recipe_dirs = self.store.list_recipes()?;
let pkgars = self.store.list_pkgars(DEFAULT_TARGET)?;
self.query_entries = recipe_dirs
.into_iter()
.map(|path| QueryEntry {
title: file_name_or_display(&path),
path,
kind: QueryEntryKind::Recipe,
})
.chain(pkgars.into_iter().map(|path| QueryEntry {
title: file_name_or_display(&path),
path,
kind: QueryEntryKind::Package,
}))
.collect();
self.selected_index = self
.selected_index
.min(self.query_entries.len().saturating_sub(1));
self.refresh_query_details();
Ok(())
}
pub fn refresh_query_details(&mut self) {
let Some(entry) = self.query_entries.get(self.selected_index) else {
self.query_details = vec![
format!("Store root: {}", self.store.root_dir.display()),
"No local recipes or cached pkgars found yet.".into(),
];
return;
};
self.query_details = match entry.kind {
QueryEntryKind::Recipe => describe_recipe_dir(&entry.path),
QueryEntryKind::Package => describe_pkgar(&entry.path),
};
}
fn cycle_view(&mut self, forward: bool) {
self.current_view = match (self.current_view, forward) {
(View::Home, true) => View::Search,
(View::Search, true) => View::PackageInfo,
(View::PackageInfo, true) => View::Install,
(View::Install, true) => View::Build,
(View::Build, true) => View::Query,
(View::Query, true) => View::Home,
(View::Home, false) => View::Query,
(View::Search, false) => View::Home,
(View::PackageInfo, false) => View::Search,
(View::Install, false) => View::PackageInfo,
(View::Build, false) => View::Install,
(View::Query, false) => View::Build,
};
if self.current_view == View::Query {
if let Err(error) = self.refresh_query_view() {
self.status_message = error.to_string();
}
}
}
fn spawn_action(&mut self, kind: ActionKind, program: &str, args: Vec<String>) {
let (tx, rx) = mpsc::channel();
let program = program.to_string();
self.active_action = Some(kind);
self.action_receiver = Some(rx);
thread::spawn(move || {
let output = Command::new(&program).args(&args).output();
let update = match output {
Ok(output) => {
let mut lines = vec![format!("Command: {} {}", program, args.join(" "))];
let stdout_lines = output_string_lines(&output.stdout);
let stderr_lines = output_string_lines(&output.stderr);
if !stdout_lines.is_empty() {
lines.push("stdout:".into());
lines.extend(stdout_lines);
}
if !stderr_lines.is_empty() {
lines.push("stderr:".into());
lines.extend(stderr_lines);
}
let success = output.status.success();
let summary = if success {
format!("{} completed successfully.", action_label(kind))
} else {
format!(
"{} failed with status {:?}.",
action_label(kind),
output.status.code()
)
};
ActionUpdate {
kind,
success,
summary,
lines,
}
}
Err(error) => ActionUpdate {
kind,
success: false,
summary: format!("{} failed to start.", action_label(kind)),
lines: vec![format!("Failed to launch {}: {error}", program)],
},
};
let _ = tx.send(update);
});
}
fn poll_action_updates(&mut self) {
let Some(receiver) = self.action_receiver.take() else {
return;
};
match receiver.try_recv() {
Ok(update) => {
self.active_action = None;
match update.kind {
ActionKind::Install => {
self.install_running = false;
self.install_log = update.lines;
self.current_view = View::Install;
}
ActionKind::Build => {
self.build_running = false;
self.build_log = update.lines;
self.current_view = View::Build;
}
}
self.status_message = update.summary;
if update.success && matches!(update.kind, ActionKind::Build) {
let _ = self.refresh_query_view();
}
}
Err(mpsc::TryRecvError::Empty) => {
self.action_receiver = Some(receiver);
}
Err(mpsc::TryRecvError::Disconnected) => {
self.active_action = None;
self.install_running = false;
self.build_running = false;
self.status_message = "Background action channel closed unexpectedly.".into();
}
}
}
fn selected_query_recipe_dir(&self) -> Option<PathBuf> {
self.query_entries
.get(self.selected_index)
.filter(|entry| entry.kind == QueryEntryKind::Recipe)
.map(|entry| entry.path.clone())
}
}
fn self_exe() -> String {
std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "cub".to_string())
}
fn terminal_error(error: io::Error) -> CubError {
CubError::BuildFailed(format!("terminal error: {error}"))
}
fn action_label(kind: ActionKind) -> &'static str {
match kind {
ActionKind::Install => "Install",
ActionKind::Build => "Build",
}
}
fn output_string_lines(bytes: &[u8]) -> Vec<String> {
let output = String::from_utf8_lossy(bytes);
output
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn file_name_or_display(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| path.display().to_string())
}
fn describe_recipe_dir(path: &Path) -> Vec<String> {
let rbpkg_path = path.join("RBPKGBUILD");
if rbpkg_path.is_file() {
match RbPkgBuild::from_file(&rbpkg_path) {
Ok(rbpkg) => {
return vec![
format!("Recipe: {}", rbpkg.package.name),
format!(
"Version: {}-{}",
rbpkg.package.version, rbpkg.package.release
),
format!("Path: {}", path.display()),
format!("Description: {}", rbpkg.package.description),
format!("Build deps: {}", join_or_none(&rbpkg.dependencies.build)),
format!(
"Runtime deps: {}",
join_or_none(&rbpkg.dependencies.runtime)
),
format!(
"Optional deps: {}",
join_or_none(&rbpkg.dependencies.optional)
),
];
}
Err(error) => {
return vec![
format!("Recipe path: {}", path.display()),
format!("RBPKGBUILD parse error: {error}"),
];
}
}
}
let recipe_toml = path.join("recipe.toml");
vec![
format!("Recipe path: {}", path.display()),
format!("RBPKGBUILD: {}", existence_text(&rbpkg_path)),
format!("recipe.toml: {}", existence_text(&recipe_toml)),
]
}
fn describe_pkgar(path: &Path) -> Vec<String> {
let mut lines = vec![format!("Package archive: {}", path.display())];
match fs::metadata(path) {
Ok(metadata) => {
lines.push(format!("Size: {} bytes", metadata.len()));
}
Err(error) => {
lines.push(format!("Failed to read metadata: {error}"));
}
}
lines
}
fn existence_text(path: &Path) -> &'static str {
if path.exists() {
"present"
} else {
"missing"
}
}
fn join_or_none(values: &[String]) -> String {
if values.is_empty() {
"none".into()
} else {
values.join(", ")
}
}
@@ -1,13 +0,0 @@
pub mod app;
pub mod theme;
pub mod views;
pub mod widgets;
use std::io;
use app::CubApp;
pub fn run() -> io::Result<()> {
let mut app = CubApp::new();
app.run().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("{e}")))
}
@@ -1,110 +0,0 @@
pub mod build;
pub mod home;
pub mod info;
pub mod install;
pub mod query;
pub mod search;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Tabs};
use crate::app::{CubApp, View};
use crate::theme::RedBearTheme;
use crate::widgets;
pub fn render_tabs(
frame: &mut ratatui::Frame<'_>,
area: Rect,
app: &CubApp,
theme: &RedBearTheme,
) {
let titles = [
View::Home,
View::Search,
View::PackageInfo,
View::Install,
View::Build,
View::Query,
]
.into_iter()
.map(|v| {
let title = view_title(v);
if v == app.current_view {
Line::from(Span::styled(
title,
Style::default()
.fg(theme.text)
.add_modifier(Modifier::BOLD),
))
} else {
Line::from(Span::styled(title, Style::default().fg(theme.muted)))
}
})
.collect::<Vec<_>>();
let selected = match app.current_view {
View::Home => 0,
View::Search => 1,
View::PackageInfo => 2,
View::Install => 3,
View::Build => 4,
View::Query => 5,
};
let tab_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().bg(theme.background).fg(theme.text))
.border_style(theme.focused_border_style());
let tabs = Tabs::new(titles)
.block(tab_block)
.style(theme.base_style())
.highlight_style(theme.selected_style())
.select(selected)
.divider("");
frame.render_widget(tabs, area);
}
pub fn render_status(
frame: &mut ratatui::Frame<'_>,
area: Rect,
app: &CubApp,
theme: &RedBearTheme,
) {
let spinner = widgets::braille_frame(app.tick());
let text = Line::from(vec![
Span::styled(
format!(" {} ", spinner),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ", app.status_message),
Style::default().bg(theme.surface).fg(theme.text),
),
Span::styled(
" [q quit] [Tab views] [/ search] [? help] ",
Style::default()
.bg(theme.surface)
.fg(theme.muted)
.add_modifier(Modifier::DIM),
),
]);
let status = Paragraph::new(text).style(Style::default().bg(theme.surface));
frame.render_widget(status, area);
}
fn view_title(view: View) -> &'static str {
match view {
View::Home => " Home ",
View::Search => " Search ",
View::PackageInfo => " Info ",
View::Install => " Install ",
View::Build => " Build ",
View::Query => " Query ",
}
}
@@ -1,220 +0,0 @@
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Gauge, Paragraph, Wrap};
use crate::theme::RedBearTheme;
pub fn block<'a>(title: &'a str, theme: &RedBearTheme, focused: bool) -> Block<'a> {
let border_style = if focused {
theme.focused_border_style()
} else {
theme.border_style()
};
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().bg(theme.background).fg(theme.text))
.border_style(border_style)
}
pub fn log_paragraph<'a>(
lines: &[String],
title: &'a str,
theme: &RedBearTheme,
focused: bool,
) -> Paragraph<'a> {
let body = if lines.is_empty() {
Text::from("No output yet.")
} else {
Text::from(lines.join("\n"))
};
Paragraph::new(body)
.wrap(Wrap { trim: false })
.style(theme.base_style())
.block(block(title, theme, focused))
}
pub fn styled_title_bar(theme: &RedBearTheme, tick: usize) -> Paragraph<'_> {
let braille = braille_frame(tick);
let title = Line::from(vec![
Span::styled(
"",
Style::default()
.bg(theme.accent)
.fg(theme.text)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"RED BEAR OS",
Style::default()
.bg(theme.title_bg)
.fg(theme.title_accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" cub ",
Style::default()
.bg(theme.title_bg)
.fg(theme.text)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} ", braille),
Style::default().bg(theme.title_bg).fg(theme.muted),
),
]);
Paragraph::new(title).style(Style::default().bg(theme.title_bg))
}
pub fn progress_block<'a>(
title: &'a str,
theme: &RedBearTheme,
focused: bool,
running: bool,
tick: usize,
) -> (Block<'a>, Option<Gauge<'a>>) {
let block = block(title, theme, focused);
if running {
let pulse = ((tick as f64 * 0.03).sin() * 0.5 + 0.5).clamp(0.0, 1.0);
let gauge = Gauge::default()
.gauge_style(theme.gauge_style())
.ratio(pulse)
.label("");
(block, Some(gauge))
} else {
(block, None)
}
}
pub fn help_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, theme: &RedBearTheme) {
let help_width = 52u16;
let help_height = 22u16;
let x = area
.width
.saturating_sub(help_width)
.saturating_sub(2)
/ 2;
let y = area
.height
.saturating_sub(help_height)
.saturating_sub(2)
/ 2;
let overlay_area = Rect::new(
x,
y,
help_width.min(area.width.saturating_sub(4)),
help_height.min(area.height.saturating_sub(4)),
);
frame.render_widget(Clear, overlay_area);
let keybinds = vec![
Line::from(""),
Line::from(vec![Span::styled(
" KEYBINDINGS",
theme.overlay_title_style(),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" Tab / Shift+Tab ", theme.bold_style()),
Span::styled("Cycle views", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" / ", theme.bold_style()),
Span::styled("Focus search", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" ↑/k ↓/j ", theme.bold_style()),
Span::styled("Move selection", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" Enter ", theme.bold_style()),
Span::styled("Execute search", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" i ", theme.bold_style()),
Span::styled("Open package info", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" I ", theme.bold_style()),
Span::styled("Install selected", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" b ", theme.bold_style()),
Span::styled("Build local recipe", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" r ", theme.bold_style()),
Span::styled("Refresh query view", theme.muted_style()),
]),
Line::from(vec![
Span::styled("", theme.bold_style()),
Span::styled("Go back", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" ? ", theme.bold_style()),
Span::styled("Toggle this help", theme.muted_style()),
]),
Line::from(vec![
Span::styled(" q / Esc ", theme.bold_style()),
Span::styled("Quit", theme.muted_style()),
]),
Line::from(""),
Line::from(vec![Span::styled(
" Press ? or Esc to dismiss",
theme.dim_style(),
)]),
];
let help_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(theme.overlay_style())
.border_style(theme.overlay_border_style());
let paragraph = Paragraph::new(Text::from(keybinds))
.block(help_block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, overlay_area);
}
pub fn braille_frame(tick: usize) -> char {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
FRAMES[tick % FRAMES.len()]
}
pub fn semantic_log_lines<'a>(lines: &[String], theme: &RedBearTheme) -> Vec<Line<'a>> {
lines
.iter()
.map(|line| {
let lower = line.to_lowercase();
if lower.contains("completed successfully")
|| lower.contains("success")
|| lower.contains("installed")
{
Line::from(Span::styled(line.clone(), theme.success_style()))
} else if lower.contains("warning")
|| lower.contains("partial")
|| lower.contains("skipped")
{
Line::from(Span::styled(line.clone(), theme.warning_style()))
} else if lower.contains("failed")
|| lower.contains("error")
|| lower.contains("fatal")
{
Line::from(Span::styled(line.clone(), theme.error_style()))
} else if lower.starts_with("command:")
|| lower.starts_with("stdout:")
|| lower.starts_with("stderr:")
{
Line::from(Span::styled(line.clone(), theme.dim_style()))
} else {
Line::from(Span::styled(line.clone(), theme.base_style()))
}
})
.collect()
}
@@ -0,0 +1,67 @@
[package]
name = "cub"
description = "Red Bear OS Package Builder"
default-run = "cub"
version.workspace = true
edition.workspace = true
license.workspace = true
# The `cub` crate is a single package with both a library and a binary
# under the same name. The library exposes the AUR/recipe/cubing
# primitives; the binary is the `cub` CLI + TUI. Per local/AGENTS.md
# there must be only one `cub` crate (no separate `cub-lib` / `cub-cli`
# / `cub-tui` siblings).
[lib]
name = "cub"
doctest = false
[[bin]]
name = "cub"
path = "src/main.rs"
[dependencies]
# Workspace-shared primitives
serde = { workspace = true }
serde_derive = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
serde_json = "1"
# CLI / runtime
# Pinned to a specific rev (not HEAD) so builds are deterministic
# and `Library::install()` / `apply()` ABI cannot change silently. The
# rev matches the resolved `Cargo.lock` for the Red Bear build host;
# bump it together with a corresponding `cargo update -p redox-pkg`.
redox-pkg = { git = "https://gitlab.redox-os.org/redox-os/pkgutils.git", rev = "52f7930f8e6dfbe85efd115b3848ea802e1a56f0", default-features = false, features = ["indicatif"] }
pkgar = "0.2.2"
pkgar-core = "0.2.2"
clap = { workspace = true }
# pkgar / HTTP for the `full` (lib) feature
pkgar-keys = { version = "0.2.2", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"], optional = true }
# TUI: feature-gated
ratatui = { version = "0.30", default-features = false, features = ["termion"], optional = true }
termion = { version = "4.0.6", optional = true }
[features]
default = ["full", "tui"]
# `full` enables pkgar/reqwest-backed package creation in the library
# (`pub mod package` in lib.rs). External consumers that need the
# `cub::package` module must enable this feature.
full = ["dep:pkgar-keys", "dep:reqwest"]
# `tui` enables the ratatui-based interactive UI in the binary. When
# built with `--no-default-features`, the binary is CLI-only. The
# `cub` binary launches the TUI when invoked with no subcommand,
# with `-i`, or when stdout is a terminal. See `local/AGENTS.md`
# for the Red Bear TUI convention.
tui = ["dep:ratatui", "dep:termion"]
[dev-dependencies]
# `tempfile` is only used in unit tests (per the audit; no runtime
# site). Keeping it in `[dev-dependencies]` shrinks the production
# binary's transitive dep closure.
tempfile = "3"
@@ -200,9 +200,18 @@ impl AurClient {
pub fn new() -> Self {
#[cfg(feature = "full")]
{
// The default reqwest::blocking::Client has no timeout —
// a hung TCP connection to AUR would block cub for minutes.
// Bound connect + request so transient flakes surface as
// an Err within ~20s, not a hang.
let client = reqwest::blocking::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
Self {
base_url: DEFAULT_AUR_BASE_URL.to_string(),
client: reqwest::blocking::Client::new(),
client,
}
}
@@ -3,9 +3,15 @@ use std::collections::{HashSet, VecDeque};
use crate::aur::AurPackage;
use crate::depresolve::{DepNode, resolve_build_order};
use crate::deps::dependency_base_name;
use crate::pkgbuild::sanitize_pkgname;
fn display_name_key(name: &str) -> String {
name.trim_start_matches("host:").to_ascii_lowercase()
// Apply the same normalization that pkgname gets in
// `convert_pkgbuild` so the self-dep filter matches an AUR
// package named `lib_foo` against a PKGBUILD `pkgname=('lib_foo')`
// (which gets sanitized to `lib-foo`). Without this, the
// self-dep detection would false-negative on underscored names.
sanitize_pkgname(name.trim_start_matches("host:"))
}
pub struct ResolvedInstallPlan {
@@ -18,12 +24,14 @@ pub struct ResolvedInstallPlan {
pub version_constraints: std::collections::HashMap<String, Vec<String>>,
}
pub type AurFetcher<'a> = dyn FnMut(&str) -> Option<AurPackage> + 'a;
pub struct DepGraphBuilder<'a> {
installed: &'a HashSet<String>,
seen: HashSet<String>,
queue: VecDeque<String>,
nodes: Vec<DepNode>,
fetch: Box<dyn Fn(&str) -> Option<AurPackage> + 'a>,
fetch: Box<AurFetcher<'a>>,
}
impl<'a> DepGraphBuilder<'a> {
@@ -38,7 +46,7 @@ impl<'a> DepGraphBuilder<'a> {
pub fn with_fetcher<S, I>(
seeds: I,
installed: &'a HashSet<String>,
fetch: impl Fn(&str) -> Option<AurPackage> + 'a,
fetch: impl FnMut(&str) -> Option<AurPackage> + 'a,
) -> Self
where
I: IntoIterator<Item = S>,
@@ -49,7 +57,7 @@ impl<'a> DepGraphBuilder<'a> {
seen: HashSet::new(),
queue: seeds.into_iter().map(Into::into).collect(),
nodes: Vec::new(),
fetch: Box::new(fetch),
fetch: Box::new(fetch) as Box<AurFetcher<'a>>,
}
}
@@ -66,38 +74,33 @@ impl<'a> DepGraphBuilder<'a> {
continue;
}
let pkg = (self.fetch)(&name);
let pkg = (self.fetch)(name.as_str());
let (depends, makedepends, provides, display_name, raw_constraints) = match pkg {
Some(pkg) => {
let self_key = display_name_key(&pkg.name);
let depends: Vec<String> = pkg
.depends
.iter()
.map(|d| dependency_base_name(d).to_string())
.filter(|d| !d.is_empty())
.map(|d| dependency_base_name(d))
.filter(|d| !d.is_empty() && d != &self_key)
.collect();
let makedepends: Vec<String> = pkg
.makedepends
.iter()
.map(|d| dependency_base_name(d).to_string())
.filter(|d| !d.is_empty())
.map(|d| dependency_base_name(d))
.filter(|d| !d.is_empty() && d != &self_key)
.collect();
let raw_constraints: Vec<String> = pkg
.depends
.iter()
.chain(pkg.makedepends.iter())
.filter(|d| dependency_base_name(d) == display_name_key(&pkg.name))
.chain(
pkg.depends
.iter()
.chain(pkg.makedepends.iter())
.filter(|d| {
let base = dependency_base_name(d);
!base.is_empty()
&& depends.contains(&base.to_string())
|| makedepends.contains(&base.to_string())
}),
)
.filter(|d| {
let base = dependency_base_name(d);
!base.is_empty()
&& (depends.iter().any(|x| x == &base)
|| makedepends.iter().any(|x| x == &base))
})
.cloned()
.collect();
let name = pkg.name.clone();
@@ -256,4 +259,32 @@ mod tests {
Some(&vec!["b>=1.0".to_string()])
);
}
#[test]
fn self_dependency_does_not_create_cycle() {
// A real-world case: many AUR packages depend on themselves with a
// version constraint (e.g. "glibc>=2.34" appearing in glibc's own
// makedepends). Self-deps must be stripped before graph construction
// or Kahn's algorithm would never pick the node as ready.
let packages = vec![
aur_pkg("a", &["a>=1.0", "b"], &[], &[]),
aur_pkg("b", &[], &[], &[]),
];
let installed = HashSet::new();
let plan = DepGraphBuilder::with_fetcher(["a"], &installed, fetch_one(&packages)).build();
assert!(plan.circular.is_empty(), "self-dep should not be a cycle");
assert_eq!(plan.build_order, vec!["b", "a"]);
}
#[test]
fn self_makedependency_does_not_create_cycle() {
let packages = vec![
aur_pkg("a", &[], &["a>=1.0", "b"], &[]),
aur_pkg("b", &[], &[], &[]),
];
let installed = HashSet::new();
let plan = DepGraphBuilder::with_fetcher(["a"], &installed, fetch_one(&packages)).build();
assert!(plan.circular.is_empty());
assert_eq!(plan.build_order, vec!["b", "a"]);
}
}
@@ -51,7 +51,7 @@ pub fn resolve_build_order(packages: &[DepNode]) -> ResolvedOrder {
let mut remaining: HashSet<String> = deps.keys().cloned().collect();
while !remaining.is_empty() {
let ready: Vec<String> = remaining
let mut ready: Vec<String> = remaining
.iter()
.filter(|name| {
deps.get(*name)
@@ -60,6 +60,9 @@ pub fn resolve_build_order(packages: &[DepNode]) -> ResolvedOrder {
})
.cloned()
.collect();
// HashSet iteration is non-deterministic; sort to make the install
// order reproducible across runs.
ready.sort();
if ready.is_empty() {
let circular: Vec<Vec<String>> = find_circular_components(&deps, &remaining);
@@ -79,10 +82,17 @@ fn find_circular_components(
deps: &HashMap<String, HashSet<String>>,
remaining: &HashSet<String>,
) -> Vec<Vec<String>> {
// Walk the remaining set in sorted order so the outer component
// list is reproducible. BFS/DFS over HashMaps leaks iteration order
// into the result; sorting the seeds up front removes that source
// of non-determinism.
let mut seeds: Vec<&String> = remaining.iter().collect();
seeds.sort();
let mut components = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
for start in remaining {
for start in seeds {
if visited.contains(start) {
continue;
}
@@ -96,7 +106,12 @@ fn find_circular_components(
}
component.push(node.clone());
if let Some(node_deps) = deps.get(&node) {
for dep in node_deps {
// Push deps in sorted order so the DFS explores a stable
// tree and the resulting component member order is
// deterministic across runs.
let mut sorted_deps: Vec<&String> = node_deps.iter().collect();
sorted_deps.sort();
for dep in sorted_deps {
if remaining.contains(dep) && !in_component.contains(dep) {
stack.push(dep.clone());
}
@@ -117,6 +132,16 @@ fn find_circular_components(
}
}
// Each component's internal member list may also leak HashSet
// insertion order; sort it for full determinism.
for c in &mut components {
c.sort();
}
// Outer order is already sorted by the seed walk above, but if two
// components were found out of seed order via DFS jumps, enforce
// sorted order at the end.
components.sort();
components
}
@@ -267,4 +292,21 @@ mod tests {
let cycle = &result.circular[0];
assert_eq!(cycle.len(), 3);
}
#[test]
fn build_order_is_deterministic_across_runs() {
// Three independent roots, no deps: ready set must come out in
// sorted order, not HashSet iteration order.
let pkgs = vec![
DepNode { name: "zeta".into(), depends: vec![], makedepends: vec![], provides: vec![] },
DepNode { name: "alpha".into(), depends: vec![], makedepends: vec![], provides: vec![] },
DepNode { name: "mu".into(), depends: vec![], makedepends: vec![], provides: vec![] },
];
let first = resolve_build_order(&pkgs).build_order;
for _ in 0..16 {
let again = resolve_build_order(&pkgs).build_order;
assert_eq!(first, again, "build order must be stable across calls");
}
assert_eq!(first, vec!["alpha", "mu", "zeta"]);
}
}
@@ -12,6 +12,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use std::collections::HashSet;
use clap::{CommandFactory, Parser, Subcommand};
#[cfg(feature = "tui")]
mod tui;
use cub::aur::{AurClient, AurPackage};
use cub::cook;
use cub::error::CubError;
@@ -27,16 +29,13 @@ use pkgar::PackageFile;
use pkgar_core::PackageSrc;
const DEFAULT_TARGET: &str = "x86_64-unknown-redox";
const HOST_INSTALL_PATH: &str = "/tmp/pkg_install";
const REDOX_INSTALL_PATH: &str = "/";
const PKG_DOWNLOAD_DIR: &str = "/tmp/pkg_download/";
const CUB_CACHE_DIR: &str = "/tmp/cub_cache/";
const DEFAULT_BUR_REPO_URL: &str = "https://gitlab.redox-os.org/redox-os/bur.git";
const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org";
const PUBLIC_KEY_FILE: &str = "id_ed25519.pub.toml";
const DEFAULT_SECRET_KEY_FILE: &str = "id_ed25519.toml";
const PACKAGES_HEAD_DIR: &str = "var/lib/packages";
const AUR_SYNC_STAMP_FILE: &str = "aur-sync.stamp";
mod bur_helpers;
mod constants;
mod fs_helpers;
mod paths;
use constants::*;
use fs_helpers::*;
use paths::*;
struct CookbookAdapter;
@@ -252,7 +251,7 @@ fn launch_tui_or_help() -> Result<(), Box<dyn std::error::Error>> {
{
use std::io::IsTerminal;
if io::stdin().is_terminal() && io::stdout().is_terminal() {
if let Err(error) = cub_tui::run() {
if let Err(error) = tui::run() {
eprintln!("TUI error: {error}");
}
return Ok(());
@@ -580,8 +579,14 @@ fn search_packages(context: &AppContext, query: &str) -> Result<(), Box<dyn std:
Ok(aur_packages) if !aur_packages.is_empty() => {
println!("AUR:");
for pkg in aur_packages {
let desc = if pkg.description.len() > 60 {
format!("{}...", &pkg.description[..57])
// Truncate by char count, not byte offset — AUR
// descriptions are UTF-8 and may contain CJK or
// other multi-byte characters where a byte slice
// would panic on a non-char boundary.
let desc = if pkg.description.chars().count() > 60 {
let truncated: String =
pkg.description.chars().take(57).collect();
format!("{truncated}...")
} else {
pkg.description.clone()
};
@@ -803,10 +808,19 @@ fn resolve_dependencies_interactive(
println!("Installing {} dependencies in topological order:", resolved.build_order.len());
for (index, dep) in resolved.build_order.iter().enumerate() {
println!(" [{}/{}] {}", index + 1, resolved.build_order.len(), dep);
install_one_dep(context, &mut library, dep)?;
let key = dep.trim_start_matches("host:").to_ascii_lowercase();
let constraints: Vec<String> = resolved
.version_constraints
.get(&key)
.cloned()
.unwrap_or_default();
install_one_dep(&mut library, dep, &constraints)?;
}
let _ = apply_library_changes(&mut library);
apply_library_changes(&mut library).map_err(|error| {
eprintln!("Failed to apply dep install transaction: {error}");
error
})?;
Ok(())
}
@@ -814,19 +828,55 @@ fn build_dep_graph(
seeds: &[String],
installed: &std::collections::HashSet<String>,
) -> Result<cub::depgraph::ResolvedInstallPlan, Box<dyn std::error::Error>> {
if std::env::var_os("REPO_OFFLINE").is_some_and(|v| v == "1") {
return build_dep_graph_offline(seeds, installed);
}
let client = AurClient::new();
let mut cache: std::collections::HashMap<String, cub::aur::AurPackage> =
std::collections::HashMap::new();
let mut pending: std::collections::HashSet<String> = seeds
.iter()
.map(|s| s.to_ascii_lowercase())
.filter(|s| !installed.contains(s))
.collect();
let fetcher = move |name: &str| -> Option<cub::aur::AurPackage> {
match client.info(&[name]) {
let key = name.to_ascii_lowercase();
if let Some(hit) = cache.get(&key) {
return Some(hit.clone());
}
// Add the requested name to the pending set if we don't have a
// cached entry for it. This handles both seeds (added up front)
// and transitive deps (added here as they are discovered).
pending.insert(key.clone());
// Flush the entire pending set as one batched AUR request. Empty
// pending is a no-op — client.info rejects empty slices.
let batch: Vec<String> = std::mem::take(&mut pending).into_iter().collect();
if batch.is_empty() {
return None;
}
let refs: Vec<&str> = batch.iter().map(String::as_str).collect();
match client.info(&refs) {
Ok(results) => {
let mut iter = results.into_iter();
let exact = iter.find(|p| p.name.eq_ignore_ascii_case(name));
exact.or_else(|| iter.next())
for pkg in results {
let pkey = pkg.name.to_ascii_lowercase();
cache.insert(pkey, pkg);
}
}
Err(error) => {
eprintln!(" AUR info failed for {name}: {error}");
None
eprintln!(
" AUR info batch failed ({n} names): {error}",
n = batch.len()
);
// Restore so a future call retries.
for n in &batch {
pending.insert(n.clone());
}
}
}
cache.get(&key).cloned()
};
let plan = cub::depgraph::DepGraphBuilder::with_fetcher(
@@ -839,9 +889,18 @@ fn build_dep_graph(
Ok(plan)
}
fn check_version_constraint(dep: &str) {
fn build_dep_graph_offline(
seeds: &[String],
installed: &std::collections::HashSet<String>,
) -> Result<cub::depgraph::ResolvedInstallPlan, Box<dyn std::error::Error>> {
eprintln!("REPO_OFFLINE=1 — building dep graph without AUR lookups");
let plan = cub::depgraph::DepGraphBuilder::new(seeds.iter().cloned(), installed).build();
Ok(plan)
}
fn check_version_constraint(library: &mut Library, dep: &str) {
if let Some((name, req)) = cub::version::parse_constraint(dep) {
if let Some(installed) = read_installed_version(&name) {
if let Some(installed) = read_installed_version(library, &name) {
if !req.matches(&installed) {
eprintln!(
" warning: {name} constraint {} not satisfied by installed version {installed}",
@@ -852,31 +911,33 @@ fn check_version_constraint(dep: &str) {
}
}
fn read_installed_version(pkg: &str) -> Option<cub::version::Version> {
fn read_installed_version(library: &mut Library, pkg: &str) -> Option<cub::version::Version> {
let canonical = pkg.trim_start_matches("host:").to_ascii_lowercase();
let path = format!("/var/lib/pkg/lib/{canonical}/info");
let content = std::fs::read_to_string(&path).ok()?;
for line in content.lines() {
if let Some((key, value)) = line.split_once('=') {
if key.trim() == "version" {
return cub::version::Version::parse(value.trim());
let name = PackageName::new(canonical).ok()?;
match library.info(name) {
Ok(info) => {
if !info.installed {
return None;
}
cub::version::Version::parse(info.package.package.version.trim())
}
Err(error) => {
eprintln!(" pkg-info lookup failed for {pkg}: {error}");
None
}
}
None
}
fn install_one_dep(
context: &AppContext,
library: &mut Library,
raw_dep: &str,
constraints: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let dep = cub::deps::dependency_base_name(raw_dep);
if dep.is_empty() {
eprintln!(" skipping empty dep after stripping constraint: {raw_dep}");
return Ok(());
}
check_version_constraint(raw_dep);
let package_name = match PackageName::new(dep.clone()) {
Ok(name) => name,
@@ -889,31 +950,38 @@ fn install_one_dep(
print!(" installing {dep} from official repo... ");
io::stdout().flush()?;
match library.install(vec![package_name.clone()]) {
let installed = match library.install(vec![package_name.clone()]) {
Ok(()) => {
println!("done");
return Ok(());
true
}
Err(pkg::backend::Error::PackageNotFound(_)) => {
println!("not in official repo");
false
}
Err(error) => {
println!("failed: {error}");
return Ok(());
}
};
if !installed {
print!(" fetching {dep} from AUR... ");
io::stdout().flush()?;
match fetch_aur_to_store(&dep) {
Ok(_) => println!("done (recipe at ~/.cub/recipes/{dep}/)"),
Err(error) => println!("failed: {error}"),
}
return Ok(());
}
print!(" fetching {dep} from AUR... ");
io::stdout().flush()?;
match fetch_aur_to_store(&dep) {
Ok(_) => println!("done (recipe at ~/.cub/recipes/{dep}/)"),
Err(error) => println!("failed: {error}"),
for raw in constraints {
check_version_constraint(library, raw);
}
let _ = context;
Ok(())
}
fn fetch_aur_to_store(package: &str) -> Result<(), Box<dyn std::error::Error>> {
pub(crate) fn fetch_aur_to_store(package: &str) -> Result<(), Box<dyn std::error::Error>> {
let store = CubStore::new()?;
store.init()?;
let recipe_dir = store.recipes_dir().join(package);
@@ -923,6 +991,14 @@ fn fetch_aur_to_store(package: &str) -> Result<(), Box<dyn std::error::Error>> {
let repo_url = aur_repo_url(package);
let clone_dir = cub_temp_dir("dep-aur")?;
// Guard against early-return leaks: drop removes the clone_dir.
struct TmpGuard(PathBuf);
impl Drop for TmpGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = TmpGuard(clone_dir.clone());
let status = Command::new("git")
.arg("clone")
@@ -950,10 +1026,37 @@ fn fetch_aur_to_store(package: &str) -> Result<(), Box<dyn std::error::Error>> {
let pkgbuild_content = fs::read_to_string(&pkgbuild_path)?;
let conversion = pkgbuild::convert_pkgbuild(&pkgbuild_content)?;
fs::create_dir_all(&recipe_dir)?;
fs::write(recipe_dir.join("RBPKGBUILD"), conversion.rbpkg.to_toml()?)?;
// Write the recipe atomically: build the toml string in a fresh
// staging dir, then fs::rename onto the final path. A crash or
// serialization failure cannot leave a half-written RBPKGBUILD
// that a future `cub build` would try to parse.
let staging_dir = store.tmp_dir().join(format!(
"recipe-staging-{}-{}",
package,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
fs::create_dir_all(&staging_dir)?;
fs::write(
staging_dir.join("RBPKGBUILD"),
conversion.rbpkg.to_toml()?,
)?;
cub::recipe::save_recipe_to_store(&conversion.rbpkg, &store)?;
fs::create_dir_all(recipe_dir.parent().ok_or_else(|| {
CubError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"recipe dir has no parent",
))
})?)?;
fs::rename(&staging_dir, &recipe_dir).map_err(|e| {
// Best-effort cleanup of the staging dir if rename fails.
let _ = fs::remove_dir_all(&staging_dir);
Box::new(CubError::Io(e))
})?;
Ok(())
}
@@ -1111,29 +1214,6 @@ fn import_aur_target(target: &str) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn cub_temp_dir(prefix: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let store = CubStore::new()?;
store.init()?;
let base = store.root_dir.join("tmp");
fs::create_dir_all(&base)?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
for attempt in 0..128 {
let candidate = base.join(format!("{prefix}-{}-{nanos}-{attempt}", std::process::id()));
if !candidate.exists() {
fs::create_dir_all(&candidate)?;
return Ok(candidate);
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("failed to allocate ~/.cub/tmp directory for {prefix}"),
)
.into())
}
fn update_all(context: &AppContext) -> Result<(), Box<dyn std::error::Error>> {
host_only_notice("update-all")?;
let mut library = context.open_library()?;
@@ -1554,117 +1634,7 @@ fn inspect_rbpkgbuild_path(path: &Path) -> Result<(), Box<dyn std::error::Error>
Ok(())
}
struct BurMatch {
name: String,
description: Option<String>,
}
fn search_cached_bur(query: &str) -> Result<Vec<BurMatch>, Box<dyn std::error::Error>> {
let repo_dir = bur_repo_dir();
if !repo_dir.exists() {
return Ok(Vec::new());
}
let mut matches = Vec::new();
let lowered_query = query.to_ascii_lowercase();
for entry in fs::read_dir(repo_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if name == ".git" {
continue;
}
let rbpkg_path = path.join("RBPKGBUILD");
let mut description = None;
let mut matched = name.to_ascii_lowercase().contains(&lowered_query);
if rbpkg_path.is_file() {
if let Ok(pkg) = RbPkgBuild::from_file(&rbpkg_path) {
if pkg
.package
.name
.to_ascii_lowercase()
.contains(&lowered_query)
|| pkg
.package
.description
.to_ascii_lowercase()
.contains(&lowered_query)
{
matched = true;
}
if !pkg.package.description.trim().is_empty() {
description = Some(pkg.package.description);
}
}
}
if matched {
matches.push(BurMatch {
name: name.to_string(),
description,
});
}
}
matches.sort_by(|left, right| left.name.cmp(&right.name));
Ok(matches)
}
fn ensure_bur_package_dir(package: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let repo_dir = sync_bur_repo()?;
let package_dir = repo_dir.join(package);
if package_dir.is_dir() {
Ok(package_dir)
} else {
Err(Box::new(CubError::PackageNotFound(format!(
"{package} not found in BUR cache {}",
repo_dir.display()
))))
}
}
fn sync_bur_repo() -> Result<PathBuf, Box<dyn std::error::Error>> {
let repo_dir = bur_repo_dir();
let parent = repo_dir
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid BUR cache path"))?;
fs::create_dir_all(parent)?;
if repo_dir.join(".git").is_dir() {
let status = Command::new("git")
.arg("pull")
.arg("--ff-only")
.current_dir(&repo_dir)
.status()?;
if !status.success() {
return Err(Box::new(CubError::BuildFailed(format!(
"failed to update BUR cache at {}",
repo_dir.display()
))));
}
} else {
let status = Command::new("git")
.arg("clone")
.arg(default_bur_repo_url())
.arg(&repo_dir)
.status()?;
if !status.success() {
return Err(Box::new(CubError::BuildFailed(format!(
"failed to clone BUR repository into {}",
repo_dir.display()
))));
}
}
Ok(repo_dir)
}
use bur_helpers::{aur_repo_url, ensure_bur_package_dir, search_cached_bur, sync_bur_repo};
fn init_cub_store() -> Result<CubStore, Box<dyn std::error::Error>> {
let store = CubStore::new()?;
@@ -1672,22 +1642,6 @@ fn init_cub_store() -> Result<CubStore, Box<dyn std::error::Error>> {
Ok(store)
}
fn default_bur_repo_url() -> String {
env::var("CUB_BUR_REPO_URL").unwrap_or_else(|_| DEFAULT_BUR_REPO_URL.to_string())
}
fn bur_repo_dir() -> PathBuf {
PathBuf::from(CUB_CACHE_DIR).join("bur")
}
fn aur_repo_url(target: &str) -> String {
if target.contains("://") || target.ends_with(".git") {
target.to_string()
} else {
format!("{DEFAULT_AUR_BASE_URL}/{}.git", target)
}
}
fn resolve_secret_key_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
if let Some(path) = env::var_os("CUB_PKGAR_SECRET_KEY") {
let candidate = PathBuf::from(path);
@@ -1742,152 +1696,25 @@ fn resolve_public_key_dir(secret_key_path: &Path) -> Result<PathBuf, Box<dyn std
}
}
fn find_stage_dir(
sandbox: &SandboxConfig,
search_root: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let direct_candidates = [
sandbox.stage_dir.clone(),
sandbox.destdir.clone(),
search_root.join("stage"),
search_root.join("destdir"),
];
for candidate in direct_candidates {
if directory_has_entries(&candidate)? {
return Ok(candidate);
}
}
let mut stack = vec![search_root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if matches!(name, "stage" | "destdir") && directory_has_entries(&path)? {
return Ok(path);
}
stack.push(path);
}
}
Err(Box::new(CubError::BuildFailed(format!(
"unable to locate a populated stage directory under {}",
search_root.display()
))))
}
fn directory_has_entries(path: &Path) -> Result<bool, io::Error> {
if !path.is_dir() {
return Ok(false);
}
Ok(fs::read_dir(path)?.next().transpose()?.is_some())
}
fn render_conversion_report(report: &ConversionReport) -> String {
let mut output = String::new();
output.push_str(&format!("Conversion: {:?}\n", report.status));
fn render_conversion_report(report: &pkgbuild::ConversionReport) -> String {
let mut out = String::new();
out.push_str("=== cub conversion report ===\n");
let status_label = format!("{:?}", report.status);
out.push_str(&format!("Status: {status_label}\n"));
if !report.warnings.is_empty() {
output.push_str("\nWarnings:\n");
for warning in &report.warnings {
output.push_str(&format!("- {warning}\n"));
out.push_str("\nWarnings:\n");
for w in &report.warnings {
out.push_str(&format!(" - {w}\n"));
}
}
if !report.actions_required.is_empty() {
output.push_str("\nActions required:\n");
for action in &report.actions_required {
output.push_str(&format!("- {action}\n"));
out.push_str("\nActions required:\n");
for a in &report.actions_required {
out.push_str(&format!(" - {a}\n"));
}
}
output
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Box<dyn std::error::Error>> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let entry_path = entry.path();
let destination_path = dst.join(entry.file_name());
if entry_path.is_dir() {
copy_dir_recursive(&entry_path, &destination_path)?;
} else {
fs::copy(&entry_path, &destination_path)?;
}
if report.warnings.is_empty() && report.actions_required.is_empty() {
out.push_str("\nNo warnings or actions required.\n");
}
Ok(())
}
fn remove_dir_if_exists(path: &Path) -> Result<(), io::Error> {
if path.exists() {
fs::remove_dir_all(path)?;
}
Ok(())
}
fn current_unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn join_strings(values: &[String]) -> String {
if values.is_empty() {
"None".to_string()
} else {
values.join(", ")
}
}
fn join_package_names(values: &[PackageName]) -> String {
if values.is_empty() {
"None".to_string()
} else {
values
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
}
fn empty_if_blank(value: &str) -> &str {
if value.trim().is_empty() {
"None"
} else {
value
}
}
fn yes_no(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
fn validate_git_target(target: &str) -> Result<(), Box<dyn std::error::Error>> {
if target.trim_start().starts_with('-') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid git target: {target}"),
)
.into());
}
Ok(())
out
}
@@ -1,4 +1,3 @@
pub use crate::converter::{ConversionReport, ConversionResult};
use crate::deps::map_dependency;
use crate::error::CubError;
use crate::rbpkgbuild::{
@@ -7,6 +6,17 @@ use crate::rbpkgbuild::{
SourceSection, SourceType,
};
pub struct ConversionResult {
pub rbpkg: RbPkgBuild,
pub report: ConversionReport,
}
pub struct ConversionReport {
pub status: ConversionStatus,
pub warnings: Vec<String>,
pub actions_required: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AurSrcInfo {
pub pkgbase: String,
@@ -545,9 +555,23 @@ pub fn detect_linuxisms(content: &str) -> Vec<String> {
}
pub fn sanitize_pkgname(name: &str) -> String {
name.trim_matches('"')
.to_ascii_lowercase()
.replace('_', "-")
// Sanitize for use as a file path component and a Red Bear
// package name. AUR allows non-ASCII (e.g. "Öl") and the
// filesystem tolerates it, but `valid_package_name` in
// `rbpkgbuild.rs` rejects non-`[a-z0-9-_]` — leaving a
// half-broken recipe. Replace any non-allowed char with `-` so
// every sanitized name is always a valid Red Bear name.
let lowered = name.trim_matches('"').to_ascii_lowercase().replace('_', "-");
lowered
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
c
} else {
'-'
}
})
.collect()
}
fn is_git_source_entry(entry: &str) -> bool {
@@ -1,15 +1,26 @@
use std::fs;
use std::path::PathBuf;
// Re-export the cookbook recipe generator and a curated subset of the
// rbpkgbuild surface. The glob re-export `pub use crate::rbpkgbuild::*`
// was a kitchen-sink: every internal type became reachable as
// `cub::recipe::SectionType`, turning any rename in rbpkgbuild.rs
// into a breaking API change. The explicit list below is the actual
// surface the public API promises.
pub use crate::cookbook::generate_recipe;
pub use crate::rbpkgbuild::*;
pub use crate::rbpkgbuild::{
BuildSection, BuildTemplate, CompatSection, ConversionStatus,
DependenciesSection, InstallEntry, InstallSection, PackageSection, PatchesSection,
PolicySection, RbPkgBuild, SourceEntry, SourceSection, SourceType,
};
pub use crate::rbsrcinfo::RbSrcInfo;
use crate::converter;
use crate::error::CubError;
use crate::pkgbuild;
use crate::storage::CubStore;
pub fn recipe_from_aur_pkgbuild(raw_pkgbuild: &str) -> Result<(RbPkgBuild, String), CubError> {
let conversion = converter::convert_pkgbuild(raw_pkgbuild)?;
let conversion = pkgbuild::convert_pkgbuild(raw_pkgbuild)?;
let recipe_toml = generate_recipe(&conversion.rbpkg)?;
Ok((conversion.rbpkg, recipe_toml))
@@ -44,6 +44,12 @@ impl CubStore {
self.root_dir.join("sources")
}
/// Per-store scratch directory for in-flight builds, recipe
/// staging, and AUR clones. Created by `init()`.
pub fn tmp_dir(&self) -> PathBuf {
self.root_dir.join("tmp")
}
pub fn repo_dir(&self, target: &str) -> PathBuf {
let repo_dir = self.root_dir.join("repo");
if target.is_empty() {
@@ -4,13 +4,13 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Gauge, Paragraph, Wrap};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
use crate::tui::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let running = app.build_running();
let braille = crate::widgets::braille_frame(app.tick());
let braille = crate::tui::widgets::braille_frame(app.tick());
let title = if running {
format!(" Build {} running ", braille)
@@ -41,7 +41,8 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &
let log_lines = widgets::semantic_log_lines(app.build_log(), theme);
let body = Paragraph::new(log_lines)
.wrap(Wrap { trim: false })
.block(log_block);
.block(log_block)
.scroll((app.build_log_scroll(), 0));
frame.render_widget(body, layout[0]);
if running {
@@ -93,9 +94,14 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Char('b') => app.start_build_selected(),
Key::Char('b') => app.request_build_selected(),
Key::Char('c') => {
if app.build_running() {
app.cancel_action();
}
}
Key::Left => {
app.current_view = crate::app::View::PackageInfo;
app.current_view = crate::tui::app::View::PackageInfo;
app.status_message = "Returned to package info.".into();
}
_ => {}
@@ -4,9 +4,9 @@ use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Paragraph, Wrap};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
use crate::tui::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
@@ -99,8 +99,8 @@ fn build_stats(app: &CubApp, theme: &RedBearTheme) -> Text<'static> {
.add_modifier(Modifier::BOLD);
let store_path = app.store.root_dir.display().to_string();
let recipe_count = app.query_entries().iter().filter(|e| e.is_recipe()).count();
let package_count = app.query_entries().iter().filter(|e| e.is_package()).count();
let recipe_count = app.query_entries_view().iter().filter(|e| e.is_recipe()).count();
let package_count = app.query_entries_view().iter().filter(|e| e.is_package()).count();
let last_sync = app.last_sync_display();
let lines = vec![
@@ -4,9 +4,9 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
use crate::tui::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
@@ -96,13 +96,13 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Left => {
app.current_view = crate::app::View::Search;
app.current_view = crate::tui::app::View::Search;
app.status_message = "Search view focused.".into();
}
Key::Up | Key::Char('k') => app.move_selection(-1),
Key::Down | Key::Char('j') => app.move_selection(1),
Key::Char('i') => app.start_install_selected(),
Key::Char('b') => app.start_build_selected(),
Key::Char('i') => app.request_install_selected(),
Key::Char('b') => app.request_build_selected(),
_ => {}
}
}
@@ -4,13 +4,13 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Gauge, Paragraph, Wrap};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
use crate::tui::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let running = app.install_running();
let braille = crate::widgets::braille_frame(app.tick());
let braille = crate::tui::widgets::braille_frame(app.tick());
let title = if running {
format!(" Install {} running ", braille)
@@ -41,7 +41,8 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &
let log_lines = widgets::semantic_log_lines(app.install_log(), theme);
let body = Paragraph::new(log_lines)
.wrap(Wrap { trim: false })
.block(log_block);
.block(log_block)
.scroll((app.install_log_scroll(), 0));
frame.render_widget(body, layout[0]);
if running {
@@ -93,9 +94,14 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Char('i') => app.start_install_selected(),
Key::Char('i') => app.request_install_selected(),
Key::Char('c') => {
if app.install_running() {
app.cancel_action();
}
}
Key::Left => {
app.current_view = crate::app::View::PackageInfo;
app.current_view = crate::tui::app::View::PackageInfo;
app.status_message = "Returned to package info.".into();
}
_ => {}
@@ -4,9 +4,9 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
use crate::tui::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
@@ -14,13 +14,13 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &
.constraints([Constraint::Percentage(38), Constraint::Percentage(62)])
.split(area);
let items = if app.query_entries().is_empty() {
let items = if app.query_entries_view().is_empty() {
vec![ListItem::new(Line::from(Span::styled(
"No local recipes or cached pkgars.",
theme.italic_style(),
)))]
} else {
app.query_entries()
app.query_entries_view()
.iter()
.map(|entry| {
let icon = if entry.is_recipe() { "" } else { "" };
@@ -38,10 +38,10 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &
};
let mut state = ListState::default();
if !app.query_entries().is_empty() {
if !app.query_entries_view().is_empty() {
state.select(Some(
app.selected_index
.min(app.query_entries().len().saturating_sub(1)),
.min(app.query_entries_view().len().saturating_sub(1)),
));
}
@@ -67,7 +67,7 @@ pub fn handle_key(app: &mut CubApp, key: Key) {
app.status_message = error.to_string();
}
},
Key::Char('b') => app.start_build_selected(),
Key::Char('b') => app.request_build_selected(),
_ => {}
}
}
@@ -4,9 +4,9 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use termion::event::Key;
use crate::app::CubApp;
use crate::theme::RedBearTheme;
use crate::widgets;
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
use crate::tui::widgets;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
@@ -125,8 +125,8 @@ pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Char('\n') => app.search(),
Key::Char('i') | Key::Right => app.open_selected_info(),
Key::Char('I') => app.start_install_selected(),
Key::Char('b') => app.start_build_selected(),
Key::Char('I') => app.request_install_selected(),
Key::Char('b') => app.request_build_selected(),
Key::Up | Key::Char('k') => app.move_selection(-1),
Key::Down | Key::Char('j') => app.move_selection(1),
Key::Backspace => {