diff --git a/iceoryx2-bb/testing/src/assert.rs b/iceoryx2-bb/testing/src/assert.rs index fe789cdc4..8417178c4 100644 --- a/iceoryx2-bb/testing/src/assert.rs +++ b/iceoryx2-bb/testing/src/assert.rs @@ -173,6 +173,34 @@ macro_rules! assert_that { } } }; + ($lhs:expr, contains_match |$element:ident| $predicate:expr) => { + { + let mut does_contain = false; + for $element in &$lhs { + if $predicate { + does_contain = true; + break; + } + } + if !does_contain { + assert_that!(message_contains_match $lhs, core::stringify!($predicate)); + } + } + }; + ($lhs:expr, not_contains_match |$element:ident| $predicate:expr) => { + { + let mut does_contain = false; + for $element in &$lhs { + if $predicate { + does_contain = true; + break; + } + } + if does_contain { + assert_that!(message_contains_match $lhs, core::stringify!($predicate)); + } + } + }; ($lhs:expr, time_at_least $rhs:expr) => { { let lval = $lhs.as_secs_f32(); @@ -237,6 +265,15 @@ macro_rules! assert_that { assert_that![color_end] ); }; + [message_contains_match $lhs:expr, $predicate:expr] => { + core::panic!( + "assertion failed: {}expr: {} contains no element matching predicate: {}{}", + assert_that![color_start], + core::stringify!($lhs), + $predicate, + assert_that![color_end] + ); + }; [message_property $lhs:expr, $lval:expr, $property:expr, $rhs:expr] => { core::panic!( "assertion failed: {}expr: {}.{} == {}; value: {} == {}{}", diff --git a/iceoryx2-cli/iox2/Cargo.toml b/iceoryx2-cli/iox2/Cargo.toml index 40cb17e30..d8da5826c 100644 --- a/iceoryx2-cli/iox2/Cargo.toml +++ b/iceoryx2-cli/iox2/Cargo.toml @@ -16,4 +16,8 @@ better-panic = { workspace = true } colored = { workspace = true } clap = { workspace = true } human-panic = { workspace = true } -thiserror = { workspace = true } +cargo_metadata = { version = "0.18.1" } + +[dev-dependencies] +iceoryx2-bb-testing = { workspace = true } +tempfile = { version = "3.12.0" } diff --git a/iceoryx2-cli/iox2/src/commands.rs b/iceoryx2-cli/iox2/src/commands.rs index f4c4765a3..b514d00c8 100644 --- a/iceoryx2-cli/iox2/src/commands.rs +++ b/iceoryx2-cli/iox2/src/commands.rs @@ -11,151 +11,217 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use anyhow::{anyhow, Context, Result}; +use cargo_metadata::MetadataCommand; use colored::*; use std::env; -use std::ffi::OsStr; use std::fs; +use std::path::Path; use std::path::PathBuf; use std::process::{Command, Stdio}; #[derive(Clone, Debug, PartialEq)] -enum CommandType { +pub enum CommandType { Installed, Development, } #[derive(Clone, Debug)] -struct CommandInfo { - name: String, - path: PathBuf, - command_type: CommandType, +pub struct CommandInfo { + pub name: String, + pub path: PathBuf, + pub command_type: CommandType, } #[derive(Debug, Clone, PartialEq, Eq)] -struct PathsList { - dev_dirs: Vec, - install_dirs: Vec, +pub struct PathsList { + build: Vec, + install: Vec, } -fn build_dirs() -> Result> { - let current_exe = env::current_exe().context("Failed to get current executable path")?; +#[cfg(windows)] +const PATH_SEPARATOR: char = ';'; +#[cfg(windows)] +const COMMAND_EXT: &str = "exe"; - let build_type = if cfg!(debug_assertions) { - "debug" - } else { - "release" - }; +#[cfg(not(windows))] +const PATH_SEPARATOR: char = ':'; +#[cfg(not(windows))] +const COMMAND_EXT: &str = ""; - let path = current_exe - .parent() - .and_then(|p| p.parent()) - .map(|p| p.join(build_type)) - .filter(|p| p.is_dir()) - .with_context(|| { - format!( - "Unable to determine build path from executable: {:?}", - current_exe - ) - })?; - - Ok(vec![path]) +pub trait Environment { + fn install_paths() -> Result>; + fn build_paths() -> Result>; } -fn install_dirs() -> Result> { - env::var("PATH") - .context("Failed to read PATH environment variable")? - .split(':') - .map(PathBuf::from) - .filter(|p| p.is_dir()) - .map(Ok) - .collect() +pub struct HostEnvironment; + +impl Environment for HostEnvironment { + fn install_paths() -> Result> { + env::var("PATH") + .context("Failed to read PATH environment variable")? + .split(PATH_SEPARATOR) + .map(PathBuf::from) + .filter(|p| p.is_dir()) + .map(Ok) + .collect() + } + + fn build_paths() -> Result> { + let target_dir = MetadataCommand::new() + .exec() + .context("Failed to execute cargo metadata")? + .target_directory + .into_std_path_buf(); + + let profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + + Ok(vec![target_dir.join(profile)]) + } } -fn list_paths() -> Result { - let dev_dirs = build_dirs().context("Failed to retrieve development binary paths")?; - let install_dirs = install_dirs().context("Failed to retrieve installed binary paths")?; +pub trait CommandFinder { + fn paths() -> Result; + fn commands() -> Result>; +} - Ok(PathsList { - dev_dirs, - install_dirs, - }) +pub struct IceoryxCommandFinder { + _phantom: std::marker::PhantomData, } -pub fn paths() -> Result<()> { - let paths = list_paths().context("Failed to list paths")?; +impl IceoryxCommandFinder +where + E: Environment, +{ + fn parse_command_name(path: &PathBuf) -> Result { + let file_stem = path + .file_stem() + .and_then(|os_str| os_str.to_str()) + .ok_or_else(|| anyhow!("Invalid file name"))?; - println!("{}", "Development Paths:".bright_green().bold()); - for dir in paths.dev_dirs { - println!(" {}", dir.display().to_string().bold()); + let command_name = file_stem + .strip_prefix("iox2-") + .ok_or_else(|| anyhow!("Not an iox2 command: {}", file_stem))?; + + let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + + if extension == COMMAND_EXT { + Ok(command_name.to_string()) + } else { + Err(anyhow!("Invalid file extension: {}", extension)) + } } - println!("\n{}", "Installed Paths:".bright_green().bold()); - for dir in paths.install_dirs { - println!(" {}", dir.display().to_string().bold()); + fn list_commands_in_path(path: &Path, command_type: CommandType) -> Result> { + let commands = fs::read_dir(path) + .with_context(|| format!("Failed to read directory at: {:?}", path.to_str()))? + .map(|entry| { + entry.map(|e| e.path()).with_context(|| { + format!("Failed to read entry in directory: {:?}", path.to_str()) + }) + }) + .filter_map(|path| { + path.as_ref() + .map_err(|e| anyhow!("Failed to get PathBuf: {}", e)) + .and_then(|path_buf| { + Self::parse_command_name(path_buf) + .map(|parsed_name| CommandInfo { + name: parsed_name, + path: path_buf.to_owned(), + command_type: command_type.clone(), + }) + .map_err(|e| anyhow!("Failed to parse command name: {}", e)) + }) + .ok() + }) + .collect(); + + Ok(commands) } +} - Ok(()) +impl CommandFinder for IceoryxCommandFinder +where + E: Environment, +{ + fn paths() -> Result { + let build = E::build_paths().context("Failed to retrieve build paths")?; + let install = E::install_paths().context("Failed to retrieve install paths")?; + + Ok(PathsList { build, install }) + } + + fn commands() -> Result> { + let paths = Self::paths().context("Failed to list paths")?; + let mut commands = Vec::new(); + + for path in &paths.build { + commands.extend(Self::list_commands_in_path(path, CommandType::Development)?); + } + for path in &paths.install { + commands.extend(Self::list_commands_in_path(path, CommandType::Installed)?); + } + commands.sort_by_cached_key(|command| { + command.path.file_name().unwrap_or_default().to_os_string() + }); + + Ok(commands) + } } -fn parse_command_name(path: &PathBuf) -> Result { - path.file_name() - .and_then(|os_str| os_str.to_str()) - .ok_or_else(|| anyhow!("Invalid file name")) - .and_then(|file_name| { - if path.extension().is_some() { - Err(anyhow!("File has an extension: {}", file_name)) - } else { - Ok(file_name) - } - }) - .and_then(|file_name| { - file_name - .strip_prefix("iox2-") - .map(String::from) - .ok_or_else(|| anyhow!("Not an iox2 command: {}", file_name)) - }) +pub trait CommandExecutor { + fn execute(command_info: &CommandInfo, args: Option<&[String]>) -> Result<()>; } -fn list_commands_at_path(dir: &PathBuf, command_type: CommandType) -> Result> { - let commands = fs::read_dir(dir)? - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - path.file_name().and_then(OsStr::to_str).and_then(|_| { - parse_command_name(&path) - .ok() - .map(|parsed_name| CommandInfo { - name: parsed_name, - path: path.clone(), - command_type: command_type.clone(), - }) - }) - }) - .collect(); +pub struct IceoryxCommandExecutor; - Ok(commands) +impl CommandExecutor for IceoryxCommandExecutor { + fn execute(command_info: &CommandInfo, args: Option<&[String]>) -> Result<()> { + let mut command = Command::new(&command_info.path); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + if let Some(arguments) = args { + command.args(arguments); + } + command + .status() + .with_context(|| format!("Failed to execute command: {:?}", command_info.path))?; + Ok(()) + } } -fn list_commands() -> Result> { - let paths = list_paths().context("Failed to list paths")?; - let mut commands = Vec::new(); +fn paths_impl() -> Result<()> +where + E: Environment, +{ + let paths = IceoryxCommandFinder::::paths().context("Failed to list search paths")?; - for dir in &paths.dev_dirs { - commands.extend(list_commands_at_path(dir, CommandType::Development)?); + println!("{}", "Build Paths:".bright_green().bold()); + for dir in paths.build { + println!(" {}", dir.display().to_string().bold()); } - for dir in &paths.install_dirs { - commands.extend(list_commands_at_path(dir, CommandType::Installed)?); + + println!("\n{}", "Install Paths:".bright_green().bold()); + for dir in paths.install { + println!(" {}", dir.display().to_string().bold()); } - commands - .sort_by_cached_key(|command| command.path.file_name().unwrap_or_default().to_os_string()); - Ok(commands) + Ok(()) } -pub fn list() -> Result<()> { - let commands = list_commands()?; +pub fn paths() -> Result<()> { + paths_impl::() +} + +fn list_impl() -> Result<()> +where + E: Environment, +{ + let commands = IceoryxCommandFinder::::commands()?; - println!("{}", "Installed Commands:".bright_green().bold()); + println!("{}", "Discovered Commands:".bright_green().bold()); for command in commands { let dev_indicator = if command.command_type == CommandType::Development { " (dev)".italic() @@ -168,16 +234,21 @@ pub fn list() -> Result<()> { Ok(()) } -pub fn execute_external_command( - command_name: &str, - args: Option<&[String]>, - dev_flag_present: bool, -) -> Result<()> { - let commands = list_commands().context("Failed to find command binaries")?; - let command_info = commands +pub fn list() -> Result<()> { + list_impl::() +} + +fn execute_impl(command_name: &str, args: Option<&[String]>, dev_flag: bool) -> Result<()> +where + E: Environment, +{ + let all_commands = + IceoryxCommandFinder::::commands().context("Failed to find command binaries")?; + + let command = all_commands .into_iter() .filter(|command| { - if dev_flag_present { + if dev_flag { command.command_type == CommandType::Development } else { command.command_type == CommandType::Installed @@ -185,17 +256,10 @@ pub fn execute_external_command( }) .find(|command| command.name == command_name) .ok_or_else(|| anyhow!("Command not found: {}", command_name))?; - execute(&command_info, args) + + IceoryxCommandExecutor::execute(&command, args) } -fn execute(command_info: &CommandInfo, args: Option<&[String]>) -> Result<()> { - let mut command = Command::new(&command_info.path); - command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); - if let Some(arguments) = args { - command.args(arguments); - } - command - .status() - .with_context(|| format!("Failed to execute command: {:?}", command_info.path))?; - Ok(()) +pub fn execute(command_name: &str, args: Option<&[String]>, dev_flag: bool) -> Result<()> { + execute_impl::(command_name, args, dev_flag) } diff --git a/iceoryx2-cli/iox2/src/lib.rs b/iceoryx2-cli/iox2/src/lib.rs new file mode 100644 index 000000000..18a4aecb5 --- /dev/null +++ b/iceoryx2-cli/iox2/src/lib.rs @@ -0,0 +1,2 @@ +pub mod cli; +pub mod commands; diff --git a/iceoryx2-cli/iox2/src/main.rs b/iceoryx2-cli/iox2/src/main.rs index 0c9fcce01..ce468d889 100644 --- a/iceoryx2-cli/iox2/src/main.rs +++ b/iceoryx2-cli/iox2/src/main.rs @@ -53,7 +53,7 @@ fn main() { } else { None }; - if let Err(e) = commands::execute_external_command(command_name, command_args, cli.dev) { + if let Err(e) = commands::execute(command_name, command_args, cli.dev) { eprintln!("Failed to execute command: {}", e); } } else { diff --git a/iceoryx2-cli/iox2/tests/commands_tests.rs b/iceoryx2-cli/iox2/tests/commands_tests.rs new file mode 100644 index 000000000..3e09a7c7b --- /dev/null +++ b/iceoryx2-cli/iox2/tests/commands_tests.rs @@ -0,0 +1,153 @@ +// Copyright (c) 2024 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache Software License 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0, or the MIT license +// which is available at https://opensource.org/licenses/MIT. +// +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#[cfg(test)] +mod tests { + + use iceoryx2_bb_testing::assert_that; + use iox2::commands::*; + use std::env; + use std::fs::File; + use tempfile::TempDir; + + #[cfg(unix)] + use std::{ + fs::{metadata, set_permissions}, + os::unix::fs::PermissionsExt, + }; + + #[cfg(windows)] + use std::io::Write; + + #[cfg(windows)] + const MINIMAL_EXE: &[u8] = include_bytes!("minimal.exe"); + + const IOX2_PREFIX: &str = "iox2-"; + const FOO_COMMAND: &str = "Xt7bK9pL"; + const BAR_COMMAND: &str = "m3Qf8RzN"; + const BAZ_COMMAND: &str = "P5hJ2wAc"; + + macro_rules! create_file { + ($path:expr, $file:expr) => {{ + let file_path = $path.join($file); + File::create(&file_path).expect("Failed to create file"); + #[cfg(unix)] + { + let mut perms = metadata(&file_path) + .expect("Failed to get metadata") + .permissions(); + perms.set_mode(0o755); + set_permissions(&file_path, perms).expect("Failed to set permissions"); + } + #[cfg(windows)] + { + const COMMAND_EXT: &str = "exe"; + + let mut file = File::create(&file_path).expect("Failed to create file"); + + if file_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("") + == COMMAND_EXT + { + file.write_all(&MINIMAL_EXE) + .expect("Failed to write executable content"); + } + } + }}; + } + + struct TestEnv { + _temp_dir: TempDir, + original_path: String, + } + + impl TestEnv { + fn setup() -> Self { + let original_path = env::var("PATH").expect("Failed to get PATH"); + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let temp_path = temp_dir.path().to_path_buf(); + + let mut paths = env::split_paths(&original_path).collect::>(); + paths.push(temp_path.clone()); + let new_path = env::join_paths(paths).expect("Failed to join paths"); + env::set_var("PATH", &new_path); + + create_file!(temp_path, format!("{}{}", IOX2_PREFIX, FOO_COMMAND)); + create_file!(temp_path, format!("{}{}.d", IOX2_PREFIX, FOO_COMMAND)); + create_file!(temp_path, format!("{}{}.exe", IOX2_PREFIX, FOO_COMMAND)); + create_file!(temp_path, format!("{}{}", IOX2_PREFIX, BAR_COMMAND)); + create_file!(temp_path, format!("{}{}.d", IOX2_PREFIX, BAR_COMMAND)); + create_file!(temp_path, format!("{}{}.exe", IOX2_PREFIX, BAR_COMMAND)); + create_file!(temp_path, BAZ_COMMAND); + create_file!(temp_path, format!("{}.d", BAZ_COMMAND)); + create_file!(temp_path, format!("{}.exe", BAZ_COMMAND)); + + TestEnv { + _temp_dir: temp_dir, + original_path, + } + } + } + + impl Drop for TestEnv { + fn drop(&mut self) { + env::set_var("PATH", &self.original_path); + } + } + + #[test] + fn test_list() { + let _test_env = TestEnv::setup(); + + let commands = IceoryxCommandFinder::::commands() + .expect("Failed to retrieve commands"); + + assert_that!( + commands, + contains_match | command | command.name == FOO_COMMAND + ); + assert_that!( + commands, + contains_match | command | command.name == BAR_COMMAND + ); + assert_that!( + commands, + not_contains_match | command | command.name == BAZ_COMMAND + ); + } + + #[test] + fn test_execute() { + let _test_env = TestEnv::setup(); + + let commands = IceoryxCommandFinder::::commands() + .expect("Failed to retrieve commands"); + + let [foo_command, ..] = commands + .iter() + .filter(|cmd| cmd.name == FOO_COMMAND) + .collect::>()[..] + else { + panic!("Failed to extract CommandInfo of test files"); + }; + + let result = IceoryxCommandExecutor::execute(&foo_command, None); + assert_that!(result, is_ok); + + let args = vec!["arg1".to_string(), "arg2".to_string()]; + let result = IceoryxCommandExecutor::execute(&foo_command, Some(&args)); + assert_that!(result, is_ok); + } +} diff --git a/iceoryx2-cli/iox2/tests/minimal.c b/iceoryx2-cli/iox2/tests/minimal.c new file mode 100644 index 000000000..33c14ce1d --- /dev/null +++ b/iceoryx2-cli/iox2/tests/minimal.c @@ -0,0 +1,3 @@ +int main() { + return 0; +} diff --git a/iceoryx2-cli/iox2/tests/minimal.exe b/iceoryx2-cli/iox2/tests/minimal.exe new file mode 100644 index 000000000..4ae223547 Binary files /dev/null and b/iceoryx2-cli/iox2/tests/minimal.exe differ