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:
+35
-14
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user