feat: add Cub recipe storage and TUI shell

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-07 20:57:33 +01:00
parent 900fefc46e
commit 714aed9610
3 changed files with 364 additions and 0 deletions
@@ -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<String, CubError> {
let (_, recipe_toml) = recipe_from_aur_pkgbuild(raw_pkgbuild)?;
Ok(recipe_toml)
}
pub fn save_recipe_to_store(rbpkg: &RbPkgBuild, store: &CubStore) -> Result<PathBuf, CubError> {
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);
}
}
@@ -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<Self, CubError> {
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<Vec<PathBuf>, CubError> {
list_subdirectories(&self.recipes_dir())
}
pub fn list_pkgars(&self, target: &str) -> Result<Vec<PathBuf>, 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<Vec<PathBuf>, 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"));
}
}
@@ -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"