diff --git a/Cargo.toml b/Cargo.toml index 64548b8d..cca438f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "packages/linting", + "packages/dependency-installer", ] resolver = "2" diff --git a/packages/dependency-installer/Cargo.toml b/packages/dependency-installer/Cargo.toml new file mode 100644 index 00000000..96f5312d --- /dev/null +++ b/packages/dependency-installer/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "torrust-dependency-installer" +version = "0.1.0" +edition = "2021" +description = "Dependency detection and installation utilities for the Torrust Tracker Deployer project" +license = "MIT" + +[lib] +name = "torrust_dependency_installer" +path = "src/lib.rs" + +[dependencies] +thiserror = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = [ "env-filter" ] } diff --git a/packages/dependency-installer/README.md b/packages/dependency-installer/README.md new file mode 100644 index 00000000..dbc02d65 --- /dev/null +++ b/packages/dependency-installer/README.md @@ -0,0 +1,77 @@ +# Torrust Dependency Installer Package + +This package provides dependency detection and installation utilities for the Torrust Tracker Deployer project. + +## Features + +- **Tool Detection**: Check if required development tools are installed +- **Extensible**: Easy to add new tool detectors +- **Logging**: Built-in tracing support for observability +- **Error Handling**: Clear, actionable error messages + +## Required Tools + +This package can detect the following development dependencies: + +- **cargo-machete** - Detects unused Rust dependencies +- **OpenTofu** - Infrastructure provisioning tool +- **Ansible** - Configuration management tool +- **LXD** - VM-based testing infrastructure + +## Usage + +### Checking Dependencies + +```rust +use torrust_dependency_installer::DependencyManager; + +fn main() -> Result<(), Box> { + let manager = DependencyManager::new(); + + // Check all dependencies + let results = manager.check_all()?; + + for result in results { + println!("{}: {}", result.tool, if result.installed { "✓" } else { "✗" }); + } + + Ok(()) +} +``` + +### Using Individual Detectors + +```rust +use torrust_dependency_installer::{ToolDetector, OpenTofuDetector}; + +fn main() -> Result<(), Box> { + let detector = OpenTofuDetector; + + if detector.is_installed()? { + println!("{} is installed", detector.name()); + } else { + println!("{} is not installed", detector.name()); + } + + Ok(()) +} +``` + +## Adding to Your Project + +Add to your `Cargo.toml`: + +```toml +[dependencies] +torrust-dependency-installer = { path = "path/to/torrust-dependency-installer" } +``` + +Or if using in a workspace: + +```toml +[workspace] +members = ["packages/torrust-dependency-installer"] + +[dependencies] +torrust-dependency-installer = { path = "packages/torrust-dependency-installer" } +``` diff --git a/packages/dependency-installer/examples/check_dependencies.rs b/packages/dependency-installer/examples/check_dependencies.rs new file mode 100644 index 00000000..058e83a3 --- /dev/null +++ b/packages/dependency-installer/examples/check_dependencies.rs @@ -0,0 +1,43 @@ +//! Example: Check all development dependencies +//! +//! This example demonstrates how to use the dependency installer package +//! to check if all required development dependencies are installed. +//! +//! Run with: `cargo run --example check_dependencies` + +use torrust_dependency_installer::{init_tracing, DependencyManager}; + +fn main() { + // Initialize tracing for structured logging + init_tracing(); + + println!("Checking development dependencies...\n"); + + // Create dependency manager + let manager = DependencyManager::new(); + + // Check all dependencies + match manager.check_all() { + Ok(results) => { + println!("Dependency Status:"); + println!("{}", "=".repeat(40)); + + for result in &results { + let status = if result.installed { "✓" } else { "✗" }; + let status_text = if result.installed { + "Installed" + } else { + "Not Installed" + }; + + println!("{} {:20} {}", status, result.tool, status_text); + } + + println!("\n{} dependencies checked", results.len()); + } + Err(e) => { + eprintln!("Error checking dependencies: {e}"); + std::process::exit(1); + } + } +} diff --git a/packages/dependency-installer/src/command.rs b/packages/dependency-installer/src/command.rs new file mode 100644 index 00000000..100d3dc5 --- /dev/null +++ b/packages/dependency-installer/src/command.rs @@ -0,0 +1,82 @@ +use std::process::Command; + +use crate::errors::CommandError; + +/// Check if a command exists in the system PATH +/// +/// # Platform Support +/// +/// Currently uses the `which` command on Unix-like systems. Windows support +/// would require using `where` command or a different approach. +/// +/// # Examples +/// +/// ```rust +/// use torrust_dependency_installer::command::command_exists; +/// +/// // Check if 'cargo' is installed +/// let exists = command_exists("cargo").unwrap(); +/// assert!(exists); +/// ``` +/// +/// # Errors +/// +/// Returns an error if the 'which' command fails to execute +pub fn command_exists(command: &str) -> Result { + // Use 'which' on Unix-like systems to check if command exists + // Note: This is Unix-specific. For Windows support, use 'where' command. + let output = + Command::new("which") + .arg(command) + .output() + .map_err(|e| CommandError::ExecutionFailed { + command: format!("which {command}"), + source: e, + })?; + + Ok(output.status.success()) +} + +/// Execute a command and return its stdout as a string +/// +/// # Examples +/// +/// ```rust,no_run +/// use torrust_dependency_installer::command::execute_command; +/// +/// // Get cargo version +/// let version = execute_command("cargo", &["--version"]).unwrap(); +/// println!("Cargo version: {}", version); +/// ``` +/// +/// # Errors +/// +/// Returns an error if the command is not found or fails to execute +pub fn execute_command(command: &str, args: &[&str]) -> Result { + let output = Command::new(command).args(args).output().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CommandError::CommandNotFound { + command: command.to_string(), + } + } else { + CommandError::ExecutionFailed { + command: format!("{command} {}", args.join(" ")), + source: e, + } + } + })?; + + if !output.status.success() { + return Err(CommandError::ExecutionFailed { + command: format!("{command} {}", args.join(" ")), + source: std::io::Error::other(format!("Command exited with status: {}", output.status)), + }); + } + + String::from_utf8(output.stdout) + .map(|s| s.trim().to_string()) + .map_err(|e| CommandError::ExecutionFailed { + command: format!("{command} {}", args.join(" ")), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + }) +} diff --git a/packages/dependency-installer/src/detector/ansible.rs b/packages/dependency-installer/src/detector/ansible.rs new file mode 100644 index 00000000..95df157c --- /dev/null +++ b/packages/dependency-installer/src/detector/ansible.rs @@ -0,0 +1,31 @@ +use tracing::info; + +use crate::command::command_exists; +use crate::detector::ToolDetector; +use crate::errors::DetectionError; + +/// Detector for `Ansible` tool +pub struct AnsibleDetector; + +impl ToolDetector for AnsibleDetector { + fn name(&self) -> &'static str { + "Ansible" + } + + fn is_installed(&self) -> Result { + info!(tool = "ansible", "Checking if Ansible is installed"); + + let installed = command_exists("ansible").map_err(|e| DetectionError::DetectionFailed { + tool: self.name().to_string(), + source: std::io::Error::other(e.to_string()), + })?; + + if installed { + info!(tool = "ansible", "Ansible is installed"); + } else { + info!(tool = "ansible", "Ansible is not installed"); + } + + Ok(installed) + } +} diff --git a/packages/dependency-installer/src/detector/cargo_machete.rs b/packages/dependency-installer/src/detector/cargo_machete.rs new file mode 100644 index 00000000..6158a1f2 --- /dev/null +++ b/packages/dependency-installer/src/detector/cargo_machete.rs @@ -0,0 +1,35 @@ +use tracing::info; + +use crate::command::command_exists; +use crate::detector::ToolDetector; +use crate::errors::DetectionError; + +/// Detector for `cargo-machete` tool +pub struct CargoMacheteDetector; + +impl ToolDetector for CargoMacheteDetector { + fn name(&self) -> &'static str { + "cargo-machete" + } + + fn is_installed(&self) -> Result { + info!( + tool = "cargo-machete", + "Checking if cargo-machete is installed" + ); + + let installed = + command_exists("cargo-machete").map_err(|e| DetectionError::DetectionFailed { + tool: self.name().to_string(), + source: std::io::Error::other(e.to_string()), + })?; + + if installed { + info!(tool = "cargo-machete", "cargo-machete is installed"); + } else { + info!(tool = "cargo-machete", "cargo-machete is not installed"); + } + + Ok(installed) + } +} diff --git a/packages/dependency-installer/src/detector/lxd.rs b/packages/dependency-installer/src/detector/lxd.rs new file mode 100644 index 00000000..df5560f3 --- /dev/null +++ b/packages/dependency-installer/src/detector/lxd.rs @@ -0,0 +1,32 @@ +use tracing::info; + +use crate::command::command_exists; +use crate::detector::ToolDetector; +use crate::errors::DetectionError; + +/// Detector for `LXD` tool +pub struct LxdDetector; + +impl ToolDetector for LxdDetector { + fn name(&self) -> &'static str { + "LXD" + } + + fn is_installed(&self) -> Result { + info!(tool = "lxd", "Checking if LXD is installed"); + + // Check for 'lxc' command (LXD client) + let installed = command_exists("lxc").map_err(|e| DetectionError::DetectionFailed { + tool: self.name().to_string(), + source: std::io::Error::other(e.to_string()), + })?; + + if installed { + info!(tool = "lxd", "LXD is installed"); + } else { + info!(tool = "lxd", "LXD is not installed"); + } + + Ok(installed) + } +} diff --git a/packages/dependency-installer/src/detector/mod.rs b/packages/dependency-installer/src/detector/mod.rs new file mode 100644 index 00000000..16d0b4c1 --- /dev/null +++ b/packages/dependency-installer/src/detector/mod.rs @@ -0,0 +1,29 @@ +pub mod ansible; +pub mod cargo_machete; +pub mod lxd; +pub mod opentofu; + +use crate::errors::DetectionError; + +pub use ansible::AnsibleDetector; +pub use cargo_machete::CargoMacheteDetector; +pub use lxd::LxdDetector; +pub use opentofu::OpenTofuDetector; + +/// Trait for detecting if a tool is installed +pub trait ToolDetector { + /// Get the tool name for display purposes + fn name(&self) -> &'static str; + + /// Check if the tool is already installed + /// + /// # Errors + /// + /// Returns an error if the detection process fails + fn is_installed(&self) -> Result; + + /// Get the required version (if applicable) + fn required_version(&self) -> Option<&str> { + None // Default implementation + } +} diff --git a/packages/dependency-installer/src/detector/opentofu.rs b/packages/dependency-installer/src/detector/opentofu.rs new file mode 100644 index 00000000..594a7697 --- /dev/null +++ b/packages/dependency-installer/src/detector/opentofu.rs @@ -0,0 +1,31 @@ +use tracing::info; + +use crate::command::command_exists; +use crate::detector::ToolDetector; +use crate::errors::DetectionError; + +/// Detector for `OpenTofu` tool +pub struct OpenTofuDetector; + +impl ToolDetector for OpenTofuDetector { + fn name(&self) -> &'static str { + "OpenTofu" + } + + fn is_installed(&self) -> Result { + info!(tool = "opentofu", "Checking if OpenTofu is installed"); + + let installed = command_exists("tofu").map_err(|e| DetectionError::DetectionFailed { + tool: self.name().to_string(), + source: std::io::Error::other(e.to_string()), + })?; + + if installed { + info!(tool = "opentofu", "OpenTofu is installed"); + } else { + info!(tool = "opentofu", "OpenTofu is not installed"); + } + + Ok(installed) + } +} diff --git a/packages/dependency-installer/src/errors.rs b/packages/dependency-installer/src/errors.rs new file mode 100644 index 00000000..851fdd9d --- /dev/null +++ b/packages/dependency-installer/src/errors.rs @@ -0,0 +1,29 @@ +use thiserror::Error; + +/// Error types for detection operations +#[derive(Debug, Error)] +pub enum DetectionError { + #[error("Failed to detect tool '{tool}': {source}")] + DetectionFailed { + tool: String, + #[source] + source: std::io::Error, + }, + + #[error("Command execution failed for tool '{tool}': {message}")] + CommandFailed { tool: String, message: String }, +} + +/// Error types for command execution utilities +#[derive(Debug, Error)] +pub enum CommandError { + #[error("Failed to execute command '{command}': {source}")] + ExecutionFailed { + command: String, + #[source] + source: std::io::Error, + }, + + #[error("Command '{command}' not found in PATH")] + CommandNotFound { command: String }, +} diff --git a/packages/dependency-installer/src/lib.rs b/packages/dependency-installer/src/lib.rs new file mode 100644 index 00000000..7f449553 --- /dev/null +++ b/packages/dependency-installer/src/lib.rs @@ -0,0 +1,19 @@ +pub mod command; +pub mod detector; +pub mod errors; +pub mod manager; + +pub use detector::*; +pub use errors::*; +pub use manager::*; + +/// Initialize tracing with default configuration +pub fn init_tracing() { + tracing_subscriber::fmt() + .with_target(true) + .with_thread_ids(false) + .with_thread_names(false) + .with_level(true) + .with_max_level(tracing::Level::INFO) + .init(); +} diff --git a/packages/dependency-installer/src/manager.rs b/packages/dependency-installer/src/manager.rs new file mode 100644 index 00000000..d01883ef --- /dev/null +++ b/packages/dependency-installer/src/manager.rs @@ -0,0 +1,78 @@ +use crate::detector::{ + AnsibleDetector, CargoMacheteDetector, LxdDetector, OpenTofuDetector, ToolDetector, +}; +use crate::errors::DetectionError; + +/// Result of checking a single dependency +#[derive(Debug, Clone)] +pub struct CheckResult { + pub tool: String, + pub installed: bool, +} + +/// Enum representing available dependencies +#[derive(Debug, Clone, Copy)] +pub enum Dependency { + CargoMachete, + OpenTofu, + Ansible, + Lxd, +} + +/// Main dependency manager for detection operations +pub struct DependencyManager { + detectors: Vec>, +} + +impl DependencyManager { + /// Create a new dependency manager with all detectors + #[must_use] + pub fn new() -> Self { + Self { + detectors: vec![ + Box::new(CargoMacheteDetector), + Box::new(OpenTofuDetector), + Box::new(AnsibleDetector), + Box::new(LxdDetector), + ], + } + } + + /// Check all dependencies and return results + /// + /// # Errors + /// + /// Returns an error if any detection operation fails + pub fn check_all(&self) -> Result, DetectionError> { + self.detectors + .iter() + .map(|detector| { + let installed = detector.is_installed()?; + Ok(CheckResult { + tool: detector.name().to_string(), + installed, + }) + }) + .collect() + } + + /// Get a specific detector by dependency type + /// + /// Note: This creates a new detector instance on each call, which is acceptable + /// since detectors are lightweight and stateless. + #[must_use] + pub fn get_detector(&self, dep: Dependency) -> Box { + match dep { + Dependency::CargoMachete => Box::new(CargoMacheteDetector), + Dependency::OpenTofu => Box::new(OpenTofuDetector), + Dependency::Ansible => Box::new(AnsibleDetector), + Dependency::Lxd => Box::new(LxdDetector), + } + } +} + +impl Default for DependencyManager { + fn default() -> Self { + Self::new() + } +} diff --git a/packages/dependency-installer/tests/detector_tests.rs b/packages/dependency-installer/tests/detector_tests.rs new file mode 100644 index 00000000..50822a26 --- /dev/null +++ b/packages/dependency-installer/tests/detector_tests.rs @@ -0,0 +1,234 @@ +//! Unit tests for detector functionality +//! +//! Tests for the `ToolDetector` trait implementations including: +//! - Individual detector implementations +//! - `DependencyManager` functionality +//! - Error handling + +use torrust_dependency_installer::{ + AnsibleDetector, CargoMacheteDetector, CheckResult, Dependency, DependencyManager, LxdDetector, + OpenTofuDetector, ToolDetector, +}; + +// ============================================================================= +// DETECTOR TRAIT TESTS +// ============================================================================= + +#[test] +fn it_should_return_cargo_machete_detector_name() { + let detector = CargoMacheteDetector; + assert_eq!(detector.name(), "cargo-machete"); +} + +#[test] +fn it_should_return_opentofu_detector_name() { + let detector = OpenTofuDetector; + assert_eq!(detector.name(), "OpenTofu"); +} + +#[test] +fn it_should_return_ansible_detector_name() { + let detector = AnsibleDetector; + assert_eq!(detector.name(), "Ansible"); +} + +#[test] +fn it_should_return_lxd_detector_name() { + let detector = LxdDetector; + assert_eq!(detector.name(), "LXD"); +} + +// ============================================================================= +// DETECTOR INSTALLATION CHECK TESTS +// ============================================================================= +// +// Note: These tests check if the detectors can run without errors. +// The actual installation status depends on the system, so we only verify +// that the detection logic executes successfully without panicking. + +#[test] +fn it_should_run_cargo_machete_detector_without_error() { + let detector = CargoMacheteDetector; + // Should not panic - result depends on system state + let result = detector.is_installed(); + assert!(result.is_ok(), "Detection should not error"); +} + +#[test] +fn it_should_run_opentofu_detector_without_error() { + let detector = OpenTofuDetector; + // Should not panic - result depends on system state + let result = detector.is_installed(); + assert!(result.is_ok(), "Detection should not error"); +} + +#[test] +fn it_should_run_ansible_detector_without_error() { + let detector = AnsibleDetector; + // Should not panic - result depends on system state + let result = detector.is_installed(); + assert!(result.is_ok(), "Detection should not error"); +} + +#[test] +fn it_should_run_lxd_detector_without_error() { + let detector = LxdDetector; + // Should not panic - result depends on system state + let result = detector.is_installed(); + assert!(result.is_ok(), "Detection should not error"); +} + +// ============================================================================= +// DETECTOR REQUIRED VERSION TESTS +// ============================================================================= + +#[test] +fn it_should_return_no_required_version_by_default_for_all_detectors() { + let cargo_machete = CargoMacheteDetector; + let opentofu = OpenTofuDetector; + let ansible = AnsibleDetector; + let lxd = LxdDetector; + + assert_eq!(cargo_machete.required_version(), None); + assert_eq!(opentofu.required_version(), None); + assert_eq!(ansible.required_version(), None); + assert_eq!(lxd.required_version(), None); +} + +// ============================================================================= +// DEPENDENCY MANAGER TESTS +// ============================================================================= + +#[test] +fn it_should_create_dependency_manager() { + let manager = DependencyManager::new(); + // Should not panic + drop(manager); +} + +#[test] +fn it_should_create_dependency_manager_with_default() { + let manager = DependencyManager::default(); + // Should not panic + drop(manager); +} + +#[test] +fn it_should_check_all_dependencies_without_error() { + let manager = DependencyManager::new(); + let results = manager.check_all(); + + // Should not panic - result depends on system state + assert!(results.is_ok(), "check_all should not error"); + + // Verify we get results for all 4 tools + let check_results = results.unwrap(); + assert_eq!(check_results.len(), 4, "Should check 4 dependencies"); + + // Verify all expected tools are in results + let tool_names: Vec = check_results.iter().map(|r| r.tool.clone()).collect(); + assert!(tool_names.contains(&"cargo-machete".to_string())); + assert!(tool_names.contains(&"OpenTofu".to_string())); + assert!(tool_names.contains(&"Ansible".to_string())); + assert!(tool_names.contains(&"LXD".to_string())); +} + +#[test] +fn it_should_get_cargo_machete_detector_from_manager() { + let manager = DependencyManager::new(); + let detector = manager.get_detector(Dependency::CargoMachete); + assert_eq!(detector.name(), "cargo-machete"); +} + +#[test] +fn it_should_get_opentofu_detector_from_manager() { + let manager = DependencyManager::new(); + let detector = manager.get_detector(Dependency::OpenTofu); + assert_eq!(detector.name(), "OpenTofu"); +} + +#[test] +fn it_should_get_ansible_detector_from_manager() { + let manager = DependencyManager::new(); + let detector = manager.get_detector(Dependency::Ansible); + assert_eq!(detector.name(), "Ansible"); +} + +#[test] +fn it_should_get_lxd_detector_from_manager() { + let manager = DependencyManager::new(); + let detector = manager.get_detector(Dependency::Lxd); + assert_eq!(detector.name(), "LXD"); +} + +// ============================================================================= +// CHECK RESULT TESTS +// ============================================================================= + +#[test] +fn it_should_create_check_result() { + let result = CheckResult { + tool: "test-tool".to_string(), + installed: true, + }; + + assert_eq!(result.tool, "test-tool"); + assert!(result.installed); +} + +#[test] +fn it_should_clone_check_result() { + let result = CheckResult { + tool: "test-tool".to_string(), + installed: false, + }; + + let cloned = result.clone(); + assert_eq!(cloned.tool, "test-tool"); + assert!(!cloned.installed); +} + +// ============================================================================= +// COMMAND UTILITY TESTS +// ============================================================================= + +#[test] +fn it_should_detect_existing_command() { + use torrust_dependency_installer::command::command_exists; + + // Test with 'sh' which should always exist on Unix systems + let result = command_exists("sh"); + assert!(result.is_ok()); + // 'sh' should exist + assert!(result.unwrap()); +} + +#[test] +fn it_should_detect_nonexistent_command() { + use torrust_dependency_installer::command::command_exists; + + // Test with a command that definitely doesn't exist + let result = command_exists("this-command-definitely-does-not-exist-12345"); + assert!(result.is_ok()); + // Command should not exist + assert!(!result.unwrap()); +} + +#[test] +fn it_should_execute_command_successfully() { + use torrust_dependency_installer::command::execute_command; + + // Test with 'echo' which should always work + let result = execute_command("echo", &["hello"]); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "hello"); +} + +#[test] +fn it_should_fail_to_execute_nonexistent_command() { + use torrust_dependency_installer::command::execute_command; + + // Test with nonexistent command + let result = execute_command("this-command-definitely-does-not-exist-12345", &["test"]); + assert!(result.is_err()); +}