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 => {