diff --git a/dotnet/src/Devolutions.Pinget.Cli/ImportCommandHandler.cs b/dotnet/src/Devolutions.Pinget.Cli/ImportCommandHandler.cs new file mode 100644 index 0000000..ae2def6 --- /dev/null +++ b/dotnet/src/Devolutions.Pinget.Cli/ImportCommandHandler.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using Devolutions.Pinget.Core; + +namespace Devolutions.Pinget.Cli; + +internal sealed record ImportPackage(string Id, string? SourceName, string? Version, JsonElement Entry = default); + +internal static class ImportCommandHandler +{ + internal static int Run( + IEnumerable packages, + bool dryRun, + bool ignoreUnavailable, + bool noUpgrade, + Func isInstalled, + Func install) + { + int total = 0; + int skipped = 0; + bool hasFailures = false; + + foreach (var package in packages) + { + if (dryRun) + { + Console.WriteLine($"[dry-run] Would install: {package.Id}"); + } + else + { + try + { + if (noUpgrade && isInstalled(package)) + { + Console.WriteLine($"[no-upgrade] Skipping already installed package: {package.Id}"); + skipped++; + } + else + { + Console.Write($"Installing {package.Id}..."); + var result = install(package); + if (result.NoOp) + { + Console.WriteLine(" no-op"); + PrintWarnings(result.Warnings); + skipped++; + } + else + { + PrintWarnings(result.Warnings); + Console.WriteLine(result.Success ? " done" : $" failed (exit {result.ExitCode})"); + hasFailures |= !result.Success; + } + } + } + catch (Exception ex) when (ignoreUnavailable && CanIgnoreUnavailableImportFailure(ex)) + { + Console.WriteLine(" unavailable"); + Console.Error.WriteLine($"warning: Skipping unavailable package '{package.Id}': {ex.Message}"); + skipped++; + } + catch (Exception ex) + { + Console.Error.WriteLine($" error: {ex.Message}"); + hasFailures = true; + } + } + total++; + } + + if (!dryRun && skipped > 0) + Console.WriteLine($"Skipped {skipped} package(s)."); + Console.WriteLine($"{total} package(s) {(dryRun ? "would be installed" : "processed")}."); + return hasFailures ? 1 : 0; + } + + private static bool CanIgnoreUnavailableImportFailure(Exception ex) => + ex is InvalidOperationException && + (ex.Message.Contains("No package matched the query", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("No package matched the supplied query", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("No applicable installer found", StringComparison.OrdinalIgnoreCase)); + + private static void PrintWarnings(IEnumerable warnings) + { + foreach (var warning in warnings) + Console.Error.WriteLine($"warning: {warning}"); + } +} diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index 98cdffe..624a9ea 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs +++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs @@ -1139,69 +1139,35 @@ var sources = doc.RootElement.GetProperty("Sources").EnumerateArray().ToList(); using var repo = Repository.Open(); - int total = 0; - int skipped = 0; - foreach (var source in sources) + var importPackages = sources.SelectMany(source => { var sourceName = source.TryGetProperty("SourceDetails", out var sourceDetails) ? GetJsonString(sourceDetails, "Name") : null; - var packages = source.GetProperty("Packages").EnumerateArray().ToList(); - foreach (var pkg in packages) - { - var pkgId = pkg.GetProperty("PackageIdentifier").GetString()!; - var pkgVersion = ignoreVersions ? null : GetJsonString(pkg, "Version"); - if (dryRun) - { - Console.WriteLine($"[dry-run] Would install: {pkgId}"); - } - else if (noUpgrade && IsInstalledPackagePresent(repo, pkgId, sourceName)) - { - Console.WriteLine($"[no-upgrade] Skipping already installed package: {pkgId}"); - skipped++; - } - else + return source.GetProperty("Packages").EnumerateArray().Select(pkg => new ImportPackage( + pkg.GetProperty("PackageIdentifier").GetString()!, + sourceName, + ignoreVersions ? null : GetJsonString(pkg, "Version"), + pkg)); + }).ToList(); + + ctx.ExitCode = ImportCommandHandler.Run( + importPackages, + dryRun, + ignoreUnavailable, + noUpgrade, + pkg => IsInstalledPackagePresent(repo, pkg.Id, pkg.SourceName), + pkg => repo.Install(ImportInstallRequestFactory.Create( + new PackageQuery { - try - { - Console.Write($"Installing {pkgId}..."); - var result = repo.Install(ImportInstallRequestFactory.Create( - new PackageQuery - { - Id = pkgId, - Source = sourceName, - Exact = true, - Version = pkgVersion, - }, - pkg, - acceptPackageAgreements, - noUpgrade)); - if (result.NoOp) - { - Console.WriteLine(" no-op"); - PrintWarnings(result.Warnings); - skipped++; - } - else - { - PrintWarnings(result.Warnings); - Console.WriteLine(result.Success ? " done" : $" failed (exit {result.ExitCode})"); - } - } - catch (Exception ex) when (ignoreUnavailable && CanIgnoreUnavailableImportFailure(ex)) - { - Console.WriteLine(" unavailable"); - Console.Error.WriteLine($"warning: Skipping unavailable package '{pkgId}': {ex.Message}"); - skipped++; - } - catch (Exception ex) { Console.Error.WriteLine($" error: {ex.Message}"); } - } - total++; - } - } - if (!dryRun && skipped > 0) - Console.WriteLine($"Skipped {skipped} package(s)."); - Console.WriteLine($"{total} package(s) {(dryRun ? "would be installed" : "processed")}."); + Id = pkg.Id, + Source = pkg.SourceName, + Exact = true, + Version = pkg.Version, + }, + pkg.Entry, + acceptPackageAgreements, + noUpgrade))); }); // ── Add all commands to root ── @@ -1770,11 +1736,6 @@ static bool IsInstalledPackagePresent(Repository repo, string packageId, string? Count = 1, }).Matches.Count > 0; -static bool CanIgnoreUnavailableImportFailure(Exception ex) => - ex is InvalidOperationException && - (ex.Message.Contains("No package matched the query", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("No applicable installer found", StringComparison.OrdinalIgnoreCase)); - void WriteJsonNode(JsonNode value, OutputFormat output) { switch (output) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/ImportCommandHandlerTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/ImportCommandHandlerTests.cs new file mode 100644 index 0000000..bed4986 --- /dev/null +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/ImportCommandHandlerTests.cs @@ -0,0 +1,257 @@ +using Devolutions.Pinget.Cli; +using Devolutions.Pinget.Core; +using Xunit; + +namespace Devolutions.Pinget.Core.Tests; + +public class ImportCommandHandlerTests +{ + private static readonly ImportPackage[] MixedPackages = + [ + new("Failing.Package", "test", "1.0"), + new("Successful.Package", "test", "1.0"), + ]; + + [Fact] + public void HandlerAttemptsSuccessAfterUnsuccessfulInstallerAndReturnsNonzero() + { + var attempted = new List(); + + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: false, + noUpgrade: false, + _ => false, + package => + { + attempted.Add(package.Id); + return Result(package.Id, success: package.Id == "Successful.Package"); + }); + + Assert.Equal(new[] { "Failing.Package", "Successful.Package" }, attempted); + Assert.Equal(1, exitCode); + } + + [Fact] + public void HandlerAttemptsSuccessAfterThrownInstallerErrorAndReturnsNonzero() + { + var attempted = new List(); + + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: false, + noUpgrade: false, + _ => false, + package => + { + attempted.Add(package.Id); + if (package.Id == "Failing.Package") + throw new InvalidOperationException("synthetic installer error"); + return Result(package.Id, success: true); + }); + + Assert.Equal(new[] { "Failing.Package", "Successful.Package" }, attempted); + Assert.Equal(1, exitCode); + } + + [Theory] + [InlineData("No applicable installer found")] + [InlineData("no package matched the supplied query")] + [InlineData("No package matched the query.")] + public void IgnoreUnavailableOnlySuppressesClassifiedErrorsAndContinues(string errorMessage) + { + var attempted = new List(); + + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: true, + noUpgrade: false, + _ => false, + package => + { + attempted.Add(package.Id); + if (package.Id == "Failing.Package") + throw new InvalidOperationException(errorMessage); + return Result(package.Id, success: true); + }); + + Assert.Equal(new[] { "Failing.Package", "Successful.Package" }, attempted); + Assert.Equal(0, exitCode); + } + + [Fact] + public void IgnoreUnavailableDoesNotSuppressUnclassifiedError() + { + var exitCode = ImportCommandHandler.Run( + [MixedPackages[0]], + dryRun: false, + ignoreUnavailable: true, + noUpgrade: false, + _ => false, + _ => throw new InvalidOperationException("source configuration is invalid")); + + Assert.Equal(1, exitCode); + } + + [Fact] + public void DryRunDoesNotInvokeInstallAndReturnsSuccess() + { + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: true, + ignoreUnavailable: false, + noUpgrade: false, + _ => throw new Xunit.Sdk.XunitException("dry-run must not query installed packages"), + _ => throw new Xunit.Sdk.XunitException("dry-run must not install packages")); + + Assert.Equal(0, exitCode); + } + + [Fact] + public void NoOpResultDoesNotFailImport() + { + var exitCode = ImportCommandHandler.Run( + [MixedPackages[0]], + dryRun: false, + ignoreUnavailable: false, + noUpgrade: false, + _ => false, + _ => Result("Failing.Package", success: false, noOp: true)); + + Assert.Equal(0, exitCode); + } + + [Fact] + public void MissingPackageWithoutIgnoreUnavailableFailsAndContinues() + { + var attempted = new List(); + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: false, + noUpgrade: false, + _ => false, + package => + { + attempted.Add(package.Id); + if (package.Id == "Failing.Package") + throw new InvalidOperationException("no package matched the supplied query"); + return Result(package.Id, success: true); + }); + + Assert.Equal(new[] { "Failing.Package", "Successful.Package" }, attempted); + Assert.Equal(1, exitCode); + } + + [Fact] + public void IgnoreUnavailableDoesNotSuppressUnsuccessfulInstaller() + { + var attempted = new List(); + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: true, + noUpgrade: false, + _ => false, + package => + { + attempted.Add(package.Id); + return Result(package.Id, success: package.Id == "Successful.Package"); + }); + + Assert.Equal(new[] { "Failing.Package", "Successful.Package" }, attempted); + Assert.Equal(1, exitCode); + } + + [Fact] + public void NoUpgradeSkipsInstalledPackageAndContinues() + { + var attempted = new List(); + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: false, + noUpgrade: true, + package => package.Id == "Failing.Package", + package => + { + attempted.Add(package.Id); + return Result(package.Id, success: true); + }); + + Assert.Equal(new[] { "Successful.Package" }, attempted); + Assert.Equal(0, exitCode); + } + + [Fact] + public void NoUpgradeLookupFailureDoesNotStopLaterPackages() + { + var attempted = new List(); + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: true, + noUpgrade: true, + package => package.Id == "Failing.Package" + ? throw new InvalidOperationException("source configuration is invalid") + : false, + package => + { + attempted.Add(package.Id); + return Result(package.Id, success: true); + }); + + Assert.Equal(new[] { "Successful.Package" }, attempted); + Assert.Equal(1, exitCode); + } + + [Fact] + public void LaterNoOpDoesNotClearEarlierFailure() + { + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: false, + noUpgrade: false, + _ => false, + package => Result(package.Id, success: false, noOp: package.Id == "Successful.Package")); + + Assert.Equal(1, exitCode); + } + + [Fact] + public void LaterIgnoredUnavailablePackageDoesNotClearEarlierFailure() + { + var attempted = new List(); + var exitCode = ImportCommandHandler.Run( + MixedPackages, + dryRun: false, + ignoreUnavailable: true, + noUpgrade: false, + _ => false, + package => + { + attempted.Add(package.Id); + if (package.Id == "Successful.Package") + throw new InvalidOperationException("no package matched the supplied query"); + return Result(package.Id, success: false); + }); + + Assert.Equal(new[] { "Failing.Package", "Successful.Package" }, attempted); + Assert.Equal(1, exitCode); + } + + private static InstallResult Result(string packageId, bool success, bool noOp = false) => new() + { + PackageId = packageId, + Version = "1.0", + InstallerPath = string.Empty, + InstallerType = "exe", + ExitCode = success ? 0 : 1, + Success = success, + NoOp = noOp, + }; +} diff --git a/rust/crates/pinget-cli/src/main.rs b/rust/crates/pinget-cli/src/main.rs index 0531452..f126a93 100644 --- a/rust/crates/pinget-cli/src/main.rs +++ b/rust/crates/pinget-cli/src/main.rs @@ -2510,8 +2510,23 @@ fn print_json_value(value: &serde_json::Value, output: OutputFormat) -> Result<( Ok(()) } -fn do_import( - repository: &mut Repository, +trait ImportRepository { + fn search(&mut self, query: &PackageQuery) -> Result; + fn install_request(&mut self, request: &InstallRequest) -> Result; +} + +impl ImportRepository for Repository { + fn search(&mut self, query: &PackageQuery) -> Result { + Repository::search(self, query) + } + + fn install_request(&mut self, request: &InstallRequest) -> Result { + Repository::install_request(self, request) + } +} + +fn do_import( + repository: &mut R, file_path: &str, dry_run: bool, ignore_unavailable: bool, @@ -2520,7 +2535,27 @@ fn do_import( accept_package_agreements: bool, ) -> Result<()> { let content = std::fs::read_to_string(file_path)?; - let doc: serde_json::Value = serde_json::from_str(&content)?; + do_import_from_content( + repository, + &content, + dry_run, + ignore_unavailable, + ignore_versions, + no_upgrade, + accept_package_agreements, + ) +} + +fn do_import_from_content( + repository: &mut R, + content: &str, + dry_run: bool, + ignore_unavailable: bool, + ignore_versions: bool, + no_upgrade: bool, + accept_package_agreements: bool, +) -> Result<()> { + let doc: serde_json::Value = serde_json::from_str(content)?; let sources = doc .get("Sources") @@ -2531,6 +2566,7 @@ fn do_import( let mut found = 0; let mut not_found = 0; let mut skipped = 0; + let mut failures = ImportFailureAccumulator::default(); for source in sources { let source_name = source @@ -2589,25 +2625,49 @@ fn do_import( print_warnings(&r.warnings); skipped += 1; } - Ok(r) if r.success => println!(" OK"), - Ok(r) => println!(" FAILED (exit {})", r.exit_code), + Ok(r) if r.success => { + println!(" OK"); + failures.record_install_result(true); + } + Ok(r) => { + println!(" FAILED (exit {})", r.exit_code); + failures.record_install_result(false); + } Err(e) if ignore_unavailable && can_ignore_unavailable_import_failure(&e) => { println!(" UNAVAILABLE"); write_stderr_line(format_args!("warning: Skipping unavailable package '{id}': {e}")); skipped += 1; } - Err(e) => println!(" ERROR: {e}"), + Err(e) => { + println!(" ERROR: {e}"); + failures.record_error(false); + } } } found += 1; } - _ => { + Ok(_) => { if ignore_unavailable { println!(" [ignored unavailable] {id} ({source_name})"); skipped += 1; } else { println!(" [not found] {id} ({source_name})"); not_found += 1; + if !dry_run { + failures.record_error(false); + } + } + } + Err(e) if ignore_unavailable && can_ignore_unavailable_import_failure(&e) => { + failures.record_error(true); + println!(" [ignored unavailable] {id} ({source_name})"); + write_stderr_line(format_args!("warning: Skipping unavailable package '{id}': {e}")); + skipped += 1; + } + Err(e) => { + write_stderr_line(format_args!(" ERROR searching for {id}: {e}")); + if !dry_run { + failures.record_error(false); } } } @@ -2623,6 +2683,9 @@ fn do_import( println!("Skipped {skipped} package(s)."); } } + if failures.has_failures() { + bail!("one or more packages failed during import"); + } Ok(()) } @@ -2654,6 +2717,25 @@ fn create_import_install_request( request } +#[derive(Default)] +struct ImportFailureAccumulator { + has_failures: bool, +} + +impl ImportFailureAccumulator { + fn record_install_result(&mut self, success: bool) { + self.has_failures |= !success; + } + + fn record_error(&mut self, ignored_unavailable: bool) { + self.has_failures |= !ignored_unavailable; + } + + fn has_failures(&self) -> bool { + self.has_failures + } +} + fn can_ignore_unavailable_import_failure(error: &anyhow::Error) -> bool { let message = error.to_string().to_ascii_lowercase(); message.contains("no package matched") || message.contains("no applicable installer found") @@ -2665,6 +2747,375 @@ mod tests { use super::*; + #[derive(Default)] + struct FakeImportRepository { + searched: Vec, + installed: Vec, + failed_package: Option, + thrown_package: Option, + unavailable_package: Option, + missing_package: Option, + search_error_package: Option, + no_op_package: Option, + } + + impl ImportRepository for FakeImportRepository { + fn search(&mut self, query: &PackageQuery) -> Result { + let id = query.id.clone().unwrap_or_default(); + self.searched.push(id.clone()); + if self.search_error_package.as_deref() == Some(id.as_str()) { + return Err(anyhow::anyhow!("source configuration is invalid")); + } + let matches = if self.missing_package.as_deref() == Some(id.as_str()) { + Vec::new() + } else { + vec![SearchMatch { + source_name: query.source.clone().unwrap_or_default(), + source_kind: SourceKind::Rest, + id: id.clone(), + name: id, + moniker: None, + version: Some("1.0".to_owned()), + channel: None, + match_criteria: None, + }] + }; + Ok(SearchResponse { + matches, + warnings: Vec::new(), + truncated: false, + }) + } + + fn install_request(&mut self, request: &InstallRequest) -> Result { + let id = request.query.id.clone().unwrap_or_default(); + self.installed.push(id.clone()); + if self.thrown_package.as_deref() == Some(id.as_str()) { + return Err(anyhow::anyhow!("synthetic installer error")); + } + if self.unavailable_package.as_deref() == Some(id.as_str()) { + return Err(anyhow::anyhow!("No applicable installer found")); + } + let success = self.failed_package.as_deref() != Some(id.as_str()); + let no_op = self.no_op_package.as_deref() == Some(id.as_str()); + Ok(InstallResult { + package_id: id, + version: "1.0".to_owned(), + installer_path: PathBuf::new(), + installer_type: "exe".to_owned(), + exit_code: if success { 0 } else { 1 }, + success, + no_op, + warnings: Vec::new(), + }) + } + } + + fn two_package_import_manifest() -> &'static str { + r#"{"Sources":[{"SourceDetails":{"Name":"test"},"Packages":[{"PackageIdentifier":"Failing.Package"},{"PackageIdentifier":"Successful.Package"}]}]}"# + } + + #[test] + fn import_handler_attempts_success_after_returned_failure_and_returns_error() { + let mut repository = FakeImportRepository { + failed_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + let result = do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + false, + false, + false, + false, + ); + + assert!(result.is_err()); + assert_eq!( + repository.installed, + vec!["Failing.Package".to_owned(), "Successful.Package".to_owned()] + ); + } + + #[test] + fn import_handler_attempts_success_after_thrown_error_and_returns_error() { + let mut repository = FakeImportRepository { + thrown_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + let result = do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + false, + false, + false, + false, + ); + + assert!(result.is_err()); + assert_eq!( + repository.installed, + vec!["Failing.Package".to_owned(), "Successful.Package".to_owned()] + ); + } + + #[test] + fn ignore_unavailable_omits_classified_failure_from_exit_status() { + let mut repository = FakeImportRepository { + unavailable_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + let result = do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + true, + false, + false, + false, + ); + + assert!(result.is_ok()); + assert_eq!( + repository.installed, + vec!["Failing.Package".to_owned(), "Successful.Package".to_owned()] + ); + } + + #[test] + fn ignore_unavailable_does_not_hide_unclassified_installer_error() { + let mut repository = FakeImportRepository { + thrown_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + let result = do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + true, + false, + false, + false, + ); + + assert!(result.is_err()); + assert_eq!( + repository.installed, + vec!["Failing.Package".to_owned(), "Successful.Package".to_owned()] + ); + } + + #[test] + fn ignore_unavailable_skips_missing_packages_but_non_ignored_missing_package_fails() { + let manifest = + r#"{"Sources":[{"SourceDetails":{"Name":"test"},"Packages":[{"PackageIdentifier":"Missing.Package"}]}]}"#; + let mut ignored = FakeImportRepository { + missing_package: Some("Missing.Package".to_owned()), + ..Default::default() + }; + assert!(do_import_from_content(&mut ignored, manifest, false, true, false, false, false).is_ok()); + assert!(ignored.installed.is_empty()); + + let mut required = FakeImportRepository { + missing_package: Some("Missing.Package".to_owned()), + ..Default::default() + }; + assert!(do_import_from_content(&mut required, manifest, false, false, false, false, false).is_err()); + } + + #[test] + fn dry_run_searches_all_packages_without_installing_or_failing() { + let mut repository = FakeImportRepository { + failed_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + let result = do_import_from_content( + &mut repository, + two_package_import_manifest(), + true, + false, + false, + false, + false, + ); + + assert!(result.is_ok()); + assert_eq!( + repository.searched, + vec!["Failing.Package".to_owned(), "Successful.Package".to_owned()] + ); + assert!(repository.installed.is_empty()); + } + + #[test] + fn dry_run_search_errors_do_not_fail_or_stop_later_searches() { + let mut repository = FakeImportRepository { + search_error_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + assert!( + do_import_from_content( + &mut repository, + two_package_import_manifest(), + true, + false, + false, + false, + false, + ) + .is_ok() + ); + assert_eq!( + repository.searched, + vec!["Failing.Package".to_owned(), "Successful.Package".to_owned()] + ); + assert!(repository.installed.is_empty()); + } + + #[test] + fn dry_run_missing_packages_do_not_fail() { + let mut repository = FakeImportRepository { + missing_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + assert!( + do_import_from_content( + &mut repository, + two_package_import_manifest(), + true, + false, + false, + false, + false, + ) + .is_ok() + ); + assert_eq!(repository.searched.len(), 2); + assert!(repository.installed.is_empty()); + } + + #[test] + fn ignore_unavailable_does_not_hide_search_errors_or_stop_later_installs() { + let mut repository = FakeImportRepository { + search_error_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + assert!( + do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + true, + false, + false, + false, + ) + .is_err() + ); + assert_eq!(repository.installed, vec!["Successful.Package".to_owned()]); + } + + #[test] + fn ignore_unavailable_does_not_hide_returned_installer_failures() { + let mut repository = FakeImportRepository { + failed_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + assert!( + do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + true, + false, + false, + false, + ) + .is_err() + ); + assert_eq!(repository.installed.len(), 2); + } + + #[test] + fn no_op_result_does_not_fail_import() { + let mut repository = FakeImportRepository { + failed_package: Some("Failing.Package".to_owned()), + no_op_package: Some("Failing.Package".to_owned()), + ..Default::default() + }; + + assert!( + do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + false, + false, + false, + false, + ) + .is_ok() + ); + assert_eq!(repository.installed.len(), 2); + } + + #[test] + fn later_no_op_does_not_clear_earlier_failure() { + let mut repository = FakeImportRepository { + failed_package: Some("Failing.Package".to_owned()), + no_op_package: Some("Successful.Package".to_owned()), + ..Default::default() + }; + + assert!( + do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + false, + false, + false, + false, + ) + .is_err() + ); + assert_eq!(repository.installed.len(), 2); + } + + #[test] + fn later_ignored_unavailable_package_does_not_clear_earlier_failure() { + let mut repository = FakeImportRepository { + failed_package: Some("Failing.Package".to_owned()), + unavailable_package: Some("Successful.Package".to_owned()), + ..Default::default() + }; + + assert!( + do_import_from_content( + &mut repository, + two_package_import_manifest(), + false, + true, + false, + false, + false, + ) + .is_err() + ); + assert_eq!(repository.installed.len(), 2); + } + #[test] fn resolve_source_add_value_accepts_option_form() { let value = resolve_source_add_value(None, Some("winget.pro"), "name").expect("value");