From 714aed9610048b232b21c17151ecd307d01fe40a Mon Sep 17 00:00:00 2001 From: Vasilito Date: Thu, 7 May 2026 20:57:33 +0100 Subject: [PATCH] feat: add Cub recipe storage and TUI shell Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../system/cub/source/cub-lib/src/recipe.rs | 103 ++++++++ .../system/cub/source/cub-lib/src/storage.rs | 247 ++++++++++++++++++ .../system/cub/source/cub-tui/Cargo.toml | 14 + 3 files changed, 364 insertions(+) create mode 100644 local/recipes/system/cub/source/cub-lib/src/recipe.rs create mode 100644 local/recipes/system/cub/source/cub-lib/src/storage.rs create mode 100644 local/recipes/system/cub/source/cub-tui/Cargo.toml diff --git a/local/recipes/system/cub/source/cub-lib/src/recipe.rs b/local/recipes/system/cub/source/cub-lib/src/recipe.rs new file mode 100644 index 000000000..598c7c372 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/recipe.rs @@ -0,0 +1,103 @@ +use std::fs; +use std::path::PathBuf; + +pub use crate::cookbook::generate_recipe; +pub use crate::rbpkgbuild::*; + +use crate::converter; +use crate::error::CubError; +use crate::storage::CubStore; + +pub fn recipe_from_aur_pkgbuild(raw_pkgbuild: &str) -> Result<(RbPkgBuild, String), CubError> { + let conversion = converter::convert_pkgbuild(raw_pkgbuild)?; + let recipe_toml = generate_recipe(&conversion.rbpkg)?; + + Ok((conversion.rbpkg, recipe_toml)) +} + +pub fn recipe_toml_from_aur(raw_pkgbuild: &str) -> Result { + let (_, recipe_toml) = recipe_from_aur_pkgbuild(raw_pkgbuild)?; + Ok(recipe_toml) +} + +pub fn save_recipe_to_store(rbpkg: &RbPkgBuild, store: &CubStore) -> Result { + let recipe_toml = generate_recipe(rbpkg)?; + let recipe_dir = store.recipes_dir().join(&rbpkg.package.name); + fs::create_dir_all(&recipe_dir)?; + + let recipe_path = recipe_dir.join("recipe.toml"); + fs::write(&recipe_path, recipe_toml)?; + + Ok(recipe_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use tempfile::tempdir; + + const SAMPLE_AUR_PKGBUILD: &str = r#" +pkgname=demo_pkg +pkgver=1.2.3 +pkgrel=4 +pkgdesc="Demo application" +url="https://example.com/demo" +license=('MIT') +depends=('glibc' 'openssl>=1.1') +makedepends=('cargo' 'pkg-config') +checkdepends=('python') +source=('https://example.com/demo-1.2.3.tar.xz') +sha256sums=('abc123deadbeef') + +build() { + cargo build --release +} +"#; + + #[test] + fn recipe_from_aur_pkgbuild_returns_rbpkg_and_recipe() { + let (rbpkg, recipe_toml) = + recipe_from_aur_pkgbuild(SAMPLE_AUR_PKGBUILD).expect("convert PKGBUILD to recipe"); + + assert_eq!(rbpkg.package.name, "demo-pkg"); + + let value: toml::Value = toml::from_str(&recipe_toml).expect("parse generated recipe"); + assert_eq!(value["build"]["template"].as_str(), Some("cargo")); + assert_eq!( + value["source"]["tar"].as_str(), + Some("https://example.com/demo-1.2.3.tar.xz") + ); + assert_eq!(value["source"]["blake3"].as_str(), Some("abc123deadbeef")); + } + + #[test] + fn recipe_toml_from_aur_returns_recipe_only() { + let recipe_toml = recipe_toml_from_aur(SAMPLE_AUR_PKGBUILD) + .expect("convert PKGBUILD directly to recipe TOML"); + + let value: toml::Value = toml::from_str(&recipe_toml).expect("parse generated recipe"); + assert_eq!(value["build"]["template"].as_str(), Some("cargo")); + assert_eq!(value["package"]["dependencies"][0].as_str(), Some("relibc")); + } + + #[test] + fn save_recipe_to_store_writes_recipe_toml() { + let (rbpkg, expected_recipe_toml) = + recipe_from_aur_pkgbuild(SAMPLE_AUR_PKGBUILD).expect("convert PKGBUILD to recipe"); + let temp = tempdir().expect("create tempdir"); + let store = CubStore { + root_dir: temp.path().join(".cub"), + }; + + let saved_path = save_recipe_to_store(&rbpkg, &store).expect("save recipe to store"); + + assert_eq!( + saved_path, + store.recipes_dir().join("demo-pkg").join("recipe.toml") + ); + let saved_recipe = fs::read_to_string(&saved_path).expect("read saved recipe"); + assert_eq!(saved_recipe, expected_recipe_toml); + } +} diff --git a/local/recipes/system/cub/source/cub-lib/src/storage.rs b/local/recipes/system/cub/source/cub-lib/src/storage.rs new file mode 100644 index 000000000..30175f323 --- /dev/null +++ b/local/recipes/system/cub/source/cub-lib/src/storage.rs @@ -0,0 +1,247 @@ +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::error::CubError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CubStore { + pub root_dir: PathBuf, +} + +impl CubStore { + pub fn new() -> Result { + let home_dir = env::var("HOME").map_err(|error| { + let kind = match error { + env::VarError::NotPresent => io::ErrorKind::NotFound, + env::VarError::NotUnicode(_) => io::ErrorKind::InvalidData, + }; + + CubError::Io(io::Error::new( + kind, + format!("failed to resolve HOME environment variable: {error}"), + )) + })?; + + Ok(Self::from_root(PathBuf::from(home_dir).join(".cub"))) + } + + pub fn init(&self) -> Result<(), CubError> { + fs::create_dir_all(&self.root_dir)?; + fs::create_dir_all(self.recipes_dir())?; + fs::create_dir_all(self.sources_dir())?; + fs::create_dir_all(self.repo_dir(""))?; + fs::create_dir_all(self.config_dir())?; + Ok(()) + } + + pub fn recipes_dir(&self) -> PathBuf { + self.root_dir.join("recipes") + } + + pub fn sources_dir(&self) -> PathBuf { + self.root_dir.join("sources") + } + + pub fn repo_dir(&self, target: &str) -> PathBuf { + let repo_dir = self.root_dir.join("repo"); + if target.is_empty() { + repo_dir + } else { + repo_dir.join(target) + } + } + + pub fn config_dir(&self) -> PathBuf { + self.root_dir.join("config") + } + + pub fn list_recipes(&self) -> Result, CubError> { + list_subdirectories(&self.recipes_dir()) + } + + pub fn list_pkgars(&self, target: &str) -> Result, CubError> { + let repo_dir = self.repo_dir(target); + if !repo_dir.exists() { + return Ok(Vec::new()); + } + + let mut pkgars = Vec::new(); + for entry in fs::read_dir(repo_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|ext| ext == "pkgar") { + pkgars.push(path); + } + } + + pkgars.sort(); + Ok(pkgars) + } + + pub fn cache_clean(&self) -> Result<(), CubError> { + let sources_dir = self.sources_dir(); + fs::create_dir_all(&sources_dir)?; + + for entry in fs::read_dir(&sources_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + + Ok(()) + } + + pub fn recipe_exists(&self, name: &str) -> bool { + self.recipes_dir().join(name).is_dir() + } + + fn from_root(root_dir: PathBuf) -> Self { + Self { root_dir } + } +} + +fn list_subdirectories(dir: &Path) -> Result, CubError> { + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut directories = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + directories.push(path); + } + } + + directories.sort(); + Ok(directories) +} + +#[cfg(test)] +mod tests { + use super::CubStore; + use std::env; + use std::fs; + use std::path::PathBuf; + + use tempfile::tempdir; + + #[test] + fn new_uses_home_directory() { + let home = env::var("HOME").expect("HOME should be set during tests"); + let store = CubStore::new().expect("store should initialize from HOME"); + + assert_eq!(store.root_dir, PathBuf::from(home).join(".cub")); + } + + #[test] + fn init_creates_expected_directories() { + let temp = tempdir().expect("tempdir should be created"); + let store = CubStore::from_root(temp.path().join(".cub")); + + store.init().expect("store init should succeed"); + + assert!(store.root_dir.is_dir()); + assert!(store.recipes_dir().is_dir()); + assert!(store.sources_dir().is_dir()); + assert!(store.repo_dir("").is_dir()); + assert!(store.config_dir().is_dir()); + } + + #[test] + fn list_recipes_returns_only_subdirectories() { + let temp = tempdir().expect("tempdir should be created"); + let store = CubStore::from_root(temp.path().join(".cub")); + store.init().expect("store init should succeed"); + + fs::create_dir_all(store.recipes_dir().join("alpha")).expect("alpha dir should be created"); + fs::create_dir_all(store.recipes_dir().join("beta")).expect("beta dir should be created"); + fs::write(store.recipes_dir().join("README.txt"), "ignore me") + .expect("non-directory file should be created"); + + let recipes = store.list_recipes().expect("recipe listing should succeed"); + + assert_eq!( + recipes, + vec![ + store.recipes_dir().join("alpha"), + store.recipes_dir().join("beta") + ] + ); + } + + #[test] + fn list_pkgars_filters_target_and_extension() { + let temp = tempdir().expect("tempdir should be created"); + let store = CubStore::from_root(temp.path().join(".cub")); + store.init().expect("store init should succeed"); + + let target_dir = store.repo_dir("x86_64-unknown-redox"); + fs::create_dir_all(&target_dir).expect("target repo dir should be created"); + fs::write(target_dir.join("alpha.pkgar"), b"a").expect("alpha pkgar should be created"); + fs::write(target_dir.join("beta.pkgar"), b"b").expect("beta pkgar should be created"); + fs::write(target_dir.join("notes.txt"), b"c").expect("notes file should be created"); + + let other_target_dir = store.repo_dir("aarch64-unknown-redox"); + fs::create_dir_all(&other_target_dir).expect("other target dir should be created"); + fs::write(other_target_dir.join("other.pkgar"), b"d") + .expect("other target pkgar should be created"); + + let pkgars = store + .list_pkgars("x86_64-unknown-redox") + .expect("pkgar listing should succeed"); + + assert_eq!( + pkgars, + vec![ + target_dir.join("alpha.pkgar"), + target_dir.join("beta.pkgar") + ] + ); + } + + #[test] + fn cache_clean_removes_contents_but_keeps_directory() { + let temp = tempdir().expect("tempdir should be created"); + let store = CubStore::from_root(temp.path().join(".cub")); + store.init().expect("store init should succeed"); + + let nested_dir = store.sources_dir().join("git-cache"); + fs::create_dir_all(&nested_dir).expect("nested sources dir should be created"); + fs::write(store.sources_dir().join("source.tar"), b"archive") + .expect("source archive should be created"); + fs::write(nested_dir.join("HEAD"), b"ref").expect("nested file should be created"); + + store.cache_clean().expect("cache clean should succeed"); + + assert!(store.sources_dir().is_dir()); + let remaining = fs::read_dir(store.sources_dir()) + .expect("sources dir should still be readable") + .count(); + assert_eq!(remaining, 0); + } + + #[test] + fn recipe_exists_checks_recipe_directory_presence() { + let temp = tempdir().expect("tempdir should be created"); + let store = CubStore::from_root(temp.path().join(".cub")); + store.init().expect("store init should succeed"); + + fs::create_dir_all(store.recipes_dir().join("demo")) + .expect("demo recipe should be created"); + fs::write(store.recipes_dir().join("plain-file"), b"not a recipe") + .expect("plain file should be created"); + + assert!(store.recipe_exists("demo")); + assert!(!store.recipe_exists("plain-file")); + assert!(!store.recipe_exists("missing")); + } +} diff --git a/local/recipes/system/cub/source/cub-tui/Cargo.toml b/local/recipes/system/cub/source/cub-tui/Cargo.toml new file mode 100644 index 000000000..75cab6ba5 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cub-tui" +description = "Red Bear OS Package Builder TUI" + +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "cub_tui" +path = "src/lib.rs" + +[dependencies] +termion = "4.0.6"