From 2eae0d32f836660818761629c35d689a7cebd597 Mon Sep 17 00:00:00 2001 From: Admin Pupkin Date: Thu, 11 Jun 2026 09:15:57 +0300 Subject: [PATCH] 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. --- local/recipes/system/cub/recipe.toml | 8 +- local/recipes/system/cub/source/Cargo.toml | 8 +- .../cub/source/cub-assessment/Cargo.toml | 2 +- .../system/cub/source/cub-cli/Cargo.toml | 27 - .../system/cub/source/cub-lib/Cargo.toml | 34 - .../cub/source/cub-lib/src/converter.rs | 466 ----------- .../system/cub/source/cub-lib/src/error.rs | 29 - .../system/cub/source/cub-lib/src/lib.rs | 20 - .../system/cub/source/cub-lib/src/version.rs | 318 -------- .../system/cub/source/cub-tui/Cargo.toml | 19 - .../system/cub/source/cub-tui/src/app.rs | 748 ------------------ .../system/cub/source/cub-tui/src/lib.rs | 13 - .../cub/source/cub-tui/src/views/mod.rs | 110 --- .../cub/source/cub-tui/src/widgets/mod.rs | 220 ------ .../recipes/system/cub/source/cub/Cargo.toml | 67 ++ .../cub/source/{cub-lib => cub}/src/aur.rs | 11 +- .../cub/source/{cub-lib => cub}/src/cook.rs | 0 .../source/{cub-lib => cub}/src/cookbook.rs | 0 .../source/{cub-lib => cub}/src/depgraph.rs | 73 +- .../source/{cub-lib => cub}/src/depresolve.rs | 48 +- .../cub/source/{cub-lib => cub}/src/deps.rs | 0 .../cub/source/{cub-cli => cub}/src/main.rs | 497 ++++-------- .../source/{cub-lib => cub}/src/package.rs | 0 .../source/{cub-lib => cub}/src/pkgbuild.rs | 32 +- .../source/{cub-lib => cub}/src/rbpkgbuild.rs | 0 .../source/{cub-lib => cub}/src/rbsrcinfo.rs | 0 .../cub/source/{cub-lib => cub}/src/recipe.rs | 17 +- .../source/{cub-lib => cub}/src/resolver.rs | 0 .../source/{cub-lib => cub}/src/sandbox.rs | 0 .../source/{cub-lib => cub}/src/storage.rs | 6 + .../{cub-tui/src => cub/src/tui}/theme.rs | 0 .../src => cub/src/tui}/views/build.rs | 20 +- .../src => cub/src/tui}/views/home.rs | 10 +- .../src => cub/src/tui}/views/info.rs | 12 +- .../src => cub/src/tui}/views/install.rs | 20 +- .../src => cub/src/tui}/views/query.rs | 16 +- .../src => cub/src/tui}/views/search.rs | 10 +- 37 files changed, 444 insertions(+), 2417 deletions(-) delete mode 100644 local/recipes/system/cub/source/cub-cli/Cargo.toml delete mode 100644 local/recipes/system/cub/source/cub-lib/Cargo.toml delete mode 100644 local/recipes/system/cub/source/cub-lib/src/converter.rs delete mode 100644 local/recipes/system/cub/source/cub-lib/src/error.rs delete mode 100644 local/recipes/system/cub/source/cub-lib/src/lib.rs delete mode 100644 local/recipes/system/cub/source/cub-lib/src/version.rs delete mode 100644 local/recipes/system/cub/source/cub-tui/Cargo.toml delete mode 100644 local/recipes/system/cub/source/cub-tui/src/app.rs delete mode 100644 local/recipes/system/cub/source/cub-tui/src/lib.rs delete mode 100644 local/recipes/system/cub/source/cub-tui/src/views/mod.rs delete mode 100644 local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs create mode 100644 local/recipes/system/cub/source/cub/Cargo.toml rename local/recipes/system/cub/source/{cub-lib => cub}/src/aur.rs (95%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/cook.rs (100%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/cookbook.rs (100%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/depgraph.rs (76%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/depresolve.rs (80%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/deps.rs (100%) rename local/recipes/system/cub/source/{cub-cli => cub}/src/main.rs (83%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/package.rs (100%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/pkgbuild.rs (97%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/rbpkgbuild.rs (100%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/rbsrcinfo.rs (100%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/recipe.rs (81%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/resolver.rs (100%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/sandbox.rs (100%) rename local/recipes/system/cub/source/{cub-lib => cub}/src/storage.rs (97%) rename local/recipes/system/cub/source/{cub-tui/src => cub/src/tui}/theme.rs (100%) rename local/recipes/system/cub/source/{cub-tui/src => cub/src/tui}/views/build.rs (86%) rename local/recipes/system/cub/source/{cub-tui/src => cub/src/tui}/views/home.rs (93%) rename local/recipes/system/cub/source/{cub-tui/src => cub/src/tui}/views/info.rs (93%) rename local/recipes/system/cub/source/{cub-tui/src => cub/src/tui}/views/install.rs (86%) rename local/recipes/system/cub/source/{cub-tui/src => cub/src/tui}/views/query.rs (86%) rename local/recipes/system/cub/source/{cub-tui/src => cub/src/tui}/views/search.rs (95%) diff --git a/local/recipes/system/cub/recipe.toml b/local/recipes/system/cub/recipe.toml index 510c1150b8..311f5a3308 100644 --- a/local/recipes/system/cub/recipe.toml +++ b/local/recipes/system/cub/recipe.toml @@ -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}" """ diff --git a/local/recipes/system/cub/source/Cargo.toml b/local/recipes/system/cub/source/Cargo.toml index 09ea3ea3bd..1dee463ca2 100644 --- a/local/recipes/system/cub/source/Cargo.toml +++ b/local/recipes/system/cub/source/Cargo.toml @@ -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"] diff --git a/local/recipes/system/cub/source/cub-assessment/Cargo.toml b/local/recipes/system/cub/source/cub-assessment/Cargo.toml index 068825b47b..18503b1c57 100644 --- a/local/recipes/system/cub/source/cub-assessment/Cargo.toml +++ b/local/recipes/system/cub/source/cub-assessment/Cargo.toml @@ -6,5 +6,5 @@ edition = "2021" [workspace] [dependencies] -cub-lib = { path = "../cub-lib" } +cub = { path = "../cub" } toml = "0.8" diff --git a/local/recipes/system/cub/source/cub-cli/Cargo.toml b/local/recipes/system/cub/source/cub-cli/Cargo.toml deleted file mode 100644 index 685db1d0f0..0000000000 --- a/local/recipes/system/cub/source/cub-cli/Cargo.toml +++ /dev/null @@ -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"] diff --git a/local/recipes/system/cub/source/cub-lib/Cargo.toml b/local/recipes/system/cub/source/cub-lib/Cargo.toml deleted file mode 100644 index 58af18b4ed..0000000000 --- a/local/recipes/system/cub/source/cub-lib/Cargo.toml +++ /dev/null @@ -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"] diff --git a/local/recipes/system/cub/source/cub-lib/src/converter.rs b/local/recipes/system/cub/source/cub-lib/src/converter.rs deleted file mode 100644 index 71f8017be9..0000000000 --- a/local/recipes/system/cub/source/cub-lib/src/converter.rs +++ /dev/null @@ -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, - pub actions_required: Vec, -} - -pub fn convert_pkgbuild(content: &str) -> Result { - 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::().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, - actions_required: &mut Vec, -) -> Vec { - 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 { - 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 { - extract_assignment(content, name).map(|raw| parse_scalar(&raw)) -} - -fn extract_array_assignment(content: &str, name: &str) -> Option> { - extract_assignment(content, name).map(|raw| parse_array(&raw)) -} - -fn extract_assignment(content: &str, name: &str) -> Option { - 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 { - 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 { - 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 { - let mut items = Vec::new(); - let mut current = String::new(); - let mut quote: Option = 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); - } -} diff --git a/local/recipes/system/cub/source/cub-lib/src/error.rs b/local/recipes/system/cub/source/cub-lib/src/error.rs deleted file mode 100644 index c0f0392fcf..0000000000 --- a/local/recipes/system/cub/source/cub-lib/src/error.rs +++ /dev/null @@ -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), -} diff --git a/local/recipes/system/cub/source/cub-lib/src/lib.rs b/local/recipes/system/cub/source/cub-lib/src/lib.rs deleted file mode 100644 index 35471c53cd..0000000000 --- a/local/recipes/system/cub/source/cub-lib/src/lib.rs +++ /dev/null @@ -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; diff --git a/local/recipes/system/cub/source/cub-lib/src/version.rs b/local/recipes/system/cub/source/cub-lib/src/version.rs deleted file mode 100644 index c64e85223d..0000000000 --- a/local/recipes/system/cub/source/cub-lib/src/version.rs +++ /dev/null @@ -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 = 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, - pub suffix: Option, - pub revision: Option, -} - -impl Version { - pub fn parse(s: &str) -> Option { - 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 = core? - .split('.') - .map(|p| p.parse::().ok()) - .collect::>()?; - 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 { - 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::>().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 { - 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()); - } -} diff --git a/local/recipes/system/cub/source/cub-tui/Cargo.toml b/local/recipes/system/cub/source/cub-tui/Cargo.toml deleted file mode 100644 index fe9daea2b7..0000000000 --- a/local/recipes/system/cub/source/cub-tui/Cargo.toml +++ /dev/null @@ -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" diff --git a/local/recipes/system/cub/source/cub-tui/src/app.rs b/local/recipes/system/cub/source/cub-tui/src/app.rs deleted file mode 100644 index 1447be44d9..0000000000 --- a/local/recipes/system/cub/source/cub-tui/src/app.rs +++ /dev/null @@ -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, -} - -pub struct CubApp { - pub search_query: String, - pub search_results: Vec, - pub selected_index: usize, - pub current_view: View, - pub status_message: String, - pub running: bool, - pub store: CubStore, - pub aur_client: Option, - query_entries: Vec, - query_details: Vec, - install_log: Vec, - build_log: Vec, - install_running: bool, - build_running: bool, - action_receiver: Option>, - active_action: Option, - tick: usize, - show_help: bool, - last_sync: Option, -} - -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>>>, - events: &mut impl Iterator>, - ) -> 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) { - 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 { - 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 { - 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 { - 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 { - 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(", ") - } -} diff --git a/local/recipes/system/cub/source/cub-tui/src/lib.rs b/local/recipes/system/cub/source/cub-tui/src/lib.rs deleted file mode 100644 index 9d1ebba12b..0000000000 --- a/local/recipes/system/cub/source/cub-tui/src/lib.rs +++ /dev/null @@ -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}"))) -} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/mod.rs b/local/recipes/system/cub/source/cub-tui/src/views/mod.rs deleted file mode 100644 index 794ad642f2..0000000000 --- a/local/recipes/system/cub/source/cub-tui/src/views/mod.rs +++ /dev/null @@ -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::>(); - - 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 ", - } -} diff --git a/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs b/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs deleted file mode 100644 index e4dcd8b935..0000000000 --- a/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs +++ /dev/null @@ -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>) { - 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> { - 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() -} diff --git a/local/recipes/system/cub/source/cub/Cargo.toml b/local/recipes/system/cub/source/cub/Cargo.toml new file mode 100644 index 0000000000..e67e9f1386 --- /dev/null +++ b/local/recipes/system/cub/source/cub/Cargo.toml @@ -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" diff --git a/local/recipes/system/cub/source/cub-lib/src/aur.rs b/local/recipes/system/cub/source/cub/src/aur.rs similarity index 95% rename from local/recipes/system/cub/source/cub-lib/src/aur.rs rename to local/recipes/system/cub/source/cub/src/aur.rs index 644bb50966..636f95ac95 100644 --- a/local/recipes/system/cub/source/cub-lib/src/aur.rs +++ b/local/recipes/system/cub/source/cub/src/aur.rs @@ -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, } } diff --git a/local/recipes/system/cub/source/cub-lib/src/cook.rs b/local/recipes/system/cub/source/cub/src/cook.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/cook.rs rename to local/recipes/system/cub/source/cub/src/cook.rs diff --git a/local/recipes/system/cub/source/cub-lib/src/cookbook.rs b/local/recipes/system/cub/source/cub/src/cookbook.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/cookbook.rs rename to local/recipes/system/cub/source/cub/src/cookbook.rs diff --git a/local/recipes/system/cub/source/cub-lib/src/depgraph.rs b/local/recipes/system/cub/source/cub/src/depgraph.rs similarity index 76% rename from local/recipes/system/cub/source/cub-lib/src/depgraph.rs rename to local/recipes/system/cub/source/cub/src/depgraph.rs index 902d8abde7..76ba58855c 100644 --- a/local/recipes/system/cub/source/cub-lib/src/depgraph.rs +++ b/local/recipes/system/cub/source/cub/src/depgraph.rs @@ -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>, } +pub type AurFetcher<'a> = dyn FnMut(&str) -> Option + 'a; + pub struct DepGraphBuilder<'a> { installed: &'a HashSet, seen: HashSet, queue: VecDeque, nodes: Vec, - fetch: Box Option + 'a>, + fetch: Box>, } impl<'a> DepGraphBuilder<'a> { @@ -38,7 +46,7 @@ impl<'a> DepGraphBuilder<'a> { pub fn with_fetcher( seeds: I, installed: &'a HashSet, - fetch: impl Fn(&str) -> Option + 'a, + fetch: impl FnMut(&str) -> Option + 'a, ) -> Self where I: IntoIterator, @@ -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>, } } @@ -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 = 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 = 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 = 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"]); + } } diff --git a/local/recipes/system/cub/source/cub-lib/src/depresolve.rs b/local/recipes/system/cub/source/cub/src/depresolve.rs similarity index 80% rename from local/recipes/system/cub/source/cub-lib/src/depresolve.rs rename to local/recipes/system/cub/source/cub/src/depresolve.rs index 1632890caf..f75acb4482 100644 --- a/local/recipes/system/cub/source/cub-lib/src/depresolve.rs +++ b/local/recipes/system/cub/source/cub/src/depresolve.rs @@ -51,7 +51,7 @@ pub fn resolve_build_order(packages: &[DepNode]) -> ResolvedOrder { let mut remaining: HashSet = deps.keys().cloned().collect(); while !remaining.is_empty() { - let ready: Vec = remaining + let mut ready: Vec = 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> = find_circular_components(&deps, &remaining); @@ -79,10 +82,17 @@ fn find_circular_components( deps: &HashMap>, remaining: &HashSet, ) -> Vec> { + // 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 = 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"]); + } } diff --git a/local/recipes/system/cub/source/cub-lib/src/deps.rs b/local/recipes/system/cub/source/cub/src/deps.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/deps.rs rename to local/recipes/system/cub/source/cub/src/deps.rs diff --git a/local/recipes/system/cub/source/cub-cli/src/main.rs b/local/recipes/system/cub/source/cub/src/main.rs similarity index 83% rename from local/recipes/system/cub/source/cub-cli/src/main.rs rename to local/recipes/system/cub/source/cub/src/main.rs index 87a5b68870..082d2c0668 100644 --- a/local/recipes/system/cub/source/cub-cli/src/main.rs +++ b/local/recipes/system/cub/source/cub/src/main.rs @@ -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> { { 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 { 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 = 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, ) -> Result> { + 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 = + std::collections::HashMap::new(); + let mut pending: std::collections::HashSet = seeds + .iter() + .map(|s| s.to_ascii_lowercase()) + .filter(|s| !installed.contains(s)) + .collect(); + let fetcher = move |name: &str| -> Option { - 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 = 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, +) -> Result> { + 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 { +fn read_installed_version(library: &mut Library, pkg: &str) -> Option { 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> { 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> { +pub(crate) fn fetch_aur_to_store(package: &str) -> Result<(), Box> { 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> { 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> { 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> { Ok(()) } -fn cub_temp_dir(prefix: &str) -> Result> { - 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> { host_only_notice("update-all")?; let mut library = context.open_library()?; @@ -1554,117 +1634,7 @@ fn inspect_rbpkgbuild_path(path: &Path) -> Result<(), Box Ok(()) } -struct BurMatch { - name: String, - description: Option, -} - -fn search_cached_bur(query: &str) -> Result, Box> { - 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> { - 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> { - 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> { let store = CubStore::new()?; @@ -1672,22 +1642,6 @@ fn init_cub_store() -> Result> { 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> { 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 Result> { - 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 { - 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> { - 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::>() - .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> { - if target.trim_start().starts_with('-') { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid git target: {target}"), - ) - .into()); - } - - Ok(()) + out } diff --git a/local/recipes/system/cub/source/cub-lib/src/package.rs b/local/recipes/system/cub/source/cub/src/package.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/package.rs rename to local/recipes/system/cub/source/cub/src/package.rs diff --git a/local/recipes/system/cub/source/cub-lib/src/pkgbuild.rs b/local/recipes/system/cub/source/cub/src/pkgbuild.rs similarity index 97% rename from local/recipes/system/cub/source/cub-lib/src/pkgbuild.rs rename to local/recipes/system/cub/source/cub/src/pkgbuild.rs index 0a534b7c1e..1ed355f342 100644 --- a/local/recipes/system/cub/source/cub-lib/src/pkgbuild.rs +++ b/local/recipes/system/cub/source/cub/src/pkgbuild.rs @@ -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, + pub actions_required: Vec, +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AurSrcInfo { pub pkgbase: String, @@ -545,9 +555,23 @@ pub fn detect_linuxisms(content: &str) -> Vec { } 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 { diff --git a/local/recipes/system/cub/source/cub-lib/src/rbpkgbuild.rs b/local/recipes/system/cub/source/cub/src/rbpkgbuild.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/rbpkgbuild.rs rename to local/recipes/system/cub/source/cub/src/rbpkgbuild.rs diff --git a/local/recipes/system/cub/source/cub-lib/src/rbsrcinfo.rs b/local/recipes/system/cub/source/cub/src/rbsrcinfo.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/rbsrcinfo.rs rename to local/recipes/system/cub/source/cub/src/rbsrcinfo.rs diff --git a/local/recipes/system/cub/source/cub-lib/src/recipe.rs b/local/recipes/system/cub/source/cub/src/recipe.rs similarity index 81% rename from local/recipes/system/cub/source/cub-lib/src/recipe.rs rename to local/recipes/system/cub/source/cub/src/recipe.rs index 46f63cfe23..b77528cddc 100644 --- a/local/recipes/system/cub/source/cub-lib/src/recipe.rs +++ b/local/recipes/system/cub/source/cub/src/recipe.rs @@ -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)) diff --git a/local/recipes/system/cub/source/cub-lib/src/resolver.rs b/local/recipes/system/cub/source/cub/src/resolver.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/resolver.rs rename to local/recipes/system/cub/source/cub/src/resolver.rs diff --git a/local/recipes/system/cub/source/cub-lib/src/sandbox.rs b/local/recipes/system/cub/source/cub/src/sandbox.rs similarity index 100% rename from local/recipes/system/cub/source/cub-lib/src/sandbox.rs rename to local/recipes/system/cub/source/cub/src/sandbox.rs diff --git a/local/recipes/system/cub/source/cub-lib/src/storage.rs b/local/recipes/system/cub/source/cub/src/storage.rs similarity index 97% rename from local/recipes/system/cub/source/cub-lib/src/storage.rs rename to local/recipes/system/cub/source/cub/src/storage.rs index 364ee2aa56..3967ee56c1 100644 --- a/local/recipes/system/cub/source/cub-lib/src/storage.rs +++ b/local/recipes/system/cub/source/cub/src/storage.rs @@ -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() { diff --git a/local/recipes/system/cub/source/cub-tui/src/theme.rs b/local/recipes/system/cub/source/cub/src/tui/theme.rs similarity index 100% rename from local/recipes/system/cub/source/cub-tui/src/theme.rs rename to local/recipes/system/cub/source/cub/src/tui/theme.rs diff --git a/local/recipes/system/cub/source/cub-tui/src/views/build.rs b/local/recipes/system/cub/source/cub/src/tui/views/build.rs similarity index 86% rename from local/recipes/system/cub/source/cub-tui/src/views/build.rs rename to local/recipes/system/cub/source/cub/src/tui/views/build.rs index 6bc07ee654..000096627e 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/build.rs +++ b/local/recipes/system/cub/source/cub/src/tui/views/build.rs @@ -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(); } _ => {} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/home.rs b/local/recipes/system/cub/source/cub/src/tui/views/home.rs similarity index 93% rename from local/recipes/system/cub/source/cub-tui/src/views/home.rs rename to local/recipes/system/cub/source/cub/src/tui/views/home.rs index c7ed563810..c45b57455e 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/home.rs +++ b/local/recipes/system/cub/source/cub/src/tui/views/home.rs @@ -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![ diff --git a/local/recipes/system/cub/source/cub-tui/src/views/info.rs b/local/recipes/system/cub/source/cub/src/tui/views/info.rs similarity index 93% rename from local/recipes/system/cub/source/cub-tui/src/views/info.rs rename to local/recipes/system/cub/source/cub/src/tui/views/info.rs index 82da683875..1ea3cdd8b2 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/info.rs +++ b/local/recipes/system/cub/source/cub/src/tui/views/info.rs @@ -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(), _ => {} } } diff --git a/local/recipes/system/cub/source/cub-tui/src/views/install.rs b/local/recipes/system/cub/source/cub/src/tui/views/install.rs similarity index 86% rename from local/recipes/system/cub/source/cub-tui/src/views/install.rs rename to local/recipes/system/cub/source/cub/src/tui/views/install.rs index 8ba4d8b3bc..7a97ea3af6 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/install.rs +++ b/local/recipes/system/cub/source/cub/src/tui/views/install.rs @@ -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(); } _ => {} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/query.rs b/local/recipes/system/cub/source/cub/src/tui/views/query.rs similarity index 86% rename from local/recipes/system/cub/source/cub-tui/src/views/query.rs rename to local/recipes/system/cub/source/cub/src/tui/views/query.rs index 1eeb336c2d..b769b0cf49 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/query.rs +++ b/local/recipes/system/cub/source/cub/src/tui/views/query.rs @@ -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(), _ => {} } } diff --git a/local/recipes/system/cub/source/cub-tui/src/views/search.rs b/local/recipes/system/cub/source/cub/src/tui/views/search.rs similarity index 95% rename from local/recipes/system/cub/source/cub-tui/src/views/search.rs rename to local/recipes/system/cub/source/cub/src/tui/views/search.rs index 83351c76bb..9b6b08f7f1 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/search.rs +++ b/local/recipes/system/cub/source/cub/src/tui/views/search.rs @@ -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 => {