diff --git a/Cargo.lock b/Cargo.lock index a69c90e..e22feec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ [[package]] name = "avocadoctl" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "serde", diff --git a/Cargo.toml b/Cargo.toml index fc6d132..19b6d15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "avocadoctl" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Avocado Linux control CLI tool" authors = ["Avocado"] diff --git a/src/commands/ext.rs b/src/commands/ext.rs index 46b88a1..cdd7b2b 100644 --- a/src/commands/ext.rs +++ b/src/commands/ext.rs @@ -13,6 +13,7 @@ use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; #[derive(Debug, Clone)] struct Extension { name: String, + version: Option, // Version extracted from filename (e.g., "1.0.0" from "app-1.0.0.raw") path: PathBuf, is_sysext: bool, is_confext: bool, @@ -390,6 +391,349 @@ pub fn refresh_extensions_direct(output: &OutputManager) { refresh_extensions(output); } +/// Enable extensions for a specific OS release version +pub fn enable_extensions( + os_release_version: Option<&str>, + extensions: &[&str], + config: &Config, + output: &OutputManager, +) { + // Determine the OS release version to use + let version_id = if let Some(version) = os_release_version { + version.to_string() + } else { + read_os_version_id() + }; + + output.info( + "Enable Extensions", + &format!("Enabling extensions for OS release version: {version_id}"), + ); + + // Get the extensions directory from config + let extensions_dir = config.get_extensions_dir(); + + // Determine os-releases directory based on test mode + let os_releases_dir = if std::env::var("AVOCADO_TEST_MODE").is_ok() { + let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); + format!("{temp_base}/avocado/os-releases/{version_id}") + } else { + format!("/var/lib/avocado/os-releases/{version_id}") + }; + + // Create the os-releases directory if it doesn't exist + if let Err(e) = fs::create_dir_all(&os_releases_dir) { + output.error( + "Enable Extensions", + &format!("Failed to create os-releases directory '{os_releases_dir}': {e}"), + ); + std::process::exit(1); + } + + // Sync the parent directory to ensure the os-releases directory is persisted + if let Err(e) = sync_directory( + Path::new(&os_releases_dir) + .parent() + .unwrap_or(Path::new("/")), + ) { + output.progress(&format!("Warning: Failed to sync parent directory: {e}")); + } + + output.step( + "Enable", + &format!("Created os-releases directory: {os_releases_dir}"), + ); + + // Process each extension + let mut success_count = 0; + let mut error_count = 0; + + for ext_name in extensions { + // Check if extension exists - try both directory and .raw file + let ext_dir_path = format!("{}/{}", extensions_dir, ext_name); + let ext_raw_path = format!("{}/{}.raw", extensions_dir, ext_name); + + let source_path = if Path::new(&ext_dir_path).exists() { + ext_dir_path + } else if Path::new(&ext_raw_path).exists() { + ext_raw_path + } else { + output.error( + "Enable Extensions", + &format!("Extension '{ext_name}' not found in {extensions_dir}"), + ); + error_count += 1; + continue; + }; + + // Create symlink in os-releases directory + let target_path = format!( + "{}/{}", + os_releases_dir, + Path::new(&source_path) + .file_name() + .unwrap() + .to_string_lossy() + ); + + // Remove existing symlink if it exists + if Path::new(&target_path).exists() { + if let Err(e) = fs::remove_file(&target_path) { + output.error( + "Enable Extensions", + &format!("Failed to remove existing symlink '{target_path}': {e}"), + ); + error_count += 1; + continue; + } + } + + // Create the symlink + if let Err(e) = unix_fs::symlink(&source_path, &target_path) { + output.error( + "Enable Extensions", + &format!("Failed to create symlink for '{ext_name}': {e}"), + ); + error_count += 1; + } else { + output.progress(&format!("Enabled extension: {ext_name}")); + success_count += 1; + } + } + + // Sync the os-releases directory to ensure all symlinks are persisted to disk + if success_count > 0 { + if let Err(e) = sync_directory(Path::new(&os_releases_dir)) { + output.error( + "Enable Extensions", + &format!("Failed to sync os-releases directory to disk: {e}"), + ); + std::process::exit(1); + } + output.progress("Synced changes to disk"); + } + + // Summary + if error_count > 0 { + output.error( + "Enable Extensions", + &format!("Completed with errors: {success_count} succeeded, {error_count} failed"), + ); + std::process::exit(1); + } else { + output.success( + "Enable Extensions", + &format!( + "Successfully enabled {success_count} extension(s) for OS release {version_id}" + ), + ); + } +} + +/// Sync a directory to ensure all changes are persisted to disk +fn sync_directory(dir_path: &Path) -> Result<(), SystemdError> { + // Open the directory + let dir = fs::File::open(dir_path).map_err(|e| SystemdError::CommandFailed { + command: format!("open directory {}", dir_path.display()), + source: e, + })?; + + // Sync the directory to disk + // This ensures directory entries (like new symlinks) are persisted + dir.sync_all().map_err(|e| SystemdError::CommandFailed { + command: format!("sync directory {}", dir_path.display()), + source: e, + })?; + + Ok(()) +} + +/// Disable extensions for a specific OS release version +pub fn disable_extensions( + os_release_version: Option<&str>, + extensions: Option<&[&str]>, + all: bool, + _config: &Config, + output: &OutputManager, +) { + // Determine the OS release version to use + let version_id = if let Some(version) = os_release_version { + version.to_string() + } else { + read_os_version_id() + }; + + output.info( + "Disable Extensions", + &format!("Disabling extensions for OS release version: {version_id}"), + ); + + // Determine os-releases directory based on test mode + let os_releases_dir = if std::env::var("AVOCADO_TEST_MODE").is_ok() { + let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); + format!("{temp_base}/avocado/os-releases/{version_id}") + } else { + format!("/var/lib/avocado/os-releases/{version_id}") + }; + + // Check if os-releases directory exists + if !Path::new(&os_releases_dir).exists() { + output.error( + "Disable Extensions", + &format!("OS releases directory '{os_releases_dir}' does not exist"), + ); + std::process::exit(1); + } + + let mut success_count = 0; + let mut error_count = 0; + + if all { + // Disable all extensions by removing all symlinks in the os-releases directory + output.step("Disable", "Removing all extensions"); + + match fs::read_dir(&os_releases_dir) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => { + let path = entry.path(); + // Only remove symlinks, not regular files or directories + if path.is_symlink() { + if let Some(file_name) = path.file_name() { + if let Some(name_str) = file_name.to_str() { + match fs::remove_file(&path) { + Ok(_) => { + output.progress(&format!( + "Disabled extension: {name_str}" + )); + success_count += 1; + } + Err(e) => { + output.error( + "Disable Extensions", + &format!("Failed to remove symlink '{name_str}': {e}"), + ); + error_count += 1; + } + } + } + } + } + } + Err(e) => { + output.error( + "Disable Extensions", + &format!("Failed to read directory entry: {e}"), + ); + error_count += 1; + } + } + } + } + Err(e) => { + output.error( + "Disable Extensions", + &format!("Failed to read os-releases directory '{os_releases_dir}': {e}"), + ); + std::process::exit(1); + } + } + } else if let Some(ext_names) = extensions { + // Disable specific extensions + for ext_name in ext_names { + // Check for both directory and .raw file symlinks + let symlink_dir = format!("{}/{}", os_releases_dir, ext_name); + let symlink_raw = format!("{}/{}.raw", os_releases_dir, ext_name); + + let mut found = false; + + // Try to remove directory symlink + if Path::new(&symlink_dir).exists() { + match fs::remove_file(&symlink_dir) { + Ok(_) => { + output.progress(&format!("Disabled extension: {ext_name}")); + success_count += 1; + found = true; + } + Err(e) => { + output.error( + "Disable Extensions", + &format!("Failed to remove symlink for '{ext_name}': {e}"), + ); + error_count += 1; + found = true; + } + } + } + + // Try to remove .raw symlink + if Path::new(&symlink_raw).exists() { + match fs::remove_file(&symlink_raw) { + Ok(_) => { + if !found { + output.progress(&format!("Disabled extension: {ext_name}")); + success_count += 1; + } + found = true; + } + Err(e) => { + output.error( + "Disable Extensions", + &format!("Failed to remove .raw symlink for '{ext_name}': {e}"), + ); + error_count += 1; + found = true; + } + } + } + + if !found { + output.error( + "Disable Extensions", + &format!("Extension '{ext_name}' is not enabled for OS release {version_id}"), + ); + error_count += 1; + } + } + } else { + // This should not happen due to clap validation, but handle it anyway + output.error( + "Disable Extensions", + "No extensions specified. Use --all to disable all extensions or specify extension names.", + ); + std::process::exit(1); + } + + // Sync the os-releases directory to ensure all removals are persisted to disk + if success_count > 0 { + if let Err(e) = sync_directory(Path::new(&os_releases_dir)) { + output.error( + "Disable Extensions", + &format!("Failed to sync os-releases directory to disk: {e}"), + ); + std::process::exit(1); + } + output.progress("Synced changes to disk"); + } + + // Summary + if error_count > 0 { + output.error( + "Disable Extensions", + &format!("Completed with errors: {success_count} succeeded, {error_count} failed"), + ); + std::process::exit(1); + } else { + output.success( + "Disable Extensions", + &format!( + "Successfully disabled {success_count} extension(s) for OS release {version_id}" + ), + ); + } +} + /// Refresh extensions (unmerge then merge) pub fn refresh_extensions(output: &OutputManager) { let environment_info = if is_running_in_initrd() { @@ -500,7 +844,6 @@ fn show_legacy_status(output: &OutputManager) { #[derive(Debug, Clone)] struct MountedExtension { name: String, - since: String, #[allow(dead_code)] // May be used in future for hierarchy-specific logic hierarchy: String, } @@ -534,16 +877,6 @@ fn get_mounted_systemd_extensions(command: &str) -> Result .unwrap_or("unknown") .to_string(); - let since_timestamp = hierarchy_obj["since"].as_u64(); - let since = if let Some(ts) = since_timestamp { - // Convert microseconds timestamp to human readable format - let secs = ts / 1_000_000; - // For now, use a simple format. In the future we could add chrono for better formatting - format!("timestamp:{secs}") - } else { - "-".to_string() - }; - // Handle extensions field - can be string "none" or array of strings if let Some(extensions) = hierarchy_obj["extensions"].as_array() { // Array of extension names @@ -551,7 +884,6 @@ fn get_mounted_systemd_extensions(command: &str) -> Result if let Some(ext_name) = ext.as_str() { mounted.push(MountedExtension { name: ext_name.to_string(), - since: since.clone(), hierarchy: hierarchy.clone(), }); } @@ -561,7 +893,6 @@ fn get_mounted_systemd_extensions(command: &str) -> Result if ext_str != "none" { mounted.push(MountedExtension { name: ext_str.to_string(), - since: since.clone(), hierarchy: hierarchy.clone(), }); } @@ -577,17 +908,24 @@ fn display_extension_status( mounted_sysext: &[MountedExtension], mounted_confext: &[MountedExtension], ) -> Result<(), SystemdError> { - // Collect all unique extension names + // Collect all unique extension names (with versions if present) let mut all_extensions = std::collections::HashSet::new(); + // For available extensions, use versioned name if available for ext in available { - all_extensions.insert(&ext.name); + if let Some(ver) = &ext.version { + all_extensions.insert(format!("{}-{}", ext.name, ver)); + } else { + all_extensions.insert(ext.name.clone()); + } } + + // Add mounted extensions (these already include versions in their names) for ext in mounted_sysext { - all_extensions.insert(&ext.name); + all_extensions.insert(ext.name.clone()); } for ext in mounted_confext { - all_extensions.insert(&ext.name); + all_extensions.insert(ext.name.clone()); } if all_extensions.is_empty() { @@ -595,19 +933,16 @@ fn display_extension_status( return Ok(()); } - // Display header - println!( - "{:<20} {:<12} {:<15} {:<30} Mount Info", - "Extension", "Status", "Type", "Origin" - ); - println!("{}", "=".repeat(100)); + // Display header - optimized for 80 columns + println!("{:<24} {:<10} {:<12} Origin", "Extension", "Status", "Type"); + println!("{}", "=".repeat(79)); // Sort extensions for consistent display let mut sorted_extensions: Vec<_> = all_extensions.into_iter().collect(); sorted_extensions.sort(); for ext_name in sorted_extensions { - display_extension_info(ext_name, available, mounted_sysext, mounted_confext); + display_extension_info(&ext_name, available, mounted_sysext, mounted_confext); } // Display summary @@ -624,100 +959,73 @@ fn display_extension_info( mounted_sysext: &[MountedExtension], mounted_confext: &[MountedExtension], ) { - let available_ext = available.iter().find(|e| e.name == ext_name); + // Find extension in available list (match by full versioned name or base name) + let available_ext = available.iter().find(|e| { + if let Some(ver) = &e.version { + format!("{}-{}", e.name, ver) == ext_name + } else { + e.name == ext_name + } + }); + let sysext_mount = mounted_sysext.iter().find(|e| e.name == ext_name); let confext_mount = mounted_confext.iter().find(|e| e.name == ext_name); // Determine status let status = match (sysext_mount.is_some(), confext_mount.is_some()) { - (true, true) => "MOUNTED", + (true, true) => "MERGED", (true, false) => "SYSEXT", (false, true) => "CONFEXT", - (false, false) => "AVAILABLE", + (false, false) => { + if available_ext.is_some() { + "READY" + } else { + "UNKNOWN" + } + } }; // Determine types let mut types = Vec::new(); if let Some(ext) = available_ext { if ext.is_sysext { - types.push("sysext"); + types.push("sys"); } if ext.is_confext { - types.push("confext"); + types.push("conf"); } } let type_str = if types.is_empty() { - "unknown".to_string() + "?".to_string() } else { types.join("+") }; - // Determine origin + // Determine origin - shortened for 80 columns let origin = if let Some(ext) = available_ext { - get_extension_origin(ext) + get_extension_origin_short(ext) } else { - "unknown".to_string() + "?".to_string() }; - // Determine mount info - let mount_info = match (sysext_mount, confext_mount) { - (Some(s), Some(c)) => format!("sys:{}, conf:{}", s.since, c.since), - (Some(s), None) => format!("sys:{}", s.since), - (None, Some(c)) => format!("conf:{}", c.since), - (None, None) => "not mounted".to_string(), - }; - - println!("{ext_name:<20} {status:<12} {type_str:<15} {origin:<30} {mount_info}"); + // For 80 columns: name(24) status(10) type(12) origin(remaining ~33) + println!("{ext_name:<24} {status:<10} {type_str:<12} {origin}"); } -/// Get extension origin description -fn get_extension_origin(ext: &Extension) -> String { +/// Get short extension origin description (for 80-column display) +fn get_extension_origin_short(ext: &Extension) -> String { let path_str = ext.path.to_string_lossy(); if path_str.contains("/hitl") { - format!("HITL ({})", get_short_path(&ext.path)) + "HITL".to_string() } else if ext.is_directory { - format!("Directory ({})", get_short_path(&ext.path)) + "Dir".to_string() } else { - format!("Loop device ({})", get_short_path(&ext.path)) - } -} - -/// Get shortened path for display -fn get_short_path(path: &Path) -> String { - let path_str = path.to_string_lossy(); - - // Show relative to common base paths - if let Some(suffix) = path_str.strip_prefix("/run/avocado/hitl/") { - format!("hitl/{suffix}") - } else if let Some(suffix) = path_str.strip_prefix("/var/lib/avocado/extensions/") { - format!("ext/{suffix}") - } else if let Some(suffix) = path_str.strip_prefix("/run/avocado/extensions/") { - format!("loop/{suffix}") - } else if path_str.contains("/tmp/") { - // For test mode, show just the final components - path.file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - } else { - // Fallback: show last two components - let components: Vec<_> = path.components().collect(); - if components.len() >= 2 { - format!( - "{}/{}", - components[components.len() - 2] - .as_os_str() - .to_string_lossy(), - components[components.len() - 1] - .as_os_str() - .to_string_lossy() - ) + // Extract just the filename from loop path + if let Some(filename) = ext.path.file_name() { + format!("Loop:{}", filename.to_string_lossy()) } else { - path.file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() + "Loop".to_string() } } } @@ -830,15 +1138,136 @@ fn prepare_extension_environment_with_output( } } + // Important: After creating symlinks for enabled extensions, ensure no stale symlinks remain + // This handles the case where an extension was previously enabled but is now disabled + cleanup_stale_extension_symlinks(&enabled_extensions, output)?; + output.progress("Extension environment prepared successfully"); Ok(enabled_extensions) } +/// Remove any symlinks in /run/extensions and /run/confexts that are NOT in the enabled list +/// This ensures disabled extensions are not merged +fn cleanup_stale_extension_symlinks( + enabled_extensions: &[Extension], + output: &OutputManager, +) -> Result<(), SystemdError> { + let sysext_dir = if std::env::var("AVOCADO_TEST_MODE").is_ok() { + let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); + format!("{temp_base}/test_extensions") + } else { + "/run/extensions".to_string() + }; + + let confext_dir = if std::env::var("AVOCADO_TEST_MODE").is_ok() { + let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); + format!("{temp_base}/test_confexts") + } else { + "/run/confexts".to_string() + }; + + // Build a set of expected symlink names (with versions) + let mut expected_names = std::collections::HashSet::new(); + for ext in enabled_extensions { + let name_with_version = if let Some(ver) = &ext.version { + format!("{}-{}", ext.name, ver) + } else { + ext.name.clone() + }; + expected_names.insert(name_with_version); + } + + // Clean up sysext directory + if Path::new(&sysext_dir).exists() { + if let Ok(entries) = fs::read_dir(&sysext_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_symlink() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + // Remove .raw suffix if present for comparison + let name_without_raw = file_name.strip_suffix(".raw").unwrap_or(file_name); + + if !expected_names.contains(file_name) + && !expected_names.contains(name_without_raw) + { + if let Err(e) = fs::remove_file(&path) { + output.progress(&format!( + "Warning: Failed to remove stale sysext symlink {}: {}", + file_name, e + )); + } else { + output.progress(&format!( + "Removed stale sysext symlink: {}", + file_name + )); + } + } + } + } + } + } + } + + // Clean up confext directory + if Path::new(&confext_dir).exists() { + if let Ok(entries) = fs::read_dir(&confext_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_symlink() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + // Remove .raw suffix if present for comparison + let name_without_raw = file_name.strip_suffix(".raw").unwrap_or(file_name); + + if !expected_names.contains(file_name) + && !expected_names.contains(name_without_raw) + { + if let Err(e) = fs::remove_file(&path) { + output.progress(&format!( + "Warning: Failed to remove stale confext symlink {}: {}", + file_name, e + )); + } else { + output.progress(&format!( + "Removed stale confext symlink: {}", + file_name + )); + } + } + } + } + } + } + } + + Ok(()) +} + /// Scan all extension sources in priority order (legacy) fn scan_extensions_from_all_sources() -> Result, SystemdError> { scan_extensions_from_all_sources_with_verbosity(true) } +/// Read VERSION_ID from /etc/os-release +fn read_os_version_id() -> String { + let os_release_path = "/etc/os-release"; + + if let Ok(contents) = fs::read_to_string(os_release_path) { + for line in contents.lines() { + if line.starts_with("VERSION_ID=") { + // Parse VERSION_ID value, removing quotes if present + let value = line.trim_start_matches("VERSION_ID="); + let value = value.trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return value.to_string(); + } + } + } + } + + // Return default if VERSION_ID not found or file doesn't exist + "unknown".to_string() +} + /// Scan all extension sources in priority order with verbosity control fn scan_extensions_from_all_sources_with_verbosity( verbose: bool, @@ -846,7 +1275,7 @@ fn scan_extensions_from_all_sources_with_verbosity( let mut extensions = Vec::new(); let mut extension_map = std::collections::HashMap::new(); - // Define search paths in priority order: HITL → Directory → Loop-mounted + // Define search paths in priority order: HITL → Runtime/ → Directory → Loop-mounted let hitl_dir = if std::env::var("AVOCADO_TEST_MODE").is_ok() { let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); format!("{temp_base}/avocado/hitl") @@ -854,6 +1283,10 @@ fn scan_extensions_from_all_sources_with_verbosity( "/run/avocado/hitl".to_string() }; + // Read OS VERSION_ID for runtime-specific extensions + let version_id = read_os_version_id(); + + // Fallback to direct extensions path (for backward compatibility) let extensions_dir = std::env::var("AVOCADO_EXTENSIONS_PATH") .unwrap_or_else(|_| "/var/lib/avocado/extensions".to_string()); @@ -874,59 +1307,183 @@ fn scan_extensions_from_all_sources_with_verbosity( } } - // 2. Second priority: Regular directory extensions (skip if already have HITL version) + // 2. Second priority: OS release-specific extensions (/var/lib/avocado/os-releases/) + // Check os-releases directory to see which extensions are explicitly enabled + let os_releases_extensions_dir = if std::env::var("AVOCADO_TEST_MODE").is_ok() { + let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); + format!("{temp_base}/avocado/os-releases/{version_id}") + } else { + format!("/var/lib/avocado/os-releases/{version_id}") + }; + + if verbose { + println!( + "Scanning OS release extensions in {os_releases_extensions_dir} (VERSION_ID: {version_id})" + ); + } + + // Check if os-releases directory exists + if !Path::new(&os_releases_extensions_dir).exists() { + if verbose { + println!("OS releases directory {os_releases_extensions_dir} does not exist, skipping"); + } + // Only warn in non-test mode + if std::env::var("AVOCADO_TEST_MODE").is_err() { + eprintln!("Warning: No extensions are enabled for VERSION_ID '{version_id}'. Directory not found: {os_releases_extensions_dir}"); + } + } else { + // Scan os-releases directory for symlinks or extensions + if let Ok(os_releases_extensions) = scan_directory_extensions(&os_releases_extensions_dir) { + for ext in os_releases_extensions { + if !extension_map.contains_key(&ext.name) { + if verbose { + println!( + "Found OS release extension: {} at {}", + ext.name, + ext.path.display() + ); + } + extension_map.insert(ext.name.clone(), ext); + } else if verbose { + println!( + "Skipping runtime extension {} (higher priority version preferred)", + ext.name + ); + } + } + } + + // Also scan for .raw files in os-releases directory (symlinks to actual extensions) + if let Ok(os_releases_raw_files) = scan_raw_files(&os_releases_extensions_dir) { + for (ext_name, ext_version, ext_path) in os_releases_raw_files { + use std::collections::hash_map::Entry; + match extension_map.entry(ext_name.clone()) { + Entry::Vacant(entry) => { + // Analyze the raw file + if let Ok(ext) = analyze_raw_extension_with_loop( + &ext_name, + &ext_version, + &ext_path, + verbose, + ) { + if verbose { + println!( + "Found OS release raw extension: {} at {}", + ext.name, + ext.path.display() + ); + } + entry.insert(ext); + } + } + Entry::Occupied(_) => { + if verbose { + println!( + "Skipping OS release raw extension {} (higher priority version preferred)", + ext_name + ); + } + } + } + } + } + } + + // 3. Third priority: Regular directory extensions (skip if already have HITL or OS release version) + // IMPORTANT: If an os-releases directory exists, we do NOT fall back to the base extensions directory + // This ensures that explicitly disabled extensions (removed from os-releases) are not merged + let os_releases_dir_exists = Path::new(&os_releases_extensions_dir).exists(); + if verbose { println!("Scanning directory extensions in {extensions_dir}"); } - if let Ok(dir_extensions) = scan_directory_extensions(&extensions_dir) { - for ext in dir_extensions { - if !extension_map.contains_key(&ext.name) { - if verbose { + + if !os_releases_dir_exists { + // Only scan base directory if no os-releases directory exists (backward compatibility) + if verbose { + println!("No OS releases directory found, scanning base extensions directory"); + } + if let Ok(dir_extensions) = scan_directory_extensions(&extensions_dir) { + for ext in dir_extensions { + if !extension_map.contains_key(&ext.name) { + if verbose { + println!( + "Found directory extension: {} at {}", + ext.name, + ext.path.display() + ); + } + extension_map.insert(ext.name.clone(), ext); + } else if verbose { println!( - "Found directory extension: {} at {}", - ext.name, - ext.path.display() + "Skipping directory extension {} (HITL or runtime version preferred)", + ext.name ); } - extension_map.insert(ext.name.clone(), ext); - } else if verbose { - println!( - "Skipping directory extension {} (HITL version preferred)", - ext.name - ); } } + } else if verbose { + println!("OS releases directory exists, skipping base extensions directory (use enable/disable to manage extensions)"); } - // 3. Third priority: Raw file extensions (skip if already have directory version) + // 4. Fourth priority: Raw file extensions (skip if already have directory version) + // IMPORTANT: Same as above - only scan if no os-releases directory exists if verbose { println!("Scanning raw file extensions in {extensions_dir}"); } - let raw_files = scan_raw_files(&extensions_dir)?; - // Cleanup stale loops before processing new ones - let mut available_extension_names: Vec = extension_map.keys().cloned().collect(); - available_extension_names.extend(raw_files.iter().map(|(name, _)| name.clone())); - cleanup_stale_loops(&available_extension_names)?; + if !os_releases_dir_exists { + if verbose { + println!("No OS releases directory found, scanning base raw files"); + } + let raw_files = scan_raw_files(&extensions_dir)?; - // Process .raw files with persistent loops (only if not already found) - for (ext_name, path) in raw_files { - match extension_map.entry(ext_name.clone()) { - std::collections::hash_map::Entry::Vacant(entry) => { - if verbose { - println!("Found raw file extension: {ext_name} at {}", path.display()); - } - let extension = analyze_raw_extension_with_loop(&ext_name, &path)?; - entry.insert(extension); + // Cleanup stale loops before processing new ones + // Build list of all valid loop names (with versions for versioned extensions) + let mut available_loop_names: Vec = Vec::new(); + + // Add extensions already in the map (these might have versioned loop names) + for ext in extension_map.values() { + if let Some(ver) = &ext.version { + available_loop_names.push(format!("{}-{}", ext.name, ver)); + } else { + available_loop_names.push(ext.name.clone()); } - std::collections::hash_map::Entry::Occupied(_) => { - if verbose { - println!( - "Skipping raw file extension {ext_name} (higher priority version preferred)" - ); + } + + // Add versioned names for raw files we're about to process + for (name, version, _path) in &raw_files { + if let Some(ver) = version { + available_loop_names.push(format!("{}-{}", name, ver)); + } else { + available_loop_names.push(name.clone()); + } + } + + cleanup_stale_loops(&available_loop_names)?; + + // Process .raw files with persistent loops (only if not already found) + for (ext_name, ext_version, path) in raw_files { + match extension_map.entry(ext_name.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + if verbose { + println!("Found raw file extension: {ext_name} at {}", path.display()); + } + let extension = + analyze_raw_extension_with_loop(&ext_name, &ext_version, &path, verbose)?; + entry.insert(extension); + } + std::collections::hash_map::Entry::Occupied(_) => { + if verbose { + println!( + "Skipping raw file extension {ext_name} (higher priority version preferred)" + ); + } } } } + } else if verbose { + println!("OS releases directory exists, skipping base raw files (use enable/disable to manage extensions)"); } // Convert map to vector @@ -969,7 +1526,7 @@ fn scan_directory_extensions(dir_path: &str) -> Result, SystemdEr } /// Scan a directory for raw file extensions -fn scan_raw_files(dir_path: &str) -> Result, SystemdError> { +fn scan_raw_files(dir_path: &str) -> Result, PathBuf)>, SystemdError> { let mut raw_files = Vec::new(); if !Path::new(dir_path).exists() { @@ -993,8 +1550,34 @@ fn scan_raw_files(dir_path: &str) -> Result, SystemdError if let Some(file_name) = path.file_name() { if let Some(name_str) = file_name.to_str() { if name_str.ends_with(".raw") { - let ext_name = name_str.strip_suffix(".raw").unwrap_or(name_str); - raw_files.push((ext_name.to_string(), path)); + // Strip .raw suffix to get the extension name (with version) + let ext_name_with_version = + name_str.strip_suffix(".raw").unwrap_or(name_str); + + // Extract base extension name and version + // Extension name pattern: -.raw -> extract and + let (ext_name, ext_version) = + if let Some(last_dash) = ext_name_with_version.rfind('-') { + // Check if what follows the last dash looks like a version (contains digits or dots) + let potential_version = &ext_name_with_version[last_dash + 1..]; + if potential_version + .chars() + .any(|c| c.is_ascii_digit() || c == '.') + { + // This looks like a version, split name and version + let name = &ext_name_with_version[..last_dash]; + let version = potential_version; + (name.to_string(), Some(version.to_string())) + } else { + // No version pattern found, use full name without version + (ext_name_with_version.to_string(), None) + } + } else { + // No dash found, use full name without version + (ext_name_with_version.to_string(), None) + }; + + raw_files.push((ext_name, ext_version, path)); } } } @@ -1009,7 +1592,8 @@ fn analyze_directory_extension(name: &str, path: &Path) -> Result Result Result Result Result { - println!("Analyzing raw extension with persistent loop: {name}"); +fn analyze_raw_extension_with_loop( + name: &str, + version: &Option, + path: &Path, + verbose: bool, +) -> Result { + if verbose { + println!("Analyzing raw extension with persistent loop: {name}"); + } // Check if we already have a persistent loop for this extension - let mount_point = if check_existing_loop_ref(name) { - println!("Using existing persistent loop for {name}"); + let mount_name = if let Some(ver) = version { + format!("{name}-{ver}") + } else { + name.to_string() + }; + + let mount_point = if check_existing_loop_ref(&mount_name) { + if verbose { + println!("Using existing persistent loop for {mount_name}"); + } if std::env::var("AVOCADO_TEST_MODE").is_ok() { let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); - format!("{temp_base}/avocado/extensions/{name}") + format!("{temp_base}/avocado/extensions/{mount_name}") } else { - format!("/run/avocado/extensions/{name}") + format!("/run/avocado/extensions/{mount_name}") } } else { // Create new persistent loop - mount_raw_file_with_loop(name, path)? + mount_raw_file_with_loop(&mount_name, path, verbose)? .to_string_lossy() .to_string() }; @@ -1078,20 +1710,50 @@ fn analyze_raw_extension_with_loop(name: &str, path: &Path) -> Result Result Result { let mount_point = if std::env::var("AVOCADO_TEST_MODE").is_ok() { let temp_base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string()); @@ -1272,7 +1944,9 @@ fn mount_raw_file_with_loop( })?; } - println!("Mounting raw file {extension_name} with persistent loop..."); + if verbose { + println!("Mounting raw file {extension_name} with persistent loop..."); + } // Check if we're in test mode and should use mock commands let command_name = if std::env::var("AVOCADO_TEST_MODE").is_ok() { @@ -1307,7 +1981,9 @@ fn mount_raw_file_with_loop( }); } - println!("Mounted {extension_name} to {mount_point}"); + if verbose { + println!("Mounted {extension_name} to {mount_point}"); + } Ok(PathBuf::from(mount_point)) } @@ -1319,6 +1995,11 @@ fn check_existing_loop_ref(extension_name: &str) -> bool { /// Cleanup stale loop refs for extensions that no longer exist fn cleanup_stale_loops(available_extensions: &[String]) -> Result<(), SystemdError> { + // Skip cleanup in test mode to avoid interfering with system loops + if std::env::var("AVOCADO_TEST_MODE").is_ok() { + return Ok(()); + } + let loop_ref_dir = "/dev/disk/by-loop-ref"; if !Path::new(loop_ref_dir).exists() { return Ok(()); @@ -1599,7 +2280,7 @@ fn scan_extension_release_files( on_merge_commands: &mut Vec, modprobe_modules: &mut Vec, ) -> Result<(), SystemdError> { - // Check for sysext release file + // Check for sysext release file - try both versioned and non-versioned let sysext_release_path = extension .path .join("usr/lib/extension-release.d") @@ -1613,9 +2294,30 @@ fn scan_extension_release_files( let mut modules = parse_avocado_modprobe(&content); modprobe_modules.append(&mut modules); } + } else { + // Try to find versioned release file + let sysext_dir = extension.path.join("usr/lib/extension-release.d"); + if sysext_dir.exists() { + if let Ok(entries) = fs::read_dir(&sysext_dir) { + for entry in entries.flatten() { + let filename = entry.file_name(); + let filename_str = filename.to_string_lossy(); + if filename_str.starts_with(&format!("extension-release.{}-", extension.name)) { + if let Ok(content) = fs::read_to_string(entry.path()) { + let mut commands = parse_avocado_on_merge_commands(&content); + on_merge_commands.append(&mut commands); + + let mut modules = parse_avocado_modprobe(&content); + modprobe_modules.append(&mut modules); + } + break; + } + } + } + } } - // Check for confext release file + // Check for confext release file - try both versioned and non-versioned let confext_release_path = extension .path .join("etc/extension-release.d") @@ -1629,6 +2331,27 @@ fn scan_extension_release_files( let mut modules = parse_avocado_modprobe(&content); modprobe_modules.append(&mut modules); } + } else { + // Try to find versioned release file + let confext_dir = extension.path.join("etc/extension-release.d"); + if confext_dir.exists() { + if let Ok(entries) = fs::read_dir(&confext_dir) { + for entry in entries.flatten() { + let filename = entry.file_name(); + let filename_str = filename.to_string_lossy(); + if filename_str.starts_with(&format!("extension-release.{}-", extension.name)) { + if let Ok(content) = fs::read_to_string(entry.path()) { + let mut commands = parse_avocado_on_merge_commands(&content); + on_merge_commands.append(&mut commands); + + let mut modules = parse_avocado_modprobe(&content); + modprobe_modules.append(&mut modules); + } + break; + } + } + } + } } Ok(()) @@ -2086,6 +2809,7 @@ mod tests { // Simulate adding a .raw file first let raw_extension = Extension { name: "test_ext".to_string(), + version: Some("1.0.0".to_string()), path: PathBuf::from("/test/test_ext.raw"), is_sysext: true, is_confext: false, @@ -2096,6 +2820,7 @@ mod tests { // Now add a directory with the same name (should replace the .raw) let dir_extension = Extension { name: "test_ext".to_string(), + version: None, path: PathBuf::from("/test/test_ext"), is_sysext: true, is_confext: true, @@ -2125,6 +2850,7 @@ mod tests { // Test directory extension symlink naming let dir_extension = Extension { name: "test_ext".to_string(), + version: None, path: PathBuf::from("/test/test_ext"), is_sysext: true, is_confext: true, @@ -2134,18 +2860,28 @@ mod tests { // Test loop-mounted raw file extension symlink naming let raw_extension = Extension { name: "test_ext".to_string(), - path: PathBuf::from("/run/avocado/extensions/test_ext"), // Points to mounted directory + version: Some("1.0.0".to_string()), + path: PathBuf::from("/run/avocado/extensions/test_ext-1.0.0"), // Points to mounted directory is_sysext: true, is_confext: false, is_directory: false, // Still false to track origin, but path points to mounted dir }; - // Both directory and loop-mounted raw extensions should use just the extension name - let dir_symlink_name = dir_extension.name.clone(); + // Directory extensions should use just the name (no version) + let dir_symlink_name = if let Some(ver) = &dir_extension.version { + format!("{}-{}", dir_extension.name, ver) + } else { + dir_extension.name.clone() + }; assert_eq!(dir_symlink_name, "test_ext"); - let raw_symlink_name = raw_extension.name.clone(); - assert_eq!(raw_symlink_name, "test_ext"); + // Raw extensions with version should include version in symlink name + let raw_symlink_name = if let Some(ver) = &raw_extension.version { + format!("{}-{}", raw_extension.name, ver) + } else { + raw_extension.name.clone() + }; + assert_eq!(raw_symlink_name, "test_ext-1.0.0"); } #[test] @@ -2378,7 +3114,7 @@ OTHER_KEY=value // This test can't easily test the actual function since it depends on filesystem state // But we can test that the function exists and returns a boolean let result = is_running_in_initrd(); - assert!(result == true || result == false); // Just ensure it returns a boolean + let _ = result; // Just ensure it returns a boolean without crashing } #[test] @@ -2398,13 +3134,11 @@ OTHER_KEY=value // This test will always return true since we can't mock is_running_in_initrd easily // But we can verify the function doesn't crash - let result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); - assert!(result == true || result == false); + let _result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); // Test case 2: Extension with system scope only fs::write(&release_file, "VERSION_ID=1.0\nSYSEXT_SCOPE=\"system\"\n").unwrap(); - let result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); - assert!(result == true || result == false); + let _result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); // Test case 3: Extension with both scopes fs::write( @@ -2412,18 +3146,17 @@ OTHER_KEY=value "VERSION_ID=1.0\nSYSEXT_SCOPE=\"initrd system\"\n", ) .unwrap(); - let result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); - assert!(result == true || result == false); + let _result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); // Test case 4: Extension with no scope (should default to enabled) fs::write(&release_file, "VERSION_ID=1.0\n").unwrap(); let result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); - assert_eq!(result, true); + assert!(result); // Test case 5: No release file (should default to enabled) fs::remove_file(&release_file).unwrap(); let result = is_sysext_enabled_for_current_environment(&ext_path, "test_ext"); - assert_eq!(result, true); + assert!(result); } #[test] @@ -2443,17 +3176,16 @@ OTHER_KEY=value // This test will always return true since we can't mock is_running_in_initrd easily // But we can verify the function doesn't crash - let result = is_confext_enabled_for_current_environment(&ext_path, "test_ext"); - assert!(result == true || result == false); + let _result = is_confext_enabled_for_current_environment(&ext_path, "test_ext"); // Test case 2: Extension with no scope (should default to enabled) fs::write(&release_file, "VERSION_ID=1.0\n").unwrap(); let result = is_confext_enabled_for_current_environment(&ext_path, "test_ext"); - assert_eq!(result, true); + assert!(result); // Test case 3: No release file (should default to enabled) fs::remove_file(&release_file).unwrap(); let result = is_confext_enabled_for_current_environment(&ext_path, "test_ext"); - assert_eq!(result, true); + assert!(result); } } diff --git a/src/main.rs b/src/main.rs index 7375612..9e65029 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,46 @@ fn main() { .subcommand( Command::new("refresh") .about("Unmerge and then merge extensions (alias for 'ext refresh')"), + ) + .subcommand( + Command::new("enable") + .about("Enable extensions for a specific runtime version") + .arg( + Arg::new("os_release") + .long("os-release") + .value_name("VERSION") + .help("OS release version (defaults to current os-release VERSION_ID)"), + ) + .arg( + Arg::new("extensions") + .help("Extension names to enable") + .required(true) + .num_args(1..) + .value_name("EXTENSION"), + ), + ) + .subcommand( + Command::new("disable") + .about("Disable extensions for a specific runtime version") + .arg( + Arg::new("os_release") + .long("os-release") + .value_name("VERSION") + .help("OS release version (defaults to current os-release VERSION_ID)"), + ) + .arg( + Arg::new("all") + .long("all") + .help("Disable all extensions") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("extensions") + .help("Extension names to disable") + .required_unless_present("all") + .num_args(1..) + .value_name("EXTENSION"), + ), ); let matches = app.get_matches(); @@ -93,6 +133,27 @@ fn main() { Some(("refresh", _)) => { ext::refresh_extensions_direct(&output); } + Some(("enable", enable_matches)) => { + let os_release = enable_matches + .get_one::("os_release") + .map(|s| s.as_str()); + let extensions: Vec<&str> = enable_matches + .get_many::("extensions") + .unwrap() + .map(|s| s.as_str()) + .collect(); + ext::enable_extensions(os_release, &extensions, &config, &output); + } + Some(("disable", disable_matches)) => { + let os_release = disable_matches + .get_one::("os_release") + .map(|s| s.as_str()); + let all = disable_matches.get_flag("all"); + let extensions: Option> = disable_matches + .get_many::("extensions") + .map(|values| values.map(|s| s.as_str()).collect()); + ext::disable_extensions(os_release, extensions.as_deref(), all, &config, &output); + } _ => { println!( "{} - {}", diff --git a/tests/ext_integration_tests.rs b/tests/ext_integration_tests.rs index 9f06df0..a280fce 100644 --- a/tests/ext_integration_tests.rs +++ b/tests/ext_integration_tests.rs @@ -807,8 +807,8 @@ fn test_ext_status_with_mocks() { "Should show configuration extension in table" ); assert!( - stdout.contains("Mount Info"), - "Should show mount information for extensions" + stdout.contains("Origin"), + "Should show origin column for extensions" ); } @@ -1079,3 +1079,997 @@ fn test_ext_merge_with_confext_commands() { "Should show execution of post-merge commands" ); } + +/// Test enable command with default runtime version +#[test] +fn test_enable_extensions_default_runtime() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::write(extensions_dir.join("ext2-1.0.0.raw"), b"mock raw data") + .expect("Failed to create test raw extension"); + fs::write(extensions_dir.join("ext3-1.0.0.raw"), b"mock raw data") + .expect("Failed to create test raw extension"); + + // Run enable command with test mode + let output = run_avocadoctl_with_env( + &[ + "enable", + "--verbose", + "ext1-1.0.0", + "ext2-1.0.0", + "ext3-1.0.0", + ], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + println!("STDOUT: {stdout}"); + println!("STDERR: {stderr}"); + panic!("enable command should succeed"); + } + + assert!( + stdout.contains("Enabling extensions for OS release version"), + "Should show OS release version message" + ); + assert!( + stdout.contains("Successfully enabled 3 extension(s)"), + "Should show success message for 3 extensions" + ); + assert!( + stdout.contains("Enabled extension: ext1-1.0.0"), + "Should show ext1 enabled" + ); + assert!( + stdout.contains("Enabled extension: ext2-1.0.0"), + "Should show ext2 enabled" + ); + assert!( + stdout.contains("Enabled extension: ext3-1.0.0"), + "Should show ext3 enabled" + ); +} + +/// Test enable command with custom runtime version +#[test] +fn test_enable_extensions_custom_runtime() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::write(extensions_dir.join("ext2-1.0.0.raw"), b"mock raw data") + .expect("Failed to create test raw extension"); + + // Run enable command with custom os-release version and test mode + let output = run_avocadoctl_with_env( + &[ + "enable", + "--verbose", + "--os-release", + "2.0.0", + "ext1-1.0.0", + "ext2-1.0.0", + ], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + println!("STDOUT: {stdout}"); + println!("STDERR: {stderr}"); + panic!("enable command should succeed with custom OS release"); + } + + assert!( + stdout.contains("Enabling extensions for OS release version: 2.0.0"), + "Should show custom OS release version" + ); + assert!( + stdout.contains("Successfully enabled 2 extension(s) for OS release 2.0.0"), + "Should show success message with OS release version" + ); +} + +/// Test enable command with nonexistent extension +#[test] +fn test_enable_nonexistent_extension() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create one valid extension + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + + // Run enable command with mix of valid and invalid extensions and test mode + let output = run_avocadoctl_with_env( + &["enable", "--verbose", "ext1-1.0.0", "nonexistent-ext"], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + println!("STDOUT: {stdout}"); + println!("STDERR: {stderr}"); + + assert!( + !output.status.success(), + "enable command should fail with nonexistent extension" + ); + + assert!( + stderr.contains("Extension 'nonexistent-ext' not found"), + "Should show error for nonexistent extension. STDERR: {stderr}" + ); + assert!( + stdout.contains("Enabled extension: ext1-1.0.0"), + "Should still enable valid extension. STDOUT: {stdout}" + ); +} + +/// Test enable command help +#[test] +fn test_enable_help() { + let output = run_avocadoctl(&["enable", "--help"]); + assert!(output.status.success(), "Enable help should succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Enable extensions for a specific runtime version"), + "Should contain enable description" + ); + assert!( + stdout.contains("--os-release"), + "Should mention --os-release flag" + ); +} + +/// Test disable command with specific extensions +#[test] +fn test_disable_extensions() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::write(extensions_dir.join("ext2-1.0.0.raw"), b"mock raw data") + .expect("Failed to create test raw extension"); + fs::write(extensions_dir.join("ext3-1.0.0.raw"), b"mock raw data") + .expect("Failed to create test raw extension"); + + // First enable extensions + let enable_output = run_avocadoctl_with_env( + &[ + "enable", + "--verbose", + "--os-release", + "2.0.0", + "ext1-1.0.0", + "ext2-1.0.0", + "ext3-1.0.0", + ], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + assert!(enable_output.status.success(), "Enable should succeed"); + + // Now disable some extensions + let disable_output = run_avocadoctl_with_env( + &[ + "disable", + "--verbose", + "--os-release", + "2.0.0", + "ext1-1.0.0", + "ext2-1.0.0", + ], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + let stdout = String::from_utf8_lossy(&disable_output.stdout); + let stderr = String::from_utf8_lossy(&disable_output.stderr); + + if !disable_output.status.success() { + println!("STDOUT: {stdout}"); + println!("STDERR: {stderr}"); + panic!("disable command should succeed"); + } + + assert!( + stdout.contains("Disabling extensions for OS release version: 2.0.0"), + "Should show OS release version message" + ); + assert!( + stdout.contains("Successfully disabled 2 extension(s)"), + "Should show success message for 2 extensions" + ); + assert!( + stdout.contains("Disabled extension: ext1-1.0.0"), + "Should show ext1 disabled" + ); + assert!( + stdout.contains("Disabled extension: ext2-1.0.0"), + "Should show ext2 disabled" + ); + assert!( + stdout.contains("Synced changes to disk"), + "Should show sync message" + ); + + // Verify ext3 still exists + let os_releases_dir = temp_dir.path().join("avocado/os-releases/2.0.0"); + assert!( + os_releases_dir.join("ext3-1.0.0.raw").exists(), + "ext3 should still be enabled" + ); + assert!( + !os_releases_dir.join("ext1-1.0.0").exists(), + "ext1 should be disabled" + ); + assert!( + !os_releases_dir.join("ext2-1.0.0.raw").exists(), + "ext2 should be disabled" + ); +} + +/// Test disable command with --all flag +#[test] +fn test_disable_all_extensions() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::write(extensions_dir.join("ext2-1.0.0.raw"), b"mock raw data") + .expect("Failed to create test raw extension"); + fs::write(extensions_dir.join("ext3-1.0.0.raw"), b"mock raw data") + .expect("Failed to create test raw extension"); + + // First enable extensions + let enable_output = run_avocadoctl_with_env( + &[ + "enable", + "--verbose", + "--os-release", + "2.0.0", + "ext1-1.0.0", + "ext2-1.0.0", + "ext3-1.0.0", + ], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + assert!(enable_output.status.success(), "Enable should succeed"); + + // Now disable all extensions + let disable_output = run_avocadoctl_with_env( + &["disable", "--verbose", "--os-release", "2.0.0", "--all"], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + let stdout = String::from_utf8_lossy(&disable_output.stdout); + let stderr = String::from_utf8_lossy(&disable_output.stderr); + + if !disable_output.status.success() { + println!("STDOUT: {stdout}"); + println!("STDERR: {stderr}"); + panic!("disable --all command should succeed"); + } + + assert!( + stdout.contains("Disabling extensions for OS release version: 2.0.0"), + "Should show OS release version message" + ); + assert!( + stdout.contains("Removing all extensions"), + "Should show removing all message" + ); + assert!( + stdout.contains("Successfully disabled 3 extension(s)"), + "Should show success message for 3 extensions" + ); + assert!( + stdout.contains("Synced changes to disk"), + "Should show sync message" + ); + + // Verify all extensions are removed + let os_releases_dir = temp_dir.path().join("avocado/os-releases/2.0.0"); + let entries = + fs::read_dir(&os_releases_dir).expect("Should be able to read os-releases directory"); + let symlink_count = entries + .filter(|e| { + if let Ok(entry) = e { + entry.path().is_symlink() + } else { + false + } + }) + .count(); + + assert_eq!(symlink_count, 0, "All symlinks should be removed"); +} + +/// Test disable command with default runtime version +#[test] +fn test_disable_extensions_default_runtime() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + + // First enable extension + let enable_output = run_avocadoctl_with_env( + &["enable", "--verbose", "ext1-1.0.0"], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + assert!(enable_output.status.success(), "Enable should succeed"); + + // Now disable with default runtime + let disable_output = run_avocadoctl_with_env( + &["disable", "--verbose", "ext1-1.0.0"], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + let stdout = String::from_utf8_lossy(&disable_output.stdout); + let stderr = String::from_utf8_lossy(&disable_output.stderr); + + if !disable_output.status.success() { + println!("STDOUT: {stdout}"); + println!("STDERR: {stderr}"); + panic!("disable command should succeed with default runtime"); + } + + assert!( + stdout.contains("Disabling extensions for OS release version"), + "Should show OS release version message" + ); + assert!( + stdout.contains("Successfully disabled 1 extension(s)"), + "Should show success message" + ); +} + +/// Test disable command with non-existent extension +#[test] +fn test_disable_nonexistent_extension() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extension + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + + // First enable extension + let enable_output = run_avocadoctl_with_env( + &["enable", "--verbose", "--os-release", "2.0.0", "ext1-1.0.0"], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + assert!(enable_output.status.success(), "Enable should succeed"); + + // Try to disable a non-existent extension + let disable_output = run_avocadoctl_with_env( + &[ + "disable", + "--verbose", + "--os-release", + "2.0.0", + "nonexistent-ext", + ], + &[ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ], + ); + + let stderr = String::from_utf8_lossy(&disable_output.stderr); + + assert!( + !disable_output.status.success(), + "disable command should fail with non-existent extension" + ); + + assert!( + stderr.contains("Extension 'nonexistent-ext' is not enabled"), + "Should show error for non-existent extension. STDERR: {stderr}" + ); +} + +/// Test disable command help +#[test] +fn test_disable_help() { + let output = run_avocadoctl(&["disable", "--help"]); + assert!(output.status.success(), "Disable help should succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Disable extensions for a specific runtime version"), + "Should contain disable description" + ); + assert!( + stdout.contains("--os-release"), + "Should mention --os-release flag" + ); + assert!(stdout.contains("--all"), "Should mention --all flag"); +} + +/// Test enable/disable/refresh workflow +#[test] +fn test_enable_disable_refresh_workflow() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::create_dir(extensions_dir.join("ext2-1.0.0")) + .expect("Failed to create test extension directory"); + + // Create release files for both extensions + let ext1_release_dir = extensions_dir.join("ext1-1.0.0/usr/lib/extension-release.d"); + fs::create_dir_all(&ext1_release_dir).expect("Failed to create release dir"); + fs::write( + ext1_release_dir.join("extension-release.ext1-1.0.0"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + + let ext2_release_dir = extensions_dir.join("ext2-1.0.0/usr/lib/extension-release.d"); + fs::create_dir_all(&ext2_release_dir).expect("Failed to create release dir"); + fs::write( + ext2_release_dir.join("extension-release.ext2-1.0.0"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Step 1: Enable both extensions + let enable_output = run_avocadoctl_with_env( + &["enable", "--verbose", "ext1-1.0.0", "ext2-1.0.0"], + &test_env, + ); + assert!( + enable_output.status.success(), + "Initial enable should succeed" + ); + let stdout = String::from_utf8_lossy(&enable_output.stdout); + assert!(stdout.contains("Successfully enabled 2 extension(s)")); + + // Step 2: Refresh with both enabled - both should be merged + let (refresh_output1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!( + refresh_output1.status.success(), + "First refresh should succeed" + ); + let stdout1 = String::from_utf8_lossy(&refresh_output1.stdout); + assert!( + stdout1.contains("Found runtime extension: ext1-1.0.0") || stdout1.contains("ext1-1.0.0"), + "Should scan ext1 from runtime" + ); + assert!( + stdout1.contains("Found runtime extension: ext2-1.0.0") || stdout1.contains("ext2-1.0.0"), + "Should scan ext2 from runtime" + ); + + // Step 3: Disable ext1 + let disable_output = + run_avocadoctl_with_env(&["disable", "--verbose", "ext1-1.0.0"], &test_env); + assert!(disable_output.status.success(), "Disable should succeed"); + + // Step 4: Refresh after disabling ext1 - only ext2 should be merged + let (refresh_output2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!( + refresh_output2.status.success(), + "Second refresh should succeed" + ); + let stdout2 = String::from_utf8_lossy(&refresh_output2.stdout); + + // ext2 should still be found from runtime + assert!( + stdout2.contains("Found runtime extension: ext2-1.0.0") || stdout2.contains("ext2-1.0.0"), + "Should still scan ext2 from runtime" + ); + + // ext1 should NOT be found from runtime (it was disabled) + // It might be found from the base extensions directory though + if stdout2.contains("ext1-1.0.0") { + // If ext1 appears, it should be from the base directory, not runtime + assert!( + !stdout2.contains("Found runtime extension: ext1-1.0.0"), + "ext1 should not be found in runtime directory" + ); + } + + // Step 5: Re-enable ext1 + let reenable_output = + run_avocadoctl_with_env(&["enable", "--verbose", "ext1-1.0.0"], &test_env); + assert!(reenable_output.status.success(), "Re-enable should succeed"); + + // Step 6: Refresh with both enabled again - both should be merged + let (refresh_output3, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!( + refresh_output3.status.success(), + "Third refresh should succeed" + ); + let stdout3 = String::from_utf8_lossy(&refresh_output3.stdout); + assert!( + stdout3.contains("Found runtime extension: ext1-1.0.0") || stdout3.contains("ext1-1.0.0"), + "Should scan ext1 from runtime again" + ); + assert!( + stdout3.contains("Found runtime extension: ext2-1.0.0") || stdout3.contains("ext2-1.0.0"), + "Should scan ext2 from runtime" + ); +} + +/// Test that disabled extensions are not merged after refresh +#[test] +fn test_disabled_extension_not_merged_after_refresh() { + // Create a temporary directory for extensions + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::create_dir(extensions_dir.join("ext2-1.0.0")) + .expect("Failed to create test extension directory"); + + // Create release files for both extensions + let ext1_release_dir = extensions_dir.join("ext1-1.0.0/usr/lib/extension-release.d"); + fs::create_dir_all(&ext1_release_dir).expect("Failed to create release dir"); + fs::write( + ext1_release_dir.join("extension-release.ext1-1.0.0"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + + let ext2_release_dir = extensions_dir.join("ext2-1.0.0/usr/lib/extension-release.d"); + fs::create_dir_all(&ext2_release_dir).expect("Failed to create release dir"); + fs::write( + ext2_release_dir.join("extension-release.ext2-1.0.0"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable both extensions + let enable_output = run_avocadoctl_with_env( + &["enable", "--verbose", "ext1-1.0.0", "ext2-1.0.0"], + &test_env, + ); + assert!(enable_output.status.success(), "Enable should succeed"); + + // Refresh with both enabled + let (refresh1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh1.status.success(), "First refresh should succeed"); + + // Verify both symlinks exist after merge + let sysext_dir = temp_dir.path().join("test_extensions"); + assert!( + sysext_dir.join("ext1-1.0.0").exists(), + "ext1 symlink should exist" + ); + assert!( + sysext_dir.join("ext2-1.0.0").exists(), + "ext2 symlink should exist" + ); + + // Disable ext1 + let disable_output = + run_avocadoctl_with_env(&["disable", "--verbose", "ext1-1.0.0"], &test_env); + assert!(disable_output.status.success(), "Disable should succeed"); + + // Refresh after disabling ext1 + let (refresh2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh2.status.success(), "Second refresh should succeed"); + let stdout2 = String::from_utf8_lossy(&refresh2.stdout); + + // Verify ext1 is NOT scanned from OS release + assert!( + !stdout2.contains("Found OS release extension: ext1-1.0.0"), + "ext1 should NOT be found from OS release after being disabled. Stdout: {}", + stdout2 + ); + + // Verify ext2 IS scanned from OS release + assert!( + stdout2.contains("Found OS release extension: ext2-1.0.0"), + "ext2 should still be found from OS release" + ); + + // Verify ext1 symlink was removed (stale cleanup) + assert!( + !sysext_dir.join("ext1-1.0.0").exists(), + "ext1 symlink should be removed after refresh" + ); + + // Verify ext2 symlink still exists + assert!( + sysext_dir.join("ext2-1.0.0").exists(), + "ext2 symlink should still exist" + ); + + // Verify base directory was skipped (because os-releases directory exists) + assert!( + stdout2.contains("OS releases directory exists, skipping base extensions directory") + || !stdout2.contains("Found directory extension: ext1-1.0.0"), + "Base directory should be skipped when OS releases directory exists" + ); +} + +/// Test that base directory is completely skipped when runtime directory exists +#[test] +fn test_base_directory_skipped_with_runtime() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create extensions in base directory + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::create_dir(extensions_dir.join("ext2-1.0.0")) + .expect("Failed to create test extension directory"); + fs::create_dir(extensions_dir.join("ext3-1.0.0")) + .expect("Failed to create test extension directory"); + + // Create release files + for ext in &["ext1-1.0.0", "ext2-1.0.0", "ext3-1.0.0"] { + let release_dir = extensions_dir.join(format!("{}/usr/lib/extension-release.d", ext)); + fs::create_dir_all(&release_dir).expect("Failed to create release dir"); + fs::write( + release_dir.join(format!("extension-release.{}", ext)), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + } + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable only ext1 + let enable_output = run_avocadoctl_with_env(&["enable", "--verbose", "ext1-1.0.0"], &test_env); + assert!(enable_output.status.success(), "Enable should succeed"); + + // Refresh - should only merge ext1, not ext2 or ext3 from base directory + let (refresh_output, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh_output.status.success(), "Refresh should succeed"); + let stdout = String::from_utf8_lossy(&refresh_output.stdout); + + // Verify ext1 is found from OS release + assert!( + stdout.contains("Found OS release extension: ext1-1.0.0"), + "ext1 should be found from OS release" + ); + + // Verify ext2 and ext3 are NOT found (base directory skipped) + assert!( + !stdout.contains("Found directory extension: ext2-1.0.0"), + "ext2 should NOT be found from base directory" + ); + assert!( + !stdout.contains("Found directory extension: ext3-1.0.0"), + "ext3 should NOT be found from base directory" + ); + + // Verify message about skipping base directory + assert!( + stdout.contains("OS releases directory exists, skipping base extensions directory") + || stdout.contains("OS releases directory exists, skipping base raw files"), + "Should show message about skipping base directory" + ); +} + +/// Test that all extensions from base are used when no runtime directory exists +#[test] +fn test_base_directory_used_without_runtime() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create extensions in base directory + fs::create_dir(extensions_dir.join("ext1-1.0.0")) + .expect("Failed to create test extension directory"); + fs::create_dir(extensions_dir.join("ext2-1.0.0")) + .expect("Failed to create test extension directory"); + + // Create release files + for ext in &["ext1-1.0.0", "ext2-1.0.0"] { + let release_dir = extensions_dir.join(format!("{}/usr/lib/extension-release.d", ext)); + fs::create_dir_all(&release_dir).expect("Failed to create release dir"); + fs::write( + release_dir.join(format!("extension-release.{}", ext)), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + } + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // DON'T enable any extensions - this means no runtime directory exists + + // Refresh - should use all extensions from base directory + let (refresh_output, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh_output.status.success(), "Refresh should succeed"); + let stdout = String::from_utf8_lossy(&refresh_output.stdout); + + // Verify both extensions are found from base directory (not OS release) + assert!( + stdout.contains("Found directory extension: ext1-1.0.0"), + "ext1 should be found from base directory. Stdout: {}", + stdout + ); + assert!( + stdout.contains("Found directory extension: ext2-1.0.0"), + "ext2 should be found from base directory. Stdout: {}", + stdout + ); + + // Verify message about no OS releases directory + assert!( + stdout.contains("No OS releases directory found") + || stdout.contains("OS releases directory") && stdout.contains("does not exist"), + "Should indicate OS releases directory doesn't exist" + ); +} + +/// Test enable with --all flag to disable all extensions +#[test] +fn test_disable_all_then_refresh() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + for ext in &["ext1-1.0.0", "ext2-1.0.0", "ext3-1.0.0"] { + fs::create_dir(extensions_dir.join(ext)) + .expect("Failed to create test extension directory"); + let release_dir = extensions_dir.join(format!("{}/usr/lib/extension-release.d", ext)); + fs::create_dir_all(&release_dir).expect("Failed to create release dir"); + fs::write( + release_dir.join(format!("extension-release.{}", ext)), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + } + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable all three extensions + let enable_output = run_avocadoctl_with_env( + &[ + "enable", + "--verbose", + "ext1-1.0.0", + "ext2-1.0.0", + "ext3-1.0.0", + ], + &test_env, + ); + assert!(enable_output.status.success(), "Enable should succeed"); + + // Refresh to merge them + let (refresh1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh1.status.success(), "First refresh should succeed"); + + // Disable all extensions + let disable_output = run_avocadoctl_with_env(&["disable", "--verbose", "--all"], &test_env); + assert!( + disable_output.status.success(), + "Disable all should succeed" + ); + + // Refresh after disabling all + let (refresh2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh2.status.success(), "Second refresh should succeed"); + let stdout2 = String::from_utf8_lossy(&refresh2.stdout); + + // Verify NO extensions are found from runtime (all were disabled) + assert!( + !stdout2.contains("Found runtime extension:"), + "No extensions should be found from runtime after disabling all" + ); + + // The os-releases directory should still exist but be empty, so base directory should still be skipped + let os_releases_dir = temp_dir.path().join("avocado/os-releases/24.04"); + assert!( + os_releases_dir.exists(), + "OS releases directory should still exist" + ); + + // Verify no symlinks exist after refresh + let sysext_dir = temp_dir.path().join("test_extensions"); + if sysext_dir.exists() { + let entries: Vec<_> = fs::read_dir(&sysext_dir) + .expect("Should read sysext dir") + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_symlink()) + .collect(); + assert_eq!( + entries.len(), + 0, + "No symlinks should exist after disabling all and refreshing" + ); + } +} + +/// Test stale symlink cleanup +#[test] +fn test_stale_symlink_cleanup() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create test extensions + for ext in &["ext1-1.0.0", "ext2-1.0.0"] { + fs::create_dir(extensions_dir.join(ext)) + .expect("Failed to create test extension directory"); + let release_dir = extensions_dir.join(format!("{}/usr/lib/extension-release.d", ext)); + fs::create_dir_all(&release_dir).expect("Failed to create release dir"); + fs::write( + release_dir.join(format!("extension-release.{}", ext)), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + } + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable both extensions + let enable_output = run_avocadoctl_with_env( + &["enable", "--verbose", "ext1-1.0.0", "ext2-1.0.0"], + &test_env, + ); + assert!(enable_output.status.success()); + + // Refresh to create symlinks + let (refresh1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh1.status.success()); + + let sysext_dir = temp_dir.path().join("test_extensions"); + assert!( + sysext_dir.join("ext1-1.0.0").exists(), + "ext1 symlink should exist" + ); + assert!( + sysext_dir.join("ext2-1.0.0").exists(), + "ext2 symlink should exist" + ); + + // Disable ext1 + let disable_output = + run_avocadoctl_with_env(&["disable", "--verbose", "ext1-1.0.0"], &test_env); + assert!(disable_output.status.success()); + + // Refresh - should clean up ext1 stale symlink + let (refresh2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh2.status.success()); + let stdout2 = String::from_utf8_lossy(&refresh2.stdout); + + // Verify stale symlink was removed + assert!( + !sysext_dir.join("ext1-1.0.0").exists(), + "ext1 stale symlink should be removed" + ); + assert!( + sysext_dir.join("ext2-1.0.0").exists(), + "ext2 symlink should still exist" + ); + + // Check for cleanup message + assert!( + stdout2.contains("Removed stale") || !sysext_dir.join("ext1-1.0.0").exists(), + "Should remove stale symlink or show cleanup message" + ); +}