From bade5b81f778fbe43b40465d19d47206de01650e Mon Sep 17 00:00:00 2001 From: Admin Pupkin Date: Thu, 11 Jun 2026 09:16:48 +0300 Subject: [PATCH] cub: TUI rewrite + AUR hardening + god-module split (v6.0 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>> shared between parent and worker drainer, Arc 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::join calls during render. - CubApp::new() returns Result; 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--, 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. --- local/AGENTS.md | 49 +- local/recipes/dev/cubl/recipe.toml | 2 +- .../system/cub/source/cub/src/bur_helpers.rs | 192 +++ .../system/cub/source/cub/src/constants.rs | 10 + .../system/cub/source/cub/src/error.rs | 74 + .../system/cub/source/cub/src/fs_helpers.rs | 192 +++ .../recipes/system/cub/source/cub/src/lib.rs | 28 + .../system/cub/source/cub/src/paths.rs | 124 ++ .../system/cub/source/cub/src/tui/app.rs | 1202 +++++++++++++++++ .../system/cub/source/cub/src/tui/mod.rs | 24 + .../cub/source/cub/src/tui/views/mod.rs | 160 +++ .../cub/source/cub/src/tui/views/remove.rs | 98 ++ .../cub/source/cub/src/tui/views/updates.rs | 82 ++ .../cub/source/cub/src/tui/widgets/mod.rs | 291 ++++ .../system/cub/source/cub/src/version.rs | 583 ++++++++ 15 files changed, 3096 insertions(+), 15 deletions(-) create mode 100644 local/recipes/system/cub/source/cub/src/bur_helpers.rs create mode 100644 local/recipes/system/cub/source/cub/src/constants.rs create mode 100644 local/recipes/system/cub/source/cub/src/error.rs create mode 100644 local/recipes/system/cub/source/cub/src/fs_helpers.rs create mode 100644 local/recipes/system/cub/source/cub/src/lib.rs create mode 100644 local/recipes/system/cub/source/cub/src/paths.rs create mode 100644 local/recipes/system/cub/source/cub/src/tui/app.rs create mode 100644 local/recipes/system/cub/source/cub/src/tui/mod.rs create mode 100644 local/recipes/system/cub/source/cub/src/tui/views/mod.rs create mode 100644 local/recipes/system/cub/source/cub/src/tui/views/remove.rs create mode 100644 local/recipes/system/cub/source/cub/src/tui/views/updates.rs create mode 100644 local/recipes/system/cub/source/cub/src/tui/widgets/mod.rs create mode 100644 local/recipes/system/cub/source/cub/src/version.rs diff --git a/local/AGENTS.md b/local/AGENTS.md index dec76236dd..99b7440adb 100644 --- a/local/AGENTS.md +++ b/local/AGENTS.md @@ -214,10 +214,14 @@ external project), the decision is: **will the edits survive a `recipes//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 ` → 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 ` → 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 `-tui` crate that the main binary depends on. +- A separate `-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. diff --git a/local/recipes/dev/cubl/recipe.toml b/local/recipes/dev/cubl/recipe.toml index 55f1c4fab4..b1d613ee34 100644 --- a/local/recipes/dev/cubl/recipe.toml +++ b/local/recipes/dev/cubl/recipe.toml @@ -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" diff --git a/local/recipes/system/cub/source/cub/src/bur_helpers.rs b/local/recipes/system/cub/source/cub/src/bur_helpers.rs new file mode 100644 index 0000000000..26e56c9a7f --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/bur_helpers.rs @@ -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, +} + +pub(crate) fn search_cached_bur(query: &str) -> Result, Box> { + let repo_dir = bur_repo_dir(); + if !repo_dir.exists() { + return Ok(Vec::new()); + } + + let mut matches = Vec::new(); + let lowered_query = query.to_ascii_lowercase(); + for entry in fs::read_dir(repo_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name == ".git" { + continue; + } + + let rbpkg_path = path.join("RBPKGBUILD"); + let mut description = None; + let mut matched = name.to_ascii_lowercase().contains(&lowered_query); + if rbpkg_path.is_file() { + if let Ok(pkg) = RbPkgBuild::from_file(&rbpkg_path) { + if pkg + .package + .name + .to_ascii_lowercase() + .contains(&lowered_query) + || pkg + .package + .description + .to_ascii_lowercase() + .contains(&lowered_query) + { + matched = true; + } + if !pkg.package.description.trim().is_empty() { + description = Some(pkg.package.description); + } + } + } + + if matched { + matches.push(BurMatch { + name: name.to_string(), + description, + }); + } + } + + matches.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(matches) +} + +pub(crate) fn ensure_bur_package_dir( + package: &str, +) -> Result> { + let repo_dir = sync_bur_repo()?; + let package_dir = repo_dir.join(package); + if package_dir.is_dir() { + Ok(package_dir) + } else { + Err(Box::new(CubError::PackageNotFound(format!( + "{package} not found in BUR cache {}", + repo_dir.display() + )))) + } +} + +pub(crate) fn sync_bur_repo() -> Result> { + let repo_dir = bur_repo_dir(); + let parent = repo_dir + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid BUR cache path"))?; + fs::create_dir_all(parent)?; + + if repo_dir.join(".git").is_dir() { + let status = Command::new("git") + .arg("pull") + .arg("--ff-only") + .current_dir(&repo_dir) + .status()?; + if !status.success() { + return Err(Box::new(CubError::BuildFailed(format!( + "failed to update BUR cache at {}", + repo_dir.display() + )))); + } + } else { + let status = Command::new("git") + .arg("clone") + .arg(default_bur_repo_url()) + .arg(&repo_dir) + .status()?; + if !status.success() { + return Err(Box::new(CubError::BuildFailed(format!( + "failed to clone BUR repository into {}", + repo_dir.display() + )))); + } + } + + Ok(repo_dir) +} + +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")); + } +} diff --git a/local/recipes/system/cub/source/cub/src/constants.rs b/local/recipes/system/cub/source/cub/src/constants.rs new file mode 100644 index 0000000000..f6fefa59d5 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/constants.rs @@ -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"; diff --git a/local/recipes/system/cub/source/cub/src/error.rs b/local/recipes/system/cub/source/cub/src/error.rs new file mode 100644 index 0000000000..381ce9f750 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/error.rs @@ -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"); + } +} diff --git a/local/recipes/system/cub/source/cub/src/fs_helpers.rs b/local/recipes/system/cub/source/cub/src/fs_helpers.rs new file mode 100644 index 0000000000..edc4eab533 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/fs_helpers.rs @@ -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> { + 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 { + 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> { + 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::>() + .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); + } +} diff --git a/local/recipes/system/cub/source/cub/src/lib.rs b/local/recipes/system/cub/source/cub/src/lib.rs new file mode 100644 index 0000000000..d69ea0ee34 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/lib.rs @@ -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; diff --git a/local/recipes/system/cub/source/cub/src/paths.rs b/local/recipes/system/cub/source/cub/src/paths.rs new file mode 100644 index 0000000000..4b61d61fa8 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/paths.rs @@ -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> { + 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> { + 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); + } +} diff --git a/local/recipes/system/cub/source/cub/src/tui/app.rs b/local/recipes/system/cub/source/cub/src/tui/app.rs new file mode 100644 index 0000000000..186acfe7f2 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/tui/app.rs @@ -0,0 +1,1202 @@ +use std::env; +use std::fs; +use std::io::{self, stdout}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use cub::aur::{self, AurClient}; +use cub::rbpkgbuild::RbPkgBuild; +use cub::storage::CubStore; +use cub::CubError; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::TermionBackend; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::{Block, BorderType, Borders}; +use ratatui::Terminal; +use termion::event::{Event, Key}; +use termion::input::TermRead; +use termion::raw::IntoRawMode; +use termion::screen::IntoAlternateScreen; + +use crate::tui::theme::RedBearTheme; + +const DEFAULT_TARGET: &str = "x86_64-unknown-redox"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum View { + Home, + Search, + PackageInfo, + Install, + Build, + Query, + Remove, + Updates, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ActionKind { + Install, + Build, +} + +#[derive(Debug)] +pub(crate) struct QueryEntry { + pub(crate) title: String, + path: PathBuf, + kind: QueryEntryKind, +} + +impl QueryEntry { + pub(crate) fn is_recipe(&self) -> bool { + self.kind == QueryEntryKind::Recipe + } + + pub(crate) fn is_package(&self) -> bool { + self.kind == QueryEntryKind::Package + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QueryEntryKind { + Recipe, + Package, +} + +#[derive(Debug)] +struct ActionUpdate { + kind: ActionKind, + success: bool, + summary: String, + lines: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ActionStatus { + None, + Success, + Failure, +} + +#[derive(Clone, Debug)] +pub(crate) enum ConfirmAction { + Install { package: String }, + Build { recipe: String }, + Remove { package: String }, + Upgrade, +} + +pub struct CubApp { + pub search_query: String, + pub search_results: Vec, + pub selected_index: usize, + pub current_view: View, + pub status_message: String, + pub running: bool, + pub store: CubStore, + pub aur_client: Option, + query_entries: Vec, + query_details: Vec, + install_log: Vec, + build_log: Vec, + install_log_scroll: u16, + build_log_scroll: u16, + install_log_joined: String, + build_log_joined: String, + install_running: bool, + build_running: bool, + action_receiver: Option>, + active_action: Option, + running_child: Option>>>, + cancel_requested: Arc, + last_action_status: ActionStatus, + pending_confirm: Option, + updates_list: Vec, + tick: usize, + show_help: bool, + last_sync: Option, +} + +impl CubApp { + pub fn new() -> Result { + // Fatal on store init failure. The previous code silently fell + // back to /tmp/.cub (data-loss on reboot) and ignored store.init() + // errors, leaving the user with a working-looking TUI whose + // writes go to a wrong location. Refuse to start instead. + let store = CubStore::new()?; + store.init()?; + + let aur_client = if env::var("AUR_OFFLINE").is_err() { + Some(AurClient::new()) + } else { + None + }; + + let mut app = Self { + search_query: String::new(), + search_results: Vec::new(), + selected_index: 0, + current_view: View::Home, + status_message: if aur_client.is_some() { + "Welcome to cub. Tab to change views, ? for help.".into() + } else { + "AUR offline — Query view available, Tab to change views.".into() + }, + running: true, + store, + aur_client, + query_entries: Vec::new(), + query_details: Vec::new(), + install_log: vec![ + "Select a package in Search or Package Info and press i to install.".into(), + ], + build_log: vec![ + "Select a local recipe in Query or a cached package in Info and press b to build." + .into(), + ], + install_running: false, + build_running: false, + action_receiver: None, + active_action: None, + running_child: None, + cancel_requested: Arc::new(AtomicBool::new(false)), + last_action_status: ActionStatus::None, + pending_confirm: None, + updates_list: Vec::new(), + install_log_scroll: 0, + build_log_scroll: 0, + install_log_joined: String::new(), + build_log_joined: String::new(), + tick: 0, + show_help: false, + last_sync: None, + }; + let _ = app.refresh_query_view(); + Ok(app) + } + + pub fn run(&mut self) -> Result<(), CubError> { + let stdout = stdout().into_raw_mode()?; + let stdout = stdout.into_alternate_screen()?; + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(terminal_error)?; + terminal.clear().map_err(terminal_error)?; + + let mut events = termion::async_stdin().events(); + self.run_inner(&mut terminal, &mut events) + } + + pub fn run_inner( + &mut self, + terminal: &mut Terminal>>>, + events: &mut impl Iterator>, + ) -> Result<(), CubError> { + let run_result = (|| -> Result<(), CubError> { + while self.running { + self.tick = self.tick.wrapping_add(1); + self.poll_action_updates(); + terminal + .draw(|frame| self.draw(frame)) + .map_err(terminal_error)?; + + if let Some(event) = events.next() { + match event { + Ok(Event::Key(key)) => self.handle_key(key), + Ok(_) => {} + Err(error) => { + self.status_message = format!("Input error: {error}"); + self.running = false; + } + } + } + + thread::sleep(Duration::from_millis(16)); + } + + Ok(()) + })(); + + let cursor_result = terminal.show_cursor().map_err(terminal_error); + run_result.and(cursor_result) + } + + pub fn draw(&self, frame: &mut ratatui::Frame<'_>) { + let theme = RedBearTheme::default(); + let area = frame.area(); + frame.render_widget( + Block::default().style(Style::default().bg(theme.background).fg(theme.text)), + area, + ); + + if area.height < 14 || area.width < 40 { + use ratatui::text::{Line, Span}; + use ratatui::widgets::{Paragraph, Wrap}; + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "Terminal too small", + Style::default() + .fg(theme.accent) + .bg(theme.background) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + format!( + "cub needs at least 40x14. Current: {}x{}", + area.width, area.height + ), + Style::default().fg(theme.text).bg(theme.background), + )), + Line::from(""), + Line::from(Span::styled( + "Resize the terminal and the UI will recover automatically.", + Style::default().fg(theme.muted).bg(theme.background), + )), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(theme.background)) + .border_style(theme.focused_border_style()), + ) + .wrap(Wrap { trim: false }), + area, + ); + return; + } + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + + let title_bar = crate::tui::widgets::styled_title_bar(&theme, self.tick); + frame.render_widget(title_bar, layout[0]); + + crate::tui::views::render_tabs(frame, layout[1], self, &theme); + + match self.current_view { + View::Home => crate::tui::views::home::render(frame, layout[2], self, &theme), + View::Search => crate::tui::views::search::render(frame, layout[2], self, &theme), + View::PackageInfo => crate::tui::views::info::render(frame, layout[2], self, &theme), + View::Install => crate::tui::views::install::render(frame, layout[2], self, &theme), + View::Build => crate::tui::views::build::render(frame, layout[2], self, &theme), + View::Query => crate::tui::views::query::render(frame, layout[2], self, &theme), + View::Remove => crate::tui::views::remove::render(frame, layout[2], self, &theme), + View::Updates => crate::tui::views::updates::render(frame, layout[2], self, &theme), + } + + crate::tui::views::render_status(frame, layout[3], self, &theme); + + if self.pending_confirm.is_some() { + crate::tui::widgets::confirm_banner(frame, area, &theme, self); + } + + if self.show_help { + crate::tui::widgets::help_overlay(frame, area, &theme, self.current_view); + } + } + + pub fn handle_key(&mut self, key: Key) { + if self.show_help { + if matches!(key, Key::Char('?') | Key::Esc) { + self.show_help = false; + } + return; + } + + if self.pending_confirm.is_some() { + match key { + Key::Char('y') | Key::Char('Y') => self.resolve_confirm(true), + Key::Char('n') | Key::Char('N') => self.resolve_confirm(false), + _ => return, + } + return; + } + + // Global scroll bindings for the log views. Pressing PgUp/PgDn + // on the Install or Build view scrolls the log without the user + // having to first focus an input. + match key { + Key::PageUp | Key::Char('{') => { + match self.current_view { + View::Install => { + self.install_log_scroll = self.install_log_scroll.saturating_add(8); + } + View::Build => { + self.build_log_scroll = self.build_log_scroll.saturating_add(8); + } + _ => {} + } + return; + } + Key::PageDown | Key::Char('}') => { + match self.current_view { + View::Install => { + self.install_log_scroll = self.install_log_scroll.saturating_sub(8); + } + View::Build => { + self.build_log_scroll = self.build_log_scroll.saturating_sub(8); + } + _ => {} + } + return; + } + _ => {} + } + + match key { + Key::Char('q') => { + self.running = false; + return; + } + Key::Esc => { + let back = crate::tui::views::parent_view(self.current_view); + if back == self.current_view { + self.running = false; + } else { + self.current_view = back; + self.status_message = format!("← Back to {}", super::views::view_title(back).trim()); + } + return; + } + Key::Char('?') => { + self.show_help = true; + return; + } + Key::Char('/') => { + self.current_view = View::Search; + self.selected_index = self + .selected_index + .min(self.search_results.len().saturating_sub(1)); + self.status_message = "Search focused. Type a query and press Enter.".into(); + return; + } + Key::Char('u') => { + self.current_view = View::Updates; + self.refresh_updates(); + return; + } + Key::Char('U') => { + self.current_view = View::Updates; + self.pending_confirm = Some(ConfirmAction::Upgrade); + self.status_message = "Press y to upgrade all packages, n to cancel.".into(); + return; + } + Key::Char('\t') => { + self.cycle_view(true); + return; + } + Key::BackTab => { + self.cycle_view(false); + return; + } + _ => {} + } + + match self.current_view { + View::Home => {} + View::Search => crate::tui::views::search::handle_key(self, key), + View::PackageInfo => crate::tui::views::info::handle_key(self, key), + View::Install => crate::tui::views::install::handle_key(self, key), + View::Build => crate::tui::views::build::handle_key(self, key), + View::Query => crate::tui::views::query::handle_key(self, key), + View::Remove => crate::tui::views::remove::handle_key(self, key), + View::Updates => {} + } + } + + pub fn selected_package(&self) -> Option<&aur::AurPackage> { + self.search_results.get(self.selected_index) + } + + pub(crate) fn query_entries_view(&self) -> &[QueryEntry] { + &self.query_entries + } + + pub(crate) fn query_details(&self) -> &[String] { + &self.query_details + } + + pub(crate) fn install_log(&self) -> &[String] { + &self.install_log + } + + pub(crate) fn build_log(&self) -> &[String] { + &self.build_log + } + + pub(crate) fn install_running(&self) -> bool { + self.install_running + } + + pub(crate) fn build_running(&self) -> bool { + self.build_running + } + + pub(crate) fn last_action_status(&self) -> ActionStatus { + self.last_action_status + } + + pub(crate) fn pending_confirm(&self) -> Option<&ConfirmAction> { + self.pending_confirm.as_ref() + } + + pub(crate) fn selected_index(&self) -> usize { + self.selected_index + } + + pub(crate) fn updates_list(&self) -> &[String] { + &self.updates_list + } + + pub fn request_remove_selected(&mut self) { + let Some(entry) = self.query_entries.get(self.selected_index) else { + self.status_message = "No package selected to remove.".into(); + return; + }; + + if !entry.is_package() { + self.status_message = "Select an installed package (not a recipe) to remove.".into(); + return; + } + + let name = entry.title.clone(); + self.pending_confirm = Some(ConfirmAction::Remove { package: name.clone() }); + self.status_message = format!("Press y to confirm remove of {name}, n to cancel."); + } + + pub fn refresh_updates(&mut self) { + self.updates_list = vec![ + "Updates view. Press U to upgrade all packages;".into(), + "or run `cub upgrade` from the CLI for the full list.".into(), + ]; + self.status_message = "Updates view ready.".into(); + } + + pub(crate) fn install_log_scroll(&self) -> u16 { + self.install_log_scroll + } + + pub(crate) fn build_log_scroll(&self) -> u16 { + self.build_log_scroll + } + + pub fn last_sync_display(&self) -> String { + match self.last_sync { + Some(instant) => { + let elapsed = instant.elapsed(); + if elapsed.as_secs() < 60 { + format!("{}s ago", elapsed.as_secs()) + } else if elapsed.as_secs() < 3600 { + format!("{}m ago", elapsed.as_secs() / 60) + } else { + format!("{}h ago", elapsed.as_secs() / 3600) + } + } + None => "never".to_string(), + } + } + + pub(crate) fn tick(&self) -> usize { + self.tick + } + + pub fn search(&mut self) { + let query = self.search_query.trim(); + if query.is_empty() { + self.status_message = "Search query cannot be empty.".into(); + self.search_results.clear(); + self.selected_index = 0; + return; + } + + let Some(client) = self.aur_client.as_ref() else { + self.status_message = "AUR offline — cannot search.".into(); + return; + }; + + match client.search(query, None) { + Ok(results) => { + self.search_results = results; + self.selected_index = 0; + self.last_sync = Some(std::time::Instant::now()); + self.status_message = format!( + "Found {} AUR package(s) for {:?}.", + self.search_results.len(), + query + ); + if self.search_results.is_empty() { + self.current_view = View::Search; + } + } + Err(error) => { + self.search_results.clear(); + self.selected_index = 0; + self.status_message = error.to_string(); + } + } + } + + pub fn request_install_selected(&mut self) { + let Some(package) = self.selected_package() else { + self.status_message = "No package selected to install.".into(); + return; + }; + + if self.install_running || self.build_running { + self.status_message = "Another action is already running.".into(); + self.current_view = View::Install; + return; + } + + let name = package.name.clone(); + self.pending_confirm = Some(ConfirmAction::Install { package: name.clone() }); + self.status_message = format!("Press y to confirm install of {}, n to cancel.", name); + } + + pub fn request_build_selected(&mut self) { + if self.install_running || self.build_running { + self.status_message = "Another action is already running.".into(); + self.current_view = View::Build; + return; + } + + let recipe_name = self + .selected_package() + .map(|p| p.name.clone()) + .or_else(|| { + self.selected_query_recipe_dir() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + }); + + let Some(name) = recipe_name else { + self.status_message = "No recipe selected to build.".into(); + return; + }; + + self.pending_confirm = Some(ConfirmAction::Build { recipe: name.clone() }); + self.status_message = format!( + "Press y to confirm build of {}, n to cancel.", + name + ); + } + + pub fn resolve_confirm(&mut self, accepted: bool) { + let action = self.pending_confirm.take(); + let Some(action) = action else { return }; + match action { + ConfirmAction::Install { package } => { + if accepted { + self.start_install_selected_inner(package); + } else { + self.status_message = "Install cancelled.".into(); + } + } + ConfirmAction::Build { recipe: _ } => { + if accepted { + self.start_build_selected_inner(); + } else { + self.status_message = "Build cancelled.".into(); + } + } + ConfirmAction::Remove { package } => { + if !accepted { + self.status_message = "Remove cancelled.".into(); + return; + } + self.current_view = View::Install; + self.install_log = vec![format!("Running: cub remove {package}")]; + self.status_message = format!("Removing {package}..."); + self.spawn_action( + ActionKind::Install, + &self_exe(), + vec!["remove".into(), package], + ); + } + ConfirmAction::Upgrade => { + if !accepted { + self.status_message = "Upgrade cancelled.".into(); + return; + } + self.current_view = View::Build; + self.build_log = vec!["Running: cub upgrade".into()]; + self.status_message = "Upgrading system...".into(); + self.spawn_action(ActionKind::Build, &self_exe(), vec!["upgrade".into()]); + } + } + } + + pub fn start_install_selected(&mut self) { + let Some(package) = self.selected_package() else { + self.status_message = "No package selected to install.".into(); + return; + }; + self.start_install_selected_inner(package.name.clone()); + } + + fn start_install_selected_inner(&mut self, package_name: String) { + if self.install_running || self.build_running { + self.status_message = "Another action is already running.".into(); + self.current_view = View::Install; + return; + } + + self.current_view = View::Install; + self.install_running = true; + self.set_install_log(vec![ + format!("Preparing installation for {}", package_name), + format!("Running command: cub install {}", package_name), + ]); + self.status_message = format!("Installing {}...", package_name); + self.spawn_action( + ActionKind::Install, + &self_exe(), + vec!["install".into(), package_name], + ); + } + + pub fn start_build_selected(&mut self) { + if self.install_running || self.build_running { + self.status_message = "Another action is already running.".into(); + self.current_view = View::Build; + return; + } + self.start_build_selected_inner(); + } + + fn start_build_selected_inner(&mut self) { + let build_target = if self.current_view == View::Query { + self.selected_query_recipe_dir() + } else { + let recipes_dir = self.store.recipes_dir().to_path_buf(); + let package_name = self.selected_package().map(|p| p.name.clone()); + package_name.map(|name| { + let dir: PathBuf = recipes_dir.join(&name); + if !dir.is_dir() { + if let Err(validation_error) = + crate::validate_git_target(&name) + { + self.set_build_log(vec![format!( + "Refusing to fetch AUR package with unsafe name: {validation_error}" + )]); + self.status_message = + format!("Invalid package name: {}", name); + return dir; + } + + if env::var("AUR_OFFLINE").is_err() { + let mut errors: Vec = Vec::new(); + match crate::fetch_aur_to_store(&name) { + Ok(()) => {} + Err(error) => { + errors.push(format!("AUR fetch failed: {error}")); + } + } + if !errors.is_empty() { + self.set_build_log( + errors + .into_iter() + .map(|e| format!("✗ {e}")) + .collect(), + ); + self.status_message = + format!("Failed to fetch {}", name); + } + } + } + dir + }).filter(|path| path.is_dir()) + }; + + let Some(recipe_dir) = build_target else { + self.current_view = View::Build; + self.set_build_log(vec![ + "No local recipe is available for the current selection.".into(), + format!( + "Expected imported recipe under {}", + self.store.recipes_dir().display() + ), + ]); + self.status_message = "Build requires an imported local recipe directory.".into(); + return; + }; + + let display = recipe_dir.display().to_string(); + self.current_view = View::Build; + self.build_running = true; + self.build_log = vec![ + format!("Preparing build for {}", display), + format!("Running command: cub build {}", display), + ]; + self.status_message = format!("Building {}...", display); + self.spawn_action(ActionKind::Build, &self_exe(), vec!["build".into(), display]); + } + + pub fn open_selected_info(&mut self) { + if self.selected_package().is_some() { + self.current_view = View::PackageInfo; + self.status_message = "Package info view. Press i to install or b to build.".into(); + } else { + self.status_message = "Select an AUR package first.".into(); + } + } + + pub fn move_selection(&mut self, delta: isize) { + let len = match self.current_view { + View::Home => 0, + View::Search | View::PackageInfo => self.search_results.len(), + View::Query => self.query_entries.len(), + View::Install | View::Build | View::Updates => 0, + View::Remove => self.query_entries.len(), + }; + if len == 0 { + self.selected_index = 0; + return; + } + + if delta.is_negative() { + let amount = delta.unsigned_abs(); + self.selected_index = self.selected_index.saturating_sub(amount); + } else { + self.selected_index = self + .selected_index + .saturating_add(delta as usize) + .min(len - 1); + } + + if self.current_view == View::Query { + self.refresh_query_details(); + } + } + + /// Set the install log and refresh the pre-joined string. The + /// joined string is what `Paragraph::new` consumes every frame, + /// so caching it avoids the O(n) join on every render tick. + fn set_install_log(&mut self, lines: Vec) { + self.install_log = lines; + self.install_log_joined = self.install_log.join("\n"); + self.install_log_scroll = 0; + } + + fn set_build_log(&mut self, lines: Vec) { + self.build_log = lines; + self.build_log_joined = self.build_log.join("\n"); + self.build_log_scroll = 0; + } + + /// Append a single line to the active log. Refreshes the joined + /// string so the next render uses the up-to-date buffer. + fn append_install_log(&mut self, line: String) { + self.install_log.push(line); + self.install_log_joined.push('\n'); + let last = self.install_log.last().map(String::as_str).unwrap_or(""); + self.install_log_joined.push_str(last); + } + + fn append_build_log(&mut self, line: String) { + self.build_log.push(line); + self.build_log_joined.push('\n'); + let last = self.build_log.last().map(String::as_str).unwrap_or(""); + self.build_log_joined.push_str(last); + } + + pub fn refresh_query_view(&mut self) -> Result<(), CubError> { + let recipe_dirs = self.store.list_recipes()?; + let pkgars = self.store.list_pkgars(DEFAULT_TARGET)?; + + self.query_entries = recipe_dirs + .into_iter() + .map(|path| QueryEntry { + title: file_name_or_display(&path), + path, + kind: QueryEntryKind::Recipe, + }) + .chain(pkgars.into_iter().map(|path| QueryEntry { + title: file_name_or_display(&path), + path, + kind: QueryEntryKind::Package, + })) + .collect(); + + self.selected_index = self + .selected_index + .min(self.query_entries.len().saturating_sub(1)); + self.refresh_query_details(); + Ok(()) + } + + pub fn refresh_query_details(&mut self) { + let Some(entry) = self.query_entries.get(self.selected_index) else { + self.query_details = vec![ + format!("Store root: {}", self.store.root_dir.display()), + "No local recipes or cached pkgars found yet.".into(), + ]; + return; + }; + + self.query_details = match entry.kind { + QueryEntryKind::Recipe => describe_recipe_dir(&entry.path), + QueryEntryKind::Package => describe_pkgar(&entry.path), + }; + } + + fn cycle_view(&mut self, forward: bool) { + self.current_view = crate::tui::views::cycle_view(self.current_view, forward); + + if self.current_view == View::Query { + if let Err(error) = self.refresh_query_view() { + self.status_message = error.to_string(); + } + } + } + + fn spawn_action(&mut self, kind: ActionKind, program: &str, args: Vec) { + let (tx, rx) = mpsc::channel(); + let program = program.to_string(); + self.active_action = Some(kind); + self.action_receiver = Some(rx); + self.cancel_requested.store(false, Ordering::SeqCst); + + let child_slot: Arc>> = Arc::new(Mutex::new(None)); + self.running_child = Some(Arc::clone(&child_slot)); + let cancel_flag = Arc::clone(&self.cancel_requested); + + thread::spawn(move || { + // Wrap the entire body in catch_unwind so a panic in + // the spawned thread becomes a visible error in the UI + // instead of silently killing the worker. Without this, + // a panic would leave the TUI stuck with a spinner that + // never resolves. + let update = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut command = Command::new(&program); + command + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::null()); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + return ActionUpdate { + kind, + success: false, + summary: format!("{} failed to start.", action_label(kind)), + lines: vec![format!("Failed to launch {}: {error}", program)], + }; + } + }; + + { + let mut slot = child_slot.lock().expect("child slot poisoned"); + *slot = Some(child); + } + + let stdout_handle = { + let mut slot = child_slot.lock().expect("child slot poisoned"); + slot.as_mut() + .and_then(|c| c.stdout.take()) + .map(|mut s| { + thread::spawn(move || { + let mut buf = Vec::new(); + let _ = io::Read::read_to_end(&mut s, &mut buf); + buf + }) + }) + }; + let stderr_handle = { + let mut slot = child_slot.lock().expect("child slot poisoned"); + slot.as_mut() + .and_then(|c| c.stderr.take()) + .map(|mut s| { + thread::spawn(move || { + let mut buf = Vec::new(); + let _ = io::Read::read_to_end(&mut s, &mut buf); + buf + }) + }) + }; + + let mut child = { + let mut slot = child_slot.lock().expect("child slot poisoned"); + slot.take() + .expect("child present in slot after stdout/stderr take") + }; + + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if cancel_flag.load(Ordering::SeqCst) { + let _ = child.kill(); + break child.wait().unwrap_or_else(|_| { + // Already-reaped or killed-via-signal: synthesize a + // non-zero status so the log reflects cancellation. + std::process::ExitStatus::default() + }); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return ActionUpdate { + kind, + success: false, + summary: format!( + "{} failed while waiting.", + action_label(kind) + ), + lines: vec![format!("wait() error: {error}")], + }; + } + } + }; + + let stdout_bytes = stdout_handle + .and_then(|h| h.join().ok()) + .unwrap_or_default(); + let stderr_bytes = stderr_handle + .and_then(|h| h.join().ok()) + .unwrap_or_default(); + + let mut lines = vec![format!("Command: {} {}", program, args.join(" "))]; + if cancel_flag.load(Ordering::SeqCst) { + lines.push("⚠ Cancelled by user.".into()); + } + let stdout_lines = output_string_lines(&stdout_bytes); + let stderr_lines = output_string_lines(&stderr_bytes); + if !stdout_lines.is_empty() { + lines.push("stdout:".into()); + lines.extend(stdout_lines); + } + if !stderr_lines.is_empty() { + lines.push("stderr:".into()); + lines.extend(stderr_lines); + } + + let cancelled = cancel_flag.load(Ordering::SeqCst); + let success = !cancelled && status.success(); + let summary = if cancelled { + format!("{} cancelled.", action_label(kind)) + } else if success { + format!("{} completed successfully.", action_label(kind)) + } else { + format!( + "{} failed with status {:?}.", + action_label(kind), + status.code() + ) + }; + + ActionUpdate { + kind, + success, + summary, + lines, + } + })); + + let update = match update { + Ok(update) => update, + Err(panic_payload) => { + let message = if let Some(s) = panic_payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = panic_payload.downcast_ref::() { + s.clone() + } else { + "background action panicked (unknown payload type)".to_string() + }; + ActionUpdate { + kind, + success: false, + summary: format!("{} panicked.", action_label(kind)), + lines: vec![format!("✗ PANIC: {message}")], + } + } + }; + + let _ = tx.send(update); + }); + } + + pub(crate) fn cancel_action(&mut self) { + if let Some(slot) = self.running_child.as_ref() { + if let Ok(mut guard) = slot.lock() { + if let Some(child) = guard.as_mut() { + let _ = child.kill(); + } + } + self.cancel_requested.store(true, Ordering::SeqCst); + self.status_message = "Cancelling...".into(); + } + } + + fn poll_action_updates(&mut self) { + let Some(receiver) = self.action_receiver.take() else { + return; + }; + + match receiver.try_recv() { + Ok(update) => { + self.active_action = None; + // Drop the Child handle; the action is done (or + // the thread sent an update before we could kill it). + self.running_child = None; + match update.kind { + ActionKind::Install => { + self.install_running = false; + self.set_install_log(update.lines); + self.current_view = View::Install; + } + ActionKind::Build => { + self.build_running = false; + self.set_build_log(update.lines); + self.current_view = View::Build; + } + } + self.status_message = update.summary; + self.last_action_status = if update.success { + ActionStatus::Success + } else { + ActionStatus::Failure + }; + if update.success && matches!(update.kind, ActionKind::Build) { + let _ = self.refresh_query_view(); + } + } + Err(mpsc::TryRecvError::Empty) => { + self.action_receiver = Some(receiver); + } + Err(mpsc::TryRecvError::Disconnected) => { + self.active_action = None; + self.install_running = false; + self.build_running = false; + self.running_child = None; + self.status_message = "Background action channel closed unexpectedly.".into(); + } + } + } + + fn selected_query_recipe_dir(&self) -> Option { + self.query_entries + .get(self.selected_index) + .filter(|entry| entry.kind == QueryEntryKind::Recipe) + .map(|entry| entry.path.clone()) + } +} + +fn self_exe() -> String { + std::env::current_exe() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "cub".to_string()) +} + +fn terminal_error(error: io::Error) -> CubError { + CubError::BuildFailed(format!("terminal error: {error}")) +} + +fn action_label(kind: ActionKind) -> &'static str { + match kind { + ActionKind::Install => "Install", + ActionKind::Build => "Build", + } +} + +fn output_string_lines(bytes: &[u8]) -> Vec { + let output = String::from_utf8_lossy(bytes); + output + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn file_name_or_display(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| path.display().to_string()) +} + +fn describe_recipe_dir(path: &Path) -> Vec { + let rbpkg_path = path.join("RBPKGBUILD"); + if rbpkg_path.is_file() { + match RbPkgBuild::from_file(&rbpkg_path) { + Ok(rbpkg) => { + return vec![ + format!("Recipe: {}", rbpkg.package.name), + format!( + "Version: {}-{}", + rbpkg.package.version, rbpkg.package.release + ), + format!("Path: {}", path.display()), + format!("Description: {}", rbpkg.package.description), + format!("Build deps: {}", join_or_none(&rbpkg.dependencies.build)), + format!( + "Runtime deps: {}", + join_or_none(&rbpkg.dependencies.runtime) + ), + format!( + "Optional deps: {}", + join_or_none(&rbpkg.dependencies.optional) + ), + ]; + } + Err(error) => { + return vec![ + format!("Recipe path: {}", path.display()), + format!("RBPKGBUILD parse error: {error}"), + ]; + } + } + } + + let recipe_toml = path.join("recipe.toml"); + vec![ + format!("Recipe path: {}", path.display()), + format!("RBPKGBUILD: {}", existence_text(&rbpkg_path)), + format!("recipe.toml: {}", existence_text(&recipe_toml)), + ] +} + +fn describe_pkgar(path: &Path) -> Vec { + let mut lines = vec![format!("Package archive: {}", path.display())]; + match fs::metadata(path) { + Ok(metadata) => { + lines.push(format!("Size: {} bytes", metadata.len())); + } + Err(error) => { + lines.push(format!("Failed to read metadata: {error}")); + } + } + lines +} + +fn existence_text(path: &Path) -> &'static str { + if path.exists() { + "present" + } else { + "missing" + } +} + +fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "none".into() + } else { + values.join(", ") + } +} diff --git a/local/recipes/system/cub/source/cub/src/tui/mod.rs b/local/recipes/system/cub/source/cub/src/tui/mod.rs new file mode 100644 index 0000000000..3e8ce8b75e --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/tui/mod.rs @@ -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}"))) +} diff --git a/local/recipes/system/cub/source/cub/src/tui/views/mod.rs b/local/recipes/system/cub/source/cub/src/tui/views/mod.rs new file mode 100644 index 0000000000..0d528cf127 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/tui/views/mod.rs @@ -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::>(); + + 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 ", + } +} diff --git a/local/recipes/system/cub/source/cub/src/tui/views/remove.rs b/local/recipes/system/cub/source/cub/src/tui/views/remove.rs new file mode 100644 index 0000000000..786aaa0e72 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/tui/views/remove.rs @@ -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 = 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(); + } + _ => {} + } +} diff --git a/local/recipes/system/cub/source/cub/src/tui/views/updates.rs b/local/recipes/system/cub/source/cub/src/tui/views/updates.rs new file mode 100644 index 0000000000..257853bc26 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/tui/views/updates.rs @@ -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 = 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]); +} diff --git a/local/recipes/system/cub/source/cub/src/tui/widgets/mod.rs b/local/recipes/system/cub/source/cub/src/tui/widgets/mod.rs new file mode 100644 index 0000000000..4e0a0d6ca3 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/tui/widgets/mod.rs @@ -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> { + 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); +} diff --git a/local/recipes/system/cub/source/cub/src/version.rs b/local/recipes/system/cub/source/cub/src/version.rs new file mode 100644 index 0000000000..89caec1912 --- /dev/null +++ b/local/recipes/system/cub/source/cub/src/version.rs @@ -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 = s.chars().collect(); + if chars.len() < 2 { + return None; + } + let (op, rest) = match (chars[0], chars[1]) { + ('=', _) => (Comparator::Eq, &s[1..]), + ('<', '=') => (Comparator::Le, &s[2..]), + ('<', _) => (Comparator::Lt, &s[1..]), + ('>', '=') => (Comparator::Ge, &s[2..]), + ('>', _) => (Comparator::Gt, &s[1..]), + _ => return None, + }; + Some((op, rest.trim_start())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Version { + pub parts: Vec, + pub suffix: Option, + pub revision: Option, +} + +impl Version { + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let (base, revision) = match s.split_once('-') { + Some((b, r)) if r.chars().next().map_or(false, |c| c.is_ascii_digit()) => { + (b, Some(r.parse().ok()?)) + } + _ => (s, None), + }; + let (core, suffix) = match base.find(|c: char| !c.is_ascii_digit() && c != '.') { + Some(i) => (Some(&base[..i]), Some(base[i..].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 = core? + .split('.') + .map(|p| p.parse::().ok()) + .collect::>()?; + Some(Version { + parts, + suffix, + revision, + }) + } + + pub fn satisfies(&self, op: &Comparator, target: &Version) -> bool { + let cmp = self.cmp(target); + match op { + Comparator::Eq => cmp == Ordering::Equal, + Comparator::Lt => cmp == Ordering::Less, + Comparator::Le => cmp == Ordering::Less || cmp == Ordering::Equal, + Comparator::Gt => cmp == Ordering::Greater, + Comparator::Ge => cmp == Ordering::Greater || cmp == Ordering::Equal, + } + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + let max = self.parts.len().max(other.parts.len()); + for i in 0..max { + let a = self.parts.get(i).copied().unwrap_or(0); + let b = other.parts.get(i).copied().unwrap_or(0); + match a.cmp(&b) { + Ordering::Equal => continue, + ord => return ord, + } + } + // Arch vercmp suffix ordering: a < b < beta < p < pre < rc < . + // 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)`. +/// Examples: "rc1" -> ("rc", Some(1)), "pre" -> ("pre", None), +/// "alpha2" -> ("alpha2", None), "rc10" -> ("rc", Some(10)). +fn split_alpha_digit_tail(s: &str) -> (&str, Option) { + 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 = 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::>().join("."))?; + if let Some(suffix) = &self.suffix { + write!(f, "{suffix}")?; + } + if let Some(rev) = self.revision { + write!(f, "-{rev}")?; + } + Ok(()) + } +} + +impl VersionReq { + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + let mut parts = s.split(','); + let first = parts.next()?.trim(); + let (op, rest) = Comparator::parse(first)?; + let version = Version::parse(rest)?; + + let second = if let Some(second_raw) = parts.next() { + if parts.next().is_some() { + return None; + } + let (op2, rest2) = Comparator::parse(second_raw.trim())?; + let version2 = Version::parse(rest2)?; + Some((op2, version2)) + } else { + None + }; + + Some(VersionReq { + comparator: op, + version, + second, + }) + } + + pub fn matches(&self, candidate: &Version) -> bool { + if !candidate.satisfies(&self.comparator, &self.version) { + return false; + } + if let Some((op, version)) = &self.second { + if !candidate.satisfies(op, version) { + return false; + } + } + true + } +} + +impl fmt::Display for VersionReq { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let op = match self.comparator { + Comparator::Eq => "=", + Comparator::Lt => "<", + Comparator::Le => "<=", + Comparator::Gt => ">", + Comparator::Ge => ">=", + }; + write!(f, "{op}{}", self.version)?; + if let Some((op, v)) = &self.second { + let op = match op { + Comparator::Eq => "=", + Comparator::Lt => "<", + Comparator::Le => "<=", + Comparator::Gt => ">", + Comparator::Ge => ">=", + }; + write!(f, ",{op}{v}")?; + } + Ok(()) + } +} + +/// Parse an AUR-style dependency string into `(package_name, VersionReq)`. +/// +/// The input is expected to be in the form `` where +/// `` 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); + } +}