diff --git a/Cargo.lock b/Cargo.lock index 3707d1e..03efa53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1246,6 +1246,7 @@ dependencies = [ name = "rb-cli" version = "0.3.1" dependencies = [ + "anyhow", "clap", "clap_complete", "colored", @@ -1255,7 +1256,9 @@ dependencies = [ "log", "rb-core", "rb-gemfile", + "rb-packages", "rb-progress", + "rb-task", "rb-tests", "semver", "serde", @@ -1380,6 +1383,8 @@ dependencies = [ "anyhow", "dunce", "flate2", + "kdl", + "log", "rb-compact-index", "rb-gem-archive", "rb-gem-installer", @@ -1579,9 +1584,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -2216,9 +2221,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/crates/rb-cli/Cargo.toml b/crates/rb-cli/Cargo.toml index ad9ea65..735b281 100644 --- a/crates/rb-cli/Cargo.toml +++ b/crates/rb-cli/Cargo.toml @@ -23,7 +23,10 @@ clap = { version = "4.0", features = ["derive", "color", "help", "usage", "env"] clap_complete = "4.0" rb-core = { path = "../rb-core" } rb-gemfile = { path = "../rb-gemfile" } -rb-progress = { path = "../rb-progress" } +rb-progress = { path = "../rb-progress", features = ["rb-task"] } +rb-task = { path = "../rb-task" } +rb-packages = { path = "../rb-packages" } +anyhow = "1" home = "0.5" colored = "2.0" log = "0.4" diff --git a/crates/rb-cli/src/bin/rb.rs b/crates/rb-cli/src/bin/rb.rs index 6eb4de3..646a502 100644 --- a/crates/rb-cli/src/bin/rb.rs +++ b/crates/rb-cli/src/bin/rb.rs @@ -1,4 +1,4 @@ -use clap::Parser; +use clap::{CommandFactory, FromArgMatches}; use rb_cli::config::TrackedConfig; use rb_cli::dispatch::dispatch_command; use rb_cli::error_display::{ @@ -33,7 +33,9 @@ fn handle_command_error(error: ButlerError, context: &CommandContext) -> ! { } fn main() { - let cli = Cli::parse(); + let matches = Cli::command().get_matches(); + let package_backend_source = matches.value_source("package_backend"); + let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); // Skip logging for bash completion (must be silent) if !matches!(cli.command, Some(Commands::BashComplete { .. })) { @@ -55,7 +57,10 @@ fn main() { std::process::exit(0); }; - let tracked_config = TrackedConfig::from_merged(&cli_parsed.config, &file_config); + let mut tracked_config = TrackedConfig::from_merged(&cli_parsed.config, &file_config); + if package_backend_source == Some(clap::parser::ValueSource::EnvVariable) { + tracked_config.package_backend.source = rb_cli::config::ConfigSource::EnvVar; + } if !tracked_config.work_dir.source.is_default() { let target_dir = tracked_config.work_dir.get(); diff --git a/crates/rb-cli/src/commands/exec.rs b/crates/rb-cli/src/commands/exec.rs index cebca32..48cab2d 100644 --- a/crates/rb-cli/src/commands/exec.rs +++ b/crates/rb-cli/src/commands/exec.rs @@ -1,8 +1,17 @@ -use super::sync_report::{Completion, report_sync, sync_reporter}; +use super::sync_report::Completion; use log::{debug, info}; use rb_core::butler::{ButlerError, ButlerRuntime, Command}; pub fn exec_command(butler: ButlerRuntime, program_args: Vec) -> Result<(), ButlerError> { + let backend = crate::packages::select(crate::config::PackageBackendKind::Bundler, None, false); + exec_with_backend(butler, program_args, backend.as_ref()) +} + +pub(crate) fn exec_with_backend( + butler: ButlerRuntime, + program_args: Vec, + backend: &dyn crate::packages::PackageBackend, +) -> Result<(), ButlerError> { if program_args.is_empty() { return Err(ButlerError::General( "No program specified for execution.\nProper usage: rb exec [arguments...]\nFor example: rb exec gem list\n rb exec bundle install".to_string() @@ -21,21 +30,18 @@ pub fn exec_command(butler: ButlerRuntime, program_args: Vec) -> Result< program ); - if let Some(bundler_runtime) = butler.bundler_runtime() { - match bundler_runtime.check_sync(&butler) { - Ok(false) => { - report_sync(sync_reporter(), Completion::Compact, |handler| { - bundler_runtime.synchronize_with_events(&butler, handler) - })?; - } - Ok(true) => { - debug!("Bundler environment already synchronized"); - } - Err(e) => { - debug!("Unable to verify bundler synchronization status: {}", e); - } + let butler = match backend + .check(&butler) + .map_err(|error| ButlerError::General(format!("{error:#}")))? + { + crate::packages::PackageStatus::Ready(runtime) => *runtime, + crate::packages::PackageStatus::NeedsPreparation => { + super::sync_report::report_packages(Completion::Compact, |events| { + backend.prepare(&butler, events) + })? + .runtime } - } + }; debug!("Program: {}", program); debug!("Arguments: {:?}", args); diff --git a/crates/rb-cli/src/commands/info/config.rs b/crates/rb-cli/src/commands/info/config.rs index a181f03..793a327 100644 --- a/crates/rb-cli/src/commands/info/config.rs +++ b/crates/rb-cli/src/commands/info/config.rs @@ -92,6 +92,10 @@ pub fn config_command(config: &TrackedConfig) -> Result<(), ButlerError> { ); println!(); + println!("Package Backend: {}", config.package_backend.get()); + println!(" Source: {}", config.package_backend.source); + println!(); + println!("{}", "Configuration sources (in priority order):".dimmed()); println!(" {} CLI arguments", "1.".dimmed()); println!(" {} Configuration file", "2.".dimmed()); diff --git a/crates/rb-cli/src/commands/run.rs b/crates/rb-cli/src/commands/run.rs index c0cedbb..baa37db 100644 --- a/crates/rb-cli/src/commands/run.rs +++ b/crates/rb-cli/src/commands/run.rs @@ -4,7 +4,7 @@ use rb_core::butler::{ButlerError, ButlerRuntime}; use rb_core::project::{ProjectRuntime, RbprojectDetector}; use std::path::PathBuf; -use super::exec::exec_command; +use super::exec::exec_with_backend; fn list_available_scripts( butler_runtime: ButlerRuntime, @@ -151,6 +151,23 @@ pub fn run_command( script_name: Option, args: Vec, project_file: Option, +) -> Result<(), ButlerError> { + let backend = crate::packages::select(crate::config::PackageBackendKind::Bundler, None, false); + run_with_backend( + butler_runtime, + script_name, + args, + project_file, + backend.as_ref(), + ) +} + +pub(crate) fn run_with_backend( + butler_runtime: ButlerRuntime, + script_name: Option, + args: Vec, + project_file: Option, + backend: &dyn crate::packages::PackageBackend, ) -> Result<(), ButlerError> { if script_name.is_none() { return list_available_scripts(butler_runtime, project_file); @@ -231,8 +248,8 @@ pub fn run_command( info!("Delegating to exec command with args: {:?}", full_args); - // Delegate to exec_command for consistent behavior (auto bundle exec, env composition) - exec_command(butler_runtime, full_args) + // Delegate to exec_with_backend for consistent behavior (auto bundle exec, env composition) + exec_with_backend(butler_runtime, full_args, backend) } /// Parse a command string into program and arguments diff --git a/crates/rb-cli/src/commands/sync.rs b/crates/rb-cli/src/commands/sync.rs index 8c0b96e..3f6ac18 100644 --- a/crates/rb-cli/src/commands/sync.rs +++ b/crates/rb-cli/src/commands/sync.rs @@ -1,22 +1,19 @@ -use super::sync_report::{Completion, report_sync, sync_reporter}; -use log::debug; +use super::sync_report::Completion; use rb_core::butler::{ButlerError, ButlerRuntime}; pub fn sync_command(butler_runtime: ButlerRuntime) -> Result<(), ButlerError> { - debug!("Starting sync command"); - - let bundler_runtime = match butler_runtime.bundler_runtime() { - Some(bundler) => bundler, - None => { - return Err(ButlerError::General( - "Bundler environment not detected.\n\nNo Gemfile found in the current directory or its ancestors.\nThe sync command requires a bundler-managed project to operate.\n\nTo create a new bundler project, create a Gemfile with: echo 'source \"https://rubygems.org\"' > Gemfile".to_string() - )); - } - }; + let backend = crate::packages::select(crate::config::PackageBackendKind::Bundler, None, false); + sync_with_backend(butler_runtime, backend.as_ref()) +} - report_sync(sync_reporter(), Completion::Tree, |handler| { - bundler_runtime.synchronize_with_events(&butler_runtime, handler) - }) +pub(crate) fn sync_with_backend( + runtime: ButlerRuntime, + backend: &dyn crate::packages::PackageBackend, +) -> Result<(), ButlerError> { + super::sync_report::report_packages(Completion::Tree, |events| { + backend.prepare(&runtime, events) + })?; + Ok(()) } #[cfg(test)] diff --git a/crates/rb-cli/src/commands/sync_report.rs b/crates/rb-cli/src/commands/sync_report.rs index 0b74238..bc46f99 100644 --- a/crates/rb-cli/src/commands/sync_report.rs +++ b/crates/rb-cli/src/commands/sync_report.rs @@ -1,4 +1,6 @@ -use rb_core::bundler::{SyncEvent, SyncResult}; +use rb_core::bundler::SyncEvent; +#[cfg(test)] +use rb_core::bundler::SyncResult; use rb_core::butler::ButlerError; use rb_progress::Reporter; use std::io::IsTerminal; @@ -16,16 +18,26 @@ pub(super) enum Completion { Compact, } -pub(super) fn report_sync( +pub(crate) fn report_preparation( reporter: Reporter, completion: Completion, - run: impl FnOnce(&mut dyn FnMut(SyncEvent<'_>)) -> std::io::Result, -) -> Result<(), ButlerError> { + run: impl FnOnce( + crate::packages::PackageEvents<'_>, + ) -> anyhow::Result, +) -> Result { let progress = reporter.handle(); - progress.worker_started(0, "Bundler"); + let failed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let tasks = rb_task::TaskEvents::default(); + tasks.subscribe(std::sync::Arc::new(FailureTracker(failed.clone()))); + tasks.subscribe(reporter.task_sink()); let mut active_phase = None; let mut active_detail = None; - let result = run(&mut |event| { + let mut started = false; + let mut handler = |event: SyncEvent<'_>| { + if !started { + progress.worker_started(0, "Bundler"); + started = true; + } let phase = match event { SyncEvent::Checking => "checking dependencies", SyncEvent::CheckingLockfile => "checking lockfile", @@ -51,27 +63,74 @@ pub(super) fn report_sync( } progress.event(phase, 0, 1, None); progress.worker_event(Some(0), phase, 0, 0, Some("Bundler".into())); + }; + let result = run(crate::packages::PackageEvents { + tasks, + sync: &mut handler, }); match result { Ok(result) => { - progress.worker_finished(0); + if started { + progress.worker_finished(0); + } if let Some(phase) = active_phase { progress.event(phase, 1, 1, active_detail); } - let summary = match result { - SyncResult::AlreadySynced => "Everything is already in order", - SyncResult::Synchronized => "Your bundle is ready", + let summary = if result.changed { + "Your bundle is ready" + } else { + "Everything is already in order" }; match completion { Completion::Tree => reporter.finish_with_summary(summary), Completion::Compact => reporter.finish_compact(summary), } - Ok(()) + Ok(result) } Err(error) => { - progress.worker_failed(0, error.to_string()); + if !failed.load(std::sync::atomic::Ordering::Relaxed) { + if !started { + progress.worker_started(0, "preparing dependencies"); + } + progress.worker_failed(0, format!("{error:#}")); + } reporter.finish(); - Err(ButlerError::General(error.to_string())) + Err(ButlerError::General(format!("{error:#}"))) + } + } +} + +pub(crate) fn report_packages( + completion: Completion, + run: impl FnOnce( + crate::packages::PackageEvents<'_>, + ) -> anyhow::Result, +) -> Result { + report_preparation(sync_reporter(), completion, run) +} + +#[cfg(test)] +fn report_sync( + reporter: Reporter, + completion: Completion, + run: impl FnOnce(&mut dyn FnMut(SyncEvent<'_>)) -> std::io::Result, +) -> Result<(), ButlerError> { + report_preparation(reporter, completion, |events| { + let result = run(events.sync)?; + Ok(crate::packages::PreparedEnvironment { + runtime: rb_core::butler::ButlerRuntime::empty(".".into(), ".".into()), + changed: result == SyncResult::Synchronized, + }) + }) + .map(|_| ()) +} + +struct FailureTracker(std::sync::Arc); + +impl rb_task::TaskEventSink for FailureTracker { + fn event(&self, event: rb_task::TaskEvent) { + if matches!(event, rb_task::TaskEvent::Failed { .. }) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); } } } @@ -127,6 +186,26 @@ mod tests { } } + #[test] + fn failure_before_task_events_is_reported_as_failure() { + let buffer = Buffer::default(); + let reporter = Reporter::with_plain_writer("sync", buffer.clone()); + let result = report_preparation(reporter, Completion::Tree, |_| { + Err(anyhow::anyhow!("No project found")) + }); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("No project found") + ); + let text = String::from_utf8(buffer.0.lock().unwrap().clone()).unwrap(); + assert!(text.contains("failed: No project found"), "{text}"); + assert!(text.contains("[overall] failed"), "{text}"); + assert!(!text.contains("[overall] complete"), "{text}"); + } + #[test] fn reporter_retains_bundler_failure() { let buffer = Buffer::default(); diff --git a/crates/rb-cli/src/config/loader.rs b/crates/rb-cli/src/config/loader.rs index 4d2dca0..329461a 100644 --- a/crates/rb-cli/src/config/loader.rs +++ b/crates/rb-cli/src/config/loader.rs @@ -60,6 +60,23 @@ fn parse_kdl_config(content: &str) -> Result { let mut config = RbConfig::default(); + if let Some(node) = doc.get("package-backend") { + let value = node + .entries() + .first() + .and_then(|entry| entry.value().as_string()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "package-backend requires a string", + ) + })?; + config.package_backend = Some( + ::from_str(value, false) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?, + ); + } + // Parse rubies-dir if let Some(node) = doc.get("rubies-dir") && let Some(entry) = node.entries().first() diff --git a/crates/rb-cli/src/config/mod.rs b/crates/rb-cli/src/config/mod.rs index 11ec6a1..a1ebc21 100644 --- a/crates/rb-cli/src/config/mod.rs +++ b/crates/rb-cli/src/config/mod.rs @@ -1,6 +1,8 @@ pub mod loader; pub mod locator; +mod package_backend; pub mod value; +pub use package_backend::PackageBackendKind; use clap::Args; use serde::{Deserialize, Serialize}; @@ -60,6 +62,16 @@ pub struct RbConfig { #[serde(rename = "no-bundler", skip_serializing_if = "Option::is_none")] pub no_bundler: Option, + #[arg( + long, + global = true, + env = "RB_PACKAGE_BACKEND", + value_enum, + help = "Choose how dependencies are resolved and installed" + )] + #[serde(rename = "package-backend", skip_serializing_if = "Option::is_none")] + pub package_backend: Option, + /// Specify working directory (run as if started in this directory) #[arg( short = 'C', @@ -117,6 +129,10 @@ impl RbConfig { debug!(" Using no-bundler from CLI arguments: {}", no_bundler); } + if self.package_backend.is_none() { + self.package_backend = other.package_backend; + } + if self.work_dir.is_none() { if let Some(ref dir) = other.work_dir { debug!(" Using work-dir from config file: {}", dir.display()); @@ -136,6 +152,7 @@ pub struct TrackedConfig { pub ruby_version: Option>, pub gem_home: ConfigValue, pub no_bundler: ConfigValue, + pub package_backend: ConfigValue, pub work_dir: ConfigValue, } @@ -260,7 +277,14 @@ impl TrackedConfig { default_work_dir, ); + let package_backend = PackageBackendKind::resolve( + cli_config.package_backend, + std::env::var("RB_PACKAGE_BACKEND").ok().as_deref(), + file_config.package_backend, + ); + Self { + package_backend, rubies_dir, ruby_version, gem_home, @@ -276,6 +300,7 @@ impl TrackedConfig { ruby_version: self.ruby_version.as_ref().map(|v| v.value.clone()), gem_home: Some(self.gem_home.value.clone()), no_bundler: Some(self.no_bundler.value), + package_backend: Some(self.package_backend.value), work_dir: Some(self.work_dir.value.clone()), } } @@ -348,6 +373,7 @@ mod tests { ruby_version: Some("3.3.0".to_string()), gem_home: Some(PathBuf::from("/test/gems")), no_bundler: None, + package_backend: None, work_dir: None, }; @@ -366,6 +392,7 @@ mod tests { ruby_version: Some("3.2.0".to_string()), gem_home: None, no_bundler: None, + package_backend: None, work_dir: None, }; let file_config = RbConfig { @@ -373,6 +400,7 @@ mod tests { ruby_version: Some("3.3.0".to_string()), gem_home: Some(PathBuf::from("/file/gems")), no_bundler: Some(true), + package_backend: None, work_dir: None, }; @@ -393,6 +421,7 @@ mod tests { ruby_version: Some("3.2.0".to_string()), gem_home: None, no_bundler: None, + package_backend: None, work_dir: None, }; let file_config = RbConfig { @@ -400,6 +429,7 @@ mod tests { ruby_version: None, gem_home: Some(PathBuf::from("/file/gems")), no_bundler: None, + package_backend: None, work_dir: None, }; @@ -433,6 +463,7 @@ mod tests { ruby_version: Some("3.3.0".to_string()), gem_home: Some(PathBuf::from("/opt/gems")), no_bundler: None, + package_backend: None, work_dir: None, }; diff --git a/crates/rb-cli/src/config/package_backend.rs b/crates/rb-cli/src/config/package_backend.rs new file mode 100644 index 0000000..fc01e55 --- /dev/null +++ b/crates/rb-cli/src/config/package_backend.rs @@ -0,0 +1,72 @@ +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PackageBackendKind { + #[default] + Bundler, + Native, +} + +impl std::fmt::Display for PackageBackendKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Bundler => "bundler", + Self::Native => "native", + }) + } +} + +impl PackageBackendKind { + pub(crate) fn resolve( + cli: Option, + environment: Option<&str>, + file: Option, + ) -> super::ConfigValue { + if let Some(value) = cli { + super::ConfigValue::from_cli(value) + } else if let Some(value) = + environment.and_then(|value| ::from_str(value, false).ok()) + { + super::ConfigValue::from_env(value) + } else if let Some(value) = file { + super::ConfigValue::from_file(value) + } else { + super::ConfigValue::default_value(Self::default()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigSource; + + #[test] + fn selection_respects_cli_environment_file_and_default_priority() { + use PackageBackendKind::{Bundler, Native}; + for (cli, environment, file, expected, source) in [ + ( + Some(Bundler), + Some("native"), + Some(Native), + Bundler, + ConfigSource::Cli, + ), + ( + None, + Some("native"), + Some(Bundler), + Native, + ConfigSource::EnvVar, + ), + (None, None, Some(Native), Native, ConfigSource::ConfigFile), + (None, None, None, Bundler, ConfigSource::Default), + ] { + let selected = PackageBackendKind::resolve(cli, environment, file); + assert_eq!(selected.value, expected); + assert_eq!(selected.source, source); + } + } +} diff --git a/crates/rb-cli/src/dispatch.rs b/crates/rb-cli/src/dispatch.rs index c076868..49f71a1 100644 --- a/crates/rb-cli/src/dispatch.rs +++ b/crates/rb-cli/src/dispatch.rs @@ -1,9 +1,7 @@ use crate::Commands; use crate::InfoCommands; use crate::commands::info::info_config_command; -use crate::commands::{ - exec_command, help_command, info_command, run_command, sync_command, version_command, -}; +use crate::commands::{help_command, info_command, version_command}; use crate::runtime_helpers::CommandContext; use rb_core::butler::ButlerError; @@ -17,6 +15,11 @@ pub fn dispatch_command( command: Commands, context: &mut CommandContext, ) -> Result<(), ButlerError> { + let backend = crate::packages::select( + *context.config.package_backend.get(), + context.project_file.clone(), + *context.config.no_bundler.get(), + ); match command { Commands::Version => version_command(), Commands::Help { command: help_cmd } => help_command(help_cmd), @@ -32,13 +35,21 @@ pub fn dispatch_command( Commands::Run { script, args } => { let project_file = context.project_file.clone(); with_butler_runtime(context, |runtime| { - run_command(runtime.clone(), script, args, project_file) + crate::commands::run::run_with_backend( + runtime.clone(), + script, + args, + project_file, + backend.as_ref(), + ) }) } - Commands::Exec { args } => { - with_butler_runtime(context, |runtime| exec_command(runtime.clone(), args)) - } - Commands::Sync => with_butler_runtime(context, |runtime| sync_command(runtime.clone())), + Commands::Exec { args } => with_butler_runtime(context, |runtime| { + crate::commands::exec::exec_with_backend(runtime.clone(), args, backend.as_ref()) + }), + Commands::Sync => with_butler_runtime(context, |runtime| { + crate::commands::sync::sync_with_backend(runtime.clone(), backend.as_ref()) + }), Commands::Info { command } => match command { InfoCommands::Config => info_config_command(&context.config), diff --git a/crates/rb-cli/src/lib.rs b/crates/rb-cli/src/lib.rs index 31329f4..409f8cd 100644 --- a/crates/rb-cli/src/lib.rs +++ b/crates/rb-cli/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod dispatch; pub mod error_display; pub mod help_formatter; +mod packages; pub mod runtime_helpers; use clap::builder::styling::{AnsiColor, Effects, Styles}; diff --git a/crates/rb-cli/src/packages/bundler.rs b/crates/rb-cli/src/packages/bundler.rs new file mode 100644 index 0000000..12b90ba --- /dev/null +++ b/crates/rb-cli/src/packages/bundler.rs @@ -0,0 +1,35 @@ +use super::{PackageBackend, PackageEvents, PackageStatus, PreparedEnvironment}; +use anyhow::Result; +use rb_core::{bundler::SyncResult, butler::ButlerRuntime}; + +pub(super) struct BundlerBackend; + +impl PackageBackend for BundlerBackend { + fn check(&self, runtime: &ButlerRuntime) -> Result { + if runtime.bundler_runtime().is_some_and(|bundler| { + !bundler.check_sync(runtime).unwrap_or_else(|error| { + log::debug!("Unable to verify bundler synchronization status: {error}"); + true + }) + }) { + Ok(PackageStatus::NeedsPreparation) + } else { + Ok(PackageStatus::Ready(Box::new(runtime.clone()))) + } + } + + fn prepare( + &self, + runtime: &ButlerRuntime, + events: PackageEvents<'_>, + ) -> Result { + let bundler = runtime.bundler_runtime().ok_or_else(|| anyhow::anyhow!( + "Bundler environment not detected.\n\nNo Gemfile found in the current directory or its ancestors.\nThe sync command requires a bundler-managed project to operate.\n\nTo create a new bundler project, create a Gemfile with: echo 'source \"https://rubygems.org\"' > Gemfile" + ))?; + let result = bundler.synchronize_with_events(runtime, events.sync)?; + Ok(PreparedEnvironment { + runtime: runtime.clone(), + changed: result == SyncResult::Synchronized, + }) + } +} diff --git a/crates/rb-cli/src/packages/mod.rs b/crates/rb-cli/src/packages/mod.rs new file mode 100644 index 0000000..98151c4 --- /dev/null +++ b/crates/rb-cli/src/packages/mod.rs @@ -0,0 +1,44 @@ +mod bundler; +mod native; + +use crate::config::PackageBackendKind; +use anyhow::Result; +use rb_core::bundler::SyncEvent; +use rb_core::butler::ButlerRuntime; +use rb_task::TaskEvents; +use std::path::PathBuf; + +pub(crate) struct PackageEvents<'a> { + pub tasks: TaskEvents, + pub sync: &'a mut dyn FnMut(SyncEvent<'_>), +} + +pub(crate) enum PackageStatus { + Ready(Box), + NeedsPreparation, +} + +pub(crate) trait PackageBackend { + fn check(&self, runtime: &ButlerRuntime) -> Result; + fn prepare( + &self, + runtime: &ButlerRuntime, + events: PackageEvents<'_>, + ) -> Result; +} + +pub(crate) struct PreparedEnvironment { + pub runtime: ButlerRuntime, + pub changed: bool, +} + +pub(crate) fn select( + kind: PackageBackendKind, + project_file: Option, + disabled: bool, +) -> Box { + match kind { + PackageBackendKind::Bundler => Box::new(bundler::BundlerBackend), + PackageBackendKind::Native => Box::new(native::NativeBackend::new(project_file, disabled)), + } +} diff --git a/crates/rb-cli/src/packages/native.rs b/crates/rb-cli/src/packages/native.rs new file mode 100644 index 0000000..1920445 --- /dev/null +++ b/crates/rb-cli/src/packages/native.rs @@ -0,0 +1,130 @@ +use super::{PackageBackend, PackageEvents, PackageStatus, PreparedEnvironment}; +use anyhow::{Context, Result, ensure}; +use rb_core::butler::ButlerRuntime; +use std::path::{Path, PathBuf}; + +pub(super) struct NativeBackend { + project_file: Option, + disabled: bool, +} + +impl NativeBackend { + pub(super) fn new(project_file: Option, disabled: bool) -> Self { + Self { + project_file, + disabled, + } + } + + fn project(&self, directory: &Path) -> Result)>> { + if let Some(file) = &self.project_file { + let file = directory + .join(file) + .canonicalize() + .context("Opening selected project file")?; + return Ok(Some(( + file.parent() + .context("Project file has no directory")? + .into(), + Some(file), + ))); + } + if let Some(file) = std::env::var_os("BUNDLE_GEMFILE").filter(|file| !file.is_empty()) { + let file = directory + .join(file) + .canonicalize() + .context("Opening BUNDLE_GEMFILE")?; + return Ok(Some(( + file.parent().context("Gemfile has no directory")?.into(), + None, + ))); + } + for root in directory.ancestors() { + for name in ["gem.kdl", "gem.toml", "rbproject.kdl", "rbproject.toml"] { + let file = root.join(name); + if file.is_file() { + return Ok(Some((root.into(), Some(file)))); + } + } + if ["gems.rb", "Gemfile"] + .iter() + .any(|name| root.join(name).is_file()) + { + return Ok(Some((root.into(), None))); + } + } + Ok(None) + } + + fn options(&self, runtime: &ButlerRuntime) -> Result> { + let Some((root, project_file)) = self.project(runtime.current_dir())? else { + return Ok(None); + }; + let ruby = runtime.selected_ruby()?.ruby_executable_path(); + let mut options = rb_packages::PrepareOptions::new(&root, ruby, root.join(".rb/cache")); + options.project_file = project_file; + options.record_state = true; + for (key, groups) in [ + ("BUNDLE_WITH", &mut options.plan.with), + ("BUNDLE_WITHOUT", &mut options.plan.without), + ] { + if let Ok(value) = std::env::var(key) { + groups.extend( + value + .split([':', ' ']) + .filter(|group| !group.is_empty()) + .map(String::from), + ); + } + } + if ["BUNDLE_FROZEN", "BUNDLE_DEPLOYMENT"] + .iter() + .any(|key| std::env::var(key).is_ok_and(|value| value == "true" || value == "1")) + { + options.plan.mode = rb_packages::LockMode::Locked; + } + Ok(Some(options)) + } + + fn activate(runtime: &ButlerRuntime, installed: rb_packages::InstalledBundle) -> ButlerRuntime { + runtime.clone().with_package_environment( + installed.environment().into_iter().collect(), + installed.gem_home.join("bin"), + ) + } +} + +impl PackageBackend for NativeBackend { + fn check(&self, runtime: &ButlerRuntime) -> Result { + if self.disabled { + return Ok(PackageStatus::Ready(Box::new(runtime.clone()))); + } + let Some(options) = self.options(runtime)? else { + return Ok(PackageStatus::Ready(Box::new(runtime.clone()))); + }; + Ok(match rb_packages::prepared(&options)? { + Some(installed) => PackageStatus::Ready(Box::new(Self::activate(runtime, installed))), + None => PackageStatus::NeedsPreparation, + }) + } + + fn prepare( + &self, + runtime: &ButlerRuntime, + events: PackageEvents<'_>, + ) -> Result { + ensure!( + !self.disabled, + "Package preparation is disabled by --no-bundler" + ); + let options = self + .options(runtime)? + .context("No project file, gems.rb or Gemfile found")?; + let prepared = rb_packages::prepare(options, events.tasks)?; + let changed = prepared.plan.lock_changed || prepared.installed.installed > 0; + Ok(PreparedEnvironment { + runtime: Self::activate(runtime, prepared.installed), + changed, + }) + } +} diff --git a/crates/rb-cli/tests/fixtures/package-backend/Gemfile b/crates/rb-cli/tests/fixtures/package-backend/Gemfile new file mode 100644 index 0000000..fa28e76 --- /dev/null +++ b/crates/rb-cli/tests/fixtures/package-backend/Gemfile @@ -0,0 +1,2 @@ +source 'https://rubygems.org' +gem 'backend_fixture', path: './local' diff --git a/crates/rb-cli/tests/fixtures/package-backend/local/backend_fixture.gemspec b/crates/rb-cli/tests/fixtures/package-backend/local/backend_fixture.gemspec new file mode 100644 index 0000000..95a2d0d --- /dev/null +++ b/crates/rb-cli/tests/fixtures/package-backend/local/backend_fixture.gemspec @@ -0,0 +1,7 @@ +Gem::Specification.new do |s| + s.name = 'backend_fixture' + s.version = '1.0.0' + s.summary = 'Local package backend fixture' + s.authors = ['Ruby Butler'] + s.files = ['lib/backend_fixture.rb'] +end diff --git a/crates/rb-cli/tests/fixtures/package-backend/local/lib/backend_fixture.rb b/crates/rb-cli/tests/fixtures/package-backend/local/lib/backend_fixture.rb new file mode 100644 index 0000000..db38bb0 --- /dev/null +++ b/crates/rb-cli/tests/fixtures/package-backend/local/lib/backend_fixture.rb @@ -0,0 +1,3 @@ +module BackendFixture + VALUE = 'native-installed, Bundler-activated' +end diff --git a/crates/rb-cli/tests/fixtures/package-backend/project.toml b/crates/rb-cli/tests/fixtures/package-backend/project.toml new file mode 100644 index 0000000..8555873 --- /dev/null +++ b/crates/rb-cli/tests/fixtures/package-backend/project.toml @@ -0,0 +1,4 @@ +[project] +name = 'backend-fixture' +[scripts] +probe = 'ruby -rbackend_fixture -e "puts BackendFixture::VALUE"' diff --git a/crates/rb-cli/tests/package_backend_config.rs b/crates/rb-cli/tests/package_backend_config.rs new file mode 100644 index 0000000..7366ba6 --- /dev/null +++ b/crates/rb-cli/tests/package_backend_config.rs @@ -0,0 +1,49 @@ +use clap::Parser; +use rb_cli::{ + Cli, + config::{PackageBackendKind, RbConfig, TrackedConfig}, +}; +use std::process::Command; + +#[test] +fn defaults_to_bundler_and_cli_overrides_file() { + let file: RbConfig = toml::from_str("package-backend = 'native'").unwrap(); + let defaults = TrackedConfig::from_merged(&RbConfig::default(), &RbConfig::default()); + assert_eq!(*defaults.package_backend.get(), PackageBackendKind::Bundler); + let cli = Cli::try_parse_from(["rb", "sync", "--package-backend", "bundler"]).unwrap(); + let config = TrackedConfig::from_merged(&cli.config, &file); + assert_eq!(*config.package_backend.get(), PackageBackendKind::Bundler); + assert_eq!( + *TrackedConfig::from_merged(&RbConfig::default(), &file) + .package_backend + .get(), + PackageBackendKind::Native + ); + assert!(Cli::try_parse_from(["rb", "--package-backend", "unknown", "sync"]).is_err()); + assert!(toml::from_str::("package-backend='unknown'").is_err()); +} + +#[test] +fn environment_overrides_file_and_cli_overrides_environment() { + let root = tempfile::tempdir().unwrap(); + let config = root.path().join("config.toml"); + std::fs::write(&config, "package-backend='bundler'\n").unwrap(); + let run = |extra: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_rb")) + .args(["--config", config.to_str().unwrap()]) + .args(extra) + .args(["info", "config"]) + .env("RB_PACKAGE_BACKEND", "native") + .output() + .unwrap() + }; + let output = run(&[]); + assert!(output.status.success(), "{:?}", output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("Package Backend: native"), "{text}"); + assert!(text.contains("Source: environment"), "{text}"); + let output = run(&["--package-backend", "bundler"]); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("Package Backend: bundler"), "{text}"); + assert!(text.contains("Source: CLI argument"), "{text}"); +} diff --git a/crates/rb-cli/tests/package_backend_native.rs b/crates/rb-cli/tests/package_backend_native.rs new file mode 100644 index 0000000..1f7dfaf --- /dev/null +++ b/crates/rb-cli/tests/package_backend_native.rs @@ -0,0 +1,403 @@ +#![cfg(unix)] + +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +struct Fixture { + root: tempfile::TempDir, + rubies: tempfile::TempDir, + version: String, +} + +impl Fixture { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let ruby = std::env::var_os("RB_GEM_BUILD_RUBY") + .map(PathBuf::from) + .unwrap_or_else(|| { + which::which("ruby").expect("Ruby is required for native package integration tests") + }); + let output = Command::new(&ruby) + .args([ + "-rrbconfig", + "-rbundler", + "-e", + "puts RbConfig::CONFIG['prefix']; puts RUBY_VERSION", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "A Ruby with Bundler is required: {}", + String::from_utf8_lossy(&output.stderr) + ); + let text = String::from_utf8(output.stdout).unwrap(); + let mut lines = text.lines(); + let prefix = lines.next().unwrap(); + let version = lines.next().unwrap().to_owned(); + let rubies = tempfile::tempdir().unwrap(); + let installation = rubies.path().join(format!("ruby-{version}")); + fs::create_dir(&installation).unwrap(); + std::os::unix::fs::symlink(Path::new(prefix).join("bin"), installation.join("bin")) + .unwrap(); + for file in [ + "Gemfile", + "rbproject.toml", + "local/backend_fixture.gemspec", + "local/lib/backend_fixture.rb", + ] { + let source_file = if file == "rbproject.toml" { + "project.toml" + } else { + file + }; + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/package-backend") + .join(source_file); + let destination = root.path().join(file); + fs::create_dir_all(destination.parent().unwrap()).unwrap(); + fs::copy(source, destination).unwrap(); + } + Self { + root, + rubies, + version, + } + } + + fn instrument_ruby(&self) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = self + .rubies + .path() + .join(format!("ruby-{}", self.version)) + .join("bin"); + let original = fs::read_link(&bin).unwrap(); + fs::remove_file(&bin).unwrap(); + fs::create_dir(&bin).unwrap(); + let calls = self.rubies.path().join("ruby-calls"); + let quote = |path: &Path| format!("'{}'", path.to_str().unwrap().replace('\'', "'\\''")); + for name in ["ruby", "bundle"] { + let log = if name == "ruby" { + format!("printf '%s\\n' \"$*\" >> {}\n", quote(&calls)) + } else { + String::new() + }; + let path = bin.join(name); + fs::write( + &path, + format!( + "#!/bin/sh\n{log}exec {} \"$@\"\n", + quote(&original.join(name)) + ), + ) + .unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + calls + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_rb")); + command.args([ + "-R", + self.rubies.path().to_str().unwrap(), + "-r", + &self.version, + "-C", + self.root.path().to_str().unwrap(), + "--package-backend", + "native", + ]); + for key in [ + "RB_CONFIG", + "RB_PACKAGE_BACKEND", + "RB_NO_BUNDLER", + "BUNDLE_GEMFILE", + "RUBYOPT", + "RUBYLIB", + "BUNDLE_FROZEN", + "BUNDLE_DEPLOYMENT", + "BUNDLE_WITH", + "BUNDLE_WITHOUT", + ] { + command.env_remove(key); + } + command + } +} + +fn success(output: Output) -> Output { + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output +} + +#[test] +fn native_sync_exec_and_run_use_the_same_bundler_compatible_installation() { + let fixture = Fixture::new(); + let output = success(fixture.command().arg("sync").output().unwrap()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Your bundle is ready")); + assert!(fixture.root.path().join("Gemfile.lock").is_file()); + assert!(!fixture.root.path().join(".rb/vendor/bundler").exists()); + let lock = fs::read(fixture.root.path().join("Gemfile.lock")).unwrap(); + let probe = "require 'backend_fixture'; puts BackendFixture::VALUE; puts Gem.loaded_specs.fetch('backend_fixture').full_gem_path; puts Bundler::VERSION"; + let output = success( + fixture + .command() + .args(["x", "ruby", "-e", probe]) + .output() + .unwrap(), + ); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let text = String::from_utf8(output.stdout).unwrap(); + assert!( + text.contains("native-installed, Bundler-activated"), + "{text}" + ); + assert!(text.contains(".rb/bundle"), "{text}"); + assert_eq!( + fs::read(fixture.root.path().join("Gemfile.lock")).unwrap(), + lock + ); + let output = success(fixture.command().args(["run", "probe"]).output().unwrap()); + assert!( + String::from_utf8_lossy(&output.stdout).contains("native-installed, Bundler-activated") + ); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn explicit_project_file_and_no_bundler_are_respected() { + let fixture = Fixture::new(); + fs::rename( + fixture.root.path().join("rbproject.toml"), + fixture.root.path().join("custom.toml"), + ) + .unwrap(); + let output = success( + fixture + .command() + .args(["-P", "custom.toml", "run", "probe"]) + .output() + .unwrap(), + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("native-installed, Bundler-activated") + ); + let output = success( + fixture + .command() + .args(["-B", "x", "ruby", "-e", "puts defined?(Bundler).inspect"]) + .output() + .unwrap(), + ); + assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), "nil"); + assert!(output.stderr.is_empty()); +} + +#[test] +fn preparation_failure_prevents_execution_without_fallback() { + let fixture = Fixture::new(); + fs::write( + fixture.root.path().join("Gemfile"), + "raise 'fixture evaluation failed'\n", + ) + .unwrap(); + let output = fixture + .command() + .args(["x", "ruby", "-e", "puts 'must not execute'"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("fixture evaluation failed")); + assert!(!fixture.root.path().join(".rb/vendor/bundler").exists()); +} + +#[test] +fn native_project_without_gemfile_activates_bundler_for_execution() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.path().join("Gemfile")).unwrap(); + fs::write( + fixture.root.path().join("rbproject.toml"), + "[project]\nname='native'\n[dependencies]\nbackend_fixture={path='./local'}\n", + ) + .unwrap(); + let output = success( + fixture + .command() + .args([ + "x", + "ruby", + "-rbackend_fixture", + "-e", + "puts BackendFixture::VALUE; puts Bundler::VERSION", + ]) + .output() + .unwrap(), + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("native-installed, Bundler-activated") + ); + assert!(fixture.root.path().join(".rb/Gemfile").is_file()); + assert!(fixture.root.path().join(".rb/Gemfile.lock").is_file()); +} + +#[test] +fn relative_bundle_gemfile_and_commands_outside_projects_work() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.path().join("rbproject.toml")).unwrap(); + fs::create_dir(fixture.root.path().join("config")).unwrap(); + fs::remove_file(fixture.root.path().join("Gemfile")).unwrap(); + fs::write( + fixture.root.path().join("config/Dependencies"), + "gem 'backend_fixture', path: '../local'\n", + ) + .unwrap(); + let output = success( + fixture + .command() + .env("BUNDLE_GEMFILE", "config/Dependencies") + .args([ + "x", + "ruby", + "-rbackend_fixture", + "-e", + "puts BackendFixture::VALUE", + ]) + .output() + .unwrap(), + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("native-installed, Bundler-activated") + ); + let output = success( + fixture + .command() + .args(["x", "ruby", "-e", "puts defined?(Bundler).inspect"]) + .output() + .unwrap(), + ); + assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), "nil"); + assert!(output.stderr.is_empty()); +} + +#[test] +fn ready_commands_skip_ruby_helpers_and_edits_or_missing_files_trigger_preparation() { + let fixture = Fixture::new(); + let calls = fixture.instrument_ruby(); + success(fixture.command().arg("sync").output().unwrap()); + assert!(fixture.root.path().join(".rb/prepared.json").is_file()); + fs::write(&calls, "").unwrap(); + let probe = [ + "x", + "ruby", + "-rbackend_fixture", + "-e", + "puts Gem.loaded_specs.fetch('backend_fixture').full_gem_path", + ]; + let output = success(fixture.command().args(probe).output().unwrap()); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let started = fs::read_to_string(&calls).unwrap(); + assert!(!started.contains("-rjson"), "{started}"); + assert!(!started.contains("rb-evaluate-gemfile"), "{started}"); + for directory in ["log", "tmp"] { + fs::create_dir(fixture.root.path().join(directory)).unwrap(); + fs::write( + fixture.root.path().join(directory).join("generated"), + "application output", + ) + .unwrap(); + } + let quiet = success(fixture.command().args(probe).output().unwrap()); + assert!( + quiet.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&quiet.stderr) + ); + let snapshot = PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()); + assert!(snapshot.starts_with(fixture.root.path().canonicalize().unwrap())); + let library = snapshot.join("lib/backend_fixture.rb"); + fs::remove_file(&library).unwrap(); + let output = success(fixture.command().args(probe).output().unwrap()); + assert!(library.is_file()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Preparing your bundle")); + fs::write( + fixture.root.path().join("local/lib/backend_fixture.rb"), + "module BackendFixture; VALUE='updated source'; end\n", + ) + .unwrap(); + let output = success(fixture.command().args(["run", "probe"]).output().unwrap()); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + "updated source" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("Preparing your bundle")); + fs::write(fixture.root.path().join(".rb/prepared.json"), "corrupt").unwrap(); + let output = success(fixture.command().args(probe).output().unwrap()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Preparing your bundle")); + let output = success( + fixture + .command() + .env("BUNDLE_WITHOUT", "test") + .args(probe) + .output() + .unwrap(), + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("Preparing your bundle")); +} + +#[test] +fn external_path_source_edits_invalidate_the_ready_environment() { + let fixture = Fixture::new(); + let external = tempfile::tempdir().unwrap(); + let source = external.path().join("local"); + fs::rename(fixture.root.path().join("local"), &source).unwrap(); + fs::write( + fixture.root.path().join("Gemfile"), + format!( + "gem 'backend_fixture', path: {:?}\n", + source.to_str().unwrap() + ), + ) + .unwrap(); + success(fixture.command().arg("sync").output().unwrap()); + let output = success(fixture.command().args(["run", "probe"]).output().unwrap()); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + fs::write( + source.join("lib/backend_fixture.rb"), + "module BackendFixture; VALUE='external source changed'; end\n", + ) + .unwrap(); + let output = success(fixture.command().args(["run", "probe"]).output().unwrap()); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + "external source changed" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("Preparing your bundle")); +} diff --git a/crates/rb-core/src/butler/command.rs b/crates/rb-core/src/butler/command.rs index e109e5b..923a9cb 100644 --- a/crates/rb-core/src/butler/command.rs +++ b/crates/rb-core/src/butler/command.rs @@ -234,7 +234,7 @@ impl Command { // Only use bundle exec if: // 1. Bundler runtime is configured // 2. The command is not a bundle command itself (bundle install, bundle check, etc.) - if let Some(_bundler_runtime) = butler_runtime.bundler_runtime() { + if butler_runtime.uses_bundler() { !self.is_bundle_command() } else { false diff --git a/crates/rb-core/src/butler/mod.rs b/crates/rb-core/src/butler/mod.rs index f9914a8..db7e1c2 100644 --- a/crates/rb-core/src/butler/mod.rs +++ b/crates/rb-core/src/butler/mod.rs @@ -120,9 +120,25 @@ pub struct ButlerRuntime { ruby_installations: Vec, requested_ruby_version: Option, gem_base_dir: Option, + package_environment: HashMap, + package_bin: Option, } impl ButlerRuntime { + pub fn with_package_environment( + mut self, + environment: HashMap, + bin: PathBuf, + ) -> Self { + self.package_environment = environment; + self.package_bin = Some(bin); + self + } + + pub fn uses_bundler(&self) -> bool { + self.bundler_runtime.is_some() || self.package_bin.is_some() + } + /// Create a simple ButlerRuntime with just Ruby and Gem runtimes (for backward compatibility) pub fn new(ruby_runtime: RubyRuntime, gem_runtime: Option) -> Self { debug!( @@ -152,6 +168,8 @@ impl ButlerRuntime { ruby_installations: vec![], requested_ruby_version: None, gem_base_dir: None, + package_environment: HashMap::new(), + package_bin: None, } } @@ -169,6 +187,8 @@ impl ButlerRuntime { ruby_installations: vec![], requested_ruby_version: None, gem_base_dir: None, + package_environment: HashMap::new(), + package_bin: None, } } @@ -339,6 +359,8 @@ impl ButlerRuntime { ruby_installations, requested_ruby_version, gem_base_dir, + package_environment: HashMap::new(), + package_bin: None, }) } @@ -445,10 +467,11 @@ impl ButlerRuntime { /// NOTE: User gems are NOT available in bundler context for proper isolation. /// Use --no-bundler to opt out of bundler context and access user gems. pub fn bin_dirs(&self) -> Vec { - let mut dirs = Vec::new(); + let mut dirs = self.package_bin.iter().cloned().collect::>(); // Bundler runtime bin dir first (if in bundler context) - if let Some(ref bundler_runtime) = self.bundler_runtime + if self.package_bin.is_none() + && let Some(ref bundler_runtime) = self.bundler_runtime && let Some(bundler_bin) = RuntimeProvider::bin_dir(bundler_runtime) { debug!( @@ -459,7 +482,7 @@ impl ButlerRuntime { } // Gem runtime bin dir (only if NOT in bundler context for isolation) - if self.bundler_runtime.is_none() { + if !self.uses_bundler() { if let Some(ref gem_runtime) = self.gem_runtime { debug!( "Adding gem bin directory to PATH: {}", @@ -638,6 +661,7 @@ impl ButlerRuntime { debug!("No bundler runtime detected - skipping bundler environment variables"); } + env.extend(self.package_environment.clone()); debug!("Environment composition complete: {} variables", env.len()); env } @@ -763,4 +787,28 @@ mod tests { ); assert_eq!(path, expected); } + + #[test] + fn prepared_package_environment_overrides_runtime_paths_without_global_mutation() { + let ruby = create_ruby_runtime("3.4.10", "/opt/ruby"); + let original = ButlerRuntime::new(ruby, None); + let bin = PathBuf::from("/prepared/bin"); + let prepared = original.clone().with_package_environment( + HashMap::from([ + ("GEM_HOME".into(), "/prepared".into()), + ("GEM_PATH".into(), "/prepared".into()), + ("BUNDLE_GEMFILE".into(), "/project/.rb/Gemfile".into()), + ]), + bin.clone(), + ); + let environment = prepared.env_vars(Some("/existing/bin".into())); + assert_eq!(environment["GEM_HOME"], "/prepared"); + assert_eq!(environment["GEM_PATH"], "/prepared"); + assert_eq!(environment["BUNDLE_GEMFILE"], "/project/.rb/Gemfile"); + assert_eq!(prepared.bin_dirs()[0], bin); + assert!(environment["PATH"].starts_with("/prepared/bin")); + assert!(prepared.uses_bundler()); + assert!(!original.uses_bundler()); + assert!(!original.env_vars(None).contains_key("BUNDLE_GEMFILE")); + } } diff --git a/crates/rb-gemfile/src/snapshot.rs b/crates/rb-gemfile/src/snapshot.rs index e9654a9..486dab2 100644 --- a/crates/rb-gemfile/src/snapshot.rs +++ b/crates/rb-gemfile/src/snapshot.rs @@ -62,6 +62,52 @@ impl SnapshotCache { self } + /// Hashes the declared filesystem and environment inputs without running Ruby. + pub fn fingerprint( + &self, + evaluator: &Evaluator, + input: impl AsRef, + ) -> io::Result { + let input = gemfile_path(input.as_ref())?; + let ruby = evaluator.ruby_executable()?; + let inputs = self + .inputs + .iter() + .map(|path| absolute(path)) + .collect::>>()?; + let mut excluded = self + .outputs + .iter() + .chain(&self.output_directories) + .map(|path| absolute(path)) + .collect::>>()?; + if excluded.iter().any(|output| { + input.starts_with(output) + || ruby.starts_with(output) + || inputs.iter().any(|path| path.starts_with(output)) + }) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Generated outputs must not contain inputs", + )); + } + let path = absolute(&self.path)?; + let mut lock = path.as_os_str().to_owned(); + lock.push(".lock"); + let lock = PathBuf::from(lock); + if [&path, &lock] + .iter() + .any(|path| **path == input || **path == ruby || inputs.contains(path)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Snapshot files must not be inputs", + )); + } + excluded.extend([path, lock]); + fingerprint(evaluator, &input, &input, &inputs, &ruby, &excluded) + } + pub fn evaluate( &self, evaluator: &Evaluator, @@ -260,7 +306,14 @@ fn hash_path( if metadata.is_symlink() { field(hash, b"symlink"); field(hash, format!("{:?}", fs::read_link(path)?).as_bytes()); - let target = dunce::canonicalize(path)?; + let target = match dunce::canonicalize(path) { + Ok(target) => target, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + field(hash, b"missing target"); + return Ok(()); + } + Err(error) => return Err(error), + }; if !visited.contains(&target) { hash_path(hash, &target, excluded, visited)?; } @@ -278,6 +331,11 @@ fn hash_path( } } else if metadata.is_file() { field(hash, b"file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + field(hash, &metadata.permissions().mode().to_le_bytes()); + } field(hash, &fs::read(path)?); } else { return Err(io::Error::other(format!( diff --git a/crates/rb-gemfile/tests/snapshot.rs b/crates/rb-gemfile/tests/snapshot.rs index efaece8..ee70d8b 100644 --- a/crates/rb-gemfile/tests/snapshot.rs +++ b/crates/rb-gemfile/tests/snapshot.rs @@ -466,3 +466,64 @@ fn generated_directory_is_excluded_but_cannot_hide_gemfile_inputs() { ); } } + +#[test] +fn fingerprints_track_declared_inputs_without_evaluating_them() { + let root = tempfile::tempdir().unwrap(); + write(root.path(), "Gemfile", "raise 'must not execute'\n"); + write(root.path(), "version", "1"); + let cache = + SnapshotCache::new(root.path().join("state.json")).input(root.path().join("version")); + let first = cache + .fingerprint(&evaluator(), root.path().join("Gemfile")) + .unwrap(); + write( + root.path(), + "unrelated", + "does not affect explicitly declared inputs", + ); + assert_eq!( + first, + cache + .fingerprint(&evaluator(), root.path().join("Gemfile")) + .unwrap() + ); + write(root.path(), "version", "2"); + assert_ne!( + first, + cache + .fingerprint(&evaluator(), root.path().join("Gemfile")) + .unwrap() + ); + assert!(!root.path().join("state.json").exists()); +} + +#[cfg(unix)] +#[test] +fn dangling_symlinks_track_target_creation_changes_and_removal() { + use std::os::unix::fs::symlink; + let root = tempfile::tempdir().unwrap(); + let external = tempfile::tempdir().unwrap(); + write(root.path(), "Gemfile", "gem 'rake'"); + let target = external.path().join("assets"); + symlink(&target, root.path().join("assets")).unwrap(); + let cache = SnapshotCache::new(root.path().join(".rb/snapshot.json")); + let evaluator = evaluator(); + let gemfile = root.path().join("Gemfile"); + assert!(!cache.evaluate(&evaluator, &gemfile).unwrap().reused); + assert!(cache.evaluate(&evaluator, &gemfile).unwrap().reused); + write(external.path(), "assets/page", "first"); + assert!(!cache.evaluate(&evaluator, &gemfile).unwrap().reused); + assert!(cache.evaluate(&evaluator, &gemfile).unwrap().reused); + write(external.path(), "assets/page", "second"); + assert!(!cache.evaluate(&evaluator, &gemfile).unwrap().reused); + fs::remove_dir_all(&target).unwrap(); + assert!(!cache.evaluate(&evaluator, &gemfile).unwrap().reused); + assert!(cache.evaluate(&evaluator, &gemfile).unwrap().reused); + fs::write(&target, "file target").unwrap(); + assert!(!cache.evaluate(&evaluator, &gemfile).unwrap().reused); + fs::remove_file(&target).unwrap(); + fs::remove_file(root.path().join("assets")).unwrap(); + symlink(external.path().join("other"), root.path().join("assets")).unwrap(); + assert!(!cache.evaluate(&evaluator, &gemfile).unwrap().reused); +} diff --git a/crates/rb-packages/Cargo.toml b/crates/rb-packages/Cargo.toml index c8e012e..300886a 100644 --- a/crates/rb-packages/Cargo.toml +++ b/crates/rb-packages/Cargo.toml @@ -7,6 +7,7 @@ description = "Standalone Ruby bundle planning and conventional installation" [dependencies] anyhow = "1" +log = "0.4" dunce = "1" rb-compact-index = { path = "../rb-compact-index" } rb-gem-archive = { path = "../rb-gem-archive" } @@ -23,6 +24,7 @@ serde_yaml_ng = "0.10" sha2 = "0.10" tempfile = "3" toml = "0.8" +kdl = "6" url = "2" flate2 = "1" diff --git a/crates/rb-packages/README.md b/crates/rb-packages/README.md index 073301e..1e8006d 100644 --- a/crates/rb-packages/README.md +++ b/crates/rb-packages/README.md @@ -1,6 +1,6 @@ # rb-packages -Standalone Ruby bundle preparation: read `rbproject.toml`/KDL or a trusted Gemfile, resolve dependencies, and install registry, Git and path gems into `.rb`, including native extensions. Work reports through `rb-task` events. The CLI is not connected yet. +Standalone Ruby bundle preparation: read `rbproject.toml`/KDL or a trusted Gemfile, resolve dependencies, and install registry, Git and path gems into `.rb`, including native extensions. Work reports through `rb-task` events. ```rust,no_run use rb_packages::{PrepareOptions, prepare}; @@ -14,9 +14,11 @@ let environment = bundle.installed.environment(); # Ok::<(), anyhow::Error>(()) ``` -The returned environment supports real RubyGems and Bundler. Bundler does not resolve or install; RubyGems still handles runtime inspection and specification serialization. Gemfile conversion is cached under `.rb`; Gemfiles must not read generated bundle files. Path gems use snapshots - prepare again after edits. Git/path sources require gemspecs. +`rb --package-backend native sync` opts into this pipeline; `RB_PACKAGE_BACKEND=native` also applies to `rb x` and `rb run`. Bundler remains the default backend. Native `x` and `run` reuse a validated environment from `.rb/prepared.json` without preparing again; explicit `sync` always prepares. Library callers enable `PrepareOptions.record_state` to use `prepared`. -Existing lockfile platforms are resolved together when the lock needs refreshing; only the local platform is installed. +The returned environment supports real RubyGems and Bundler. Bundler does not resolve or install; RubyGems still handles runtime inspection and specification serialization. Gemfile conversion is cached under `.rb`. Cached Gemfiles must depend on tracked files and environment; generated `.rb`, `log`, `tmp` directories and lockfiles are excluded. Path gems use snapshots - prepare again after edits. Git/path sources require gemspecs. + +Existing lockfile platforms are resolved together when the lock needs refreshing; only the local platform is installed. Git/path declarations may be platform-scoped when they apply to a resolution target and their sources agree across targets. Rakefile builds use the resolved Rake or a bundled Rake from the selected Ruby, without adding a runtime dependency. Transitive registry dependencies prefer the parent source, then fall back to the global source when absent. Competing scoped sources require an explicit declaration. @@ -30,6 +32,6 @@ username = "USERNAME" Commands run directly, without a shell, from the config directory. Stdout supplies the token for HTTP Basic authentication; credentials stay in memory for the invocation and out of source URLs and lockfiles. Offline preparation does not run credential commands. -Credential stores, platform-specific Git/path sources, custom lock paths, plugins and MicroGems application runtime are not supported yet. +Credential stores, different sources for the same gem across targets, custom lock paths, plugins and MicroGems application runtime are not supported yet. Run `cargo test -p rb-packages`, or try `cargo run -p rb-packages --example prepare -- /path/to/app /path/to/ruby /path/to/cache`. diff --git a/crates/rb-packages/src/build_tools.rs b/crates/rb-packages/src/build_tools.rs new file mode 100644 index 0000000..27e68db --- /dev/null +++ b/crates/rb-packages/src/build_tools.rs @@ -0,0 +1,13 @@ +use rb_gem_archive::GemMetadata; +use std::path::Path; + +pub(crate) fn requires_rake(metadata: &GemMetadata) -> bool { + metadata.extensions.iter().any(|extension| { + matches!( + Path::new(extension) + .file_name() + .and_then(|name| name.to_str()), + Some("Rakefile" | "rakefile" | "mkrf_conf.rb") + ) + }) +} diff --git a/crates/rb-packages/src/constants.rs b/crates/rb-packages/src/constants.rs index 0992510..dd6c973 100644 --- a/crates/rb-packages/src/constants.rs +++ b/crates/rb-packages/src/constants.rs @@ -5,6 +5,7 @@ pub(crate) const AUTH_CONFIG_FILE: &str = "auth.toml"; pub(crate) const GEM_HOME_DIRECTORY: &str = "bundle"; pub(crate) const SNAPSHOT_FILE: &str = "gemfile.snapshot.json"; pub(crate) const MANIFEST_FILE: &str = "rbproject.toml"; +pub(crate) const PREPARED_STATE: &str = "prepared.json"; pub(crate) const INSTALL_RECEIPT: &str = "installation.json"; pub(crate) const DEFAULT_WORKERS: usize = 4; pub(crate) const SPECIFICATIONS_DIRECTORY: &str = "specifications"; diff --git a/crates/rb-packages/src/gemfile.rs b/crates/rb-packages/src/gemfile.rs index 2aafbcc..d04a262 100644 --- a/crates/rb-packages/src/gemfile.rs +++ b/crates/rb-packages/src/gemfile.rs @@ -36,7 +36,7 @@ pub(crate) fn render(manifest: &Manifest) -> Result { } for group in &manifest.bundle.optional_groups { output.push_str(&format!( - "group {}, optional: true do\nend\n", + "group :{}, optional: true do\nend\n", literal(group) )); } diff --git a/crates/rb-packages/src/input.rs b/crates/rb-packages/src/input.rs index 4bd5700..b4a71c1 100644 --- a/crates/rb-packages/src/input.rs +++ b/crates/rb-packages/src/input.rs @@ -14,24 +14,46 @@ pub struct ProjectInput { pub lockfile: PathBuf, pub bundler_gemfile: PathBuf, pub reused: bool, + pub(crate) evaluation_inputs: Vec, } impl ProjectInput { pub fn load(root: impl AsRef, ruby: impl AsRef) -> Result { + Self::load_with_project_file(root, ruby, None) + } + + pub fn load_with_project_file( + root: impl AsRef, + ruby: impl AsRef, + project_file: Option<&Path>, + ) -> Result { let root = dunce::canonicalize(root)?; - let toml = root.join("rbproject.toml"); - let kdl = root.join("rbproject.kdl"); + let selected = project_file + .map(|file| dunce::canonicalize(root.join(file))) + .transpose()?; + let is_kdl = selected + .as_ref() + .is_some_and(|path| path.extension().is_some_and(|ext| ext == "kdl")); + let toml = selected + .clone() + .filter(|_| !is_kdl) + .unwrap_or_else(|| root.join("rbproject.toml")); + let kdl = selected + .filter(|_| is_kdl) + .unwrap_or_else(|| root.join("rbproject.kdl")); ensure!( - !(toml.exists() && kdl.exists()), + project_file.is_some() || !(toml.exists() && kdl.exists()), "Choose one rbproject format" ); - let project = if toml.exists() { + let project = if toml.exists() && !is_kdl { Some(fs::read_to_string(toml)?) } else { None }; let gemfile = std::env::var_os("BUNDLE_GEMFILE") - .map(|name| root.join(name)) + .filter(|name| !name.is_empty()) + .map(|name| std::env::current_dir().map(|cwd| cwd.join(name))) + .transpose()? .or_else(|| { ["gems.rb", "Gemfile"] .into_iter() @@ -44,16 +66,23 @@ impl ProjectInput { .transpose()? .as_ref() .is_some_and(|v| v.get("dependencies").is_some() || v.get("sources").is_some()); - let (mut manifest, reused, gemfile) = if kdl.exists() { - (Manifest::from_kdl(&fs::read_to_string(kdl)?)?, false, None) + let kdl_manifest = if is_kdl || (project_file.is_none() && kdl.exists()) { + parse_kdl_project(&fs::read_to_string(kdl)?)? + } else { + None + }; + let mut evaluation_inputs = Vec::new(); + let (mut manifest, reused, gemfile) = if let Some(manifest) = kdl_manifest { + (manifest, false, None) } else if has_dependencies { (parse_project(project.as_ref().unwrap())?, false, None) } else { let gemfile = gemfile.context("No rbproject dependencies, gems.rb or Gemfile found")?; let state = root.join(STATE_DIRECTORY); fs::create_dir_all(&state)?; - let snapshot = SnapshotCache::new(state.join(SNAPSHOT_FILE)).output_directory(&state); + let snapshot = snapshot_cache(&root, SNAPSHOT_FILE); let cached = snapshot.evaluate(&Evaluator::new(ruby.as_ref()), &gemfile)?; + evaluation_inputs = cached.evaluation.inputs.clone(); let document = if let Some(settings) = project { cached.evaluation.manifest.merge_project(&settings)? } else { @@ -121,17 +150,53 @@ impl ProjectInput { lockfile, bundler_gemfile, reused, + evaluation_inputs, }) } } fn parse_project(input: &str) -> Result { let mut value: toml::Value = input.parse()?; - value - .as_table_mut() - .context("Project must be a table")? - .remove("project"); + let table = value.as_table_mut().context("Project must be a table")?; + table.remove("project"); + table.remove("scripts"); value .try_into() .context("Reading project dependency declarations") } + +fn parse_kdl_project(input: &str) -> Result> { + let mut document: kdl::KdlDocument = input.parse()?; + document + .nodes_mut() + .retain(|node| !matches!(node.name().value(), "project" | "scripts")); + if document.nodes().is_empty() { + return Ok(None); + } + Ok(Some(Manifest::from_kdl(&document.to_string())?)) +} + +pub(crate) fn snapshot_cache(root: &Path, filename: &str) -> SnapshotCache { + let cache = SnapshotCache::new(root.join(STATE_DIRECTORY).join(filename)) + .output_directory(root.join(STATE_DIRECTORY)) + .output(root.join("Gemfile.lock")) + .output(root.join("gems.locked")); + let cache = ["log", "tmp"].into_iter().fold(cache, |cache, directory| { + let path = root.join(directory); + if path.is_file() { + cache + } else { + cache.output_directory(path) + } + }); + if let Some(file) = std::env::var_os("BUNDLE_GEMFILE").filter(|file| !file.is_empty()) { + let mut filename = std::env::current_dir() + .unwrap_or_else(|_| root.to_path_buf()) + .join(file) + .into_os_string(); + filename.push(".lock"); + cache.output(PathBuf::from(filename)) + } else { + cache + } +} diff --git a/crates/rb-packages/src/install.rs b/crates/rb-packages/src/install.rs index 4da72bd..9062595 100644 --- a/crates/rb-packages/src/install.rs +++ b/crates/rb-packages/src/install.rs @@ -71,11 +71,23 @@ pub fn install( .filter(|p| p.install) .map(|p| (p.record.package.id.name.clone(), p.record.clone())) .collect(); + let tools: Vec<_> = records + .get("rake") + .map(|rake| { + std::iter::once(rake) + .chain( + dependencies(&records, "rake") + .into_iter() + .map(|name| &records[&name]), + ) + .collect() + }) + .unwrap_or_default(); let mut slots = BTreeMap::new(); for planned in plan.packages.iter().filter(|p| p.install) { let record = &planned.record; record.validate()?; - let mut receipt = Receipt::new(record, &runtime, &options.build)?; + let mut receipt = Receipt::new(record, &runtime, &options.build, &tools)?; receipt.dependencies = dependencies(&records, &record.package.id.name) .into_iter() .map(|name| { @@ -220,6 +232,21 @@ pub fn install( Executor::new(options.workers, events.clone()).run(graph, actions)?; let mut graph = TaskGraph::new(); let mut actions: BTreeMap<_, TaskAction> = BTreeMap::new(); + if let Some(rake) = slots.get("rake") { + let dependencies = rake.lock().unwrap().dependencies.clone(); + for (name, slot) in slots.iter() { + let mut slot = slot.lock().unwrap(); + if name != "rake" + && slot + .metadata + .as_ref() + .is_some_and(crate::build_tools::requires_rake) + { + slot.dependencies.insert("rake".into()); + slot.dependencies.extend(dependencies.iter().cloned()); + } + } + } let mut pending: BTreeMap<_, _> = plan .packages .iter() diff --git a/crates/rb-packages/src/installed_bundle.rs b/crates/rb-packages/src/installed_bundle.rs index c6178ed..bccf597 100644 --- a/crates/rb-packages/src/installed_bundle.rs +++ b/crates/rb-packages/src/installed_bundle.rs @@ -1,6 +1,7 @@ use std::{collections::BTreeMap, path::PathBuf}; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct InstalledBundle { pub gem_home: PathBuf, pub bundle_path: PathBuf, diff --git a/crates/rb-packages/src/lib.rs b/crates/rb-packages/src/lib.rs index c5f44a6..fa3b492 100644 --- a/crates/rb-packages/src/lib.rs +++ b/crates/rb-packages/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +mod build_tools; mod bundler_credentials; mod constants; mod copy_tree; @@ -15,6 +16,7 @@ mod package_install; mod package_source; mod plan; mod prepare; +mod prepared_state; mod provider; mod receipt; mod registry_index; @@ -40,6 +42,7 @@ pub use install_options::InstallOptions; pub use installed_bundle::InstalledBundle; pub use plan::{LockMode, PackagePlan, PlanOptions, PlannedPackage, plan}; pub use prepare::{PrepareOptions, PreparedBundle, prepare}; +pub use prepared_state::prepared; pub use provider::{MemoryIndex, PackageIndex, PackageRecord}; pub use registry_index::RegistryIndex; pub use runtime::RubyEnvironment; diff --git a/crates/rb-packages/src/package_install.rs b/crates/rb-packages/src/package_install.rs index 6ca20a6..358aec2 100644 --- a/crates/rb-packages/src/package_install.rs +++ b/crates/rb-packages/src/package_install.rs @@ -49,6 +49,13 @@ pub(crate) fn install_slot( ); } } + if crate::build_tools::requires_rake(metadata) + && !build.dependencies.iter().any(|gem| gem.name == "rake") + { + let rake = runtime.build_tools.iter().find(|gem| gem.name == "rake") + .context("Rakefile extension requires rake; provide a build dependency or install rake with the selected Ruby")?; + build.dependencies.push(rake.clone()); + } let report = if metadata.extensions.is_empty() { None } else { diff --git a/crates/rb-packages/src/plan.rs b/crates/rb-packages/src/plan.rs index 5949290..933fd92 100644 --- a/crates/rb-packages/src/plan.rs +++ b/crates/rb-packages/src/plan.rs @@ -171,47 +171,14 @@ pub fn plan( return Ok(plan); } } - let mut targets = vec![runtime.clone()]; - if options.mode != LockMode::Locked - && let Some(previous) = previous - { - for platform in &previous.platforms { - if targets.iter().any(|target| &target.platform == platform) { - continue; - } - let mut target = runtime.clone(); - target.platform = platform.clone(); - target.engine = if target.target().os == "java" { - "jruby".into() - } else if runtime.engine == "jruby" { - "ruby".into() - } else { - runtime.engine.clone() - }; - targets.push(target); - } - } + let targets = crate::target_plan::runtimes(runtime, previous, options.mode); let target_plans = targets .iter() .map(|target| crate::target_plan::TargetPlan::new(manifest, target, options)) .collect::>>()?; let declarations = target_plans[0].declarations.clone(); let install_roots = target_plans[0].install_roots.clone(); - let mut sources = BTreeMap::new(); - for target in &target_plans { - for (name, source) in &target.sources { - if let Some(previous) = sources.insert(name.clone(), source.clone()) { - ensure!( - previous.source == source.source - && previous.git == source.git - && previous.path == source.path - && crate::package_source::git_options(&previous) - == crate::package_source::git_options(source), - "Conflicting sources for {name}" - ); - } - } - } + let sources = crate::target_plan::sources(&target_plans)?; let mut locked = Vec::new(); let mut pins: BTreeMap> = BTreeMap::new(); if let Some(previous) = previous { diff --git a/crates/rb-packages/src/prepare.rs b/crates/rb-packages/src/prepare.rs index 5119ee6..ee06e64 100644 --- a/crates/rb-packages/src/prepare.rs +++ b/crates/rb-packages/src/prepare.rs @@ -9,6 +9,8 @@ use std::{path::PathBuf, sync::Arc}; #[derive(Clone, Debug)] pub struct PrepareOptions { pub project: PathBuf, + pub project_file: Option, + pub record_state: bool, pub ruby: PathBuf, pub cache: PathBuf, pub offline: bool, @@ -25,6 +27,8 @@ impl PrepareOptions { let ruby = ruby.into(); Self { project: project.into(), + project_file: None, + record_state: false, cache: cache.into(), offline: false, plan: PlanOptions::default(), @@ -43,26 +47,29 @@ pub struct PreparedBundle { pub fn prepare(options: PrepareOptions, events: TaskEvents) -> Result { let project = options.project.clone(); let _project_lock = crate::task::run("lock project", events.clone(), move |_| { - let state = project.join(crate::constants::STATE_DIRECTORY); - std::fs::create_dir_all(&state)?; - let lock = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(state.join(crate::constants::PROJECT_LOCK))?; - lock.lock()?; - Ok(lock) + crate::prepared_state::lock(&project) })?; + crate::prepared_state::invalidate(&options.project)?; + let candidate = options + .record_state + .then(|| { + crate::prepared_state::attempt(crate::prepared_state::Candidate::capture(&options)) + }) + .flatten(); + let cache_options = options.clone(); let ruby = options.ruby.clone(); let runtime = crate::task::run("inspect Ruby", events.clone(), move |_| { RubyEnvironment::inspect(&ruby) })?; let project = options.project.clone(); let ruby = options.ruby.clone(); + let project_file = options.project_file.clone(); let input = crate::task::run("load dependencies", events.clone(), move |_| { - ProjectInput::load(&project, &ruby) + ProjectInput::load_with_project_file(&project, &ruby, project_file.as_deref()) })?; + let observed = candidate.as_ref().and_then(|candidate| { + crate::prepared_state::attempt(candidate.observe(&options, &input, &runtime)) + }); let source_input = input.clone(); let source_runtime = runtime.clone(); let mut plan_options = options.plan.clone(); @@ -127,9 +134,13 @@ pub fn prepare(options: PrepareOptions, events: TaskEvents) -> Result, + input_hash: String, + output_hash: String, + installed: InstalledBundle, + lockfile: PathBuf, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Envelope { + state: State, + checksum: String, +} + +pub(crate) struct Candidate { + anchor: PathBuf, + before: String, +} + +pub(crate) fn lock(root: &Path) -> Result { + let state = root.join(STATE_DIRECTORY); + fs::create_dir_all(&state)?; + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(state.join(PROJECT_LOCK))?; + file.lock()?; + Ok(file) +} + +/// Returns a prepared installation when its tracked inputs and files still match. +pub fn prepared(options: &PrepareOptions) -> Result> { + if !options.record_state { + return Ok(None); + } + let filename = options.project.join(STATE_DIRECTORY).join(PREPARED_STATE); + if !filename.is_file() { + return Ok(None); + } + let lock_path = options.project.join(STATE_DIRECTORY).join(PROJECT_LOCK); + let _lock = match File::open(lock_path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + if _lock.try_lock_shared().is_err() { + return Ok(None); + } + Ok(attempt(read(options, &filename))) +} + +fn read(options: &PrepareOptions, filename: &Path) -> Result { + let envelope: Envelope = serde_json::from_slice(&fs::read(filename)?)?; + let state = envelope.state; + ensure!( + checksum(&state)? == envelope.checksum, + "Prepared state checksum changed" + ); + ensure!( + state.configuration == configuration(options)?, + "Preparation options changed" + ); + ensure!(state.anchor == anchor(options)?, "Project input changed"); + ensure!( + state.input_hash == inputs(options, &state.anchor, &state.inputs)?, + "Preparation inputs changed" + ); + ensure!( + state.output_hash == outputs(options, &state.installed, &state.lockfile)?, + "Installed bundle changed" + ); + let mut installed = state.installed; + installed.reused += installed.installed; + installed.installed = 0; + Ok(installed) +} + +impl Candidate { + pub(crate) fn capture(options: &PrepareOptions) -> Result { + let anchor = anchor(options)?; + let before = inputs(options, &anchor, &BTreeSet::new())?; + Ok(Self { anchor, before }) + } + + pub(crate) fn observe( + &self, + options: &PrepareOptions, + input: &ProjectInput, + runtime: &crate::RubyEnvironment, + ) -> Result<(BTreeSet, String)> { + ensure!( + self.before == inputs(options, &self.anchor, &BTreeSet::new())?, + "Project changed during evaluation" + ); + let mut paths: BTreeSet<_> = input + .evaluation_inputs + .iter() + .chain(&runtime.configuration_inputs) + .cloned() + .collect(); + for dependency in input + .manifest + .dependencies + .values() + .flat_map(|dependency| std::iter::once(dependency).chain(&dependency.variants)) + { + if dependency.enabled + && let Some(path) = &dependency.path + { + paths.insert(path.into()); + } + } + ensure!( + input.evaluation_inputs.iter().all(|file| { + file.starts_with(&input.root) + || paths + .iter() + .any(|directory| directory.is_dir() && file.starts_with(directory)) + }), + "External evaluation inputs are not covered by project or path-source directories" + ); + Ok((paths.clone(), inputs(options, &self.anchor, &paths)?)) + } + + pub(crate) fn publish( + self, + options: &PrepareOptions, + prepared: &PreparedBundle, + paths: BTreeSet, + before: String, + ) -> Result<()> { + ensure!( + self.before == inputs(options, &self.anchor, &BTreeSet::new())?, + "Project changed during preparation" + ); + ensure!( + before == inputs(options, &self.anchor, &paths)?, + "Source inputs changed during preparation" + ); + let state = State { + configuration: configuration(options)?, + input_hash: before, + output_hash: outputs(options, &prepared.installed, &prepared.input.lockfile)?, + lockfile: prepared.input.lockfile.clone(), + installed: prepared.installed.clone(), + anchor: self.anchor, + inputs: paths, + }; + let checksum = checksum(&state)?; + rb_gemfile::write_project( + options.project.join(STATE_DIRECTORY).join(PREPARED_STATE), + &serde_json::to_string(&Envelope { state, checksum })?, + )?; + Ok(()) + } +} + +pub(crate) fn invalidate(root: &Path) -> Result<()> { + match fs::remove_file(root.join(STATE_DIRECTORY).join(PREPARED_STATE)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn anchor(options: &PrepareOptions) -> Result { + if let Some(file) = &options.project_file { + return Ok(dunce::canonicalize(options.project.join(file))?); + } + if let Some(file) = std::env::var_os("BUNDLE_GEMFILE").filter(|file| !file.is_empty()) { + return Ok(dunce::canonicalize(std::env::current_dir()?.join(file))?); + } + ["rbproject.kdl", "rbproject.toml", "gems.rb", "Gemfile"] + .iter() + .map(|name| options.project.join(name)) + .find(|path| path.is_file()) + .map(dunce::canonicalize) + .transpose()? + .context("No project input found") +} + +fn inputs(options: &PrepareOptions, anchor: &Path, paths: &BTreeSet) -> Result { + let mut cache = + crate::input::snapshot_cache(&options.project, PREPARED_STATE).input(&options.project); + for path in paths { + cache = cache.input(path); + } + Ok(cache.fingerprint(&Evaluator::new(&options.ruby), anchor)?) +} + +fn outputs( + options: &PrepareOptions, + installed: &InstalledBundle, + lockfile: &Path, +) -> Result { + let gemfile = installed.gemfile.as_ref().context("No runtime Gemfile")?; + let mut cache = SnapshotCache::new(options.project.join(STATE_DIRECTORY).join(PREPARED_STATE)) + .input(&installed.bundle_path) + .input(lockfile) + .input(options.project.join("Gemfile.lock")) + .input(options.project.join("gems.locked")) + .input(options.project.join(STATE_DIRECTORY).join(MANIFEST_FILE)); + let mut filename = gemfile.as_os_str().to_owned(); + filename.push(".lock"); + cache = cache.input(PathBuf::from(filename)); + Ok(cache.fingerprint(&Evaluator::new(&options.ruby), gemfile)?) +} + +fn configuration(options: &PrepareOptions) -> Result { + let executable = std::env::current_exe()?; + let metadata = fs::metadata(&executable)?; + let data = format!( + "prepared state 1 {options:?} {executable:?} {:?}", + (metadata.len(), metadata.modified()?) + ); + Ok(format!("{:x}", Sha256::digest(data.as_bytes()))) +} + +fn checksum(state: &State) -> Result { + Ok(format!("{:x}", Sha256::digest(serde_json::to_vec(state)?))) +} + +pub(crate) fn attempt(result: Result) -> Option { + match result { + Ok(value) => Some(value), + Err(error) => { + log::debug!("Prepared environment cache unavailable: {error:#}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn busy_preparation_is_a_cache_miss_without_waiting() { + let root = tempfile::tempdir().unwrap(); + let writer = lock(root.path()).unwrap(); + fs::write(root.path().join(STATE_DIRECTORY).join(PREPARED_STATE), "{}").unwrap(); + let mut options = + PrepareOptions::new(root.path(), "missing-ruby", root.path().join("cache")); + options.record_state = true; + let (sender, receiver) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || sender.send(prepared(&options)).unwrap()); + let result = receiver.recv_timeout(std::time::Duration::from_secs(5)); + drop(writer); + reader.join().unwrap(); + assert!( + result + .expect("Cache check waited for an active preparation") + .unwrap() + .is_none() + ); + } +} diff --git a/crates/rb-packages/src/receipt.rs b/crates/rb-packages/src/receipt.rs index 3130d1b..e9c9299 100644 --- a/crates/rb-packages/src/receipt.rs +++ b/crates/rb-packages/src/receipt.rs @@ -23,6 +23,7 @@ impl Receipt { record: &PackageRecord, runtime: &RubyEnvironment, build: &BuildOptions, + tools: &[&PackageRecord], ) -> Result { Ok(Self { checksum: record.checksum.clone(), @@ -32,7 +33,7 @@ impl Receipt { version: runtime.version.to_string(), platform: runtime.platform.clone(), abi: runtime.abi.clone(), - configuration_hash: configuration_hash(build)?, + configuration_hash: configuration_hash(build, runtime, tools)?, dependencies: BTreeMap::new(), }) } @@ -47,7 +48,11 @@ pub(crate) struct InstalledReceipt { pub(crate) extension: Option, } -fn configuration_hash(build: &BuildOptions) -> Result { +fn configuration_hash( + build: &BuildOptions, + runtime: &RubyEnvironment, + tools: &[&PackageRecord], +) -> Result { let mut environment: BTreeMap<_, _> = [ "CC", "CXX", @@ -88,7 +93,7 @@ fn configuration_hash(build: &BuildOptions) -> Result { .map(|value| value.to_str().context("Build argument is not UTF-8")) .collect::>()?; let bytes = serde_json::to_vec( - &serde_json::json!({"arguments":arguments,"environment":environment,"load_paths":build.load_paths,"dependencies":build.dependencies,"make":build.make,"cargo":build.cargo,"cmake":build.cmake}), + &serde_json::json!({"arguments":arguments,"environment":environment,"load_paths":build.load_paths,"dependencies":build.dependencies,"make":build.make,"cargo":build.cargo,"cmake":build.cmake,"resolved_build_tools":tools.iter().map(|record| (&record.source, &record.archive_version, &record.checksum)).collect::>(),"build_tools":runtime.build_tools}), )?; Ok(format!("{:x}", Sha256::digest(bytes))) } diff --git a/crates/rb-packages/src/runtime.rs b/crates/rb-packages/src/runtime.rs index 41e62b5..486f75b 100644 --- a/crates/rb-packages/src/runtime.rs +++ b/crates/rb-packages/src/runtime.rs @@ -17,6 +17,8 @@ pub struct RubyEnvironment { pub abi: String, pub rubygems: Version, pub bundler: Option, + pub build_tools: Vec, + pub configuration_inputs: Vec, } impl RubyEnvironment { diff --git a/crates/rb-packages/src/source_index.rs b/crates/rb-packages/src/source_index.rs index f67b829..5f20185 100644 --- a/crates/rb-packages/src/source_index.rs +++ b/crates/rb-packages/src/source_index.rs @@ -24,26 +24,41 @@ impl SourceIndex { previous: Option<&rb_lockfile::Lockfile>, mode: LockMode, ) -> Result { - ensure!( - input.manifest.dependencies.values().all(|dependency| { - std::iter::once(dependency) - .chain(&dependency.variants) - .all(|declaration| { - !declaration.enabled - || declaration.platforms.is_empty() - || (declaration.git.is_none() && declaration.path.is_none()) - }) - }), - "Platform-specific Git/path sources require multi-target source routing" - ); + let targets = crate::target_plan::runtimes(runtime, previous, mode); + let plans = targets + .iter() + .map(|target| { + crate::target_plan::TargetPlan::new( + &input.manifest, + target, + &crate::PlanOptions::default(), + ) + }) + .collect::>>()?; + let roots = crate::target_plan::sources(&plans)?; + for (name, dependency) in &input.manifest.dependencies { + for declaration in std::iter::once(dependency) + .chain(&dependency.variants) + .filter(|declaration| { + declaration.enabled && (declaration.git.is_some() || declaration.path.is_some()) + }) + { + ensure!( + roots + .get(name) + .is_some_and(|active| active.git == declaration.git + && active.path == declaration.path + && crate::package_source::git_options(active) + == crate::package_source::git_options(declaration)), + "Git/path source for {name} has no applicable resolution target" + ); + } + } let mut packages: BTreeMap> = BTreeMap::new(); let mut seen = std::collections::BTreeSet::new(); - for declaration in input - .manifest - .dependencies + for declaration in roots .values() - .flat_map(|d| std::iter::once(d).chain(&d.variants)) - .filter(|d| d.enabled && (d.git.is_some() || d.path.is_some())) + .filter(|d| d.git.is_some() || d.path.is_some()) { let key = serde_json::to_string( &serde_json::json!({"git":declaration.git,"path":declaration.path,"options":crate::package_source::git_options(declaration)}), @@ -127,17 +142,7 @@ impl SourceIndex { Ok(Self { registry, packages, - roots: input - .manifest - .dependencies - .iter() - .filter_map(|(name, d)| { - std::iter::once(d) - .chain(&d.variants) - .find(|d| d.enabled) - .map(|d| (name.clone(), d.clone())) - }) - .collect(), + roots, affinity: std::sync::Mutex::new(BTreeMap::new()), }) } diff --git a/crates/rb-packages/src/target_plan.rs b/crates/rb-packages/src/target_plan.rs index 2ec1c08..1870589 100644 --- a/crates/rb-packages/src/target_plan.rs +++ b/crates/rb-packages/src/target_plan.rs @@ -135,3 +135,50 @@ fn platform_applies(platforms: &[String], runtime: &RubyEnvironment) -> Result, + mode: crate::LockMode, +) -> Vec { + let mut targets = vec![runtime.clone()]; + if mode != crate::LockMode::Locked + && let Some(previous) = previous + { + for platform in &previous.platforms { + if targets.iter().any(|target| &target.platform == platform) { + continue; + } + let mut target = runtime.clone(); + target.platform = platform.clone(); + target.engine = if target.target().os == "java" { + "jruby".into() + } else if runtime.engine == "jruby" { + "ruby".into() + } else { + runtime.engine.clone() + }; + targets.push(target); + } + } + targets +} + +pub(crate) fn sources(plans: &[TargetPlan]) -> Result> { + let mut sources = BTreeMap::new(); + for target in plans { + for (name, source) in &target.sources { + if let Some(previous) = sources.insert(name.clone(), source.clone()) { + ensure!( + previous.source == source.source + && previous.git == source.git + && previous.path == source.path + && crate::package_source::git_options(&previous) + == crate::package_source::git_options(source), + "Different sources across targets for {name} are not supported" + ); + } + } + } + Ok(sources) +} diff --git a/crates/rb-packages/tests/fixtures/rake/Rakefile b/crates/rb-packages/tests/fixtures/rake/Rakefile new file mode 100644 index 0000000..c66f608 --- /dev/null +++ b/crates/rb-packages/tests/fixtures/rake/Rakefile @@ -0,0 +1,3 @@ +task :default do + File.write(File.join(ENV.fetch('RUBYARCHDIR'), 'polished.rb'), "POLISHED = #{Rake::VERSION.inspect}\n") +end diff --git a/crates/rb-packages/tests/fixtures/rake/selected.rb b/crates/rb-packages/tests/fixtures/rake/selected.rb new file mode 100644 index 0000000..bb10302 --- /dev/null +++ b/crates/rb-packages/tests/fixtures/rake/selected.rb @@ -0,0 +1,21 @@ +require 'tool-helper' + +module Rake + VERSION = TOOL_VERSION + + def self.application + self + end + + def self.run + ARGV.grep(/\ARUBY(?:ARCH|LIB)DIR=/).each do |argument| + key, value = argument.split('=', 2) + ENV[key] = value + end + load ARGV.fetch(ARGV.index('-f') + 1) + end +end + +def task(*) + yield +end diff --git a/crates/rb-packages/tests/fixtures/sources/optional.Gemfile b/crates/rb-packages/tests/fixtures/sources/optional.Gemfile new file mode 100644 index 0000000..2a0d075 --- /dev/null +++ b/crates/rb-packages/tests/fixtures/sources/optional.Gemfile @@ -0,0 +1,4 @@ +gem 'fixture', path: 'source-repository', glob: 'fixture.gemspec' +group :extras, optional: true do + gem 'helper', path: 'source-repository', glob: 'helper.gemspec' +end diff --git a/crates/rb-packages/tests/input.rs b/crates/rb-packages/tests/input.rs index e0fa938..f6212b1 100644 --- a/crates/rb-packages/tests/input.rs +++ b/crates/rb-packages/tests/input.rs @@ -51,3 +51,83 @@ fn custom_lockfile_path_cannot_silently_diverge_from_bundler() { let error = ProjectInput::load(root.path(), "missing-ruby").unwrap_err(); assert!(error.to_string().contains("Custom lockfile paths")); } + +#[test] +fn selected_project_file_overrides_both_standard_formats() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("rbproject.toml"), "invalid TOML").unwrap(); + fs::write(root.path().join("rbproject.kdl"), "invalid KDL").unwrap(); + let selected = root.path().join("custom.toml"); + fs::write(&selected, "[dependencies]\nrake='>= 0'\n").unwrap(); + let input = + ProjectInput::load_with_project_file(root.path(), "missing-ruby", Some(&selected)).unwrap(); + assert!(input.manifest.dependencies.contains_key("rake")); + assert!(input.gemfile.is_none()); + assert_eq!(input.root, dunce::canonicalize(root.path()).unwrap()); +} + +#[test] +fn selected_missing_project_file_is_an_error() { + let root = tempfile::tempdir().unwrap(); + fs::write( + root.path().join("rbproject.toml"), + "[dependencies]\nrake='*'\n", + ) + .unwrap(); + assert!( + ProjectInput::load_with_project_file( + root.path(), + "missing-ruby", + Some(&root.path().join("missing.toml")) + ) + .is_err() + ); +} + +#[test] +fn project_scripts_are_not_dependency_declarations() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("settings.toml"); + fs::write( + &project, + "[project]\nname='sample'\n[scripts]\nprobe='ruby -v'\n[dependencies]\nrake='>= 0'\n", + ) + .unwrap(); + let input = + ProjectInput::load_with_project_file(root.path(), "missing-ruby", Some(&project)).unwrap(); + assert_eq!(input.manifest.dependencies.len(), 1); + assert!(input.manifest.dependencies.contains_key("rake")); +} + +#[test] +fn kdl_project_metadata_and_scripts_are_not_dependency_declarations() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("settings.kdl"); + fs::write( + &project, + "project { name \"sample\"; }\nscripts { probe \"ruby -v\"; }\ngem \"rake\" \">= 0\"\n", + ) + .unwrap(); + let input = + ProjectInput::load_with_project_file(root.path(), "missing-ruby", Some(&project)).unwrap(); + assert_eq!(input.manifest.dependencies.len(), 1); + assert!(input.manifest.dependencies.contains_key("rake")); +} + +#[test] +fn scripts_only_kdl_project_still_reads_gemfile() { + let root = tempfile::tempdir().unwrap(); + fs::write( + root.path().join("Gemfile"), + "source 'https://rubygems.org'\ngem 'rake'\n", + ) + .unwrap(); + fs::write( + root.path().join("rbproject.kdl"), + "project { name \"sample\"; }\nscripts { probe \"ruby -v\"; }\n", + ) + .unwrap(); + let input = ProjectInput::load(root.path(), common::ruby()).unwrap(); + assert!(input.manifest.dependencies.contains_key("rake")); + assert!(input.gemfile.is_some()); +} diff --git a/crates/rb-packages/tests/installation.rs b/crates/rb-packages/tests/installation.rs index a2a140a..6d12efb 100644 --- a/crates/rb-packages/tests/installation.rs +++ b/crates/rb-packages/tests/installation.rs @@ -664,3 +664,160 @@ fn refreshed_multi_target_lock_runs_with_frozen_bundler_without_installing_forei lock ); } + +#[test] +fn rake_extension_uses_a_build_tool_without_a_runtime_dependency() { + let root = tempfile::tempdir().unwrap(); + let mut index = MemoryIndex::default(); + add_archive( + &mut index, + root.path(), + "polished", + &[], + &["Rakefile"], + &[("Rakefile", include_bytes!("fixtures/rake/Rakefile"))], + ); + let runtime = runtime(); + let plan = plan( + &manifest("https://rubygems.org", "polished='>= 0'"), + &runtime, + None, + &index, + &PlanOptions::default(), + ) + .unwrap(); + let index = Arc::new(index); + let bundle = install( + root.path(), + &plan, + &runtime, + index.clone(), + InstallOptions::new(&runtime.executable), + TaskEvents::default(), + ) + .unwrap(); + let output = run( + &runtime, + &bundle, + root.path(), + "require 'polished'; puts POLISHED; raise if Gem.loaded_specs.key?('rake')", + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + stdout(&output).trim(), + runtime + .build_tools + .iter() + .find(|tool| tool.name == "rake") + .unwrap() + .version + ); + let reused = install( + root.path(), + &plan, + &runtime, + index.clone(), + InstallOptions::new(&runtime.executable), + TaskEvents::default(), + ) + .unwrap(); + assert_eq!(reused.installed, 0); + assert_eq!(reused.reused, 1); + let mut changed = runtime.clone(); + changed.build_tools[0] + .metadata + .insert("build-profile".into(), "changed".into()); + let rebuilt = install( + root.path(), + &plan, + &changed, + index.clone(), + InstallOptions::new(&runtime.executable), + TaskEvents::default(), + ) + .unwrap(); + assert_eq!(rebuilt.installed, 1); + changed.build_tools.clear(); + let error = install( + root.path(), + &plan, + &changed, + index, + InstallOptions::new(&runtime.executable), + TaskEvents::default(), + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("Rakefile extension requires rake")); + assert!( + bundle + .gem_home + .join("gems/polished-1.0.0/lib/polished.rb") + .is_file() + ); +} + +#[test] +fn rake_extension_uses_the_resolved_tool_before_the_bundled_tool() { + let root = tempfile::tempdir().unwrap(); + let mut index = MemoryIndex::default(); + add_archive( + &mut index, + root.path(), + "rake", + &[("tool-helper", ">= 0")], + &[], + &[("lib/rake.rb", include_bytes!("fixtures/rake/selected.rb"))], + ); + add_archive( + &mut index, + root.path(), + "polished", + &[], + &["Rakefile"], + &[("Rakefile", include_bytes!("fixtures/rake/Rakefile"))], + ); + add_archive( + &mut index, + root.path(), + "tool-helper", + &[], + &[], + &[("lib/tool-helper.rb", b"TOOL_VERSION='9.0.0'\n")], + ); + let mut runtime = runtime(); + runtime.build_tools.clear(); + let plan = plan( + &manifest("https://rubygems.org", "polished='>= 0'\nrake='>= 0'"), + &runtime, + None, + &index, + &PlanOptions::default(), + ) + .unwrap(); + let bundle = install( + root.path(), + &plan, + &runtime, + Arc::new(index), + InstallOptions::new(&runtime.executable), + TaskEvents::default(), + ) + .unwrap(); + let output = run( + &runtime, + &bundle, + root.path(), + "require 'polished'; puts POLISHED", + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(stdout(&output), "9.0.0\n"); + assert_eq!(bundle.installed, 3); +} diff --git a/crates/rb-packages/tests/prepared_state.rs b/crates/rb-packages/tests/prepared_state.rs new file mode 100644 index 0000000..1c61420 --- /dev/null +++ b/crates/rb-packages/tests/prepared_state.rs @@ -0,0 +1,108 @@ +mod common; +use common::server::Server; +use rb_packages::{PrepareOptions, prepare, prepared}; +use rb_task::TaskEvents; +use std::{collections::BTreeMap, fs}; + +#[test] +fn ready_state_checks_inputs_configuration_and_installed_files_without_registry_requests() { + let root = tempfile::tempdir().unwrap(); + let archives = tempfile::tempdir().unwrap(); + let mut index = rb_packages::MemoryIndex::default(); + common::add_archive( + &mut index, + archives.path(), + "teacup", + &[], + &[], + &[("lib/teacup.rb", b"TEACUP=42\n")], + ); + let server = Server::new(BTreeMap::from([ + ( + "/info/teacup".into(), + format!( + "---\n1.0.0 |checksum:{}\n", + index.packages["teacup"][0].checksum + ) + .into_bytes(), + ), + ( + "/gems/teacup-1.0.0.gem".into(), + fs::read(index.archives.values().next().unwrap()).unwrap(), + ), + ])); + let gemfile = root.path().join("Gemfile"); + fs::write(&gemfile, format!("source '{}'\ngem 'teacup'\n", server.url)).unwrap(); + let mut options = + PrepareOptions::new(root.path(), common::ruby(), root.path().join(".rb/cache")); + options.record_state = true; + let bundle = prepare(options.clone(), TaskEvents::default()).unwrap(); + assert!(root.path().join(".rb/prepared.json").is_file()); + let ready = prepared(&options).unwrap().unwrap(); + assert_eq!(ready.installed, 0); + assert_eq!(ready.reused, 1); + assert_eq!(ready.environment(), bundle.installed.environment()); + let requests = server.requests.lock().unwrap().len(); + for path in [ + gemfile.clone(), + root.path().join("Gemfile.lock"), + root.path().join(".rb/rbproject.toml"), + root.path().join(".rb/prepared.json"), + bundle + .installed + .gem_home + .join("specifications/teacup-1.0.0.gemspec"), + bundle + .installed + .gem_home + .join("gems/teacup-1.0.0/installation.json"), + bundle + .installed + .gem_home + .join("gems/teacup-1.0.0/lib/teacup.rb"), + ] { + let bytes = fs::read(&path).unwrap(); + let permissions = fs::metadata(&path).unwrap().permissions(); + fs::remove_file(&path).unwrap(); + assert!( + prepared(&options).unwrap().is_none(), + "Missing {}", + path.display() + ); + fs::write(&path, b"corrupt").unwrap(); + assert!( + prepared(&options).unwrap().is_none(), + "Changed {}", + path.display() + ); + fs::write(&path, bytes).unwrap(); + fs::set_permissions(&path, permissions).unwrap(); + assert!( + prepared(&options).unwrap().is_some(), + "Restored {}", + path.display() + ); + } + let mut changed = options.clone(); + changed.plan.without.insert("test".into()); + assert!(prepared(&changed).unwrap().is_none()); + changed = options.clone(); + changed + .install + .build + .arguments + .push("--changed-build".into()); + assert!(prepared(&changed).unwrap().is_none()); + changed = options.clone(); + changed.ruby = root.path().join("another-ruby"); + assert!(prepared(&changed).unwrap().is_none()); + let new_input = root.path().join("Gemfile.extra"); + fs::write(&new_input, "gem 'saucer'\n").unwrap(); + assert!(prepared(&options).unwrap().is_none()); + fs::remove_file(new_input).unwrap(); + assert!(prepared(&options).unwrap().is_some()); + assert_eq!(server.requests.lock().unwrap().len(), requests); + fs::write(&gemfile, "raise 'preparation failed'\n").unwrap(); + assert!(prepare(options.clone(), TaskEvents::default()).is_err()); + assert!(!root.path().join(".rb/prepared.json").exists()); +} diff --git a/crates/rb-packages/tests/sources.rs b/crates/rb-packages/tests/sources.rs index a073f31..4fac87b 100644 --- a/crates/rb-packages/tests/sources.rs +++ b/crates/rb-packages/tests/sources.rs @@ -531,7 +531,7 @@ fn path_sources_with_distinct_globs_keep_distinct_compatibility_locations() { } #[test] -fn platform_specific_git_source_is_rejected_before_acquisition() { +fn inactive_platform_git_source_is_rejected_before_acquisition() { let root = tempfile::tempdir().unwrap(); fs::write( root.path().join("Gemfile"), @@ -541,6 +541,123 @@ fn platform_specific_git_source_is_rejected_before_acquisition() { let mut options = options(root.path()); options.offline = true; let error = prepare(options, TaskEvents::default()).err().unwrap(); - assert!(format!("{error:#}").contains("Platform-specific Git/path sources")); + assert!( + format!("{error:#}") + .contains("Git/path source for fixture has no applicable resolution target") + ); assert!(!root.path().join(".rb/cache/git").exists()); } + +#[test] +fn active_platform_path_source_is_prepared() { + let root = tempfile::tempdir().unwrap(); + source(root.path()); + fs::write( + root.path().join("Gemfile"), + "gem 'fixture', path: 'source-repository', platforms: [:ruby, :windows]\n", + ) + .unwrap(); + let bundle = prepare(options(root.path()), TaskEvents::default()).unwrap(); + assert_eq!( + require(&bundle, "require 'fixture'; puts Fixture::VERSION"), + "1.0.0\n" + ); +} + +#[test] +fn platform_git_source_selects_a_pinned_local_repository() { + let root = tempfile::tempdir().unwrap(); + let directory = source(root.path()); + git(&directory, &["init", "-q"]); + let revision = commit(&directory); + fs::write( + root.path().join("Gemfile"), + format!( + "gem 'fixture', git: {:?}, ref: {revision:?}, platforms: [:ruby, :windows]\n", + directory.to_string_lossy() + ), + ) + .unwrap(); + let bundle = prepare(options(root.path()), TaskEvents::default()).unwrap(); + assert_eq!( + require(&bundle, "require 'fixture'; puts Fixture::VERSION"), + "1.0.0\n" + ); + assert!( + bundle + .plan + .lockfile + .sources + .iter() + .any(|source| source.options.get("revision") == Some(&revision)) + ); +} + +#[test] +fn different_sources_across_targets_are_rejected_before_acquisition() { + let root = tempfile::tempdir().unwrap(); + fs::write( + root.path().join("Gemfile"), + "gem 'fixture', git: 'https://ruby.example.test/repo', platforms: [:ruby, :windows]\n", + ) + .unwrap(); + let runtime = common::runtime(); + let mut input = rb_packages::ProjectInput::load(root.path(), &runtime.executable).unwrap(); + let mut alternate = input.manifest.dependencies["fixture"].clone(); + alternate.git = Some("https://java.example.test/repo".into()); + alternate.platforms = vec!["jruby".into()]; + input + .manifest + .dependencies + .get_mut("fixture") + .unwrap() + .variants + .push(alternate); + let previous = rb_lockfile::Lockfile { + platforms: vec![runtime.platform.clone(), "java".into()], + ..Default::default() + }; + let result = rb_packages::SourceIndex::new( + &input, + &runtime, + &root.path().join(".rb/cache"), + true, + Some(&previous), + LockMode::Update, + ); + let error = result.err().unwrap(); + assert!( + format!("{error:#}").contains("Different sources across targets for fixture"), + "{error:#}" + ); + assert!(!root.path().join(".rb/cache/git").exists()); +} + +#[test] +fn compatibility_gemfile_keeps_optional_groups_inactive() { + let root = tempfile::tempdir().unwrap(); + let directory = source(root.path()); + fs::write( + directory.join("helper.gemspec"), + include_str!("fixtures/sources/helper.gemspec"), + ) + .unwrap(); + fs::write(directory.join("lib/helper.rb"), "HELPER=42\n").unwrap(); + fs::write( + root.path().join("Gemfile"), + include_str!("fixtures/sources/optional.Gemfile"), + ) + .unwrap(); + let bundle = prepare(options(root.path()), TaskEvents::default()).unwrap(); + assert_eq!( + require( + &bundle, + "raise 'optional helper activated' if Gem.loaded_specs.key?('helper'); puts 'default only'" + ), + "default only\n" + ); + let mut enabled = options(root.path()); + enabled.plan.with.insert("extras".into()); + let bundle = prepare(enabled, TaskEvents::default()).unwrap(); + assert_eq!(require(&bundle, "require 'helper'; puts HELPER"), "42\n"); +} diff --git a/gems/rb-gem-installer/lib/rb_gem_installer/runtime.rb b/gems/rb-gem-installer/lib/rb_gem_installer/runtime.rb index 7a00209..bb6a56a 100644 --- a/gems/rb-gem-installer/lib/rb_gem_installer/runtime.rb +++ b/gems/rb-gem-installer/lib/rb_gem_installer/runtime.rb @@ -5,12 +5,38 @@ module GemInstaller module Runtime def self.read require 'rubygems' + bundlers = Gem::Specification.find_all_by_name('bundler') { executable: RbConfig.ruby, version: RUBY_VERSION, engine: RUBY_ENGINE, engine_version: RUBY_ENGINE_VERSION, patchlevel: RUBY_PATCHLEVEL, platform: Gem::Platform.local.to_s, abi: Gem.extension_api_version, - rubygems: Gem::VERSION, - bundler: Gem::Specification.find_all_by_name('bundler').map(&:version).max&.to_s } + rubygems: Gem::VERSION, bundler: bundlers.map(&:version).max&.to_s, + build_tools: build_tools, configuration_inputs: configuration_inputs(bundlers) } end + + def self.build_tools + Gem::Specification.find_all_by_name('rake') + .select { |spec| spec.full_gem_path.start_with?(File.join(Gem.default_dir, 'gems') + File::SEPARATOR) } + .max_by(&:version).then do |spec| + spec ? [{ name: spec.name, version: spec.version.to_s, platform: spec.platform.to_s, + root: spec.full_gem_path, require_paths: spec.require_paths, + metadata: spec.metadata, executables: spec.executables, bindir: spec.bindir }] : [] + end + end + private_class_method :build_tools + + def self.configuration_inputs(bundlers) + loaded = $LOADED_FEATURES.select { |path| %w[rbconfig.rb rubygems.rb].include?(File.basename(path)) } + libraries = loaded.select { |path| File.basename(path) == 'rubygems.rb' } + .map { |path| File.join(File.dirname(path), 'rubygems') } + shared = RbConfig::CONFIG['LIBRUBY_SO'] + paths = [RbConfig.ruby, *loaded, *libraries, Gem.default_specifications_dir, + *Gem.path.map { |path| File.join(path, 'specifications') }] + paths.concat(%w[bindir libdir].map { |key| File.join(RbConfig::CONFIG.fetch(key), shared) }) if shared && !shared.empty? + bundlers.each { |spec| paths.concat([spec.loaded_from, File.join(spec.full_gem_path, 'lib')]) } + build_tools.each { |tool| paths << tool.fetch(:root) } + paths.compact.map { |path| File.expand_path(path) }.uniq.sort + end + private_class_method :configuration_inputs end end end diff --git a/gems/rb-gem-installer/test/runtime_test.rb b/gems/rb-gem-installer/test/runtime_test.rb index be3411d..c73fb1d 100644 --- a/gems/rb-gem-installer/test/runtime_test.rb +++ b/gems/rb-gem-installer/test/runtime_test.rb @@ -14,4 +14,40 @@ def test_reports_selected_ruby_and_available_compatibility_runtime assert_equal Gem::VERSION, runtime.fetch(:rubygems) assert_equal Gem::Specification.find_all_by_name('bundler').map(&:version).max&.to_s, runtime.fetch(:bundler) end + + def test_build_tools_belong_to_the_selected_ruby + tools = RubyButler::GemInstaller::Runtime.read.fetch(:build_tools) + expected = Gem::Specification.find_all_by_name('rake') + .select { |spec| spec.full_gem_path.start_with?(File.join(Gem.default_dir, 'gems') + File::SEPARATOR) } + .max_by(&:version) + assert_equal expected ? 1 : 0, tools.length + return unless expected + + tool = tools.fetch(0) + assert_equal 'rake', tool.fetch(:name) + assert_equal expected.version.to_s, tool.fetch(:version) + assert_equal expected.full_gem_path, tool.fetch(:root) + assert_equal expected.require_paths, tool.fetch(:require_paths) + assert_includes RubyButler::GemInstaller::Runtime.read.fetch(:configuration_inputs), expected.full_gem_path + end + + def test_configuration_inputs_cover_the_runtime_and_compatibility_libraries + inputs = RubyButler::GemInstaller::Runtime.read.fetch(:configuration_inputs) + assert_includes inputs, File.expand_path(RbConfig.ruby) + assert_includes inputs, File.expand_path(Gem.default_specifications_dir) + Gem.path.each { |path| assert_includes inputs, File.expand_path(File.join(path, 'specifications')) } + Gem::Specification.find_all_by_name('bundler').each do |spec| + assert_includes inputs, File.expand_path(spec.loaded_from) + assert_includes inputs, File.expand_path(File.join(spec.full_gem_path, 'lib')) + end + shared = RbConfig::CONFIG['LIBRUBY_SO'] + if shared && !shared.empty? + %w[bindir libdir].each do |key| + assert_includes inputs, File.expand_path(File.join(RbConfig::CONFIG.fetch(key), shared)) + end + end + assert(inputs.any? { |path| File.basename(path) == 'rbconfig.rb' }) + assert(inputs.any? { |path| File.basename(path) == 'rubygems.rb' }) + assert_equal inputs.uniq.sort, inputs + end end diff --git a/spec/commands/completion/context_aware_spec.sh b/spec/commands/completion/context_aware_spec.sh index ddd3ec5..aaa4f3d 100644 --- a/spec/commands/completion/context_aware_spec.sh +++ b/spec/commands/completion/context_aware_spec.sh @@ -7,7 +7,7 @@ Describe "Ruby Butler Context-Aware Completion" Describe "Ruby version completion with custom rubies-dir" setup_custom_rubies() { - CUSTOM_RUBIES_DIR="$SHELLSPEC_TMPBASE/custom-rubies" + CUSTOM_RUBIES_DIR=$(mktemp -d "$SHELLSPEC_TMPBASE/custom-rubies.XXXXXXXX") mkdir -p "$CUSTOM_RUBIES_DIR/ruby-2.7.8/bin" mkdir -p "$CUSTOM_RUBIES_DIR/ruby-9.9.9/bin" @@ -63,7 +63,7 @@ Describe "Ruby Butler Context-Aware Completion" Describe "Exec completion with -C flag (work-dir)" setup_custom_workdir() { - CUSTOM_WORKDIR="$SHELLSPEC_TMPBASE/custom-work" + CUSTOM_WORKDIR=$(mktemp -d "$SHELLSPEC_TMPBASE/custom-work.XXXXXXXX") mkdir -p "$CUSTOM_WORKDIR" # Create Gemfile for bundler project @@ -123,8 +123,8 @@ Describe "Ruby Butler Context-Aware Completion" Describe "Combined flag context" setup_combined_context() { - CUSTOM_RUBIES_DIR="$SHELLSPEC_TMPBASE/ctx-rubies" - CUSTOM_WORKDIR="$SHELLSPEC_TMPBASE/ctx-work" + CUSTOM_RUBIES_DIR=$(mktemp -d "$SHELLSPEC_TMPBASE/ctx-rubies.XXXXXXXX") + CUSTOM_WORKDIR=$(mktemp -d "$SHELLSPEC_TMPBASE/ctx-work.XXXXXXXX") mkdir -p "$CUSTOM_RUBIES_DIR/ruby-8.8.8/bin" mkdir -p "$CUSTOM_WORKDIR"