cub: TUI rewrite + AUR hardening + god-module split (v6.0 2026)

TUI rewrite (the visible half of the change):

- 8 views instead of 6: Home, Search, Info, Install, Build,
  Query, Remove, Updates — central VIEW_ORDER const in
  tui/views/mod.rs and cycle_view() / parent_view() helpers
  mean adding a 9th view is one line.
- Help overlay is now view-scoped: shows a Global section and
  an In this view section that only lists keybindings the
  current view actually honors.
- Esc goes back one view (parent_view map); q still quits.
- New ConfirmAction enum and full-screen confirm_banner
  widget: pressing i / b / r / U now sets pending_confirm
  and shows a banner; y confirms, n cancels.
- TUI build routed through the canonical fetch_aur_to_store
  + validate_git_target instead of an inline git clone. The
  6 swallowed errors are now surfaced in build_log and
  status_message.
- panic-catching wrapper around spawn_action so a panic in
  the worker thread becomes a visible ActionUpdate instead
  of a frozen spinner.
- TUI cancel architecture: Arc<Mutex<Option<Child>>> shared
  between parent and worker drainer, Arc<AtomicBool> cancel
  flag, c keypress in Install/Build views calls child.kill().
- Terminal too-small guard: < 14 rows or < 40 cols renders
  a Terminal too small overlay instead of broken layout.
- Sticky error coloring in the status bar: success uses
  theme.success, failure uses theme.error, neutral uses
  status_style.
- PgUp / PgDn global scroll bindings for Install/Build log
  views; scroll position tracked in install_log_scroll /
  build_log_scroll u16 fields; install_log_joined /
  build_log_joined pre-joined strings avoid repeated
  Vec<String>::join calls during render.
- CubApp::new() returns Result<Self, CubError>; HOME missing
  or store.init() failure is now a fatal error overlay
  instead of a silent /tmp/.cub fallback.

AUR hardening:

- AUR client (reqwest::blocking::Client) gets 5s connect +
  15s request timeouts.
- fetch_aur_to_store writes the recipe atomically: stage in
  store.tmp_dir()/recipe-staging-<name>-<nanos>, then
  fs::rename. TmpGuard drop cleans up the clone directory.
- validate_git_target rejects names with .., ://, leading -,
  empty, or NUL bytes (was previously only catching leading -).
- redox-pkg dependency pinned to rev
  52f7930f8e6dfbe85efd115b3848ea802e1a56f0 to match the
  resolved Cargo.lock.

God-module split (main.rs 2063 -> 1723 lines):

- constants.rs: 10 path / URL constants.
- bur_helpers.rs: search_cached_bur, ensure_bur_package_dir,
  sync_bur_repo, default_bur_repo_url, bur_repo_dir,
  aur_repo_url, BurMatch struct.
- fs_helpers.rs: find_stage_dir, directory_has_entries,
  copy_dir_recursive, remove_dir_if_exists,
  current_unix_timestamp, join_strings, join_package_names,
  empty_if_blank, yes_no.
- paths.rs: cub_temp_dir, validate_git_target.

Tests:

- 19 unit tests in main-side modules (was 5); 121 in the
  lib (unchanged). Total 140/140 pass.
- New CubError variant tests, validate_git_target
  happy-path + 4 attack vectors, cub_temp_dir unique-name
  under concurrent calls, bur_repo_dir / aur_repo_url
  composition, fs_helpers round-trips with tempfile
  scratch dirs.

Policy:

- local/AGENTS.md gains a TUI CONVENTION section: single
  binary, -i flag, no separate -tui crate, ratatui 0.30
  + termion 4.0.6, anti-pattern list. cub, redbear-info,
  and redbear-netctl-console are listed as already
  compliant.
- cubl (the cub lib-only consumer) recipe path updated
  from -p cub-cli to -p cub.

