diff --git a/CHANGELOG.md b/CHANGELOG.md index fc634c5..573f4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Configuration file support via `rb.toml` for global settings +- Project script management with `rbproject.toml` and `rb run` command +- Project bootstrap script with `rb init` + ## [0.1.0] - 2025-09-26 ### Added @@ -24,4 +29,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -*Distinguished releases crafted with appropriate ceremony by RubyElders.com* \ No newline at end of file +*Distinguished releases crafted with appropriate ceremony by RubyElders.com* diff --git a/Cargo.lock b/Cargo.lock index e685aa8..cd47444 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,6 +158,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.13" @@ -186,6 +192,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "heck" version = "0.5.0" @@ -201,6 +213,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -320,7 +342,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rb-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "colored", @@ -330,12 +352,14 @@ dependencies = [ "rb-core", "rb-tests", "semver", + "serde", + "toml", "which", ] [[package]] name = "rb-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "colored", "home", @@ -343,13 +367,15 @@ dependencies = [ "rb-tests", "regex", "semver", + "serde", "tempfile", + "toml", "which", ] [[package]] name = "rb-tests" -version = "0.1.0" +version = "0.2.0" dependencies = [ "tempfile", ] @@ -435,6 +461,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -465,6 +500,47 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -651,6 +727,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index c4b13cc..e749edd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2024" authors = ["RubyElders.com"] description = "A sophisticated Ruby environment manager that orchestrates installations and gem collections with distinguished precision" diff --git a/README.md b/README.md index bf7bb7f..e6bed47 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ Ruby Butler expects Ruby installations in `~/.rubies/` (the standard location fo - `rb exec` / `rb x` - Execute commands within meticulously prepared environments - `rb environment` / `rb env` - Display current environment composition - `rb sync` - Manually synchronize bundler environments (auto-triggered when needed) +- `rb run` / `rb r` - Execute project scripts defined in `gem.toml` or `rbproject.toml` + +## Configuration + +- **`rb.toml`** - Global configuration file (in `%APPDATA%/rb/` or `~/.rb.toml`) +- **`gem.toml`** or **`rbproject.toml`** - Project-level script definitions and metadata ## Development diff --git a/crates/rb-cli/Cargo.toml b/crates/rb-cli/Cargo.toml index 21c93ab..f285554 100644 --- a/crates/rb-cli/Cargo.toml +++ b/crates/rb-cli/Cargo.toml @@ -27,6 +27,8 @@ log = "0.4" env_logger = "0.11" semver = "1.0.26" which = "6.0" +toml = "0.8" +serde = { version = "1.0", features = ["derive"] } [dev-dependencies] rb-tests = { path = "../rb-tests" } diff --git a/crates/rb-cli/src/bin/rb.rs b/crates/rb-cli/src/bin/rb.rs index e5fedb0..26a32ad 100644 --- a/crates/rb-cli/src/bin/rb.rs +++ b/crates/rb-cli/src/bin/rb.rs @@ -1,7 +1,7 @@ use clap::Parser; use rb_cli::{ - Cli, Commands, environment_command, exec_command, init_logger, resolve_search_dir, - runtime_command, sync_command, + Cli, Commands, environment_command, exec_command, init_command, init_logger, + resolve_search_dir, run_command, runtime_command, sync_command, }; use rb_core::butler::{ButlerError, ButlerRuntime}; @@ -53,15 +53,35 @@ fn main() { let cli = Cli::parse(); - // Initialize logger with the effective log level (considering -v/-vv flags) + // Initialize logger early with the effective log level (considering -v/-vv flags) + // This allows us to see config file loading and merging logs init_logger(cli.effective_log_level()); + // Merge config file defaults with CLI arguments + let cli = match cli.with_config_defaults() { + Ok(cli) => cli, + Err(e) => { + eprintln!("Configuration error: {}", e); + std::process::exit(1); + } + }; + + // Handle init command early - doesn't require Ruby environment + if let Commands::Init = cli.command { + let current_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + if let Err(e) = init_command(¤t_dir) { + eprintln!("{}", e); + std::process::exit(1); + } + return; + } + // Handle sync command differently since it doesn't use ButlerRuntime in the same way if let Commands::Sync = cli.command { if let Err(e) = sync_command( - cli.rubies_dir.clone(), - cli.ruby_version.clone(), - cli.gem_home.clone(), + cli.config.rubies_dir.clone(), + cli.config.ruby_version.clone(), + cli.config.gem_home.clone(), ) { eprintln!("Sync failed: {}", e); std::process::exit(1); @@ -70,13 +90,13 @@ fn main() { } // Resolve search directory for Ruby installations - let rubies_dir = resolve_search_dir(cli.rubies_dir); + let rubies_dir = resolve_search_dir(cli.config.rubies_dir); // Perform comprehensive environment discovery once let butler_runtime = match ButlerRuntime::discover_and_compose_with_gem_base( rubies_dir, - cli.ruby_version, - cli.gem_home, + cli.config.ruby_version, + cli.config.gem_home, ) { Ok(runtime) => runtime, Err(e) => match e { @@ -109,11 +129,18 @@ fn main() { runtime_command(&butler_runtime); } Commands::Environment => { - environment_command(&butler_runtime); + environment_command(&butler_runtime, cli.project_file); } Commands::Exec { args } => { exec_command(butler_runtime, args); } + Commands::Run { script, args } => { + run_command(butler_runtime, script, args, cli.project_file); + } + Commands::Init => { + // Already handled above + unreachable!() + } Commands::Sync => { // Already handled above unreachable!() diff --git a/crates/rb-cli/src/commands/environment.rs b/crates/rb-cli/src/commands/environment.rs index f954558..967ccc5 100644 --- a/crates/rb-cli/src/commands/environment.rs +++ b/crates/rb-cli/src/commands/environment.rs @@ -1,15 +1,17 @@ use colored::*; -use log::{debug, info}; +use log::{debug, info, warn}; use rb_core::bundler::BundlerRuntime; use rb_core::butler::ButlerRuntime; +use rb_core::project::{ProjectRuntime, RbprojectDetector}; use rb_core::ruby::RubyType; +use std::path::PathBuf; -pub fn environment_command(butler_runtime: &ButlerRuntime) { +pub fn environment_command(butler_runtime: &ButlerRuntime, project_file: Option) { info!("Presenting current Ruby environment from the working directory"); - present_current_environment(butler_runtime); + present_current_environment(butler_runtime, project_file); } -fn present_current_environment(butler_runtime: &ButlerRuntime) { +fn present_current_environment(butler_runtime: &ButlerRuntime, project_file: Option) { println!("{}", "🌍 Your Current Ruby Environment".to_string().bold()); println!(); @@ -26,14 +28,60 @@ fn present_current_environment(butler_runtime: &ButlerRuntime) { // Get gem runtime from butler runtime let gem_runtime = butler_runtime.gem_runtime(); + // Detect or load project runtime + let project_runtime = if let Some(path) = project_file { + // Use specified project file + debug!( + "Loading project config from specified path: {}", + path.display() + ); + match ProjectRuntime::from_file(&path) { + Ok(project) => { + debug!( + "Loaded {} with {} scripts", + project.config_filename, + project.scripts.len() + ); + Some(project) + } + Err(e) => { + warn!( + "Failed to load specified project config at {}: {}", + path.display(), + e + ); + None + } + } + } else { + // Auto-detect project file + RbprojectDetector::discover(current_dir) + .ok() + .flatten() + .inspect(|project| { + debug!( + "Discovered {} with {} scripts", + project.config_filename, + project.scripts.len() + ); + }) + }; + // Present the environment - present_environment_details(ruby, gem_runtime, bundler_runtime, butler_runtime); + present_environment_details( + ruby, + gem_runtime, + bundler_runtime, + project_runtime.as_ref(), + butler_runtime, + ); } fn present_environment_details( ruby: &rb_core::ruby::RubyRuntime, gem_runtime: Option<&rb_core::gems::GemRuntime>, bundler_runtime: Option<&BundlerRuntime>, + project_runtime: Option<&ProjectRuntime>, butler: &ButlerRuntime, ) { let label_width = [ @@ -189,6 +237,79 @@ fn present_environment_details( println!(" {}", "Bundler environment not detected".bright_black()); } + // Present Project Environment (if detected) + if let Some(project) = project_runtime { + println!(); + println!("{}", "πŸ“‹ Project".green().bold()); + + // Display project name if available + if let Some(name) = &project.metadata.name { + println!( + " {: std::io::Result<()> { + use rb_core::gems::GemRuntime; + use rb_core::project::{ProjectMetadata, ScriptDefinition}; + use rb_tests::RubySandbox; + use std::collections::HashMap; + + let ruby_sandbox = RubySandbox::new()?; + let ruby_dir = ruby_sandbox.add_ruby_dir("3.2.5")?; + let ruby = rb_core::ruby::RubyRuntime::new( + rb_core::ruby::RubyType::CRuby, + semver::Version::parse("3.2.5").unwrap(), + &ruby_dir, + ); + + // Create a project runtime with some scripts + let mut scripts = HashMap::new(); + scripts.insert( + "test".to_string(), + ScriptDefinition::Detailed { + command: "rspec".to_string(), + description: Some("Run the test suite".to_string()), + }, + ); + scripts.insert( + "lint:fix".to_string(), + ScriptDefinition::Simple("rubocop -a".to_string()), + ); + + let metadata = ProjectMetadata::default(); + let project_runtime = + ProjectRuntime::new(ruby_sandbox.root(), "rbproject.toml", metadata, scripts); + + // Use sandboxed gem directory + let gem_runtime = GemRuntime::for_base_dir(&ruby_sandbox.gem_base_dir(), &ruby.version); + let butler = ButlerRuntime::new(ruby.clone(), Some(gem_runtime.clone())); + + // Test with project environment + present_environment_details( + &ruby, + Some(&gem_runtime), + None, + Some(&project_runtime), + &butler, + ); Ok(()) } diff --git a/crates/rb-cli/src/commands/init.rs b/crates/rb-cli/src/commands/init.rs new file mode 100644 index 0000000..d1e296c --- /dev/null +++ b/crates/rb-cli/src/commands/init.rs @@ -0,0 +1,114 @@ +use std::fs; +use std::path::Path; + +const DEFAULT_RBPROJECT_TOML: &str = r#"[project] +name = "Butler project template" +description = "Please fill in" + +[scripts] +ruby-version = "ruby -v" +"#; + +/// Initialize a new rbproject.toml in the current directory +pub fn init_command(current_dir: &Path) -> Result<(), String> { + let project_file = current_dir.join("rbproject.toml"); + + // Check if file already exists + if project_file.exists() { + return Err( + "🎩 My sincerest apologies, but an rbproject.toml file already graces\n\ + this directory with its presence.\n\n\ + This humble Butler cannot overwrite existing project configurations\n\ + without explicit instruction, as such an action would be most improper.\n\n\ + If you wish to recreate the file, kindly delete the existing one first." + .to_string(), + ); + } + + // Write the default template + fs::write(&project_file, DEFAULT_RBPROJECT_TOML) + .map_err(|e| format!("Failed to create rbproject.toml: {}", e))?; + + println!("✨ Splendid! A new rbproject.toml has been created with appropriate ceremony."); + println!(); + println!("πŸ“ This template includes:"); + println!(" β€’ Project metadata (name and description)"); + println!(" β€’ A sample script (ruby-version) to demonstrate usage"); + println!(); + println!("🎯 You may now:"); + println!(" β€’ Edit rbproject.toml to add your own scripts"); + println!(" β€’ Run 'rb run' to list available scripts"); + println!(" β€’ Execute scripts with: rb run "); + println!(); + println!("For comprehensive examples, please consult:"); + println!(" https://github.com/RubyElders/ruby-butler/blob/main/examples/rbproject.toml"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_init_creates_rbproject_toml() { + let temp_dir = std::env::temp_dir().join(format!("rb-init-test-{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + let result = init_command(&temp_dir); + + assert!(result.is_ok()); + let project_file = temp_dir.join("rbproject.toml"); + assert!(project_file.exists()); + + let content = fs::read_to_string(&project_file).unwrap(); + assert!(content.contains("[project]")); + assert!(content.contains("name = \"Butler project template\"")); + assert!(content.contains("[scripts]")); + assert!(content.contains("ruby-version = \"ruby -v\"")); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_init_fails_if_file_exists() { + let temp_dir = + std::env::temp_dir().join(format!("rb-init-test-exists-{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + let project_file = temp_dir.join("rbproject.toml"); + + // Create existing file + fs::write(&project_file, "existing content").unwrap(); + + let result = init_command(&temp_dir); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.contains("already graces")); + assert!(error.contains("this directory")); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn test_init_creates_valid_toml() { + let temp_dir = + std::env::temp_dir().join(format!("rb-init-test-valid-{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + let result = init_command(&temp_dir); + + assert!(result.is_ok()); + let project_file = temp_dir.join("rbproject.toml"); + let content = fs::read_to_string(&project_file).unwrap(); + + // Verify it's valid TOML + let parsed: Result = toml::from_str(&content); + assert!(parsed.is_ok(), "Generated TOML should be valid"); + + // Cleanup + fs::remove_dir_all(&temp_dir).ok(); + } +} diff --git a/crates/rb-cli/src/commands/mod.rs b/crates/rb-cli/src/commands/mod.rs index 17bcba3..215b1e1 100644 --- a/crates/rb-cli/src/commands/mod.rs +++ b/crates/rb-cli/src/commands/mod.rs @@ -1,9 +1,13 @@ pub mod environment; pub mod exec; +pub mod init; +pub mod run; pub mod runtime; pub mod sync; pub use environment::environment_command; pub use exec::exec_command; +pub use init::init_command; +pub use run::run_command; pub use runtime::runtime_command; pub use sync::sync_command; diff --git a/crates/rb-cli/src/commands/run.rs b/crates/rb-cli/src/commands/run.rs new file mode 100644 index 0000000..531175a --- /dev/null +++ b/crates/rb-cli/src/commands/run.rs @@ -0,0 +1,434 @@ +use colored::*; +use log::{debug, info, warn}; +use rb_core::butler::ButlerRuntime; +use rb_core::project::{ProjectRuntime, RbprojectDetector}; +use std::path::PathBuf; + +use super::exec::exec_command; + +fn list_available_scripts(butler_runtime: ButlerRuntime, project_file: Option) { + info!("Listing available project scripts"); + + // Detect or load project runtime + let current_dir = butler_runtime.current_dir(); + let project_runtime = if let Some(path) = project_file { + // Use specified project file + debug!( + "Loading project config from specified path: {}", + path.display() + ); + match ProjectRuntime::from_file(&path) { + Ok(project) => Some(project), + Err(e) => { + eprintln!("{}", "❌ Selection Failed".red().bold()); + eprintln!(); + eprintln!("The specified project configuration could not be loaded:"); + eprintln!(" File: {}", path.display().to_string().bright_black()); + eprintln!(" Error: {}", e.to_string().bright_black()); + eprintln!(); + eprintln!("Please verify the file exists and contains valid TOML configuration."); + std::process::exit(1); + } + } + } else { + // Auto-detect project file + match RbprojectDetector::discover(current_dir) { + Ok(Some(project)) => { + debug!( + "Discovered {} with {} scripts", + project.config_filename, + project.scripts.len() + ); + Some(project) + } + Ok(None) => None, + Err(e) => { + warn!("Error detecting project config: {}", e); + None + } + } + }; + + // Ensure we have a project configuration + let project = match project_runtime { + Some(p) => p, + None => { + eprintln!("{}", "❌ No Project Configuration".red().bold()); + eprintln!(); + eprintln!("No project configuration detected in the current directory hierarchy."); + eprintln!(); + eprintln!( + "To define project scripts, create an {} or {} file:", + "rbproject.toml".cyan(), + "gem.toml".cyan() + ); + eprintln!(); + eprintln!(" {}", "[scripts]".bright_black()); + eprintln!(" {} = {}", "test".cyan(), "\"rspec\"".bright_black()); + eprintln!( + " {} = {{ command = {}, description = {} }}", + "lint".cyan(), + "\"rubocop\"".bright_black(), + "\"Check code quality\"".bright_black() + ); + eprintln!(); + eprintln!( + "Or specify a custom location: {} -P path/to/rbproject.toml run", + "rb".green().bold() + ); + std::process::exit(1); + } + }; + + // Display help-style output + println!("{}", "🎯 Run Project Scripts".green().bold()); + println!(); + + // Show project metadata if available + if let Some(name) = &project.metadata.name { + println!("{}", name); + } + + if let Some(description) = &project.metadata.description { + println!("{}", description.bright_black()); + } + + if project.metadata.name.is_some() || project.metadata.description.is_some() { + println!(); + } + + // Usage section + println!("{}", "Usage:".green().bold()); + println!(" rb run