Skip to content

Commit fd60aac

Browse files
committed
Stabilize test, improve locators.
1 parent dcdeed5 commit fd60aac

File tree

9 files changed

+372
-248
lines changed

9 files changed

+372
-248
lines changed

crates/rb-cli/src/config/locator.rs

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
use log::debug;
22
use std::path::PathBuf;
33

4+
/// Trait for reading environment variables - allows mocking in tests
5+
pub trait EnvReader {
6+
fn var(&self, key: &str) -> Result<String, std::env::VarError>;
7+
}
8+
9+
/// Production implementation using std::env
10+
pub struct StdEnvReader;
11+
12+
impl EnvReader for StdEnvReader {
13+
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
14+
std::env::var(key)
15+
}
16+
}
17+
418
/// Locate the configuration file following XDG Base Directory specification
519
///
620
/// Supports both rb.kdl and rb.toml (preferring .kdl)
@@ -13,6 +27,14 @@ use std::path::PathBuf;
1327
/// 5. %APPDATA%/rb/rb.kdl or rb.toml (Windows)
1428
/// 6. ~/.rb.kdl or ~/.rb.toml (cross-platform fallback)
1529
pub fn locate_config_file(override_path: Option<PathBuf>) -> Option<PathBuf> {
30+
locate_config_file_with_env(override_path, &StdEnvReader)
31+
}
32+
33+
/// Internal function that accepts an environment reader for testing
34+
fn locate_config_file_with_env(
35+
override_path: Option<PathBuf>,
36+
env: &dyn EnvReader,
37+
) -> Option<PathBuf> {
1638
debug!("Searching for configuration file...");
1739

1840
// 1. Check for explicit override first
@@ -25,7 +47,7 @@ pub fn locate_config_file(override_path: Option<PathBuf>) -> Option<PathBuf> {
2547
}
2648

2749
// 2. Check RB_CONFIG environment variable
28-
if let Ok(rb_config) = std::env::var("RB_CONFIG") {
50+
if let Ok(rb_config) = env.var("RB_CONFIG") {
2951
let config_path = PathBuf::from(rb_config);
3052
debug!(" Checking RB_CONFIG env var: {}", config_path.display());
3153
if config_path.exists() {
@@ -35,7 +57,7 @@ pub fn locate_config_file(override_path: Option<PathBuf>) -> Option<PathBuf> {
3557
}
3658

3759
// 3. Try XDG_CONFIG_HOME (Unix/Linux)
38-
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
60+
if let Ok(xdg_config) = env.var("XDG_CONFIG_HOME") {
3961
let base_path = PathBuf::from(xdg_config).join("rb");
4062
// Try .kdl first, then .toml
4163
for ext in &["rb.kdl", "rb.toml"] {
@@ -98,6 +120,34 @@ pub fn locate_config_file(override_path: Option<PathBuf>) -> Option<PathBuf> {
98120
#[cfg(test)]
99121
mod tests {
100122
use super::*;
123+
use std::collections::HashMap;
124+
125+
/// Mock environment reader for testing without global state mutation
126+
struct MockEnvReader {
127+
vars: HashMap<String, String>,
128+
}
129+
130+
impl MockEnvReader {
131+
fn new() -> Self {
132+
Self {
133+
vars: HashMap::new(),
134+
}
135+
}
136+
137+
fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
138+
self.vars.insert(key.into(), value.into());
139+
self
140+
}
141+
}
142+
143+
impl EnvReader for MockEnvReader {
144+
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
145+
self.vars
146+
.get(key)
147+
.cloned()
148+
.ok_or(std::env::VarError::NotPresent)
149+
}
150+
}
101151

102152
#[test]
103153
fn test_locate_config_file_returns_option() {
@@ -127,24 +177,44 @@ mod tests {
127177
fn test_locate_config_file_with_env_var() {
128178
use std::fs;
129179
let temp_dir = std::env::temp_dir();
130-
let config_path = temp_dir.join("test_rb_env.toml");
180+
let config_path = temp_dir.join("test_rb_env_mock.toml");
131181

132182
// Create a temporary config file
133183
fs::write(&config_path, "# test config").expect("Failed to write test config");
134184

135-
// Set environment variable (unsafe but required for testing)
136-
unsafe {
137-
std::env::set_var("RB_CONFIG", &config_path);
138-
}
185+
// Use mock environment - no global state mutation!
186+
let mock_env =
187+
MockEnvReader::new().with_var("RB_CONFIG", config_path.to_string_lossy().to_string());
139188

140189
// Should return the env var path
141-
let result = locate_config_file(None);
190+
let result = locate_config_file_with_env(None, &mock_env);
142191
assert_eq!(result, Some(config_path.clone()));
143192

144193
// Cleanup
145-
unsafe {
146-
std::env::remove_var("RB_CONFIG");
147-
}
148194
let _ = fs::remove_file(&config_path);
149195
}
196+
197+
#[test]
198+
fn test_locate_config_file_with_xdg_config_home() {
199+
use std::fs;
200+
let temp_dir = std::env::temp_dir();
201+
let xdg_base = temp_dir.join("test_xdg_config");
202+
let rb_dir = xdg_base.join("rb");
203+
let config_path = rb_dir.join("rb.toml");
204+
205+
// Create directory structure
206+
fs::create_dir_all(&rb_dir).expect("Failed to create test directory");
207+
fs::write(&config_path, "# test config").expect("Failed to write test config");
208+
209+
// Use mock environment
210+
let mock_env = MockEnvReader::new()
211+
.with_var("XDG_CONFIG_HOME", xdg_base.to_string_lossy().to_string());
212+
213+
// Should return the XDG config path
214+
let result = locate_config_file_with_env(None, &mock_env);
215+
assert_eq!(result, Some(config_path.clone()));
216+
217+
// Cleanup
218+
let _ = fs::remove_dir_all(&xdg_base);
219+
}
150220
}

crates/rb-cli/src/lib.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,21 @@ mod tests {
242242
.expect("Failed to set permissions");
243243
}
244244

245-
let result = create_ruby_context(Some(sandbox.root().to_path_buf()), None);
245+
// Create gem directories so gem runtime is inferred
246+
let gem_base = sandbox.gem_base_dir();
247+
let gem_dir = gem_base.join("3.2.5");
248+
std::fs::create_dir_all(&gem_dir).expect("Failed to create gem dir");
249+
250+
// Use the internal method that accepts current_dir to avoid global state
251+
use rb_core::butler::ButlerRuntime;
252+
let result = ButlerRuntime::discover_and_compose_with_current_dir(
253+
sandbox.root().to_path_buf(),
254+
None,
255+
None,
256+
false,
257+
sandbox.root().to_path_buf(), // Current dir = sandbox root
258+
)
259+
.expect("Failed to create ButlerRuntime");
246260

247261
// Should successfully create a ButlerRuntime
248262
let current_path = std::env::var("PATH").ok();

crates/rb-core/src/bundler/mod.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::butler::Command;
22
use crate::butler::runtime_provider::RuntimeProvider;
3+
use crate::ruby::RubyVersionExt;
34
use log::debug;
45
use semver::Version;
56
use std::path::{Path, PathBuf};
@@ -40,14 +41,12 @@ impl BundlerRuntime {
4041
self.app_config_dir().join("vendor").join("bundler")
4142
}
4243

43-
/// Returns the ruby-specific vendor directory (.rb/vendor/bundler/ruby/X.Y.Z)
44-
/// This requires a Ruby version to be detected
44+
/// Returns the ruby-specific vendor directory (.rb/vendor/bundler/ruby/X.Y.0)
45+
/// Uses Ruby ABI version (major.minor.0) for compatibility grouping
4546
pub fn ruby_vendor_dir(&self, ruby_version: &Version) -> PathBuf {
46-
let version_str = format!(
47-
"{}.{}.{}",
48-
ruby_version.major, ruby_version.minor, ruby_version.patch
49-
);
50-
self.vendor_dir().join("ruby").join(version_str)
47+
self.vendor_dir()
48+
.join("ruby")
49+
.join(ruby_version.ruby_abi_version())
5150
}
5251

5352
/// Detect Ruby version from .ruby-version file or Gemfile ruby declaration
@@ -58,7 +57,7 @@ impl BundlerRuntime {
5857
}
5958

6059
/// Returns the bin directory where bundler-installed executables live
61-
/// Path: .rb/vendor/bundler/ruby/X.Y.Z/bin
60+
/// Path: .rb/vendor/bundler/ruby/X.Y.0/bin
6261
pub fn bin_dir(&self) -> PathBuf {
6362
let bin_dir = self.ruby_vendor_dir(&self.ruby_version).join("bin");
6463
debug!("Bundler bin directory: {}", bin_dir.display());
@@ -418,8 +417,8 @@ mod tests {
418417
#[test]
419418
fn bin_dir_is_vendor_bin() {
420419
let br = bundler_rt("/home/user/project");
421-
// bin_dir should include Ruby version: .rb/vendor/bundler/ruby/3.3.7/bin
422-
let expected = Path::new("/home/user/project/.rb/vendor/bundler/ruby/3.3.7/bin");
420+
// bin_dir should include Ruby minor version: .rb/vendor/bundler/ruby/3.3.0/bin
421+
let expected = Path::new("/home/user/project/.rb/vendor/bundler/ruby/3.3.0/bin");
423422
assert_eq!(br.bin_dir(), expected);
424423
}
425424

@@ -432,10 +431,10 @@ mod tests {
432431
// Should be configured since we created vendor structure
433432
assert!(br.is_configured());
434433

435-
// bin_dir should include Ruby version path
436-
let expected_bin = br.vendor_dir().join("ruby").join("3.3.7").join("bin");
437-
// gem_dir should be the Ruby-specific vendor directory
438-
let expected_gem = br.vendor_dir().join("ruby").join("3.3.7");
434+
// bin_dir should include Ruby minor version path (X.Y.0)
435+
let expected_bin = br.vendor_dir().join("ruby").join("3.3.0").join("bin");
436+
// gem_dir should be the Ruby-minor-specific vendor directory
437+
let expected_gem = br.vendor_dir().join("ruby").join("3.3.0");
439438

440439
assert_eq!(
441440
<BundlerRuntime as RuntimeProvider>::bin_dir(&br),

crates/rb-core/src/butler/mod.rs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ impl ButlerRuntime {
129129
ButlerError::General(format!("Unable to determine current directory: {}", e))
130130
})?;
131131

132+
Self::discover_and_compose_with_current_dir(
133+
rubies_dir,
134+
requested_ruby_version,
135+
gem_base_dir,
136+
skip_bundler,
137+
current_dir,
138+
)
139+
}
140+
141+
/// Internal method: Perform comprehensive environment discovery with explicit current directory
142+
///
143+
/// This method accepts the current directory as a parameter instead of reading it from
144+
/// the environment, which makes it suitable for testing without global state mutation.
145+
///
146+
/// Note: This method is primarily intended for testing but is made public to allow
147+
/// flexible usage patterns where the current directory needs to be explicitly controlled.
148+
pub fn discover_and_compose_with_current_dir(
149+
rubies_dir: PathBuf,
150+
requested_ruby_version: Option<String>,
151+
gem_base_dir: Option<PathBuf>,
152+
skip_bundler: bool,
153+
current_dir: PathBuf,
154+
) -> Result<Self, ButlerError> {
132155
debug!("Starting comprehensive environment discovery");
133156
debug!("Rubies directory: {}", rubies_dir.display());
134157
debug!("Current directory: {}", current_dir.display());
@@ -197,18 +220,18 @@ impl ButlerRuntime {
197220
bundler_root.map(|root| BundlerRuntime::new(root, selected_ruby.version.clone()));
198221

199222
// Step 6: Create gem runtime (using custom base directory if provided)
200-
// IMPORTANT: When in bundler context, user gems are NOT available for isolation
201-
let gem_runtime = if bundler_runtime.is_some() {
223+
// IMPORTANT: Custom gem base (-G flag) takes precedence over bundler isolation
224+
let gem_runtime = if let Some(ref custom_gem_base) = gem_base_dir {
202225
debug!(
203-
"Bundler context detected - user gems will NOT be available (bundler isolation)"
204-
);
205-
None
206-
} else if let Some(ref custom_gem_base) = gem_base_dir {
207-
debug!(
208-
"Using custom gem base directory: {}",
226+
"Using custom gem base directory (overrides bundler isolation): {}",
209227
custom_gem_base.display()
210228
);
211229
Some(selected_ruby.gem_runtime_for_base(custom_gem_base))
230+
} else if bundler_runtime.is_some() {
231+
debug!(
232+
"Bundler context detected - user gems will NOT be available (bundler isolation)"
233+
);
234+
None
212235
} else {
213236
match selected_ruby.infer_gem_runtime() {
214237
Ok(gem_runtime) => {

crates/rb-core/src/ruby/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ use std::env::consts::EXE_SUFFIX;
66
use std::path::{Path, PathBuf};
77

88
pub mod version_detector;
9+
pub mod version_ext;
10+
911
pub use version_detector::{
1012
CompositeDetector, GemfileDetector, RubyVersionDetector, RubyVersionFileDetector,
1113
};
14+
pub use version_ext::RubyVersionExt;
1215

1316
/// Errors that can occur during Ruby discovery
1417
#[derive(Debug, Clone)]

crates/rb-core/src/ruby/version_detector/mod.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
//! # Usage
2727
//!
2828
//! For standard Ruby projects:
29-
//! ```rust,ignore
29+
//! ```text
3030
//! use rb_core::ruby::CompositeDetector;
3131
//!
3232
//! let detector = CompositeDetector::standard();
@@ -36,7 +36,7 @@
3636
//! ```
3737
//!
3838
//! For bundler-managed projects:
39-
//! ```rust,ignore
39+
//! ```text
4040
//! let detector = CompositeDetector::bundler();
4141
//! let version = detector.detect(bundler_root);
4242
//! ```
@@ -46,7 +46,7 @@
4646
//! To add support for new version sources (e.g., `.tool-versions` for asdf):
4747
//!
4848
//! 1. Implement the `RubyVersionDetector` trait:
49-
//! ```rust,ignore
49+
//! ```text
5050
//! pub struct ToolVersionsDetector;
5151
//! impl RubyVersionDetector for ToolVersionsDetector {
5252
//! fn detect(&self, context: &Path) -> Option<Version> {
@@ -57,7 +57,7 @@
5757
//! ```
5858
//!
5959
//! 2. Add to the detector chain:
60-
//! ```rust,ignore
60+
//! ```text
6161
//! CompositeDetector {
6262
//! detectors: vec![
6363
//! Box::new(RubyVersionFileDetector),
@@ -66,8 +66,6 @@
6666
//! ]
6767
//! }
6868
//! ```
69-
//!
70-
//! See `tool_versions.rs` for a complete example implementation.
7169
7270
use log::debug;
7371
use semver::Version;

0 commit comments

Comments
 (0)