Skip to main content

iceoryx2_cli/
command.rs

1// Copyright (c) 2025 Contributors to the Eclipse Foundation
2//
3// See the NOTICE file(s) distributed with this work for additional
4// information regarding copyright ownership.
5//
6// This program and the accompanying materials are made available under the
7// terms of the Apache Software License 2.0 which is available at
8// https://www.apache.org/licenses/LICENSE-2.0, or the MIT license
9// which is available at https://opensource.org/licenses/MIT.
10//
11// SPDX-License-Identifier: Apache-2.0 OR MIT
12
13use anyhow::{Context, Result, anyhow};
14use cargo_metadata::MetadataCommand;
15use colored::*;
16use std::env;
17use std::fs;
18use std::path::Path;
19use std::path::PathBuf;
20use std::process::{Command, Stdio};
21
22#[cfg(windows)]
23const PATH_ENV_VAR_SEPARATOR: char = ';';
24#[cfg(windows)]
25const COMMAND_EXT: &str = "exe";
26
27#[cfg(not(windows))]
28const PATH_ENV_VAR_SEPARATOR: char = ':';
29#[cfg(not(windows))]
30const COMMAND_EXT: &str = "";
31
32#[derive(Clone, Debug, PartialEq)]
33pub enum CommandType {
34    Installed,
35    Development,
36}
37
38#[derive(Clone, Debug)]
39pub struct CommandInfo {
40    pub name: String,
41    pub path: PathBuf,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PathsList {
46    pub build: Vec<PathBuf>,
47    pub install: Vec<PathBuf>,
48}
49
50pub trait Environment {
51    fn install_paths() -> Result<Vec<PathBuf>>;
52    fn build_paths() -> Result<Vec<PathBuf>>;
53}
54
55pub struct HostEnvironment;
56
57impl HostEnvironment {
58    pub fn target_dir() -> Result<PathBuf> {
59        let target_dir = MetadataCommand::new()
60            .exec()
61            .context("Failed to execute cargo metadata")?
62            .target_directory
63            .into_std_path_buf();
64        Ok(target_dir)
65    }
66}
67
68impl Environment for HostEnvironment {
69    // TODO: This can be optimized to make the command look-up quicker
70    fn install_paths() -> Result<Vec<PathBuf>> {
71        let mut install_paths: Vec<PathBuf> = env::var("PATH")
72            .context("Failed to read PATH environment variable")?
73            .split(PATH_ENV_VAR_SEPARATOR)
74            .map(PathBuf::from)
75            .filter(|p| p.is_dir())
76            .collect();
77
78        install_paths.sort();
79        install_paths.dedup();
80
81        Ok(install_paths)
82    }
83
84    fn build_paths() -> Result<Vec<PathBuf>> {
85        let target_dir = Self::target_dir()?;
86        let build_paths: Vec<PathBuf> = fs::read_dir(target_dir)?
87            .filter_map(|entry| {
88                if let Ok(entry) = entry {
89                    if entry.path().is_dir() {
90                        return Some(entry.path());
91                    }
92                }
93                None
94            })
95            .collect();
96
97        Ok(build_paths)
98    }
99}
100
101pub struct ExternalCommandFinder<E: Environment> {
102    _phantom: core::marker::PhantomData<E>,
103}
104
105impl<E> ExternalCommandFinder<E>
106where
107    E: Environment,
108{
109    fn parse_command_name(path: &Path, prefix: &str) -> Result<String> {
110        let file_stem = path
111            .file_stem()
112            .and_then(|os_str| os_str.to_str())
113            .ok_or_else(|| anyhow!("Invalid file name"))?;
114
115        let command_name = file_stem.strip_prefix(prefix).ok_or_else(|| {
116            anyhow!(
117                "Not a {} command: {}",
118                prefix.trim_end_matches('-'),
119                file_stem
120            )
121        })?;
122
123        let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
124        if extension == COMMAND_EXT {
125            Ok(command_name.to_string())
126        } else {
127            Err(anyhow!("Invalid file extension: {}", extension))
128        }
129    }
130
131    fn list_commands_in_path(
132        path: &Path,
133        prefix: &str,
134        command_type: CommandType,
135    ) -> Result<Vec<CommandInfo>> {
136        let commands = fs::read_dir(path)
137            .with_context(|| format!("Failed to read directory at: {:?}", path.to_str()))?
138            .map(|entry| {
139                entry.map(|e| e.path()).with_context(|| {
140                    format!("Failed to read entry in directory: {:?}", path.to_str())
141                })
142            })
143            .filter_map(|entry_path| {
144                entry_path
145                    .as_ref()
146                    .map_err(|e| anyhow!("Failed to get PathBuf: {}", e))
147                    .and_then(|entry_path_buf| {
148                        Self::parse_command_name(entry_path_buf, prefix)
149                            .map(|parsed_name| {
150                                // Given that development builds can have different build types
151                                // (debug, release, something else), the name needs to be unique to
152                                // allow for selection.
153                                // Thus, the build type is appended as a suffix.
154                                // e.g. foo-debug or foo-release
155                                let mut command_name = parsed_name.to_string();
156                                if command_type == CommandType::Development {
157                                    if let Some(build_type) =
158                                        path.file_name().and_then(|os_str| os_str.to_str())
159                                    {
160                                        const NAME_SEPARATOR: &str = "-";
161                                        command_name.push_str(NAME_SEPARATOR);
162                                        command_name.push_str(build_type);
163                                    }
164                                };
165
166                                CommandInfo {
167                                    name: command_name,
168                                    path: entry_path_buf.to_owned(),
169                                }
170                            })
171                            .map_err(|e| anyhow!("Failed to parse command name: {}", e))
172                    })
173                    .ok()
174            })
175            .collect();
176
177        Ok(commands)
178    }
179
180    pub fn paths_for_prefix(_prefix: &str) -> Result<PathsList> {
181        let build = E::build_paths().unwrap_or_default();
182        let install = E::install_paths().unwrap_or_default();
183
184        Ok(PathsList { build, install })
185    }
186
187    pub fn commands_with_prefix(prefix: &str) -> Result<Vec<CommandInfo>> {
188        let search_paths = Self::paths_for_prefix(prefix).context("Failed to list paths")?;
189        let mut commands = Vec::new();
190
191        for path in &search_paths.build {
192            commands.extend(Self::list_commands_in_path(
193                path,
194                prefix,
195                CommandType::Development,
196            )?);
197        }
198        for path in &search_paths.install {
199            commands.extend(Self::list_commands_in_path(
200                path,
201                prefix,
202                CommandType::Installed,
203            )?);
204        }
205        commands.sort_by_cached_key(|command| {
206            command.path.file_name().unwrap_or_default().to_os_string()
207        });
208
209        Ok(commands)
210    }
211}
212
213pub trait CommandExecutor {
214    fn execute(command_info: &CommandInfo, args: Option<&[String]>) -> Result<()>;
215}
216
217pub struct ExternalCommandExecutor;
218
219impl CommandExecutor for ExternalCommandExecutor {
220    fn execute(command_info: &CommandInfo, args: Option<&[String]>) -> Result<()> {
221        let mut command = Command::new(&command_info.path);
222        command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
223        if let Some(arguments) = args {
224            command.args(arguments);
225        }
226        command
227            .status()
228            .with_context(|| format!("Failed to execute command: {:?}", command_info.path))?;
229        Ok(())
230    }
231}
232
233pub fn execute<E: Environment>(
234    prefix: &str,
235    command_name: &str,
236    args: Option<&[String]>,
237) -> Result<()> {
238    let all_commands = ExternalCommandFinder::<E>::commands_with_prefix(prefix)
239        .context("Failed to find command binaries")?;
240
241    let command = all_commands
242        .into_iter()
243        .find(|command| command.name == command_name)
244        .ok_or_else(|| anyhow!("Command not found: {}", command_name))?;
245
246    ExternalCommandExecutor::execute(&command, args)
247}
248
249pub fn list<E: Environment>(prefix: &str) -> Result<()> {
250    let commands = ExternalCommandFinder::<E>::commands_with_prefix(prefix)?;
251
252    println!("{}", "Discovered Commands:".bright_green().bold());
253    for command in commands {
254        println!("  {}", command.name.bold());
255    }
256
257    Ok(())
258}
259
260pub fn paths<E: Environment>(prefix: &str) -> Result<()> {
261    let search_paths = ExternalCommandFinder::<E>::paths_for_prefix(prefix)
262        .context("Failed to list search paths")?;
263
264    if !search_paths.build.is_empty() {
265        println!("{}", "Build Paths:".bright_green().bold());
266        for dir in &search_paths.build {
267            println!("  {}", dir.display().to_string().bold());
268        }
269        println!();
270    }
271    if !search_paths.install.is_empty() {
272        println!("{}", "Install Paths:".bright_green().bold());
273        for dir in &search_paths.install {
274            println!("  {}", dir.display().to_string().bold());
275        }
276    }
277
278    Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use iceoryx2_bb_testing::assert_that;
285    use std::env;
286    use std::fs::File;
287    use tempfile::TempDir;
288
289    const PREFIX: &str = "iox2-";
290    const FOO_COMMAND: &str = "Xt7bK9pL";
291    const BAR_COMMAND: &str = "m3Qf8RzN";
292    const BAZ_COMMAND: &str = "P5hJ2wAc";
293
294    fn create_noop_executable(file_path: &std::path::Path) -> std::io::Result<()> {
295        use std::process::Command;
296
297        let src_file = file_path.with_extension("rs");
298        std::fs::write(&src_file, "fn main() {}")?;
299        let output = Command::new("rustc")
300            .arg(&src_file)
301            .arg("-o")
302            .arg(file_path)
303            .arg("--crate-type")
304            .arg("bin")
305            .output()?;
306        std::fs::remove_file(&src_file).ok();
307
308        if !output.status.success() {
309            return Err(std::io::Error::other(format!(
310                "Failed to compile noop executable: {}",
311                String::from_utf8_lossy(&output.stderr)
312            )));
313        }
314
315        Ok(())
316    }
317
318    macro_rules! create_file {
319        ($path:expr, $file:expr) => {{
320            let file_path = $path.join($file);
321
322            #[cfg(unix)]
323            const COMMAND_EXT: &str = "";
324            #[cfg(windows)]
325            const COMMAND_EXT: &str = "exe";
326
327            let extension = file_path
328                .extension()
329                .and_then(|ext| ext.to_str())
330                .unwrap_or("");
331
332            if extension == COMMAND_EXT || (cfg!(unix) && extension.is_empty()) {
333                create_noop_executable(&file_path).expect("Failed to create noop executable");
334            } else {
335                // For non-executable files (like .d files), just create an empty file
336                File::create(&file_path).expect("Failed to create file");
337            }
338        }};
339    }
340
341    struct TestEnv {
342        _temp_dir: TempDir,
343        original_path: String,
344    }
345
346    impl TestEnv {
347        fn setup() -> Self {
348            let original_path = env::var("PATH").expect("Failed to get PATH");
349
350            let temp_dir = TempDir::new().expect("Failed to create temp dir");
351            let temp_path = temp_dir.path().to_path_buf();
352
353            let mut paths = env::split_paths(&original_path).collect::<Vec<_>>();
354            paths.push(temp_path.clone());
355            let new_path = env::join_paths(paths).expect("Failed to join paths");
356            unsafe {
357                env::set_var("PATH", &new_path);
358            }
359
360            create_file!(temp_path, format!("{}{}", PREFIX, FOO_COMMAND));
361            create_file!(temp_path, format!("{}{}.d", PREFIX, FOO_COMMAND));
362            create_file!(temp_path, format!("{}{}.exe", PREFIX, FOO_COMMAND));
363            create_file!(temp_path, format!("{}{}", PREFIX, BAR_COMMAND));
364            create_file!(temp_path, format!("{}{}.d", PREFIX, BAR_COMMAND));
365            create_file!(temp_path, format!("{}{}.exe", PREFIX, BAR_COMMAND));
366            create_file!(temp_path, BAZ_COMMAND);
367            create_file!(temp_path, format!("{}.d", BAZ_COMMAND));
368            create_file!(temp_path, format!("{}.exe", BAZ_COMMAND));
369
370            TestEnv {
371                _temp_dir: temp_dir,
372                original_path,
373            }
374        }
375    }
376
377    impl Drop for TestEnv {
378        fn drop(&mut self) {
379            unsafe {
380                env::set_var("PATH", &self.original_path);
381            }
382        }
383    }
384
385    #[test]
386    fn test_list() {
387        let _test_env = TestEnv::setup();
388
389        let commands = ExternalCommandFinder::<HostEnvironment>::commands_with_prefix(PREFIX)
390            .expect("Failed to retrieve commands");
391
392        assert_that!(
393            commands,
394            contains_match | command | command.name == FOO_COMMAND
395        );
396        assert_that!(
397            commands,
398            contains_match | command | command.name == BAR_COMMAND
399        );
400        assert_that!(
401            commands,
402            not_contains_match | command | command.name == BAZ_COMMAND
403        );
404    }
405
406    #[test]
407    fn test_execute() {
408        let _test_env = TestEnv::setup();
409
410        let commands = ExternalCommandFinder::<HostEnvironment>::commands_with_prefix(PREFIX)
411            .unwrap_or_else(|e| {
412                panic!("Failed to retrieve commands: {}", e);
413            });
414
415        let [foo_command, ..] = commands
416            .iter()
417            .filter(|cmd| cmd.name == FOO_COMMAND)
418            .collect::<Vec<_>>()[..]
419        else {
420            panic!("Failed to extract CommandInfo of test files");
421        };
422
423        let result = ExternalCommandExecutor::execute(foo_command, None);
424        if let Err(ref e) = result {
425            println!("Error executing command: {}", e);
426        }
427
428        assert_that!(result, is_ok);
429
430        let args = vec!["arg1".to_string(), "arg2".to_string()];
431        let result = ExternalCommandExecutor::execute(foo_command, Some(&args));
432        assert_that!(result, is_ok);
433    }
434}