Verified: cargo build --workspace and --no-default-features
and --features full and --features tui all clean; cargo
test --workspace 140/140 pass; cub --version cub 0.2.3;
cub --help 21 subcommands.
This commit is contained in:
2026-06-11 09:16:48 +03:00
parent 2eae0d32f8
commit bade5b81f7
15 changed files with 3096 additions and 15 deletions
+35 -14
View File
@@ -214,10 +214,14 @@ external project), the decision is: **will the edits survive a
`recipes/<pkg>/source/`?** If yes, Rule 1. If no, Rule 2 — and
Rule 2 means **external patches, not a Red Bear source fork**.
## TUI CONVENTION — `-i` INTERACTIVE SWITCH
## TUI CONVENTION — SINGLE BINARY, `-i` INTERACTIVE SWITCH
All Red Bear desktop applications that offer a TUI mode MUST use `-i`/`--interactive`
as the standard switch. Applications without a subcommand default to launching their TUI.
All Red Bear desktop applications that offer both a TUI and a CLI mode **MUST be
a single binary**. The TUI code lives behind a `tui` feature inside the same
crate; there is no separate `-tui` sub-crate, no `cub-tui`-style split. This
applies to **every** program with a TUI in the Red Bear surface — `cub`,
`redbear-info`, `redbear-netctl`, `redbear-wifictl`, `redbear-btctl`, and any
future app.
| App | TUI | `-i` flag | Description |
|-----|-----|-----------|-------------|
@@ -225,12 +229,28 @@ as the standard switch. Applications without a subcommand default to launching t
| `redbear-info` | ratatui | ✅ | System dashboard (System/Hardware/Network/Integrations/Health tabs) |
| `redbear-netctl` | ratatui | ✅ | Network profile manager console |
**Pattern:**
- `app` (no args) → launches TUI if terminal available, else help
- `app -i` → launches TUI regardless
- `app <command>` → CLI mode
- Feature-gate TUI behind `tui` feature in Cargo.toml for minimal builds
- Use `ratatui 0.30 + termion` (same stack as cub-tui)
**Pattern (enforced for every program):**
- One binary (`app`), one Cargo.toml, one set of `[[bin]]` entries.
- TUI module lives at `src/tui/` (or similar) and is gated by a `tui` Cargo
feature so headless `--no-default-features` builds still work.
- `app` (no args) → launches TUI if stdin/stdout is a terminal, else prints help.
- `app -i` / `app --interactive` → forces TUI launch (use this when piping
through `script(1)` or when you want TUI even if stdout is redirected).
- `app <command>` → CLI mode; subcommands are unchanged whether the TUI
feature is enabled or not.
- Use `ratatui 0.30 + termion 4.0.6` (no `crossterm`).
**Anti-patterns (forbidden):**
- A separate `<app>-tui` crate that the main binary depends on.
- A separate `<app>-gui` binary that wraps a TUI launch.
- A build flag that produces a different binary name for the TUI mode.
The TUI feature flag is for **build-time size / dependency slimming** (drop
ratatui+termion when the target doesn't have a display, or when running
strictly headless), not for splitting the binary. The recipe must build
with default features (TUI on) unless the program is intentionally
non-interactive (e.g. `redbear-firmware`, `redbear-acmd` are CLI-only by
design and have no TUI).
This directory contains ALL custom work on top of mainline Redox. When mainline Redox
updates (`git pull` on the build system repo), this directory is untouched.
@@ -612,7 +632,7 @@ scripts/build-iso.sh redbear-grub # Text-only + GRUB
# Then run inside the guest:
# ./local/scripts/test-vm-network-runtime.sh
# Phase 1 runtime-substrate validation (canonical plan: CONSOLE-TO-KDE v4.0)
# Phase 1 runtime-substrate validation (canonical plan: CONSOLE-TO-KDE v6.0)
# firmware-loader, DRM/KMS, time — covers acceptance areas + POSIX compat)
./local/scripts/test-phase1-runtime.sh --qemu redbear-full
@@ -729,7 +749,7 @@ When mainline updates affect our work:
## PLANNING NOTES
- `docs/07-RED-BEAR-OS-IMPLEMENTATION-PLAN.md` is the canonical public execution plan.
- `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` (v4.0) is the canonical comprehensive plan —
- `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` (v6.0) is the canonical comprehensive plan —
supersedes all individual subsystem docs. See it for current state, blockers, and roadmap.
- `local/docs/WAYLAND-IMPLEMENTATION-PLAN.md` is the canonical Wayland subsystem plan beneath the
desktop path. Use it for Wayland-specific stability, completeness, ownership, and runtime-proof
@@ -739,8 +759,8 @@ When mainline updates affect our work:
display/KMS maturity from render/3D maturity.
- Older GPU-specific docs (`AMD-FIRST-INTEGRATION.md`, `HARDWARE-3D-ASSESSMENT.md`, `DMA-BUF-IMPROVEMENT-PLAN.md`) have been retired and removed from the tree. Their content is subsumed by `CONSOLE-TO-KDE-DESKTOP-PLAN.md` and `DRM-MODERNIZATION-EXECUTION-PLAN.md`.
- `DESKTOP-STACK-CURRENT-STATUS.md` has been retired — its content merged into `CONSOLE-TO-KDE-DESKTOP-PLAN.md`.
- `local/docs/AMD-FIRST-INTEGRATION.md` remains the deeper AMD-specific technical roadmap, but AMD
and Intel machines are now equal-priority Red Bear OS targets.
- AMD and Intel machines are now equal-priority Red Bear OS targets; the older AMD-first path
is preserved only in the canonical DRM plan and the desktop path plan.
- `local/docs/WIFI-IMPLEMENTATION-PLAN.md` is the current Wi-Fi architecture and rollout plan,
including the bounded role of `linux-kpi` and the native wireless control-plane direction.
- `local/docs/USB-IMPLEMENTATION-PLAN.md` and `local/docs/BLUETOOTH-IMPLEMENTATION-PLAN.md` should
@@ -749,7 +769,8 @@ When mainline updates affect our work:
IRQ delivery, MSI/MSI-X quality, IOMMU validation, and other low-level controller completeness work.
- `local/docs/QUIRKS-SYSTEM.md` documents the hardware quirks infrastructure: compiled-in tables,
TOML runtime files, DMI matching, driver integration, and the linux-kpi C FFI bridge.
- `local/docs/QUIRKS-IMPROVEMENT-PLAN.md` has been retired — quirks convergence is tracked in `QUIRKS-SYSTEM.md` and the canonical desktop path plan.
- The historical `QUIRKS-IMPROVEMENT-PLAN.md` has been retired — quirks convergence is tracked in
`local/docs/QUIRKS-SYSTEM.md` and the canonical desktop path plan.
- `local/docs/DBUS-INTEGRATION-PLAN.md` is the canonical D-Bus architecture and implementation plan for KDE Plasma 6 on Wayland. It defines the phased approach to D-Bus service integration, the `redbear-sessiond` login1-compatible session broker, and the gap analysis for desktop-facing D-Bus services.
- `local/docs/GREETER-LOGIN-IMPLEMENTATION-PLAN.md` is the canonical Red Bear-native greeter/login design and current implementation plan for the `redbear-full` desktop path. It defines the `redbear-authd` / `redbear-session-launch` / `redbear-greeter` split, service wiring, validation surface, and the current boundary between the active greeter path and the older `redbear-validation-session` helper flows.
+1 -1
View File
@@ -8,7 +8,7 @@ script = """
DYNAMIC_INIT
cd "${COOKBOOK_SOURCE}/source"
cargo build --release --target x86_64-unknown-linux-gnu -p cub-cli
cargo build --release --target x86_64-unknown-linux-gnu -p cub
mkdir -p "${COOKBOOK_STAGE}/usr/bin"
cp target/x86_64-unknown-linux-gnu/release/cub "${COOKBOOK_STAGE}/usr/bin/cub"
@@ -0,0 +1,192 @@
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use cub::error::CubError;
use cub::rbpkgbuild::RbPkgBuild;
use crate::constants::{CUB_CACHE_DIR, DEFAULT_AUR_BASE_URL, DEFAULT_BUR_REPO_URL};
#[derive(Debug, Clone)]
pub(crate) struct BurMatch {
pub(crate) name: String,
pub(crate) description: Option<String>,
}
pub(crate) fn search_cached_bur(query: &str) -> Result<Vec<BurMatch>, Box<dyn std::error::Error>> {
let repo_dir = bur_repo_dir();
if !repo_dir.exists() {
return Ok(Vec::new());
}
let mut matches = Vec::new();
let lowered_query = query.to_ascii_lowercase();
for entry in fs::read_dir(repo_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if name == ".git" {
continue;
}
let rbpkg_path = path.join("RBPKGBUILD");
let mut description = None;
let mut matched = name.to_ascii_lowercase().contains(&lowered_query);
if rbpkg_path.is_file() {
if let Ok(pkg) = RbPkgBuild::from_file(&rbpkg_path) {
if pkg
.package
.name
.to_ascii_lowercase()
.contains(&lowered_query)
|| pkg
.package
.description
.to_ascii_lowercase()
.contains(&lowered_query)
{
matched = true;
}
if !pkg.package.description.trim().is_empty() {
description = Some(pkg.package.description);
}
}
}
if matched {
matches.push(BurMatch {
name: name.to_string(),
description,
});
}
}
matches.sort_by(|left, right| left.name.cmp(&right.name));
Ok(matches)
}
pub(crate) fn ensure_bur_package_dir(
package: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let repo_dir = sync_bur_repo()?;
let package_dir = repo_dir.join(package);
if package_dir.is_dir() {
Ok(package_dir)
} else {
Err(Box::new(CubError::PackageNotFound(format!(
"{package} not found in BUR cache {}",
repo_dir.display()
))))
}
}
pub(crate) fn sync_bur_repo() -> Result<PathBuf, Box<dyn std::error::Error>> {
let repo_dir = bur_repo_dir();
let parent = repo_dir
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid BUR cache path"))?;
fs::create_dir_all(parent)?;
if repo_dir.join(".git").is_dir() {
let status = Command::new("git")
.arg("pull")
.arg("--ff-only")
.current_dir(&repo_dir)
.status()?;
if !status.success() {
return Err(Box::new(CubError::BuildFailed(format!(
"failed to update BUR cache at {}",
repo_dir.display()
))));
}
} else {
let status = Command::new("git")
.arg("clone")
.arg(default_bur_repo_url())
.arg(&repo_dir)
.status()?;
if !status.success() {
return Err(Box::new(CubError::BuildFailed(format!(
"failed to clone BUR repository into {}",
repo_dir.display()
))));
}
}
Ok(repo_dir)
}
pub(crate) fn default_bur_repo_url() -> String {
env::var("CUB_BUR_REPO_URL").unwrap_or_else(|_| DEFAULT_BUR_REPO_URL.to_string())
}
pub(crate) fn bur_repo_dir() -> PathBuf {
PathBuf::from(CUB_CACHE_DIR).join("bur")
}
pub(crate) 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)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bur_repo_dir_is_under_cache() {
let dir = bur_repo_dir();
assert!(dir.ends_with("bur"));
assert!(dir.starts_with(CUB_CACHE_DIR));
}
#[test]
fn default_bur_repo_url_falls_back() {
// Ensure the env override is not set for the duration of this test.
let saved = env::var("CUB_BUR_REPO_URL").ok();
env::remove_var("CUB_BUR_REPO_URL");
let url = default_bur_repo_url();
assert_eq!(url, DEFAULT_BUR_REPO_URL);
if let Some(value) = saved {
env::set_var("CUB_BUR_REPO_URL", value);
}
}
#[test]
fn aur_repo_url_passthrough() {
assert_eq!(
aur_repo_url("https://example.com/repo.git"),
"https://example.com/repo.git"
);
assert_eq!(aur_repo_url("foo.git"), "foo.git");
}
#[test]
fn aur_repo_url_composes_for_bare_name() {
assert_eq!(
aur_repo_url("yay"),
format!("{DEFAULT_AUR_BASE_URL}/yay.git")
);
}
#[test]
fn bur_match_carries_description() {
let m = BurMatch {
name: "linux".into(),
description: Some("Linux kernel".into()),
};
assert_eq!(m.name, "linux");
assert_eq!(m.description.as_deref(), Some("Linux kernel"));
}
}
@@ -0,0 +1,10 @@
pub(crate) const HOST_INSTALL_PATH: &str = "/tmp/pkg_install";
pub(crate) const REDOX_INSTALL_PATH: &str = "/";
pub(crate) const PKG_DOWNLOAD_DIR: &str = "/tmp/pkg_download/";
pub(crate) const CUB_CACHE_DIR: &str = "/tmp/cub_cache/";
pub(crate) const DEFAULT_BUR_REPO_URL: &str = "https://gitlab.redox-os.org/redox-os/bur.git";
pub(crate) const DEFAULT_AUR_BASE_URL: &str = "https://aur.archlinux.org";
pub(crate) const PUBLIC_KEY_FILE: &str = "id_ed25519.pub.toml";
pub(crate) const DEFAULT_SECRET_KEY_FILE: &str = "id_ed25519.toml";
pub(crate) const PACKAGES_HEAD_DIR: &str = "var/lib/packages";
pub(crate) const AUR_SYNC_STAMP_FILE: &str = "aur-sync.stamp";
@@ -0,0 +1,74 @@
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),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_includes_variant_context() {
let err = CubError::PackageNotFound("linux".into());
assert_eq!(err.to_string(), "Package not found: linux");
}
#[test]
fn display_includes_build_failure_reason() {
let err = CubError::BuildFailed("missing -lcrypto".into());
assert_eq!(err.to_string(), "Build failed: missing -lcrypto");
}
#[test]
fn from_io_error_preserves_message() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
let err: CubError = io_err.into();
assert!(
err.to_string().contains("nope"),
"expected io error message in {err}"
);
assert!(
err.to_string().contains("IO error"),
"expected IO error prefix in {err}"
);
}
#[test]
fn debug_printable() {
let err = CubError::Aur("timeout".into());
let debug = format!("{err:?}");
assert!(debug.contains("Aur"));
assert!(debug.contains("timeout"));
}
#[test]
fn dependency_error_round_trip() {
let err = CubError::Dependency("cycle: a -> b -> a".into());
assert_eq!(err.to_string(), "Dependency resolution failed: cycle: a -> b -> a");
}
}
@@ -0,0 +1,192 @@
use std::fs;
use std::io;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use pkg::PackageName;
use cub::sandbox::SandboxConfig;
pub(crate) fn find_stage_dir(
sandbox: &SandboxConfig,
search_root: &Path,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
let direct_candidates = [
sandbox.stage_dir.clone(),
sandbox.destdir.clone(),
search_root.join("stage"),
search_root.join("destdir"),
];
for candidate in direct_candidates {
if directory_has_entries(&candidate)? {
return Ok(candidate);
}
}
let mut stack = vec![search_root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if matches!(name, "stage" | "destdir") && directory_has_entries(&path)? {
return Ok(path);
}
stack.push(path);
}
}
Err(Box::new(io::Error::new(
io::ErrorKind::NotFound,
format!("no stage/destdir found under {}", search_root.display()),
)))
}
pub(crate) fn directory_has_entries(path: &Path) -> Result<bool, io::Error> {
if !path.is_dir() {
return Ok(false);
}
let mut iter = fs::read_dir(path)?;
Ok(iter.next().is_some())
}
pub(crate) fn copy_dir_recursive(
src: &Path,
dst: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&from, &to)?;
} else if file_type.is_symlink() {
let target = fs::read_link(&from)?;
std::os::unix::fs::symlink(&target, &to)?;
} else {
fs::copy(&from, &to)?;
}
}
Ok(())
}
pub(crate) fn remove_dir_if_exists(path: &Path) -> Result<(), io::Error> {
if path.is_dir() {
fs::remove_dir_all(path)?;
}
Ok(())
}
pub(crate) fn current_unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn join_strings(values: &[String]) -> String {
values.join(", ")
}
pub(crate) fn join_package_names(values: &[PackageName]) -> String {
values
.iter()
.map(|name| name.as_str().to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn empty_if_blank(value: &str) -> &str {
let trimmed = value.trim();
if trimmed.is_empty() {
""
} else {
value
}
}
pub(crate) fn yes_no(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn yes_no_round_trip() {
assert_eq!(yes_no(true), "yes");
assert_eq!(yes_no(false), "no");
}
#[test]
fn empty_if_blank_normalizes_whitespace() {
assert_eq!(empty_if_blank(""), "");
assert_eq!(empty_if_blank(" "), "");
assert_eq!(empty_if_blank("\t\n"), "");
assert_eq!(empty_if_blank("hi"), "hi");
assert_eq!(empty_if_blank(" hi "), " hi ");
}
#[test]
fn current_unix_timestamp_is_recent() {
let ts = current_unix_timestamp();
assert!(ts > 1_700_000_000, "timestamp {ts} not after 2023");
}
#[test]
fn join_strings_handles_empty() {
assert_eq!(join_strings(&[]), "");
assert_eq!(join_strings(&["a".into()]), "a");
assert_eq!(join_strings(&["a".into(), "b".into()]), "a, b");
}
#[test]
fn directory_has_entries_distinguishes_empty_and_missing() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(!directory_has_entries(dir.path()).expect("read empty"));
std::fs::write(dir.path().join("file"), b"x").expect("write");
assert!(directory_has_entries(dir.path()).expect("read populated"));
let missing = dir.path().join("nonexistent");
assert!(!directory_has_entries(&missing).expect("read missing"));
}
#[test]
fn remove_dir_if_exists_noop_for_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("nope");
assert!(remove_dir_if_exists(&missing).is_ok());
}
#[test]
fn copy_dir_recursive_preserves_files() {
let src = tempfile::tempdir().expect("src");
std::fs::write(src.path().join("a"), b"hello").expect("write a");
std::fs::create_dir(src.path().join("nested")).expect("mkdir");
std::fs::write(src.path().join("nested").join("b"), b"world").expect("write b");
let dst = src.path().parent().unwrap().join("dst");
copy_dir_recursive(src.path(), &dst).expect("copy");
assert_eq!(std::fs::read(dst.join("a")).expect("read a"), b"hello");
assert_eq!(
std::fs::read(dst.join("nested").join("b")).expect("read b"),
b"world"
);
let _ = std::fs::remove_dir_all(&dst);
}
}
@@ -0,0 +1,28 @@
// Library surface of the `cub` crate.
//
// The `cub` binary in `main.rs` is the canonical user of this library;
// external Red Bear crates import modules via `use cub::aur::...` etc.
// The `full` feature gates the pkgar/reqwest-backed package creation
// in `package.rs`. The `tui` feature is binary-only (gates `mod tui` in
// `main.rs`); this library is intentionally TUI-free so external
// consumers that depend on `cub` are not forced to link ratatui+termion.
pub mod aur;
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;
@@ -0,0 +1,124 @@
use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use cub::storage::CubStore;
pub(crate) fn cub_temp_dir(prefix: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let store = CubStore::new()?;
store.init()?;
let base = store.root_dir.join("tmp");
fs::create_dir_all(&base)?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
for attempt in 0..128 {
let candidate = base.join(format!("{prefix}-{}-{nanos}-{attempt}", std::process::id()));
if !candidate.exists() {
fs::create_dir_all(&candidate)?;
return Ok(candidate);
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("failed to allocate ~/.cub/tmp directory for {prefix}"),
)
.into())
}
pub(crate) fn validate_git_target(target: &str) -> Result<(), Box<dyn std::error::Error>> {
if target.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid git target: empty",
)
.into());
}
if target.trim_start().starts_with('-') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid git target: {target}"),
)
.into());
}
if target.contains("..") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid git target: contains '..': {target}"),
)
.into());
}
if target.contains("://") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid git target: contains '://': {target}"),
)
.into());
}
if target.contains('\0') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid git target: contains NUL byte"),
)
.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_git_target_accepts_simple_name() {
assert!(validate_git_target("yay").is_ok());
assert!(validate_git_target("linux-firmware").is_ok());
assert!(validate_git_target("c_ares").is_ok());
}
#[test]
fn validate_git_target_rejects_leading_dash() {
assert!(validate_git_target("-rf").is_err());
assert!(validate_git_target("--upload-pack=foo").is_err());
}
#[test]
fn validate_git_target_rejects_dotdot() {
assert!(validate_git_target("..").is_err());
assert!(validate_git_target("../etc/passwd").is_err());
assert!(validate_git_target("foo/..").is_err());
}
#[test]
fn validate_git_target_rejects_url_scheme() {
assert!(validate_git_target("file:///etc/passwd").is_err());
assert!(validate_git_target("https://evil.com/x").is_err());
}
#[test]
fn validate_git_target_rejects_empty_and_nul() {
assert!(validate_git_target("").is_err());
assert!(validate_git_target("foo\0bar").is_err());
}
#[test]
fn cub_temp_dir_creates_unique_directory() {
let dir = cub_temp_dir("test-prefix").expect("create temp dir");
assert!(dir.is_dir());
assert!(dir
.components()
.any(|c| c.as_os_str() == "tmp"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cub_temp_dir_avoids_preexisting_candidate() {
let first = cub_temp_dir("concurrent-prefix").expect("first create");
let second = cub_temp_dir("concurrent-prefix").expect("second create");
assert_ne!(first, second, "two consecutive calls must yield distinct paths");
let _ = std::fs::remove_dir_all(&first);
let _ = std::fs::remove_dir_all(&second);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
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 = match CubApp::new() {
Ok(app) => app,
Err(error) => {
// Render the fatal-error overlay to stderr (the TUI
// terminal hasn't been claimed yet, so this is the only
// available surface). Include the underlying cause so the
// user knows what to fix.
eprintln!("cub: failed to start TUI: {error}");
eprintln!("(set the HOME environment variable to a writable directory)");
return Err(io::Error::new(io::ErrorKind::Other, format!("{error}")));
}
};
app.run().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("{e}")))
}
@@ -0,0 +1,160 @@
pub mod build;
pub mod home;
pub mod info;
pub mod install;
pub mod query;
pub mod remove;
pub mod search;
pub mod updates;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Tabs};
use crate::tui::app::{CubApp, View};
use crate::tui::theme::RedBearTheme;
use crate::tui::widgets;
/// Canonical tab order. Centralised so adding a 7th view touches
/// one place (this array) plus the `View` enum and a new view file,
/// not the 5 separate match sites that the original code had.
const VIEW_ORDER: &[View] = &[
View::Home,
View::Search,
View::PackageInfo,
View::Install,
View::Build,
View::Query,
View::Remove,
View::Updates,
];
pub fn render_tabs(
frame: &mut ratatui::Frame<'_>,
area: Rect,
app: &CubApp,
theme: &RedBearTheme,
) {
let titles = VIEW_ORDER
.iter()
.map(|&v| {
let title = view_title(v);
if v == app.current_view {
Line::from(Span::styled(
title,
Style::default()
.fg(theme.text)
.add_modifier(Modifier::BOLD),
))
} else {
Line::from(Span::styled(title, Style::default().fg(theme.muted)))
}
})
.collect::<Vec<_>>();
let selected = VIEW_ORDER
.iter()
.position(|v| *v == app.current_view)
.unwrap_or(0);
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 message_style = match app.last_action_status() {
crate::tui::app::ActionStatus::None => theme.status_style(),
crate::tui::app::ActionStatus::Success => Style::default()
.fg(theme.success)
.bg(theme.surface)
.add_modifier(Modifier::BOLD),
crate::tui::app::ActionStatus::Failure => Style::default()
.fg(theme.error)
.bg(theme.surface)
.add_modifier(Modifier::BOLD),
};
let text = Line::from(vec![
Span::styled(
format!(" {} ", spinner),
Style::default()
.fg(theme.accent)
.bg(theme.surface)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {} ", app.status_message), message_style),
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);
}
/// Cycle the current view forward (Tab) or backward (BackTab). Uses
/// the canonical `VIEW_ORDER` so adding a 7th view is one line.
pub fn cycle_view(current: View, forward: bool) -> View {
let idx = VIEW_ORDER
.iter()
.position(|v| *v == current)
.unwrap_or(0);
let len = VIEW_ORDER.len();
let next = if forward {
(idx + 1) % len
} else {
(idx + len - 1) % len
};
VIEW_ORDER[next]
}
/// Map a view to its parent (for Esc-back navigation). Home is its
/// own parent. The convention follows the Tab cycle in reverse:
/// `Search` returns to `Home`, `PackageInfo` returns to `Search`,
/// `Install`/`Build` return to `PackageInfo`, `Query` returns to
/// `Home`.
pub fn parent_view(current: View) -> View {
match current {
View::Home => View::Home,
View::Search => View::Home,
View::PackageInfo => View::Search,
View::Install => View::PackageInfo,
View::Build => View::PackageInfo,
View::Query => View::Home,
View::Remove => View::Home,
View::Updates => View::Home,
}
}
pub(crate) 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 ",
View::Remove => " Remove ",
View::Updates => " Updates ",
}
}
@@ -0,0 +1,98 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, Paragraph};
use termion::event::Key;
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let title = " Remove Package ";
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(4), Constraint::Length(2)])
.split(area);
let entries: Vec<ListItem> = if app.query_entries_view().is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No installed packages. Use Query (r) to refresh the local view first.",
theme.dim_style(),
)))]
} else {
app.query_entries_view()
.iter()
.enumerate()
.map(|(idx, entry)| {
let style = if idx == app.selected_index() {
Style::default()
.bg(theme.surface)
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else if entry.is_package() {
theme.base_style()
} else {
theme.dim_style()
};
ListItem::new(Line::from(Span::styled(
format!(" {} ", entry.title),
style,
)))
})
.collect()
};
let list = List::new(entries)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.focused_border_style()),
)
.style(theme.base_style());
frame.render_widget(list, layout[0]);
let hints = Paragraph::new(Line::from(vec![
Span::styled(
" r",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" remove ", theme.dim_style()),
Span::styled(
" R",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" refresh ", theme.dim_style()),
Span::styled(
" c",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" cancel running action ", theme.dim_style()),
Span::styled(
"",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" back", theme.dim_style()),
]))
.style(theme.base_style());
frame.render_widget(hints, layout[1]);
}
pub fn handle_key(app: &mut CubApp, key: Key) {
match key {
Key::Char('r') => app.request_remove_selected(),
Key::Char('R') => {
if let Err(error) = app.refresh_query_view() {
app.status_message = error.to_string();
}
}
Key::Up | Key::Char('k') => app.move_selection(-1),
Key::Down | Key::Char('j') => app.move_selection(1),
Key::Left => {
app.current_view = crate::tui::app::View::Home;
app.status_message = "Back to home.".into();
}
_ => {}
}
}
@@ -0,0 +1,82 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Gauge, List, ListItem, Paragraph};
use crate::tui::app::CubApp;
use crate::tui::theme::RedBearTheme;
pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(4),
Constraint::Length(2),
])
.split(area);
let braille = crate::tui::widgets::braille_frame(app.tick());
let title = format!(" Updates {} ", braille);
let pulse = ((app.tick() as f64 * 0.03).sin() * 0.5 + 0.5).clamp(0.0, 1.0);
let gauge = Gauge::default()
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.focused_border_style()),
)
.gauge_style(theme.gauge_style())
.ratio(pulse)
.label("Scanning...");
frame.render_widget(gauge, layout[0]);
let entries: Vec<ListItem> = if app.updates_list().is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No updates detected. Press u to refresh.",
theme.dim_style(),
)))]
} else {
app.updates_list()
.iter()
.map(|line| {
ListItem::new(Line::from(Span::styled(
format!(" {line}"),
theme.base_style(),
)))
})
.collect()
};
let list = List::new(entries)
.block(
Block::default()
.title(" Available Updates ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme.border_style()),
)
.style(theme.base_style());
frame.render_widget(list, layout[1]);
let hints = Paragraph::new(Line::from(vec![
Span::styled(
" u",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" refresh ", theme.dim_style()),
Span::styled(
" U",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" upgrade all ", theme.dim_style()),
Span::styled(
"",
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" back", theme.dim_style()),
]))
.style(theme.base_style());
frame.render_widget(hints, layout[2]);
}
@@ -0,0 +1,291 @@
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap};
use crate::tui::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("", theme.logo_accent_style()),
Span::styled("RED BEAR OS", theme.title_text_style()),
Span::styled(" cub ", theme.title_text_style()),
Span::styled(
format!(" {} ", braille),
Style::default().bg(theme.title_bg).fg(theme.muted),
),
]);
Paragraph::new(title).style(theme.title_bar_style())
}
pub fn help_overlay(
frame: &mut ratatui::Frame<'_>,
area: Rect,
theme: &RedBearTheme,
current_view: crate::tui::app::View,
) {
let help_width = 56u16;
let help_height = 26u16;
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);
// View-specific bindings shown after the Global section.
let view_specific: &[(&str, &str)] = match current_view {
crate::tui::app::View::Home => &[],
crate::tui::app::View::Search => &[
(" Enter ", "Run search"),
(" i ", "Open package info"),
(" I ", "Install selected"),
(" b ", "Build local recipe"),
],
crate::tui::app::View::PackageInfo => &[
(" I ", "Install this package"),
(" b ", "Build local recipe"),
("", "Back to search"),
],
crate::tui::app::View::Install => &[
(" PgUp/{ ", "Scroll log up"),
(" PgDown/} ", "Scroll log down"),
("", "Back to package info"),
],
crate::tui::app::View::Build => &[
(" PgUp/{ ", "Scroll log up"),
(" PgDown/} ", "Scroll log down"),
("", "Back to package info"),
],
crate::tui::app::View::Query => &[
(" r ", "Refresh query view"),
(" b ", "Build local recipe"),
],
crate::tui::app::View::Remove | crate::tui::app::View::Updates => &[],
};
let mut keybinds = vec![
Line::from(""),
Line::from(vec![Span::styled(
" KEYBINDINGS",
theme.overlay_title_style(),
)]),
Line::from(""),
Line::from(vec![Span::styled(
" Global",
theme.overlay_title_style(),
)]),
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(" ? ", 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()),
]),
];
if !view_specific.is_empty() {
keybinds.push(Line::from(""));
keybinds.push(Line::from(vec![Span::styled(
" In this view",
theme.overlay_title_style(),
)]));
for (key, desc) in view_specific {
keybinds.push(Line::from(vec![
Span::styled(*key, theme.bold_style()),
Span::styled(*desc, theme.muted_style()),
]));
}
}
keybinds.push(Line::from(""));
keybinds.push(Line::from(vec![Span::styled(
" Press ? or Esc to dismiss",
theme.dim_style(),
)]));
let help_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(theme.overlay_style())
.border_style(theme.overlay_border_style());
let paragraph = Paragraph::new(Text::from(keybinds))
.block(help_block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, overlay_area);
}
pub fn braille_frame(tick: usize) -> char {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
FRAMES[tick % FRAMES.len()]
}
pub fn semantic_log_lines<'a>(lines: &[String], theme: &RedBearTheme) -> Vec<Line<'a>> {
lines
.iter()
.map(|line| {
let lower = line.to_lowercase();
if lower.contains("completed successfully")
|| lower.contains("success")
|| lower.contains("installed")
{
Line::from(Span::styled(line.clone(), theme.success_style()))
} else if lower.contains("warning")
|| lower.contains("partial")
|| lower.contains("skipped")
{
Line::from(Span::styled(line.clone(), theme.warning_style()))
} else if lower.contains("failed")
|| lower.contains("error")
|| lower.contains("fatal")
{
Line::from(Span::styled(line.clone(), theme.error_style()))
} else if lower.starts_with("command:")
|| lower.starts_with("stdout:")
|| lower.starts_with("stderr:")
{
Line::from(Span::styled(line.clone(), theme.dim_style()))
} else {
Line::from(Span::styled(line.clone(), theme.base_style()))
}
})
.collect()
}
pub fn confirm_banner(
frame: &mut ratatui::Frame<'_>,
area: Rect,
theme: &RedBearTheme,
app: &crate::tui::app::CubApp,
) {
let Some(action) = app.pending_confirm() else {
return;
};
let (verb, name) = match action {
crate::tui::app::ConfirmAction::Install { package } => ("install", package.clone()),
crate::tui::app::ConfirmAction::Build { recipe } => ("build", recipe.clone()),
crate::tui::app::ConfirmAction::Remove { package } => ("remove", package.clone()),
crate::tui::app::ConfirmAction::Upgrade => ("upgrade", "all packages".to_string()),
};
let width = 64u16.min(area.width.saturating_sub(4));
let height = 7u16.min(area.height.saturating_sub(4));
let x = area.width.saturating_sub(width).saturating_sub(2) / 2;
let y = area.height.saturating_sub(height).saturating_sub(2) / 2;
let overlay_area = Rect::new(x, y, width, height);
frame.render_widget(Clear, overlay_area);
let block = Block::default()
.title(format!(" Confirm {verb} "))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().bg(theme.overlay_bg))
.border_style(Style::default().fg(theme.warning).add_modifier(Modifier::BOLD));
let text = vec![
Line::from(""),
Line::from(Span::styled(
format!(" About to {verb}: ",),
Style::default()
.fg(theme.text)
.bg(theme.overlay_bg)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" {name}"),
Style::default()
.fg(theme.accent)
.bg(theme.overlay_bg)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
" y",
Style::default()
.fg(theme.success)
.bg(theme.overlay_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" confirm ",
Style::default().fg(theme.text).bg(theme.overlay_bg),
),
Span::styled(
"n",
Style::default()
.fg(theme.error)
.bg(theme.overlay_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" cancel",
Style::default().fg(theme.text).bg(theme.overlay_bg),
),
]),
];
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false });
frame.render_widget(paragraph, overlay_area);
}
@@ -0,0 +1,583 @@
use std::cmp::Ordering;
use std::fmt;
// Arch version comparison.
//
// The authoritative source is pacman's `rpmvercmp` (libalpm/version.c),
// which is segment-based: it splits each version string into alternating
// alpha / digit / non-alphanumeric segments and compares them in order.
// Numeric segments are compared as natural numbers (with leading-zero
// skip), alpha segments byte-lex, non-alnum by length.
//
// This module implements a *simplified* comparison that handles the
// common Arch cases (semver, date-based with -pkgrel, known suffixes
// a/b/beta/p/pre/rc with natural-number tails) but is NOT a full
// rpmvercmp. Known deviations from the canonical man page examples:
// - "1" == "1.0" (cub) vs "1" < "1.0" (man page). Real AUR packages
// never have a single bare digit, so this is a harmless edge case.
// - Unknown suffixes (anything not starting with a known Arch prefix)
// use byte-lex compare rather than rpmvercmp's segment split.
//
// See man.archlinux.org/man/vercmp.8 for the canonical spec.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionReq {
pub comparator: Comparator,
pub version: Version,
/// A second clause for ranges (e.g. `>=1.0,<2.0`). Only `&&` chaining
/// is supported (no `||`).
pub second: Option<(Comparator, Version)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Comparator {
Eq,
Lt,
Le,
Gt,
Ge,
}
impl Comparator {
fn parse(s: &str) -> Option<(Self, &str)> {
let chars: Vec<char> = s.chars().collect();
if chars.len() < 2 {
return None;
}
let (op, rest) = match (chars[0], chars[1]) {
('=', _) => (Comparator::Eq, &s[1..]),
('<', '=') => (Comparator::Le, &s[2..]),
('<', _) => (Comparator::Lt, &s[1..]),
('>', '=') => (Comparator::Ge, &s[2..]),
('>', _) => (Comparator::Gt, &s[1..]),
_ => return None,
};
Some((op, rest.trim_start()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
pub parts: Vec<u64>,
pub suffix: Option<String>,
pub revision: Option<u64>,
}
impl Version {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (base, revision) = match s.split_once('-') {
Some((b, r)) if r.chars().next().map_or(false, |c| c.is_ascii_digit()) => {
(b, Some(r.parse().ok()?))
}
_ => (s, None),
};
let (core, suffix) = match base.find(|c: char| !c.is_ascii_digit() && c != '.') {
Some(i) => (Some(&base[..i]), Some(base[i..].trim_start_matches('-').to_string())),
None => (Some(base), None),
};
// An empty string is not a real suffix.
let suffix = suffix.filter(|s| !s.is_empty());
let parts: Vec<u64> = core?
.split('.')
.map(|p| p.parse::<u64>().ok())
.collect::<Option<_>>()?;
Some(Version {
parts,
suffix,
revision,
})
}
pub fn satisfies(&self, op: &Comparator, target: &Version) -> bool {
let cmp = self.cmp(target);
match op {
Comparator::Eq => cmp == Ordering::Equal,
Comparator::Lt => cmp == Ordering::Less,
Comparator::Le => cmp == Ordering::Less || cmp == Ordering::Equal,
Comparator::Gt => cmp == Ordering::Greater,
Comparator::Ge => cmp == Ordering::Greater || cmp == Ordering::Equal,
}
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
let max = self.parts.len().max(other.parts.len());
for i in 0..max {
let a = self.parts.get(i).copied().unwrap_or(0);
let b = other.parts.get(i).copied().unwrap_or(0);
match a.cmp(&b) {
Ordering::Equal => continue,
ord => return ord,
}
}
// Arch vercmp suffix ordering: a < b < beta < p < pre < rc < <empty>.
// A version with no suffix is "newer" than any named pre-release.
let sa = self.suffix.as_deref();
let sb = other.suffix.as_deref();
let suffix_order = match (sa, sb) {
(Some(a), Some(b)) => {
let ra = suffix_rank(a);
let rb = suffix_rank(b);
match ra.cmp(&rb) {
Ordering::Equal => compare_suffix_known_pair(a, b, ra),
ord => return ord,
}
}
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
};
// Arch vercmp: pkgrel is only compared if both sides have one.
// A missing pkgrel is treated as 0 — so "1.5-1" == "1.5" and
// "1.5-1" < "1.5-2". (Arch pacman vercmp(8) documented rule.)
match (self.revision, other.revision) {
(Some(a), Some(b)) => suffix_order.then_with(|| a.cmp(&b)),
_ => suffix_order,
}
}
}
/// Map known Arch suffix prefixes to a stable sort rank.
/// Unknown suffixes sort by string comparison after all known ranks
/// (the man page lists: a < b < beta < p < pre < rc < "").
fn suffix_rank(s: &str) -> u8 {
if s.starts_with("rc") {
6
} else if s.starts_with("pre") {
5
} else if s.starts_with("p") {
4
} else if s.starts_with("beta") {
3
} else if s.starts_with("b") {
2
} else if s.starts_with("a") {
1
} else {
8
}
}
/// Compare two suffixes of the same `suffix_rank`. For known Arch ranks
/// (1-6: a, b, beta, p, pre, rc) libalpm's rpmvercmp splits the suffix
/// into an alpha prefix and a trailing digit run, then compares the
/// prefix byte-lex and the tail as a natural number. For unknown ranks
/// (8: anything else, e.g. "alpha") the entire string is byte-lex
/// compared — segment-numeric comparison does NOT apply.
fn compare_suffix_known_pair(a: &str, b: &str, rank: u8) -> Ordering {
if rank == 8 {
return a.cmp(b);
}
let (ap, an) = split_alpha_digit_tail(a);
let (bp, bn) = split_alpha_digit_tail(b);
match ap.cmp(bp) {
Ordering::Equal => match (an, bn) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
},
ord => ord,
}
}
/// Split a suffix string into `(alpha_prefix, Option<numeric_tail>)`.
/// Examples: "rc1" -> ("rc", Some(1)), "pre" -> ("pre", None),
/// "alpha2" -> ("alpha2", None), "rc10" -> ("rc", Some(10)).
fn split_alpha_digit_tail(s: &str) -> (&str, Option<u64>) {
let split = s
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match split {
Some(i) => {
let (alpha, tail) = s.split_at(i);
let n: Option<u64> = tail.parse().ok();
if let Some(n) = n {
(alpha, Some(n))
} else {
(s, None)
}
}
None => (s, None),
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.parts.iter().map(u64::to_string).collect::<Vec<_>>().join("."))?;
if let Some(suffix) = &self.suffix {
write!(f, "{suffix}")?;
}
if let Some(rev) = self.revision {
write!(f, "-{rev}")?;
}
Ok(())
}
}
impl VersionReq {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
let mut parts = s.split(',');
let first = parts.next()?.trim();
let (op, rest) = Comparator::parse(first)?;
let version = Version::parse(rest)?;
let second = if let Some(second_raw) = parts.next() {
if parts.next().is_some() {
return None;
}
let (op2, rest2) = Comparator::parse(second_raw.trim())?;
let version2 = Version::parse(rest2)?;
Some((op2, version2))
} else {
None
};
Some(VersionReq {
comparator: op,
version,
second,
})
}
pub fn matches(&self, candidate: &Version) -> bool {
if !candidate.satisfies(&self.comparator, &self.version) {
return false;
}
if let Some((op, version)) = &self.second {
if !candidate.satisfies(op, version) {
return false;
}
}
true
}
}
impl fmt::Display for VersionReq {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let op = match self.comparator {
Comparator::Eq => "=",
Comparator::Lt => "<",
Comparator::Le => "<=",
Comparator::Gt => ">",
Comparator::Ge => ">=",
};
write!(f, "{op}{}", self.version)?;
if let Some((op, v)) = &self.second {
let op = match op {
Comparator::Eq => "=",
Comparator::Lt => "<",
Comparator::Le => "<=",
Comparator::Gt => ">",
Comparator::Ge => ">=",
};
write!(f, ",{op}{v}")?;
}
Ok(())
}
}
/// Parse an AUR-style dependency string into `(package_name, VersionReq)`.
///
/// The input is expected to be in the form `<name><op><version>` where
/// `<op>` is a comparator (`=`, `<`, `<=`, `>`, `>=`). Real AUR
/// `Depends`/`Makedepends` strings are always in this form (e.g.
/// `"openssl>=1.1"`, `"glibc<2.0"`, `"foo=1.0"`, `"bar>=1.0,<2.0"`).
///
/// The first occurrence of a comparator character splits the string
/// into the package name and the rest. The comparator is then
/// reattached to the rest before being passed to
/// [`VersionReq::parse`].
///
/// Returns `None` for inputs that do not contain a comparator (e.g.
/// `"foo"` alone), that have a malformed version (e.g. `"foo>>"`),
/// or whose package name contains a comma or whitespace — a
/// realistic failure mode is `"1.0,<2.0"` which splits to base
/// `"1.0,"` (a comma leaks in). Such inputs are not valid AUR
/// dependencies and should be rejected here.
pub fn parse_constraint(s: &str) -> Option<(String, VersionReq)> {
let split_at = s.find(|c: char| matches!(c, '<' | '>' | '='))?;
let (base, rest) = (&s[..split_at], &s[split_at..]);
let base = base.trim();
if base.is_empty() || base.contains(',') || base.contains(char::is_whitespace) {
return None;
}
let req = VersionReq::parse(rest.trim())?;
Some((base.to_string(), 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_leading_dash_suffix_correctly() {
// A version like "1.0-rc" must strip the leading dash from the
// suffix so the known-suffix ranker ("rc" → 6) matches.
let v = v("1.0-rc");
assert_eq!(v.parts, vec![1, 0]);
assert_eq!(v.suffix.as_deref(), Some("rc"));
assert_eq!(v.revision, None);
}
#[test]
fn empty_string_after_dash_strip_is_not_a_suffix() {
// "1.0-" should produce parts=[1,0], no suffix, no revision.
// The trailing dash alone is not a meaningful suffix token.
let v = v("1.0-");
assert_eq!(v.parts, vec![1, 0]);
assert_eq!(v.suffix, None);
assert_eq!(v.revision, None);
}
#[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")));
// Per Arch man page: comparing 1.5-1 and 1.5 yields 0, so a missing
// pkgrel is treated as equal. 1.2.3 == 1.2.3-1 here.
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 parse_constraint_accepts_aur_realistic_forms() {
// AUR dependency strings always use a leading comparator.
// `parse_constraint` requires that — strings without one
// (e.g. "foo" alone) return None.
let (name, req) = parse_constraint("foo=1.0").expect("parse");
assert_eq!(name, "foo");
assert!(req.matches(&v("1.0")));
let (name, req) = parse_constraint("glibc<2.0").expect("parse");
assert_eq!(name, "glibc");
assert!(req.matches(&v("1.9.9")));
assert!(!req.matches(&v("2.0")));
let (name, req) = parse_constraint("bar>=1.0,<2.0").expect("parse");
assert_eq!(name, "bar");
assert!(req.matches(&v("1.5.0")));
assert!(!req.matches(&v("2.0")));
}
#[test]
fn parse_constraint_rejects_no_leading_comparator() {
// No comparator anywhere → None.
assert!(parse_constraint("foo").is_none());
// Has a leading comparator (`>`) followed by a range → Some.
assert!(parse_constraint("foo>1.0,<2.0").is_some());
// Comma in base → None (not a valid AUR dep).
assert!(parse_constraint("1.0,<2.0").is_none());
// Whitespace in base → None.
assert!(parse_constraint("foo bar>=1.0").is_none());
// Empty base (degenerate) → None.
assert!(parse_constraint(">=1.0").is_none());
}
#[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());
}
// Arch vercmp suffix ordering: a < b < beta < p < pre < rc < "".
// A version with no suffix is "newer" than any named pre-release.
// See pacman vercmp(8): https://man.archlinux.org/man/vercmp.8
#[test]
fn suffix_rc_is_less_than_no_suffix() {
assert!(v("1.0rc1") < v("1.0"));
assert!(v("1.0rc1").cmp(&v("1.0")) == Ordering::Less);
}
#[test]
fn suffix_alpha_is_less_than_beta() {
assert!(v("1.0a") < v("1.0b"));
assert!(v("1.0b") < v("1.0beta"));
assert!(v("1.0beta") < v("1.0p"));
assert!(v("1.0p") < v("1.0pre"));
assert!(v("1.0pre") < v("1.0rc"));
assert!(v("1.0rc") < v("1.0"));
}
#[test]
fn known_suffix_natural_number_tail_compare() {
// For known Arch suffixes (a, b, beta, p, pre, rc) libalpm's
// rpmvercmp compares the trailing digit run as a natural number.
// "rc2" < "rc10" (not byte-lex), "pre1" < "pre2", "b5" < "b50".
assert!(v("1.0rc2") < v("1.0rc10"));
assert!(v("1.0pre1") < v("1.0pre2"));
assert!(v("1.0b5") < v("1.0b50"));
assert!(v("1.0p1") < v("1.0p9"));
}
#[test]
fn known_suffix_alpha_prefix_tiebreak() {
// When the digit tail is equal, fall back to byte-lex on the
// alpha prefix. "rc1" < "rc01" only if we treat them as
// different alphas — but they share the same tail parsing
// (leading zeros are parsed as a number), so "rc01" tail=1
// and "rc1" tail=1 → both equal at the tail → alpha prefix
// tiebreak "rc" == "rc" → Equal overall.
assert_eq!(v("1.0rc1").cmp(&v("1.0rc01")), Ordering::Equal);
// Different alpha prefixes at the same rank: "rc1" < "rcA1"?
// Both have prefix "rc" (the first char after rc is the digit
// split) — wait, "rcA1" has 'A' before '1', so alpha prefix
// would be "rcA" (the first digit position is index 3 where
// '1' appears). Then tail = 1. "rc1" prefix is "rc" (digit
// at index 2), tail = 1. "rc" < "rcA" lex → "rc1" < "rcA1".
assert!(v("1.0rc1") < v("1.0rcA1"));
}
#[test]
fn unknown_suffix_uses_byte_lex_compare() {
// "x" doesn't start with any known Arch prefix (a/b/beta/p/pre/rc),
// so it gets rank 8 and the entire string is byte-lex compared.
// "x2" > "x10" because '2' (0x32) > '1' (0x31) at offset 1.
assert!(v("1.0x2") > v("1.0x10"));
assert!(v("1.0x2") < v("1.0x9"));
// "z" is also rank 8: "z1" < "z2" by lex (only choice since no
// digit split for rank 8 in our code path).
assert!(v("1.0z1") < v("1.0z2"));
}
#[test]
fn alpha_prefix_uses_natural_number_tail() {
// "alpha2" vs "alpha10": both have suffix_rank 1 (starts with "a").
// They share alpha prefix "alpha" and have numeric tails 2 and 10.
// libalpm rpmvercmp would say 2 < 10 → "alpha2" < "alpha10".
assert!(v("1.0alpha2") < v("1.0alpha10"));
}
// Arch vercmp: pkgrel is only compared if both sides have one.
// Per man page: "comparing 1.5-1 and 1.5 will yield 0".
#[test]
fn missing_pkgrel_is_treated_as_zero() {
assert_eq!(v("1.5-1").cmp(&v("1.5")), Ordering::Equal);
assert_eq!(v("1.5").cmp(&v("1.5-1")), Ordering::Equal);
assert!(v("1.5-1") < v("1.5-2"));
}
#[test]
fn suffix_with_pkgrel_missing_uses_equal() {
assert_eq!(v("1.0rc1-1").cmp(&v("1.0rc1")), Ordering::Equal);
}
}