diff --git a/packages/dependency-installer/Cargo.toml b/packages/dependency-installer/Cargo.toml index b78b4f8..2b34189 100644 --- a/packages/dependency-installer/Cargo.toml +++ b/packages/dependency-installer/Cargo.toml @@ -18,3 +18,11 @@ clap = { version = "4.0", features = [ "derive" ] } thiserror = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = [ "env-filter" ] } + +[dev-dependencies] +testcontainers = "0.25" +tokio = { version = "1.0", features = [ "full" ] } + +[[test]] +name = "docker_check_command" +path = "tests/docker_check_command.rs" diff --git a/packages/dependency-installer/README.md b/packages/dependency-installer/README.md index 4ca90fa..9c2af90 100644 --- a/packages/dependency-installer/README.md +++ b/packages/dependency-installer/README.md @@ -2,14 +2,28 @@ This package provides dependency detection and installation utilities for the Torrust Tracker Deployer project. +## Design Philosophy + +**This is an internal automation tool** - Its primary purpose is to check dependencies in CI/CD pipelines and automated workflows. As such, it uses **structured logging only** (via the `tracing` crate) rather than user-facing console output. + +This design choice offers several benefits: + +- **Automation-friendly**: Structured logs are easy to parse and filter programmatically +- **Consistent**: Same output format as the rest of the Torrust ecosystem +- **Simple**: The tool is straightforward enough that logging output is sufficient even for manual use +- **Observable**: Rich contextual information through structured fields + +For manual usage, you can control log verbosity with the `--verbose` flag or `RUST_LOG` environment variable. + ## Features -- **Tool Detection**: Check if required development tools are installed -- **Extensible**: Easy to add new tool detectors -- **Logging**: Built-in tracing support for observability +- **Dependency Detection**: Check if required development tools are installed +- **Extensible**: Easy to add new dependency detectors +- **Structured Logging**: Built-in tracing support for observability and automation +- **Type-Safe**: Uses strongly-typed enums for dependencies - **Error Handling**: Clear, actionable error messages -## Required Tools +## Supported Dependencies This package can detect the following development dependencies: @@ -25,16 +39,20 @@ This package can detect the following development dependencies: The package provides a `dependency-installer` binary for command-line usage: ```bash -# Check all dependencies +# Check all dependencies (default: info log level) dependency-installer check -# Check specific tool -dependency-installer check --tool opentofu +# Check specific dependency +dependency-installer check --dependency opentofu -# List all tools with status +# List all dependencies with status dependency-installer list -# Enable verbose logging +# Control log level (off, error, warn, info, debug, trace) +dependency-installer check --log-level debug +dependency-installer check --log-level off # Disable all logging + +# Enable verbose logging (equivalent to --log-level debug) dependency-installer check --verbose # Get help @@ -49,42 +67,49 @@ dependency-installer check --help - **2**: Invalid arguments - **3**: Internal error -#### Examples +#### Output Format + +The tool uses structured logging (via `tracing`) instead of plain text output: ```bash -# Check all dependencies +# Check all dependencies (default log level shows INFO and above) $ dependency-installer check -Checking dependencies... - -✓ cargo-machete: installed -✗ OpenTofu: not installed -✗ Ansible: not installed -✓ LXD: installed - -Missing 2 out of 4 required dependencies - -# Check specific tool (with aliases) -$ dependency-installer check --tool tofu -✗ OpenTofu: not installed - -# List all tools +2025-11-04T17:33:20.959847Z INFO torrust_dependency_installer::handlers::check: Checking all dependencies +2025-11-04T17:33:20.960126Z INFO torrust_dependency_installer::handlers::check: Dependency check result dependency="cargo-machete" status="installed" +2025-11-04T17:33:20.960131Z INFO torrust_dependency_installer::handlers::check: Dependency check result dependency="OpenTofu" status="not installed" +2025-11-04T17:33:20.960136Z INFO torrust_dependency_installer::handlers::check: Dependency check result dependency="Ansible" status="not installed" +2025-11-04T17:33:20.960139Z INFO torrust_dependency_installer::handlers::check: Dependency check result dependency="LXD" status="installed" +2025-11-04T17:33:20.960144Z INFO torrust_dependency_installer::handlers::check: Missing dependencies missing_count=2 total_count=4 +Error: Check command failed: Failed to check all dependencies: Missing 2 out of 4 required dependencies + +# Check specific dependency +$ dependency-installer check --dependency opentofu +2025-11-04T17:33:20.959855Z INFO torrust_dependency_installer::handlers::check: Checking specific dependency dependency=opentofu +2025-11-04T17:33:20.960473Z INFO torrust_dependency_installer::detector::opentofu: OpenTofu is not installed dependency="opentofu" +2025-11-04T17:33:20.960482Z INFO torrust_dependency_installer::handlers::check: Dependency is not installed dependency="OpenTofu" status="not installed" +Error: Check command failed: Failed to check specific dependency: opentofu: not installed + +# List all dependencies $ dependency-installer list -Available tools: - -- cargo-machete (installed) -- OpenTofu (not installed) -- Ansible (not installed) -- LXD (installed) +2025-11-04T17:33:20.960482Z INFO torrust_dependency_installer::handlers::list: Available dependency dependency="cargo-machete" status="installed" +2025-11-04T17:33:20.960494Z INFO torrust_dependency_installer::handlers::list: Available dependency dependency="OpenTofu" status="not installed" +2025-11-04T17:33:20.960962Z INFO torrust_dependency_installer::handlers::list: Available dependency dependency="Ansible" status="not installed" +2025-11-04T17:33:20.961521Z INFO torrust_dependency_installer::handlers::list: Available dependency dependency="LXD" status="installed" + +# Enable verbose logging (includes DEBUG level) +$ dependency-installer check --verbose +2025-11-04T17:33:20.959872Z DEBUG torrust_dependency_installer::detector::cargo_machete: Checking if cargo-machete is installed dependency="cargo-machete" +... ``` -#### Tool Aliases +#### Dependency Names -The CLI accepts multiple aliases for tools: +The CLI accepts the following dependency names: -- `cargo-machete` or `machete` -- `opentofu` or `tofu` -- `ansible` -- `lxd` +- `cargo-machete` - Rust dependency analyzer +- `opentofu` - Infrastructure provisioning tool +- `ansible` - Configuration management tool +- `lxd` - Lightweight VM manager ### Library Usage @@ -94,15 +119,23 @@ The CLI accepts multiple aliases for tools: use torrust_dependency_installer::DependencyManager; fn main() -> Result<(), Box> { + // Initialize tracing for structured logging + tracing_subscriber::fmt::init(); + let manager = DependencyManager::new(); - + // Check all dependencies let results = manager.check_all()?; - + for result in results { - println!("{}: {}", result.tool, if result.installed { "✓" } else { "✗" }); + let detector = manager.get_detector(result.dependency); + tracing::info!( + dependency = detector.name(), + installed = result.installed, + "Dependency status" + ); } - + Ok(()) } ``` @@ -110,17 +143,20 @@ fn main() -> Result<(), Box> { #### Using Individual Detectors ```rust -use torrust_dependency_installer::{ToolDetector, OpenTofuDetector}; +use torrust_dependency_installer::{DependencyDetector, Dependency, DependencyManager}; fn main() -> Result<(), Box> { - let detector = OpenTofuDetector; - + tracing_subscriber::fmt::init(); + + let manager = DependencyManager::new(); + let detector = manager.get_detector(Dependency::OpenTofu); + if detector.is_installed()? { - println!("{} is installed", detector.name()); + tracing::info!(dependency = detector.name(), "Dependency is installed"); } else { - println!("{} is not installed", detector.name()); + tracing::warn!(dependency = detector.name(), "Dependency is not installed"); } - + Ok(()) } ``` diff --git a/packages/dependency-installer/docker/README.md b/packages/dependency-installer/docker/README.md new file mode 100644 index 0000000..7449331 --- /dev/null +++ b/packages/dependency-installer/docker/README.md @@ -0,0 +1,60 @@ +# Docker Testing Infrastructure + +This directory contains Docker configurations for testing the dependency-installer CLI. + +## Images + +### ubuntu-24.04.Dockerfile + +Base Ubuntu 24.04 image for testing the CLI binary in a clean environment. + +**Purpose**: Verify that the `dependency-installer check` command correctly detects missing tools. + +**Usage in tests**: + +```rust +let image = GenericImage::new("ubuntu", "24.04") + .with_wait_for(WaitFor::message_on_stdout("Ready")); +``` + +## Testing Strategy + +1. Build the binary: `cargo build --bin dependency-installer` +2. Copy binary into container using testcontainers +3. Run `check` command in container +4. Verify it correctly reports missing tools +5. (Phase 4) Install tools and verify installation + +## Building Images + +```bash +cd packages/dependency-installer +docker build -f docker/ubuntu-24.04.Dockerfile -t dependency-installer-test:ubuntu-24.04 . +``` + +## Running Tests + +```bash +# Run all Docker-based integration tests +cd packages/dependency-installer +cargo test --test docker_check_command + +# Run a specific test +cargo test --test docker_check_command test_check_all_reports_missing_dependencies +``` + +## Container Architecture + +The tests use testcontainers to: + +- Automatically start and stop Docker containers +- Copy the compiled binary into containers +- Execute commands and capture output +- Verify exit codes and output messages + +This ensures tests run in isolated, reproducible environments. + +## Related + +- `tests/docker_check_command.rs` - Integration tests using this infrastructure +- `tests/containers/` - Container helper utilities diff --git a/packages/dependency-installer/docker/ubuntu-24.04.Dockerfile b/packages/dependency-installer/docker/ubuntu-24.04.Dockerfile new file mode 100644 index 0000000..92f58c3 --- /dev/null +++ b/packages/dependency-installer/docker/ubuntu-24.04.Dockerfile @@ -0,0 +1,30 @@ +# Dockerfile for Testing the Dependency Installer CLI +# +# This minimal Ubuntu 24.04 image is used for integration testing of the +# dependency-installer CLI binary. It intentionally does NOT include the +# tools we need to detect (cargo-machete, OpenTofu, Ansible, LXD) so we +# can verify that the CLI correctly identifies missing dependencies. + +FROM ubuntu:24.04 + +# Metadata +LABEL description="Ubuntu 24.04 testing environment for dependency-installer CLI" +LABEL maintainer="Torrust Development Team" +LABEL version="1.0.0" +LABEL purpose="dependency-installer-integration-testing" + +# Install minimal dependencies needed for running the binary +# Note: We intentionally do NOT install the tools we're testing for +RUN apt-get update && \ + apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# The binary will be copied by testcontainers at runtime +# No need to copy it here - testcontainers handles that dynamically + +# Default command - keeps container running for test execution +CMD ["/bin/bash", "-c", "while true; do sleep 1000; done"] diff --git a/packages/dependency-installer/examples/check_dependencies.rs b/packages/dependency-installer/examples/check_dependencies.rs index 058e83a..90196f7 100644 --- a/packages/dependency-installer/examples/check_dependencies.rs +++ b/packages/dependency-installer/examples/check_dependencies.rs @@ -8,8 +8,8 @@ use torrust_dependency_installer::{init_tracing, DependencyManager}; fn main() { - // Initialize tracing for structured logging - init_tracing(); + // Initialize tracing for structured logging with INFO level + init_tracing(Some(tracing::Level::INFO)); println!("Checking development dependencies...\n"); @@ -23,6 +23,8 @@ fn main() { println!("{}", "=".repeat(40)); for result in &results { + let detector = manager.get_detector(result.dependency); + let name = detector.name(); let status = if result.installed { "✓" } else { "✗" }; let status_text = if result.installed { "Installed" @@ -30,7 +32,7 @@ fn main() { "Not Installed" }; - println!("{} {:20} {}", status, result.tool, status_text); + println!("{status} {name:20} {status_text}"); } println!("\n{} dependencies checked", results.len()); diff --git a/packages/dependency-installer/src/app.rs b/packages/dependency-installer/src/app.rs new file mode 100644 index 0000000..0de7196 --- /dev/null +++ b/packages/dependency-installer/src/app.rs @@ -0,0 +1,154 @@ +//! Application logic for the dependency installer CLI +//! +//! This module contains the core application logic for running the CLI. + +// External crates +use clap::Parser; +use thiserror::Error; + +// Internal crate +use crate::cli::{Cli, Commands}; +use crate::handlers::check::CheckError; +use crate::handlers::list::ListError; +use crate::DependencyManager; + +// ============================================================================ +// PUBLIC API - Main Types +// ============================================================================ + +/// Exit codes for the CLI application +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitCode { + /// Success - all checks passed + Success = 0, + /// Missing dependencies (tool not installed or missing dependencies) + MissingDependencies = 1, + /// Invalid arguments (unknown tool name) + InvalidArguments = 2, + /// Internal error (detection failures or other errors) + InternalError = 3, +} + +// ============================================================================ +// PUBLIC API - Implementations +// ============================================================================ + +impl From for i32 { + fn from(code: ExitCode) -> Self { + code as i32 + } +} + +// ============================================================================ +// PUBLIC API - Functions +// ============================================================================ + +/// Run the CLI application +/// +/// Returns the appropriate exit code based on the operation result. +/// Errors are logged using tracing and do not propagate to stderr. +pub fn run() -> ExitCode { + let cli = Cli::parse(); + + // Determine log level: verbose flag overrides log-level argument + let log_level = if cli.verbose { + Some(tracing::Level::DEBUG) + } else { + cli.log_level.to_tracing_level() + }; + + // Initialize tracing with the determined log level + crate::init_tracing(log_level); + + let manager = DependencyManager::new(); + + let result: Result<(), AppError> = match cli.command { + Commands::Check { dependency } => { + crate::handlers::check::handle_check(&manager, dependency).map_err(AppError::from) + } + Commands::List => crate::handlers::list::handle_list(&manager).map_err(AppError::from), + }; + + match result { + Ok(()) => ExitCode::Success, + Err(e) => { + // Log the error using tracing instead of eprintln + tracing::error!(error = %e, "Command failed"); + e.to_exit_code() + } + } +} + +// ============================================================================ +// ERROR TYPES - Secondary Concerns +// ============================================================================ + +/// Errors that can occur when running the application +#[derive(Debug, Error)] +pub enum AppError { + /// Failed to execute the check command + /// + /// This occurs when the check command fails to verify dependencies. + #[error("Check command failed: {source}")] + CheckFailed { + #[source] + source: CheckError, + }, + + /// Failed to execute the list command + /// + /// This occurs when the list command fails to list dependencies. + #[error("List command failed: {source}")] + ListFailed { + #[source] + source: ListError, + }, +} + +impl AppError { + /// Convert the error to an appropriate exit code for the CLI + /// + /// # Exit Codes + /// + /// - `ExitCode::MissingDependencies`: Tool not installed or missing dependencies + /// - `ExitCode::InvalidArguments`: Unknown dependency name + /// - `ExitCode::InternalError`: Detection failures or other errors + #[must_use] + pub fn to_exit_code(&self) -> ExitCode { + use crate::handlers::check::{ + CheckAllDependenciesError, CheckError, CheckSpecificDependencyError, + }; + + match self { + Self::CheckFailed { source } => match source { + CheckError::CheckAllFailed { source } => match source { + CheckAllDependenciesError::MissingDependencies { .. } => { + ExitCode::MissingDependencies + } + CheckAllDependenciesError::DependencyCheckFailed { .. } => { + ExitCode::InternalError + } + }, + CheckError::CheckSpecificFailed { source } => match source { + CheckSpecificDependencyError::DependencyNotInstalled { .. } => { + ExitCode::MissingDependencies + } + CheckSpecificDependencyError::DetectionFailed { .. } => ExitCode::InternalError, + }, + }, + Self::ListFailed { .. } => ExitCode::InternalError, + } + } +} + +impl From for AppError { + fn from(source: CheckError) -> Self { + Self::CheckFailed { source } + } +} + +impl From for AppError { + fn from(source: ListError) -> Self { + Self::ListFailed { source } + } +} diff --git a/packages/dependency-installer/src/bin/dependency-installer.rs b/packages/dependency-installer/src/bin/dependency-installer.rs index e952933..22be64b 100644 --- a/packages/dependency-installer/src/bin/dependency-installer.rs +++ b/packages/dependency-installer/src/bin/dependency-installer.rs @@ -12,171 +12,9 @@ use std::process; -use clap::{Parser, Subcommand}; -use torrust_dependency_installer::{Dependency, DependencyManager}; -use tracing::{error, info}; - -/// Manage development dependencies for E2E tests -#[derive(Parser)] -#[command(name = "dependency-installer")] -#[command(version)] -#[command(about = "Manage development dependencies for E2E tests", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Commands, - - /// Enable verbose output - #[arg(short, long, global = true)] - verbose: bool, -} - -#[derive(Subcommand)] -enum Commands { - /// Check if dependencies are installed - Check { - /// Specific tool to check (if omitted, checks all) - #[arg(short, long)] - tool: Option, - }, - - /// List all available tools and their status - List, -} +use torrust_dependency_installer::app; fn main() { - let exit_code = match run() { - Ok(()) => 0, - Err(e) => { - eprintln!("Error: {e}"); - - // Determine exit code based on error type - let error_msg = e.to_string(); - if error_msg.contains("not installed") || error_msg.contains("Missing") { - 1 // Missing dependency - } else if error_msg.contains("Unknown tool") || error_msg.contains("invalid") { - 2 // Invalid argument - } else { - 3 // Internal error - } - } - }; - - process::exit(exit_code); -} - -fn run() -> Result<(), Box> { - let cli = Cli::parse(); - - // Initialize tracing based on verbose flag - // Must set environment variable before calling init_tracing() - if cli.verbose { - std::env::set_var("RUST_LOG", "debug"); - } - torrust_dependency_installer::init_tracing(); - - let manager = DependencyManager::new(); - - match cli.command { - Commands::Check { tool } => handle_check(&manager, tool), - Commands::List => handle_list(&manager), - } -} - -fn handle_check( - manager: &DependencyManager, - tool: Option, -) -> Result<(), Box> { - match tool { - Some(tool_name) => check_specific_tool(manager, &tool_name), - None => check_all_tools(manager), - } -} - -fn check_all_tools(manager: &DependencyManager) -> Result<(), Box> { - info!("Checking all dependencies"); - println!("Checking dependencies...\n"); - - let results = manager.check_all()?; - let mut missing_count = 0; - - for result in &results { - if result.installed { - println!("✓ {}: installed", result.tool); - } else { - println!("✗ {}: not installed", result.tool); - missing_count += 1; - } - } - - println!(); - if missing_count > 0 { - let msg = format!( - "Missing {missing_count} out of {} required dependencies", - results.len() - ); - error!("{}", msg); - eprintln!("{msg}"); - Err(msg.into()) - } else { - info!("All dependencies are installed"); - println!("All dependencies are installed"); - Ok(()) - } -} - -fn check_specific_tool( - manager: &DependencyManager, - tool_name: &str, -) -> Result<(), Box> { - info!(tool = tool_name, "Checking specific tool"); - - // Parse tool name to Dependency enum - let dep = parse_tool_name(tool_name)?; - let detector = manager.get_detector(dep); - - let installed = detector.is_installed()?; - - if installed { - info!(tool = detector.name(), "Tool is installed"); - println!("✓ {}: installed", detector.name()); - Ok(()) - } else { - let msg = format!("{}: not installed", detector.name()); - error!(tool = detector.name(), "Tool is not installed"); - eprintln!("✗ {msg}"); - Err(msg.into()) - } -} - -fn handle_list(manager: &DependencyManager) -> Result<(), Box> { - info!("Listing all available tools"); - println!("Available tools:\n"); - - let results = manager.check_all()?; - for result in results { - let status = if result.installed { - "installed" - } else { - "not installed" - }; - println!("- {} ({status})", result.tool); - } - - Ok(()) -} - -fn parse_tool_name(name: &str) -> Result { - match name.to_lowercase().as_str() { - "cargo-machete" | "machete" => Ok(Dependency::CargoMachete), - "opentofu" | "tofu" => Ok(Dependency::OpenTofu), - "ansible" => Ok(Dependency::Ansible), - "lxd" => Ok(Dependency::Lxd), - _ => { - // List of available tools - should be kept in sync with the match arms above - const AVAILABLE_TOOLS: &str = "cargo-machete, opentofu, ansible, lxd"; - Err(format!( - "Unknown tool: {name}. Available: {AVAILABLE_TOOLS}" - )) - } - } + let exit_code = app::run(); + process::exit(exit_code.into()); } diff --git a/packages/dependency-installer/src/cli.rs b/packages/dependency-installer/src/cli.rs new file mode 100644 index 0000000..9b520b3 --- /dev/null +++ b/packages/dependency-installer/src/cli.rs @@ -0,0 +1,73 @@ +//! CLI argument parsing structures +//! +//! This module defines the command-line interface structure and commands +//! for the dependency installer application. + +use clap::{Parser, Subcommand, ValueEnum}; + +use crate::Dependency; + +/// Log level for controlling output verbosity +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum LogLevel { + /// Disable all logging + Off, + /// Show only errors + Error, + /// Show warnings and errors + Warn, + /// Show info, warnings, and errors (default) + Info, + /// Show debug logs and above + Debug, + /// Show all logs including trace + Trace, +} + +impl LogLevel { + /// Convert to tracing Level + /// + /// Returns None for Off level + #[must_use] + pub fn to_tracing_level(self) -> Option { + match self { + Self::Off => None, + Self::Error => Some(tracing::Level::ERROR), + Self::Warn => Some(tracing::Level::WARN), + Self::Info => Some(tracing::Level::INFO), + Self::Debug => Some(tracing::Level::DEBUG), + Self::Trace => Some(tracing::Level::TRACE), + } + } +} + +/// Manage development dependencies for E2E tests +#[derive(Parser)] +#[command(name = "dependency-installer")] +#[command(version)] +#[command(about = "Manage development dependencies for E2E tests", long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + /// Set logging level (default: info) + #[arg(short = 'l', long, value_enum, default_value = "info", global = true)] + pub log_level: LogLevel, + + /// Enable verbose output (equivalent to --log-level debug) + #[arg(short, long, global = true)] + pub verbose: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Check if dependencies are installed + Check { + /// Specific dependency to check (if omitted, checks all) + #[arg(short = 'd', long)] + dependency: Option, + }, + + /// List all available tools and their status + List, +} diff --git a/packages/dependency-installer/src/command.rs b/packages/dependency-installer/src/command.rs index 100d3dc..e36efba 100644 --- a/packages/dependency-installer/src/command.rs +++ b/packages/dependency-installer/src/command.rs @@ -1,6 +1,17 @@ +//! Command execution utilities +//! +//! This module provides utilities for executing system commands and checking +//! if commands exist in the system PATH. + +// Standard library use std::process::Command; -use crate::errors::CommandError; +// External crates +use thiserror::Error; + +// ============================================================================ +// PUBLIC API - Functions +// ============================================================================ /// Check if a command exists in the system PATH /// @@ -80,3 +91,21 @@ pub fn execute_command(command: &str, args: &[&str]) -> Result &'static str { "Ansible" } fn is_installed(&self) -> Result { - info!(tool = "ansible", "Checking if Ansible is installed"); + info!(dependency = "ansible", "Checking if Ansible is installed"); let installed = command_exists("ansible").map_err(|e| DetectionError::DetectionFailed { - tool: self.name().to_string(), + dependency: Dependency::Ansible, source: std::io::Error::other(e.to_string()), })?; if installed { - info!(tool = "ansible", "Ansible is installed"); + info!( + dependency = "ansible", + status = "installed", + "Ansible is installed" + ); } else { - info!(tool = "ansible", "Ansible is not installed"); + info!( + dependency = "ansible", + status = "not installed", + "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 index 6158a1f..e1869dc 100644 --- a/packages/dependency-installer/src/detector/cargo_machete.rs +++ b/packages/dependency-installer/src/detector/cargo_machete.rs @@ -1,33 +1,56 @@ +//! `cargo-machete` dependency detector +//! +//! This module provides detection logic for the `cargo-machete` dependency. + +// External crates use tracing::info; +// Internal crate use crate::command::command_exists; -use crate::detector::ToolDetector; -use crate::errors::DetectionError; +use crate::Dependency; + +use super::{DependencyDetector, DetectionError}; -/// Detector for `cargo-machete` tool +// ============================================================================ +// PUBLIC API - Main Types +// ============================================================================ + +/// Detector for `cargo-machete` dependency pub struct CargoMacheteDetector; -impl ToolDetector for CargoMacheteDetector { +// ============================================================================ +// PUBLIC API - Implementations +// ============================================================================ + +impl DependencyDetector for CargoMacheteDetector { fn name(&self) -> &'static str { "cargo-machete" } fn is_installed(&self) -> Result { info!( - tool = "cargo-machete", + dependency = "cargo-machete", "Checking if cargo-machete is installed" ); let installed = command_exists("cargo-machete").map_err(|e| DetectionError::DetectionFailed { - tool: self.name().to_string(), + dependency: Dependency::CargoMachete, source: std::io::Error::other(e.to_string()), })?; if installed { - info!(tool = "cargo-machete", "cargo-machete is installed"); + info!( + dependency = "cargo-machete", + status = "installed", + "cargo-machete is installed" + ); } else { - info!(tool = "cargo-machete", "cargo-machete is not installed"); + info!( + dependency = "cargo-machete", + status = "not installed", + "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 index df5560f..6437424 100644 --- a/packages/dependency-installer/src/detector/lxd.rs +++ b/packages/dependency-installer/src/detector/lxd.rs @@ -1,30 +1,49 @@ +//! `LXD` dependency detector +//! +//! This module provides detection logic for the `LXD` dependency. + +// External crates use tracing::info; +// Internal crate use crate::command::command_exists; -use crate::detector::ToolDetector; -use crate::errors::DetectionError; +use crate::Dependency; + +use super::{DependencyDetector, DetectionError}; -/// Detector for `LXD` tool +// ============================================================================ +// PUBLIC API - Main Types +// ============================================================================ + +/// Detector for `LXD` dependency pub struct LxdDetector; -impl ToolDetector for LxdDetector { +// ============================================================================ +// PUBLIC API - Implementations +// ============================================================================ + +impl DependencyDetector for LxdDetector { fn name(&self) -> &'static str { "LXD" } fn is_installed(&self) -> Result { - info!(tool = "lxd", "Checking if LXD is installed"); + info!(dependency = "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(), + dependency: Dependency::Lxd, source: std::io::Error::other(e.to_string()), })?; if installed { - info!(tool = "lxd", "LXD is installed"); + info!(dependency = "lxd", status = "installed", "LXD is installed"); } else { - info!(tool = "lxd", "LXD is not installed"); + info!( + dependency = "lxd", + status = "not installed", + "LXD is not installed" + ); } Ok(installed) diff --git a/packages/dependency-installer/src/detector/mod.rs b/packages/dependency-installer/src/detector/mod.rs index 16d0b4c..515800c 100644 --- a/packages/dependency-installer/src/detector/mod.rs +++ b/packages/dependency-installer/src/detector/mod.rs @@ -1,21 +1,34 @@ +//! Dependency detection system +//! +//! This module provides a trait-based system for detecting whether dependencies +//! are installed, along with implementations for specific tools. + pub mod ansible; pub mod cargo_machete; pub mod lxd; pub mod opentofu; -use crate::errors::DetectionError; +// External crates +use thiserror::Error; + +// Internal crate +use crate::Dependency; 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 +// ============================================================================ +// PUBLIC API - Traits +// ============================================================================ + +/// Trait for detecting if a dependency is installed +pub trait DependencyDetector { + /// Get the dependency name for display purposes fn name(&self) -> &'static str; - /// Check if the tool is already installed + /// Check if the dependency is already installed /// /// # Errors /// @@ -27,3 +40,24 @@ pub trait ToolDetector { None // Default implementation } } + +// ============================================================================ +// ERROR TYPES - Secondary Concerns +// ============================================================================ + +/// Error types for detection operations +#[derive(Debug, Error)] +pub enum DetectionError { + #[error("Failed to detect dependency '{dependency}': {source}")] + DetectionFailed { + dependency: Dependency, + #[source] + source: std::io::Error, + }, + + #[error("Command execution failed for dependency '{dependency}': {message}")] + CommandFailed { + dependency: Dependency, + message: String, + }, +} diff --git a/packages/dependency-installer/src/detector/opentofu.rs b/packages/dependency-installer/src/detector/opentofu.rs index 594a769..1f61ded 100644 --- a/packages/dependency-installer/src/detector/opentofu.rs +++ b/packages/dependency-installer/src/detector/opentofu.rs @@ -1,29 +1,52 @@ +//! `OpenTofu` dependency detector +//! +//! This module provides detection logic for the `OpenTofu` dependency. + +// External crates use tracing::info; +// Internal crate use crate::command::command_exists; -use crate::detector::ToolDetector; -use crate::errors::DetectionError; +use crate::Dependency; + +use super::{DependencyDetector, DetectionError}; -/// Detector for `OpenTofu` tool +// ============================================================================ +// PUBLIC API - Main Types +// ============================================================================ + +/// Detector for `OpenTofu` dependency pub struct OpenTofuDetector; -impl ToolDetector for OpenTofuDetector { +// ============================================================================ +// PUBLIC API - Implementations +// ============================================================================ + +impl DependencyDetector for OpenTofuDetector { fn name(&self) -> &'static str { "OpenTofu" } fn is_installed(&self) -> Result { - info!(tool = "opentofu", "Checking if OpenTofu is installed"); + info!(dependency = "opentofu", "Checking if OpenTofu is installed"); let installed = command_exists("tofu").map_err(|e| DetectionError::DetectionFailed { - tool: self.name().to_string(), + dependency: Dependency::OpenTofu, source: std::io::Error::other(e.to_string()), })?; if installed { - info!(tool = "opentofu", "OpenTofu is installed"); + info!( + dependency = "opentofu", + status = "installed", + "OpenTofu is installed" + ); } else { - info!(tool = "opentofu", "OpenTofu is not installed"); + info!( + dependency = "opentofu", + status = "not installed", + "OpenTofu is not installed" + ); } Ok(installed) diff --git a/packages/dependency-installer/src/errors.rs b/packages/dependency-installer/src/errors.rs deleted file mode 100644 index 851fdd9..0000000 --- a/packages/dependency-installer/src/errors.rs +++ /dev/null @@ -1,29 +0,0 @@ -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/handlers/check.rs b/packages/dependency-installer/src/handlers/check.rs new file mode 100644 index 0000000..20a77fe --- /dev/null +++ b/packages/dependency-installer/src/handlers/check.rs @@ -0,0 +1,205 @@ +//! Check command handler +//! +//! This module handles checking whether dependencies are installed. + +// External crates +use thiserror::Error; +use tracing::info; + +// Internal crate +use crate::detector::DetectionError; +use crate::{Dependency, DependencyManager}; + +// ============================================================================ +// PUBLIC API - Functions +// ============================================================================ + +/// Handle the check command +/// +/// # Errors +/// +/// Returns an error if: +/// - Dependencies are missing +/// - Internal error occurs during dependency checking +pub fn handle_check( + manager: &DependencyManager, + dependency: Option, +) -> Result<(), CheckError> { + match dependency { + Some(dep) => check_specific_dependency(manager, dep)?, + None => check_all_dependencies(manager)?, + } + + Ok(()) +} + +// ============================================================================ +// PRIVATE - Helper Functions +// ============================================================================ + +fn check_all_dependencies(manager: &DependencyManager) -> Result<(), CheckAllDependenciesError> { + info!("Checking all dependencies"); + + let results = manager.check_all()?; + + let mut missing_count = 0; + + for result in &results { + let detector = manager.get_detector(result.dependency); + let name = detector.name(); + if result.installed { + info!( + dependency = name, + status = "installed", + "Dependency check result" + ); + } else { + info!( + dependency = name, + status = "not installed", + "Dependency check result" + ); + missing_count += 1; + } + } + + if missing_count > 0 { + info!( + missing_count, + total_count = results.len(), + "Missing dependencies" + ); + Err(CheckAllDependenciesError::MissingDependencies { + missing_count, + total_count: results.len(), + }) + } else { + info!("All dependencies are installed"); + Ok(()) + } +} + +fn check_specific_dependency( + manager: &DependencyManager, + dependency: Dependency, +) -> Result<(), CheckSpecificDependencyError> { + info!(dependency = %dependency, "Checking specific dependency"); + + let detector = manager.get_detector(dependency); + + let installed = detector.is_installed()?; + + if installed { + info!( + dependency = detector.name(), + status = "installed", + "Dependency is installed" + ); + Ok(()) + } else { + info!( + dependency = detector.name(), + status = "not installed", + "Dependency is not installed" + ); + Err(CheckSpecificDependencyError::DependencyNotInstalled { dependency }) + } +} + +// ============================================================================ +// ERROR TYPES - Secondary Concerns +// ============================================================================ + +/// Errors that can occur when handling the check command +#[derive(Debug, Error)] +pub enum CheckError { + /// Failed to check all dependencies + /// + /// This occurs when checking all dependencies at once. + #[error("Failed to check all dependencies: {source}")] + CheckAllFailed { + #[source] + source: CheckAllDependenciesError, + }, + + /// Failed to check a specific dependency + /// + /// This occurs when checking a single specified dependency. + #[error("Failed to check specific dependency: {source}")] + CheckSpecificFailed { + #[source] + source: CheckSpecificDependencyError, + }, +} + +impl From for CheckError { + fn from(source: CheckAllDependenciesError) -> Self { + Self::CheckAllFailed { source } + } +} + +impl From for CheckError { + fn from(source: CheckSpecificDependencyError) -> Self { + Self::CheckSpecificFailed { source } + } +} + +/// Errors that can occur when checking all dependencies +#[derive(Debug, Error)] +pub enum CheckAllDependenciesError { + /// Failed to check dependencies + /// + /// This occurs when the dependency detection system fails to check + /// the status of installed tools. + #[error("Failed to check dependencies: {source}")] + DependencyCheckFailed { + #[source] + source: DetectionError, + }, + + /// One or more dependencies are missing + /// + /// This occurs when required tools are not installed on the system. + #[error("Missing {missing_count} out of {total_count} required dependencies")] + MissingDependencies { + /// Number of missing dependencies + missing_count: usize, + /// Total number of dependencies checked + total_count: usize, + }, +} + +impl From for CheckAllDependenciesError { + fn from(source: DetectionError) -> Self { + Self::DependencyCheckFailed { source } + } +} + +/// Errors that can occur when checking a specific dependency +#[derive(Debug, Error)] +pub enum CheckSpecificDependencyError { + /// Failed to detect if the dependency is installed + /// + /// This occurs when the dependency detection system fails to check + /// whether a specific dependency is installed. + #[error("Failed to detect dependency installation: {source}")] + DetectionFailed { + #[source] + source: DetectionError, + }, + + /// Dependency is not installed + /// + /// This occurs when the specified dependency is not found on the system. + #[error("{dependency}: not installed")] + DependencyNotInstalled { + /// The dependency that is not installed + dependency: Dependency, + }, +} + +impl From for CheckSpecificDependencyError { + fn from(source: DetectionError) -> Self { + Self::DetectionFailed { source } + } +} diff --git a/packages/dependency-installer/src/handlers/list.rs b/packages/dependency-installer/src/handlers/list.rs new file mode 100644 index 0000000..79d2a6a --- /dev/null +++ b/packages/dependency-installer/src/handlers/list.rs @@ -0,0 +1,63 @@ +//! List command handler +//! +//! This module handles listing all available dependencies and their status. + +// External crates +use thiserror::Error; +use tracing::info; + +// Internal crate +use crate::detector::DetectionError; +use crate::DependencyManager; + +// ============================================================================ +// PUBLIC API - Functions +// ============================================================================ + +/// Handle the list command +/// +/// # Errors +/// +/// Returns an error if dependency checking fails +pub fn handle_list(manager: &DependencyManager) -> Result<(), ListError> { + info!("Listing all available dependencies"); + + let results = manager.check_all()?; + + for result in results { + let detector = manager.get_detector(result.dependency); + let name = detector.name(); + let status = if result.installed { + "installed" + } else { + "not installed" + }; + info!(dependency = name, status, "Available dependency"); + } + + Ok(()) +} + +// ============================================================================ +// ERROR TYPES - Secondary Concerns +// ============================================================================ + +/// Errors that can occur when listing dependencies +#[derive(Debug, Error)] +pub enum ListError { + /// Failed to check dependencies + /// + /// This occurs when the dependency detection system fails to check + /// the status of installed tools. + #[error("Failed to check dependencies: {source}")] + DependencyCheckFailed { + #[source] + source: DetectionError, + }, +} + +impl From for ListError { + fn from(source: DetectionError) -> Self { + Self::DependencyCheckFailed { source } + } +} diff --git a/packages/dependency-installer/src/handlers/mod.rs b/packages/dependency-installer/src/handlers/mod.rs new file mode 100644 index 0000000..0cfe383 --- /dev/null +++ b/packages/dependency-installer/src/handlers/mod.rs @@ -0,0 +1,6 @@ +//! Command handlers for the dependency installer CLI +//! +//! This module contains handlers for different CLI commands. + +pub mod check; +pub mod list; diff --git a/packages/dependency-installer/src/lib.rs b/packages/dependency-installer/src/lib.rs index 7f44955..9398bc5 100644 --- a/packages/dependency-installer/src/lib.rs +++ b/packages/dependency-installer/src/lib.rs @@ -1,19 +1,11 @@ +pub mod app; +pub mod cli; pub mod command; pub mod detector; -pub mod errors; +pub mod handlers; +pub mod logging; pub mod manager; pub use detector::*; -pub use errors::*; +pub use logging::*; 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/logging.rs b/packages/dependency-installer/src/logging.rs new file mode 100644 index 0000000..bd41c49 --- /dev/null +++ b/packages/dependency-installer/src/logging.rs @@ -0,0 +1,17 @@ +//! Logging configuration for the dependency installer + +/// Initialize tracing with the specified log level +/// +/// If `level` is `None`, logging is disabled completely. +pub fn init_tracing(level: Option) { + if let Some(max_level) = level { + tracing_subscriber::fmt() + .with_target(true) + .with_thread_ids(false) + .with_thread_names(false) + .with_level(true) + .with_max_level(max_level) + .init(); + } + // If level is None (Off), don't initialize tracing at all +} diff --git a/packages/dependency-installer/src/manager.rs b/packages/dependency-installer/src/manager.rs index d01883e..029e3e4 100644 --- a/packages/dependency-installer/src/manager.rs +++ b/packages/dependency-installer/src/manager.rs @@ -1,17 +1,24 @@ +//! Dependency management and detection coordination +//! +//! This module provides the main dependency manager that coordinates detection +//! operations for all supported dependencies. + +// Standard library +use std::fmt; +use std::str::FromStr; + +// Internal crate use crate::detector::{ - AnsibleDetector, CargoMacheteDetector, LxdDetector, OpenTofuDetector, ToolDetector, + AnsibleDetector, CargoMacheteDetector, DependencyDetector, DetectionError, LxdDetector, + OpenTofuDetector, }; -use crate::errors::DetectionError; -/// Result of checking a single dependency -#[derive(Debug, Clone)] -pub struct CheckResult { - pub tool: String, - pub installed: bool, -} +// ============================================================================ +// PUBLIC API - Main Types +// ============================================================================ /// Enum representing available dependencies -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Dependency { CargoMachete, OpenTofu, @@ -19,23 +26,68 @@ pub enum Dependency { Lxd, } +/// Result of checking a single dependency +#[derive(Debug, Clone)] +pub struct CheckResult { + /// The dependency that was checked + pub dependency: Dependency, + /// Whether the dependency is installed + pub installed: bool, +} + /// Main dependency manager for detection operations -pub struct DependencyManager { - detectors: Vec>, +pub struct DependencyManager; + +// ============================================================================ +// PUBLIC API - Implementations +// ============================================================================ + +impl Dependency { + /// Returns all available dependencies + #[must_use] + pub const fn all() -> &'static [Self] { + &[Self::CargoMachete, Self::OpenTofu, Self::Ansible, Self::Lxd] + } + + /// Returns the canonical name for this dependency + #[must_use] + pub const fn canonical_name(&self) -> &'static str { + match self { + Self::CargoMachete => "cargo-machete", + Self::OpenTofu => "opentofu", + Self::Ansible => "ansible", + Self::Lxd => "lxd", + } + } +} + +impl fmt::Display for Dependency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.canonical_name()) + } +} + +impl FromStr for Dependency { + type Err = DependencyParseError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "cargo-machete" | "machete" => Ok(Self::CargoMachete), + "opentofu" | "tofu" => Ok(Self::OpenTofu), + "ansible" => Ok(Self::Ansible), + "lxd" => Ok(Self::Lxd), + _ => Err(DependencyParseError::UnknownDependency { + name: s.to_string(), + }), + } + } } impl DependencyManager { - /// Create a new dependency manager with all detectors + /// Create a new dependency manager #[must_use] - pub fn new() -> Self { - Self { - detectors: vec![ - Box::new(CargoMacheteDetector), - Box::new(OpenTofuDetector), - Box::new(AnsibleDetector), - Box::new(LxdDetector), - ], - } + pub const fn new() -> Self { + Self } /// Check all dependencies and return results @@ -44,12 +96,13 @@ impl DependencyManager { /// /// Returns an error if any detection operation fails pub fn check_all(&self) -> Result, DetectionError> { - self.detectors + Dependency::all() .iter() - .map(|detector| { + .map(|&dependency| { + let detector = self.get_detector(dependency); let installed = detector.is_installed()?; Ok(CheckResult { - tool: detector.name().to_string(), + dependency, installed, }) }) @@ -61,7 +114,7 @@ impl DependencyManager { /// 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 { + pub fn get_detector(&self, dep: Dependency) -> Box { match dep { Dependency::CargoMachete => Box::new(CargoMacheteDetector), Dependency::OpenTofu => Box::new(OpenTofuDetector), @@ -76,3 +129,31 @@ impl Default for DependencyManager { Self::new() } } + +// ============================================================================ +// ERROR TYPES - Secondary Concerns +// ============================================================================ + +/// Error that occurs when parsing a dependency name +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DependencyParseError { + /// Unknown dependency name provided + UnknownDependency { name: String }, +} + +impl fmt::Display for DependencyParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownDependency { name } => { + let available = Dependency::all() + .iter() + .map(Dependency::canonical_name) + .collect::>() + .join(", "); + write!(f, "Unknown dependency: {name}. Available: {available}") + } + } + } +} + +impl std::error::Error for DependencyParseError {} diff --git a/packages/dependency-installer/tests/containers/command_output.rs b/packages/dependency-installer/tests/containers/command_output.rs new file mode 100644 index 0000000..687fcb1 --- /dev/null +++ b/packages/dependency-installer/tests/containers/command_output.rs @@ -0,0 +1,100 @@ +//! Command output representation for container execution results. +//! +//! This module provides a type to represent the output from executing commands +//! in Docker containers, capturing both stdout and stderr streams. + +use std::fmt; + +/// Output from executing a command in a container. +/// +/// This type captures both stdout and stderr streams separately, allowing +/// tests to inspect either stream individually or combined. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandOutput { + /// Standard output stream + stdout: String, + /// Standard error stream + stderr: String, +} + +impl CommandOutput { + /// Create a new command output from stdout and stderr strings. + pub fn new(stdout: String, stderr: String) -> Self { + Self { stdout, stderr } + } + + /// Get the stdout content. + pub fn stdout(&self) -> &str { + &self.stdout + } + + /// Get the stderr content. + pub fn stderr(&self) -> &str { + &self.stderr + } + + /// Get both stdout and stderr combined. + /// + /// Returns stderr followed by stdout, matching the typical order + /// where logs (stderr) appear before user output (stdout). + pub fn combined(&self) -> String { + format!("{}{}", self.stderr, self.stdout) + } + + /// Check if either stdout or stderr contains the given string. + /// + /// This is useful in tests where you don't care which stream + /// contains the expected output. + pub fn contains(&self, needle: &str) -> bool { + self.stdout.contains(needle) || self.stderr.contains(needle) + } +} + +impl fmt::Display for CommandOutput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.combined()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_command_output_with_separate_stdout_and_stderr() { + let output = CommandOutput::new("hello".to_string(), "error".to_string()); + assert_eq!(output.stdout(), "hello"); + assert_eq!(output.stderr(), "error"); + } + + #[test] + fn it_should_combine_stderr_and_stdout_with_stderr_first() { + let output = + CommandOutput::new("stdout content".to_string(), "stderr content\n".to_string()); + assert_eq!(output.combined(), "stderr content\nstdout content"); + } + + #[test] + fn it_should_find_text_in_stdout_when_checking_contains() { + let output = CommandOutput::new("hello world".to_string(), String::new()); + assert!(output.contains("hello")); + assert!(output.contains("world")); + assert!(!output.contains("missing")); + } + + #[test] + fn it_should_find_text_in_stderr_when_checking_contains() { + let output = CommandOutput::new(String::new(), "error message".to_string()); + assert!(output.contains("error")); + assert!(output.contains("message")); + assert!(!output.contains("missing")); + } + + #[test] + fn it_should_find_text_in_either_stream_when_checking_contains() { + let output = CommandOutput::new("stdout text".to_string(), "stderr text".to_string()); + assert!(output.contains("stdout")); + assert!(output.contains("stderr")); + assert!(output.contains("text")); + } +} diff --git a/packages/dependency-installer/tests/containers/container_id.rs b/packages/dependency-installer/tests/containers/container_id.rs new file mode 100644 index 0000000..bff0b1e --- /dev/null +++ b/packages/dependency-installer/tests/containers/container_id.rs @@ -0,0 +1,62 @@ +//! Docker container identifier type + +use std::ffi::OsStr; +use std::fmt; + +/// Docker container identifier +/// +/// A validated Docker container ID, which is a hexadecimal string. +/// Docker generates these IDs and guarantees they contain only hex characters (0-9, a-f). +/// +/// # Examples +/// +/// ``` +/// # use std::path::PathBuf; +/// // Container IDs come from Docker/testcontainers and are always valid hex strings +/// let id = ContainerId::new("a1b2c3d4e5f6".to_string()).expect("valid hex string"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerId(String); + +impl ContainerId { + /// Create a new container ID with validation + /// + /// # Arguments + /// + /// * `id` - The container ID string (must be hexadecimal) + /// + /// # Returns + /// + /// `Ok(ContainerId)` if valid, `Err` with error message if invalid + /// + /// # Validation Rules + /// + /// - Must not be empty + /// - Must contain only hexadecimal characters (0-9, a-f, A-F) + /// - Typically 12 characters (short form) or 64 characters (full SHA256) + pub fn new(id: String) -> Result { + if id.is_empty() { + return Err("Container ID cannot be empty".to_string()); + } + + if !id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "Container ID must contain only hexadecimal characters, got: '{id}'" + )); + } + + Ok(Self(id)) + } +} + +impl fmt::Display for ContainerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for ContainerId { + fn as_ref(&self) -> &OsStr { + OsStr::new(&self.0) + } +} diff --git a/packages/dependency-installer/tests/containers/mod.rs b/packages/dependency-installer/tests/containers/mod.rs new file mode 100644 index 0000000..86a0817 --- /dev/null +++ b/packages/dependency-installer/tests/containers/mod.rs @@ -0,0 +1,8 @@ +//! Container utilities for Docker-based integration testing +//! +//! This module provides helper types and functions for managing test containers. + +pub(super) mod command_output; +pub(super) mod container_id; +pub(super) mod running_binary_container; +pub mod ubuntu_container_builder; diff --git a/packages/dependency-installer/tests/containers/running_binary_container.rs b/packages/dependency-installer/tests/containers/running_binary_container.rs new file mode 100644 index 0000000..f874c0a --- /dev/null +++ b/packages/dependency-installer/tests/containers/running_binary_container.rs @@ -0,0 +1,121 @@ +//! Running container with an installed binary + +use std::path::Path; +use std::process::Command; + +use testcontainers::{ContainerAsync, GenericImage}; + +use super::command_output::CommandOutput; +use super::container_id::ContainerId; + +/// Exit code returned by a command execution +pub type ExitCode = i32; + +/// A running Ubuntu container with a binary installed and ready to execute +/// +/// This struct provides methods for executing commands and managing a running +/// Ubuntu container that has been prepared with a binary. It handles the container +/// lifecycle, ensuring the container stays alive while tests run, and provides +/// convenient methods for command execution and file operations. +pub struct RunningBinaryContainer { + // Keep a reference to the container so it stays alive + #[allow(dead_code)] + container: ContainerAsync, + container_id: ContainerId, +} + +impl RunningBinaryContainer { + /// Create a new running binary container + /// + /// # Arguments + /// + /// * `container` - The running Docker container + /// * `container_id` - The validated container ID + pub(super) fn new(container: ContainerAsync, container_id: ContainerId) -> Self { + Self { + container, + container_id, + } + } + + /// Execute a command in the container and return the output + /// + /// # Arguments + /// + /// * `command` - Command and arguments to execute + /// + /// # Returns + /// + /// A `CommandOutput` containing both stdout and stderr streams + /// + /// # Note + /// + /// The CLI uses tracing which writes logs to stderr, while user-facing messages + /// go to stdout. The `CommandOutput` type allows tests to inspect either stream + /// individually or combined. + pub fn exec(&self, command: &[&str]) -> CommandOutput { + let output = Command::new("docker") + .arg("exec") + .arg(&self.container_id) + .args(command) + .output() + .expect("Failed to execute docker exec command"); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + CommandOutput::new(stdout, stderr) + } + + /// Execute a command and return the exit code + /// + /// # Arguments + /// + /// * `command` - Command and arguments to execute + /// + /// # Returns + /// + /// The exit code of the command, or 1 if the process was terminated by a signal + /// + /// # Note + /// + /// If the process was terminated by a signal (returns None from `code()`), we return 1 + /// to indicate failure rather than 0, which would incorrectly suggest success. + pub fn exec_with_exit_code(&self, command: &[&str]) -> ExitCode { + let status = Command::new("docker") + .arg("exec") + .arg(&self.container_id) + .args(command) + .status() + .expect("Failed to execute docker exec command"); + + // Return 1 (failure) if terminated by signal, otherwise use actual exit code + status.code().unwrap_or(1) + } + + /// Copy a file from the host into this running container + /// + /// This method uses Docker CLI to copy files into the running container. + /// + /// # Arguments + /// + /// * `source_path` - Path to the file on the host system + /// * `dest_path` - Destination path inside the container + /// + /// # Panics + /// + /// Panics if the Docker copy command fails + pub(super) fn copy_file_to_container(&self, source_path: &Path, dest_path: &str) { + let output = Command::new("docker") + .arg("cp") + .arg(source_path) + .arg(format!("{}:{dest_path}", self.container_id)) + .output() + .expect("Failed to execute docker cp command"); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("Failed to copy file to container: {stderr}"); + } + } +} diff --git a/packages/dependency-installer/tests/containers/ubuntu_container_builder.rs b/packages/dependency-installer/tests/containers/ubuntu_container_builder.rs new file mode 100644 index 0000000..ea87ad6 --- /dev/null +++ b/packages/dependency-installer/tests/containers/ubuntu_container_builder.rs @@ -0,0 +1,70 @@ +//! Builder for Ubuntu test containers + +use std::path::{Path, PathBuf}; + +use testcontainers::{core::WaitFor, runners::AsyncRunner, GenericImage, ImageExt}; + +use super::container_id::ContainerId; +use super::running_binary_container::RunningBinaryContainer; + +/// Builder for creating Ubuntu test containers with a binary +/// +/// This builder provides a fluent API for configuring and starting Ubuntu containers +/// with a binary installed. Call `new()` with the binary path, then `start().await` +/// to launch the container and install the binary. +pub struct UbuntuContainerBuilder { + binary_path: PathBuf, +} + +impl UbuntuContainerBuilder { + /// Create a new Ubuntu container builder + /// + /// # Arguments + /// + /// * `binary_path` - Path to the binary on the host + /// + /// # Returns + /// + /// A builder that can start the container with the binary + pub fn new(binary_path: &Path) -> Self { + Self { + binary_path: binary_path.to_path_buf(), + } + } + + /// Start the container with the binary + /// + /// This method: + /// 1. Starts an Ubuntu 24.04 container + /// 2. Copies the binary into the container + /// 3. Makes the binary executable + /// + /// # Returns + /// + /// A running container ready for test execution + pub async fn start(self) -> RunningBinaryContainer { + // Create Ubuntu 24.04 image + let image = GenericImage::new("ubuntu", "24.04") + .with_wait_for(WaitFor::seconds(2)) + .with_cmd(vec!["sleep", "infinity"]); + + // Start the container + let container = image.start().await.expect("Failed to start container"); + + // Get container ID for docker CLI operations + let container_id = ContainerId::new(container.id().to_string()) + .expect("Docker container ID should always be valid hexadecimal"); + + // Create the container wrapper + let test_container = RunningBinaryContainer::new(container, container_id); + + // Copy the binary into the container + test_container + .copy_file_to_container(&self.binary_path, "/usr/local/bin/dependency-installer"); + + // Make the binary executable + test_container.exec(&["chmod", "+x", "/usr/local/bin/dependency-installer"]); + + test_container + } +} diff --git a/packages/dependency-installer/tests/detector_tests.rs b/packages/dependency-installer/tests/detector_tests.rs index 50822a2..d0561ce 100644 --- a/packages/dependency-installer/tests/detector_tests.rs +++ b/packages/dependency-installer/tests/detector_tests.rs @@ -1,13 +1,13 @@ //! Unit tests for detector functionality //! -//! Tests for the `ToolDetector` trait implementations including: +//! Tests for the `DependencyDetector` trait implementations including: //! - Individual detector implementations //! - `DependencyManager` functionality //! - Error handling use torrust_dependency_installer::{ - AnsibleDetector, CargoMacheteDetector, CheckResult, Dependency, DependencyManager, LxdDetector, - OpenTofuDetector, ToolDetector, + AnsibleDetector, CargoMacheteDetector, CheckResult, Dependency, DependencyDetector, + DependencyManager, LxdDetector, OpenTofuDetector, }; // ============================================================================= @@ -101,16 +101,14 @@ fn it_should_return_no_required_version_by_default_for_all_detectors() { #[test] fn it_should_create_dependency_manager() { - let manager = DependencyManager::new(); + let _manager = DependencyManager::new(); // Should not panic - drop(manager); } #[test] fn it_should_create_dependency_manager_with_default() { - let manager = DependencyManager::default(); + let _manager = DependencyManager::new(); // Should not panic - drop(manager); } #[test] @@ -121,16 +119,16 @@ fn it_should_check_all_dependencies_without_error() { // 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 + // Verify we get results for all 4 dependencies 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())); + // Verify all expected dependencies are in results + let dependencies: Vec = check_results.iter().map(|r| r.dependency).collect(); + assert!(dependencies.contains(&Dependency::CargoMachete)); + assert!(dependencies.contains(&Dependency::OpenTofu)); + assert!(dependencies.contains(&Dependency::Ansible)); + assert!(dependencies.contains(&Dependency::Lxd)); } #[test] @@ -167,24 +165,26 @@ fn it_should_get_lxd_detector_from_manager() { #[test] fn it_should_create_check_result() { + use torrust_dependency_installer::Dependency; let result = CheckResult { - tool: "test-tool".to_string(), + dependency: Dependency::CargoMachete, installed: true, }; - assert_eq!(result.tool, "test-tool"); + assert_eq!(result.dependency, Dependency::CargoMachete); assert!(result.installed); } #[test] fn it_should_clone_check_result() { + use torrust_dependency_installer::Dependency; let result = CheckResult { - tool: "test-tool".to_string(), + dependency: Dependency::OpenTofu, installed: false, }; let cloned = result.clone(); - assert_eq!(cloned.tool, "test-tool"); + assert_eq!(cloned.dependency, Dependency::OpenTofu); assert!(!cloned.installed); } diff --git a/packages/dependency-installer/tests/docker_check_command.rs b/packages/dependency-installer/tests/docker_check_command.rs new file mode 100644 index 0000000..43bf25a --- /dev/null +++ b/packages/dependency-installer/tests/docker_check_command.rs @@ -0,0 +1,144 @@ +//! Integration tests for the dependency-installer CLI using Docker containers. +//! +//! These tests verify that the CLI binary works correctly in a clean Ubuntu 24.04 +//! environment. They use testcontainers to spin up isolated Docker containers. + +use std::path::PathBuf; + +mod containers; +use containers::ubuntu_container_builder::UbuntuContainerBuilder; + +/// Test that the check command correctly identifies missing dependencies +/// in a fresh Ubuntu 24.04 container +#[tokio::test] +async fn it_should_report_missing_dependencies_when_checking_all_in_fresh_ubuntu_container() { + // Get the binary path (built by cargo before running tests) + let binary_path = get_binary_path(); + + // Start Ubuntu container with the binary + let container = UbuntuContainerBuilder::new(&binary_path).start().await; + + // Verify exit code is non-zero (failure) when dependencies are missing + let exit_code = + container.exec_with_exit_code(&["dependency-installer", "check", "--log-level", "off"]); + assert_eq!( + exit_code, 1, + "check command should exit with 1 when dependencies missing" + ); +} + +/// Test that the check command works for specific dependencies +#[tokio::test] +async fn it_should_exit_with_error_code_when_checking_missing_specific_dependency() { + let binary_path = get_binary_path(); + + let container = UbuntuContainerBuilder::new(&binary_path).start().await; + + // Verify exit code when checking missing specific dependency + let exit_code = container.exec_with_exit_code(&[ + "dependency-installer", + "check", + "--dependency", + "opentofu", + "--log-level", + "off", + ]); + assert_eq!( + exit_code, 1, + "check command should exit with 1 for missing specific dependency" + ); +} + +/// Test that the list command works correctly +#[tokio::test] +async fn it_should_list_all_dependencies_with_their_installation_status() { + let binary_path = get_binary_path(); + + let container = UbuntuContainerBuilder::new(&binary_path).start().await; + + // Run list command with default log level (info) to verify logging output + let output = container.exec(&["dependency-installer", "list"]); + + // Verify all tools are listed in the logging output + assert!( + output.contains("cargo-machete"), + "Expected cargo-machete to be listed, got: {output}" + ); + assert!( + output.contains("OpenTofu"), + "Expected OpenTofu to be listed, got: {output}" + ); + assert!( + output.contains("Ansible"), + "Expected Ansible to be listed, got: {output}" + ); + assert!( + output.contains("LXD"), + "Expected LXD to be listed, got: {output}" + ); + + // Verify status is shown + assert!( + output.contains("not installed"), + "Expected 'not installed' status to be shown, got: {output}" + ); +} + +/// Test verbose output flag +#[tokio::test] +async fn it_should_display_debug_logs_when_verbose_flag_is_enabled() { + let binary_path = get_binary_path(); + + let container = UbuntuContainerBuilder::new(&binary_path).start().await; + + let output = container.exec(&["dependency-installer", "check", "--verbose"]); + + // Verify debug/info logs are present + // The CLI uses tracing, so we should see timestamp-prefixed log messages + assert!( + output.contains("INFO") || output.contains("Checking"), + "Expected verbose output to contain INFO logs or 'Checking' message, got: {output}" + ); +} + +/// Get the path to the compiled binary +/// +/// This function assumes the binary was built before running tests. +/// Run `cargo build --bin dependency-installer` before running these tests. +/// +/// # Implementation Note +/// +/// We use `CARGO_MANIFEST_DIR` and navigate up to the workspace root, then into +/// the target directory. This works because: +/// 1. `CARGO_MANIFEST_DIR` points to packages/dependency-installer +/// 2. The workspace root is two directories up +/// 3. The target directory is in the workspace root +/// +/// Alternative approaches considered: +/// - `CARGO_TARGET_DIR`: Not always set +/// - `OUT_DIR`: Points to build script output, not target/debug +/// - Searching for target dir: Too expensive +fn get_binary_path() -> PathBuf { + // Get the package manifest directory (packages/dependency-installer) + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + // Navigate to workspace root (two levels up from packages/dependency-installer) + let workspace_root = manifest_dir + .parent() // packages/ + .and_then(|p| p.parent()) // workspace root + .expect("Failed to find workspace root"); + + // Build path to the binary in target/debug + let path = workspace_root + .join("target") + .join("debug") + .join("dependency-installer"); + + assert!( + path.exists(), + "Binary not found at {}. Run 'cargo build --bin dependency-installer' first", + path.display() + ); + + path +} diff --git a/project-words.txt b/project-words.txt index 04b7402..495d050 100644 --- a/project-words.txt +++ b/project-words.txt @@ -57,6 +57,7 @@ Gossman Graça handleable Herberto +hexdigit hexdump Hostnames hotfixes