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:
@@ -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"
|
||||||
Reference in New Issue
Block a user