diff --git a/src/bundler.rs b/src/bundler.rs index 809367b..88116f3 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -1,17 +1,67 @@ use std::path::Path; -use zed_extension_api::{Command, Result}; +use zed_extension_api::{process::Output, Command, Result}; + +pub trait CommandExecutor { + fn execute_bundle( + &self, + sub_command: String, + args: Vec, + envs: Vec<(String, String)>, + bundle_gemfile_path: &str, + ) -> Result; +} + +pub struct RealCommandExecutor; + +impl CommandExecutor for RealCommandExecutor { + fn execute_bundle( + &self, + sub_command: String, + args: Vec, + envs: Vec<(String, String)>, + bundle_gemfile_path: &str, + ) -> Result { + Command::new("bundle") + .arg(sub_command) + .args(args) + .envs(envs) + .env("BUNDLE_GEMFILE", bundle_gemfile_path) + .output() + } +} /// A simple wrapper around the `bundle` command. pub struct Bundler { pub working_dir: String, envs: Vec<(String, String)>, + command_executor: Box, } impl Bundler { - pub fn new(working_dir: String, envs: Vec<(String, String)>) -> Self { - Bundler { working_dir, envs } + /// Creates a new `Bundler` instance. + /// + /// # Arguments + /// * `working_dir` - The working directory where `bundle` commands should be executed. + /// * `command_executor` - An executor for `bundle` commands. + pub fn new( + working_dir: String, + envs: Vec<(String, String)>, + command_executor: Box, + ) -> Self { + Bundler { + working_dir, + envs, + command_executor, + } } + /// Retrieves the installed version of a gem using `bundle info --version `. + /// + /// # Arguments + /// * `name` - The name of the gem. + /// + /// # Returns + /// A `Result` containing the version string if successful, or an error message. pub fn installed_gem_version(&self, name: &str) -> Result { let args = vec!["--version".into(), name.into()]; @@ -22,14 +72,10 @@ impl Bundler { let bundle_gemfile_path = Path::new(&self.working_dir).join("Gemfile"); let bundle_gemfile = bundle_gemfile_path .to_str() - .ok_or("Invalid path to Gemfile")?; + .ok_or_else(|| "Invalid path to Gemfile".to_string())?; - Command::new("bundle") - .arg(cmd) - .args(args) - .envs(self.envs.clone()) - .env("BUNDLE_GEMFILE", bundle_gemfile) - .output() + self.command_executor + .execute_bundle(cmd, args, self.envs.clone(), bundle_gemfile) .and_then(|output| match output.status { Some(0) => Ok(String::from_utf8_lossy(&output.stdout).to_string()), Some(status) => { @@ -46,3 +92,178 @@ impl Bundler { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + struct MockExecutorConfig { + output_to_return: Option>, + expected_sub_command: Option, + expected_args: Option>, + expected_envs: Option>, + expected_bundle_gemfile_path: Option, + } + + struct MockCommandExecutor { + config: RefCell, + } + + impl MockCommandExecutor { + fn new() -> Self { + MockCommandExecutor { + config: RefCell::new(MockExecutorConfig { + output_to_return: None, + expected_sub_command: None, + expected_args: None, + expected_envs: None, + expected_bundle_gemfile_path: None, + }), + } + } + + fn expect( + &self, + sub_command: &str, + args: &[&str], + envs: &[(&str, &str)], + bundle_gemfile_path: &str, + output: super::Result, + ) { + let mut config = self.config.borrow_mut(); + config.expected_sub_command = Some(sub_command.to_string()); + config.expected_args = Some(args.iter().map(|s| s.to_string()).collect()); + config.expected_envs = Some( + envs.iter() + .map(|&(k, v)| (k.to_string(), v.to_string())) + .collect(), + ); + config.expected_bundle_gemfile_path = Some(bundle_gemfile_path.to_string()); + config.output_to_return = Some(output); + } + } + + impl CommandExecutor for MockCommandExecutor { + fn execute_bundle( + &self, + sub_command: String, + args: Vec, + envs: Vec<(String, String)>, + bundle_gemfile_path: &str, + ) -> super::Result { + let mut config = self.config.borrow_mut(); + + if let Some(expected_cmd) = &config.expected_sub_command { + assert_eq!(&sub_command, expected_cmd, "Mock: Sub-command mismatch"); + } + if let Some(expected_args) = &config.expected_args { + assert_eq!(&args, expected_args, "Mock: Args mismatch"); + } + if let Some(expected_envs) = &config.expected_envs { + assert_eq!(&envs, expected_envs, "Mock: Env mismatch"); + } + if let Some(expected_path) = &config.expected_bundle_gemfile_path { + assert_eq!( + bundle_gemfile_path, expected_path, + "Mock: Gemfile path mismatch" + ); + } + + config.output_to_return.take().expect( + "MockCommandExecutor: output_to_return was not set or already consumed for the test", + ) + } + } + + fn create_mock_executor_for_success( + version: &str, + dir: &str, + gem: &str, + ) -> MockCommandExecutor { + let mock = MockCommandExecutor::new(); + mock.expect( + "info", + &["--version", gem], + &[], + &format!("{}/Gemfile", dir), + Ok(Output { + status: Some(0), + stdout: version.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + mock + } + + #[test] + fn test_installed_gem_version_success() { + let mock_executor = create_mock_executor_for_success("8.0.0", "test_dir", "rails"); + let bundler = Bundler::new("test_dir".into(), vec![], Box::new(mock_executor)); + let version = bundler + .installed_gem_version("rails") + .expect("Expected successful version"); + assert_eq!(version, "8.0.0", "Installed gem version should match"); + } + + #[test] + fn test_installed_gem_version_command_error() { + let mock_executor = MockCommandExecutor::new(); + let gem_name = "unknown_gem"; + let error_output = "Could not find gem 'unknown_gem'."; + + mock_executor.expect( + "info", + &["--version", gem_name], + &[], + "test_dir/Gemfile", + Ok(Output { + status: Some(1), + stdout: Vec::new(), + stderr: error_output.as_bytes().to_vec(), + }), + ); + + let bundler = Bundler::new("test_dir".into(), vec![], Box::new(mock_executor)); + let result = bundler.installed_gem_version(gem_name); + + assert!( + result.is_err(), + "Expected error for failed gem version check" + ); + let err_msg = result.unwrap_err(); + assert!( + err_msg.contains("'bundle' command failed (status: 1)"), + "Error message should contain status" + ); + assert!( + err_msg.contains(error_output), + "Error message should contain stderr output" + ); + } + + #[test] + fn test_installed_gem_version_execution_failure_from_executor() { + let mock_executor = MockCommandExecutor::new(); + let gem_name = "critical_gem"; + let specific_error_msg = "Mocked execution failure"; + + mock_executor.expect( + "info", + &["--version", gem_name], + &[], + "test_dir/Gemfile", + Err(specific_error_msg.to_string()), + ); + + let bundler = Bundler::new("test_dir".into(), vec![], Box::new(mock_executor)); + let result = bundler.installed_gem_version(gem_name); + + assert!(result.is_err(), "Expected error from executor failure"); + assert_eq!( + result.unwrap_err(), + specific_error_msg, + "Error message should match executor error" + ); + } +} diff --git a/src/gemset.rs b/src/gemset.rs index 18bcdf0..e49ee2f 100644 --- a/src/gemset.rs +++ b/src/gemset.rs @@ -1,24 +1,44 @@ use regex::Regex; -use zed_extension_api::{Command, Result}; +use zed_extension_api::{process::Output, Command, Result}; + +pub trait GemCommandExecutor { + fn execute_gem(&self, gem_home: &str, sub_command: String, args: Vec) + -> Result; +} + +pub struct RealGemCommandExecutor; + +impl GemCommandExecutor for RealGemCommandExecutor { + fn execute_gem( + &self, + gem_home: &str, + sub_command: String, + args: Vec, + ) -> Result { + Command::new("gem") + .env("GEM_HOME", gem_home) + .arg(sub_command) + .arg("--norc") + .args(args) + .output() + } +} /// A simple wrapper around the `gem` command. pub struct Gemset { pub gem_home: String, + command_executor: Box, } impl Gemset { - pub fn new(gem_home: String) -> Self { - Self { gem_home } + pub fn new(gem_home: String, command_executor: Box) -> Self { + Self { + gem_home, + command_executor, + } } /// Returns the full path to a gem binary executable. - /// - /// # Arguments - /// * `bin_name` - The name of the binary executable - /// - /// # Returns - /// The full path to the binary as a `String`, or an error if the path - /// cannot be represented as a valid UTF-8 string pub fn gem_bin_path(&self, bin_name: impl Into) -> Result { let bin_name = bin_name.into(); let path = std::path::Path::new(&self.gem_home) @@ -33,17 +53,16 @@ impl Gemset { pub fn gem_path_env(&self) -> Vec<(String, String)> { vec![( "GEM_PATH".to_string(), - format!("{}:$GEM_PATH", self.gem_home.clone()), + format!("{}:$GEM_PATH", self.gem_home), )] } - pub fn install_gem(&self, name: String) -> Result<()> { + pub fn install_gem(&self, name: &str) -> Result<()> { let args = vec![ - "--no-user-install", // Do not install gems in user's home directory - "--no-format-executable", // Do not make installed executable names match Ruby - "--no-document", // Do not generate documentation - // "--env-shebang", // Use /usr/bin/env as a shebang - &name, + "--no-user-install".to_string(), + "--no-format-executable".to_string(), + "--no-document".to_string(), + name.into(), ]; self.execute_gem_command("install".into(), args) @@ -52,28 +71,20 @@ impl Gemset { Ok(()) } - pub fn update_gem(&self, name: String) -> Result<()> { - self.execute_gem_command("update".into(), vec![&name]) + pub fn update_gem(&self, name: &str) -> Result<()> { + self.execute_gem_command("update".into(), vec![name.into()]) .map_err(|e| format!("Failed to update gem '{}': {}", name, e))?; - Ok(()) } - pub fn installed_gem_version(&self, name: String) -> Result> { - // Example output from `gem list`: - /* - *** LOCAL GEMS *** - abbrev (0.1.2) - prism (default: 1.2.0) - test-unit (3.6.7) - */ - let re = Regex::new(r"^(\S+) \((\S+)\)$") + pub fn installed_gem_version(&self, name: &str) -> Result> { + let re = Regex::new(r"^(\S+) \((.+)\)$") .map_err(|e| format!("Failed to compile regex: {}", e))?; - let args = vec!["--exact", &name]; - let output = self.execute_gem_command("list".into(), args)?; + let args = vec!["--exact".to_string(), name.into()]; + let output_str = self.execute_gem_command("list".into(), args)?; - for line in output.lines() { + for line in output_str.lines() { let captures = match re.captures(line) { Some(c) => c, None => continue, @@ -82,15 +93,14 @@ impl Gemset { let gem_package = captures.get(1).map(|m| m.as_str()); let version = captures.get(2).map(|m| m.as_str()); - if gem_package == Some(&name) { + if gem_package == Some(name) { return Ok(version.map(|v| v.to_owned())); } } - Ok(None) } - pub fn is_outdated_gem(&self, name: String) -> Result { + pub fn is_outdated_gem(&self, name: &str) -> Result { self.execute_gem_command("outdated".into(), vec![]) .map(|output| { output @@ -99,13 +109,9 @@ impl Gemset { }) } - fn execute_gem_command(&self, command: String, args: Vec<&str>) -> Result { - Command::new("gem") - .env("GEM_HOME", self.gem_home.clone()) - .arg(command) - .arg("--norc") - .args(args) - .output() + fn execute_gem_command(&self, command: String, args: Vec) -> Result { + self.command_executor + .execute_gem(&self.gem_home, command, args) .and_then(|output| match output.status { Some(0) => Ok(String::from_utf8_lossy(&output.stdout).to_string()), Some(status) => { @@ -122,3 +128,345 @@ impl Gemset { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + struct MockExecutorConfig { + expected_gem_home: Option, + expected_sub_command: Option, + expected_args: Option>, + output_to_return: Option>, + } + + struct MockGemCommandExecutor { + config: RefCell, + } + + impl MockGemCommandExecutor { + fn new() -> Self { + MockGemCommandExecutor { + config: RefCell::new(MockExecutorConfig { + expected_gem_home: None, + expected_sub_command: None, + expected_args: None, + output_to_return: None, + }), + } + } + + fn expect(&self, gem_home: &str, sub_command: &str, args: &[&str], output: Result) { + let mut config = self.config.borrow_mut(); + config.expected_gem_home = Some(gem_home.to_string()); + config.expected_sub_command = Some(sub_command.to_string()); + config.expected_args = Some(args.iter().map(|s| s.to_string()).collect()); + config.output_to_return = Some(output); + } + } + + impl GemCommandExecutor for MockGemCommandExecutor { + fn execute_gem( + &self, + gem_home: &str, + sub_command: String, + args: Vec, + ) -> Result { + let mut config = self.config.borrow_mut(); + + if let Some(expected_home) = &config.expected_gem_home { + assert_eq!(gem_home, expected_home, "Mock: GEM_HOME mismatch"); + } + if let Some(expected_cmd) = &config.expected_sub_command { + assert_eq!(&sub_command, expected_cmd, "Mock: Sub-command mismatch"); + } + if let Some(expected_args) = &config.expected_args { + assert_eq!(&args, expected_args, "Mock: Args mismatch"); + } + + config + .output_to_return + .take() + .expect("MockGemCommandExecutor: output_to_return was not set or already consumed") + } + } + + const TEST_GEM_HOME: &str = "/test/gem_home"; + + fn create_gemset(mock_executor: MockGemCommandExecutor) -> Gemset { + Gemset::new(TEST_GEM_HOME.to_string(), Box::new(mock_executor)) + } + + #[test] + fn test_gem_bin_path() { + let gemset = Gemset::new( + TEST_GEM_HOME.to_string(), + Box::new(MockGemCommandExecutor::new()), + ); + let path = gemset.gem_bin_path("ruby-lsp").unwrap(); + assert_eq!(path, "/test/gem_home/bin/ruby-lsp"); + } + + #[test] + fn test_gem_path_env() { + let gemset = Gemset::new( + TEST_GEM_HOME.to_string(), + Box::new(MockGemCommandExecutor::new()), + ); + let env = gemset.gem_path_env(); + assert_eq!(env.len(), 1); + assert_eq!(env[0].0, "GEM_PATH"); + assert_eq!(env[0].1, "/test/gem_home:$GEM_PATH"); + } + + #[test] + fn test_install_gem_success() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + mock_executor.expect( + TEST_GEM_HOME, + "install", + &[ + "--no-user-install", + "--no-format-executable", + "--no-document", + gem_name, + ], + Ok(Output { + status: Some(0), + stdout: "Successfully installed ruby-lsp-1.0.0".as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + let gemset = create_gemset(mock_executor); + assert!(gemset.install_gem(gem_name).is_ok()); + } + + #[test] + fn test_install_gem_failure() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + mock_executor.expect( + TEST_GEM_HOME, + "install", + &[ + "--no-user-install", + "--no-format-executable", + "--no-document", + gem_name, + ], + Ok(Output { + status: Some(1), + stdout: Vec::new(), + stderr: "Installation error".as_bytes().to_vec(), + }), + ); + let gemset = create_gemset(mock_executor); + let result = gemset.install_gem(gem_name); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Failed to install gem 'ruby-lsp'")); + } + + #[test] + fn test_update_gem_success() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + mock_executor.expect( + TEST_GEM_HOME, + "update", + &[gem_name], + Ok(Output { + status: Some(0), + stdout: "Gems updated: ruby-lsp".as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + let gemset = create_gemset(mock_executor); + assert!(gemset.update_gem(gem_name).is_ok()); + } + + #[test] + fn test_update_gem_failure() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + mock_executor.expect( + TEST_GEM_HOME, + "update", + &[gem_name], + Ok(Output { + status: Some(1), + stdout: Vec::new(), + stderr: "Update error".as_bytes().to_vec(), + }), + ); + let gemset = create_gemset(mock_executor); + let result = gemset.update_gem(gem_name); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Failed to update gem 'ruby-lsp'")); + } + + #[test] + fn test_installed_gem_version_found() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + let expected_version = "1.2.3"; + let gem_list_output = format!( + "{}\n{} ({})\n{}", + "ignore this", gem_name, expected_version, "other_gem (3.2.1)" + ); + + mock_executor.expect( + TEST_GEM_HOME, + "list", + &["--exact", gem_name], + Ok(Output { + status: Some(0), + stdout: gem_list_output.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + let gemset = create_gemset(mock_executor); + let version = gemset.installed_gem_version(gem_name).unwrap(); + assert_eq!(version, Some(expected_version.to_string())); + } + + #[test] + fn test_installed_gem_version_found_with_default() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "prism"; + let version_in_output = "default: 1.2.0"; + let gem_list_output = format!( + "{}\n{} ({})\n{}", + "*** LOCAL GEMS ***", gem_name, version_in_output, "abbrev (0.1.2)" + ); + + mock_executor.expect( + TEST_GEM_HOME, + "list", + &["--exact", gem_name], + Ok(Output { + status: Some(0), + stdout: gem_list_output.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + let gemset = create_gemset(mock_executor); + let version = gemset.installed_gem_version(gem_name).unwrap(); + assert_eq!(version, Some(version_in_output.to_string())); + } + + #[test] + fn test_installed_gem_version_not_found() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "non_existent_gem"; + let gem_list_output = "other_gem (1.0.0)\nanother_gem (2.0.0)"; + + mock_executor.expect( + TEST_GEM_HOME, + "list", + &["--exact", gem_name], + Ok(Output { + status: Some(0), + stdout: gem_list_output.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + let gemset = create_gemset(mock_executor); + let version = gemset.installed_gem_version(gem_name).unwrap(); + assert_eq!(version, None); + } + + #[test] + fn test_installed_gem_version_command_failure() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + mock_executor.expect( + TEST_GEM_HOME, + "list", + &["--exact", gem_name], + Ok(Output { + status: Some(127), + stdout: Vec::new(), + stderr: "gem list error".as_bytes().to_vec(), + }), + ); + let gemset = create_gemset(mock_executor); + let result = gemset.installed_gem_version(gem_name); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Gem command failed (status: 127)")); + } + + #[test] + fn test_is_outdated_gem_true() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + let outdated_output = format!( + "{} (3.3.2 < 3.3.4)\n{} (2.9.1 < 2.11.3)\n{} (0.5.6 < 0.5.8)", + "csv", gem_name, "net-imap" + ); + + mock_executor.expect( + TEST_GEM_HOME, + "outdated", + &[], + Ok(Output { + status: Some(0), + stdout: outdated_output.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + let gemset = create_gemset(mock_executor); + let is_outdated = gemset.is_outdated_gem(gem_name).unwrap(); + assert!(is_outdated); + } + + #[test] + fn test_is_outdated_gem_false() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + let outdated_output = "csv (3.3.2 < 3.3.4)"; + + mock_executor.expect( + TEST_GEM_HOME, + "outdated", + &[], + Ok(Output { + status: Some(0), + stdout: outdated_output.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + let gemset = create_gemset(mock_executor); + let is_outdated = gemset.is_outdated_gem(gem_name).unwrap(); + assert!(!is_outdated); + } + + #[test] + fn test_is_outdated_gem_command_failure() { + let mock_executor = MockGemCommandExecutor::new(); + let gem_name = "ruby-lsp"; + mock_executor.expect( + TEST_GEM_HOME, + "outdated", + &[], + Ok(Output { + status: Some(1), + stdout: Vec::new(), + stderr: "outdated command error".as_bytes().to_vec(), + }), + ); + let gemset = create_gemset(mock_executor); + let result = gemset.is_outdated_gem(gem_name); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Gem command failed (status: 1)")); + } +} diff --git a/src/language_servers/language_server.rs b/src/language_servers/language_server.rs index f284b1b..57e426f 100644 --- a/src/language_servers/language_server.rs +++ b/src/language_servers/language_server.rs @@ -3,7 +3,10 @@ use zed_extension_api::{ LanguageServerInstallationStatus, Result, }; -use crate::{bundler::Bundler, gemset::Gemset}; +use crate::{ + bundler::{Bundler, RealCommandExecutor}, + gemset::{Gemset, RealGemCommandExecutor}, +}; #[derive(Clone, Debug)] pub struct LanguageServerBinary { @@ -67,7 +70,11 @@ pub trait LanguageServer { return self.try_find_on_path_or_extension_gemset(language_server_id, worktree); } - let bundler = Bundler::new(worktree.root_path(), worktree.shell_env()); + let bundler = Bundler::new( + worktree.root_path(), + worktree.shell_env(), + Box::new(RealCommandExecutor), + ); match bundler.installed_gem_version(Self::GEM_NAME) { Ok(_version) => { let bundle_path = worktree @@ -114,17 +121,17 @@ pub trait LanguageServer { .to_string_lossy() .to_string(); - let gemset = Gemset::new(gem_home.clone()); + let gemset = Gemset::new(gem_home.clone(), Box::new(RealGemCommandExecutor)); set_language_server_installation_status( language_server_id, &LanguageServerInstallationStatus::CheckingForUpdate, ); - match gemset.installed_gem_version(Self::GEM_NAME.into()) { + match gemset.installed_gem_version(Self::GEM_NAME) { Ok(Some(_version)) => { if gemset - .is_outdated_gem(Self::GEM_NAME.into()) + .is_outdated_gem(Self::GEM_NAME) .map_err(|e| e.to_string())? { set_language_server_installation_status( @@ -133,7 +140,7 @@ pub trait LanguageServer { ); gemset - .update_gem(Self::GEM_NAME.into()) + .update_gem(Self::GEM_NAME) .map_err(|e| e.to_string())?; } @@ -154,7 +161,7 @@ pub trait LanguageServer { ); gemset - .install_gem(Self::GEM_NAME.into()) + .install_gem(Self::GEM_NAME) .map_err(|e| e.to_string())?; let executable_path = gemset @@ -189,6 +196,10 @@ mod tests { #[test] fn test_default_executable_args() { - assert!(TestServer::get_executable_args() == vec!["--test-arg"]); + assert_eq!( + TestServer::get_executable_args(), + vec!["--test-arg"], + "Default executable args should match expected vector" + ); } } diff --git a/src/language_servers/ruby_lsp.rs b/src/language_servers/ruby_lsp.rs index 3129b6d..b2ddbd6 100644 --- a/src/language_servers/ruby_lsp.rs +++ b/src/language_servers/ruby_lsp.rs @@ -76,7 +76,7 @@ impl RubyLsp { }) } SymbolKind::Constant => { - let code = name.to_uppercase().to_string(); + let code = name.to_uppercase(); let filter_range = 0..name.len(); let display_range = 0..name.len();