Skip to content

Commit cdd0271

Browse files
davidfowlCopilot
andcommitted
Fix interactive fuzzy add confirmation
Prompt before adding a single fuzzy or no-match fallback candidate in interactive aspire add flows, while preserving exact-match auto-selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ec03c67 commit cdd0271

2 files changed

Lines changed: 233 additions & 8 deletions

File tree

src/Aspire.Cli/Commands/AddCommand.cs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,16 +205,33 @@ CommandResult AddCommandFromExitCode(int exitCode)
205205
: ProfilingTelemetry.Values.AddPackageMatchKindNone;
206206
}
207207

208-
// If we didn't match any, show a complete list. If we matched one, and its
208+
// If the user supplied a partial/fuzzy search term, keep the package prompt even when
209+
// the fallback only found one candidate; otherwise `aspire add kube` can silently add
210+
// the lone fuzzy match without asking the interactive user to confirm it.
211+
var promptForSingleFuzzyPackage = packageMatchKind == ProfilingTelemetry.Values.AddPackageMatchKindFuzzy;
212+
213+
// If we didn't match any, show a complete list. If we matched one, and it's
209214
// an exact match, then we still prompt, but it will only prompt for
210215
// the version. If there is more than one match then we prompt.
211216
(string FriendlyName, NuGetPackage Package, PackageChannel Channel) selectedNuGetPackage;
212217
selectedNuGetPackage = filteredPackagesWithShortName.Count switch
213218
{
214-
0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(effectiveAppHostProjectFile.Directory!, packagesWithShortName, integrationName, version, cancellationToken),
215-
1 when filteredPackagesWithShortName[0].Package.Version == version
219+
0 => await GetPackageByInteractiveFlowWithNoMatchesMessage(
220+
effectiveAppHostProjectFile.Directory!,
221+
packagesWithShortName,
222+
integrationName,
223+
version,
224+
cancellationToken,
225+
promptForSinglePackage: integrationName is not null),
226+
1 when packageMatchKind == ProfilingTelemetry.Values.AddPackageMatchKindExact
227+
&& filteredPackagesWithShortName[0].Package.Version == version
216228
=> filteredPackagesWithShortName[0],
217-
_ => await GetPackageByInteractiveFlow(effectiveAppHostProjectFile.Directory!, filteredPackagesWithShortName, version, cancellationToken)
229+
_ => await GetPackageByInteractiveFlow(
230+
effectiveAppHostProjectFile.Directory!,
231+
filteredPackagesWithShortName,
232+
version,
233+
cancellationToken,
234+
promptForSingleFuzzyPackage)
218235
};
219236
using (var selectPackageActivity = _profilingTelemetry.StartAddSelectPackage(integrationName, version))
220237
{
@@ -365,14 +382,21 @@ CommandResult AddCommandFromExitCode(int exitCode)
365382
return versions;
366383
}
367384

368-
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? preferredVersion, CancellationToken cancellationToken)
385+
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlow(
386+
DirectoryInfo workingDirectory,
387+
IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages,
388+
string? preferredVersion,
389+
CancellationToken cancellationToken,
390+
bool promptForSinglePackage = false)
369391
{
370392
var distinctPackages = possiblePackages.DistinctBy(p => p.Package.Id).ToArray();
371393

372-
// If there is only one package, we can skip the prompt and just use it.
394+
// Exact matches can skip the package prompt when one package remains. Fuzzy/no-match
395+
// fallbacks opt into prompting so interactive users confirm the candidate first.
373396
// In non-interactive mode, auto-select the first package.
374397
var selectedPackage = distinctPackages.Length switch
375398
{
399+
1 when promptForSinglePackage && _hostEnvironment.SupportsInteractiveInput => await PromptForIntegrationAsync(distinctPackages, cancellationToken),
376400
1 => distinctPackages.First(),
377401
> 1 when !_hostEnvironment.SupportsInteractiveInput => distinctPackages.First(),
378402
> 1 => await PromptForIntegrationAsync(distinctPackages, cancellationToken),
@@ -448,14 +472,20 @@ CommandResult AddCommandFromExitCode(int exitCode)
448472
return await _prompter.PromptForIntegrationVersionAsync(packages, cancellationToken);
449473
}
450474

451-
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(DirectoryInfo workingDirectory, IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages, string? searchTerm, string? preferredVersion, CancellationToken cancellationToken)
475+
private async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> GetPackageByInteractiveFlowWithNoMatchesMessage(
476+
DirectoryInfo workingDirectory,
477+
IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> possiblePackages,
478+
string? searchTerm,
479+
string? preferredVersion,
480+
CancellationToken cancellationToken,
481+
bool promptForSinglePackage = false)
452482
{
453483
if (searchTerm is not null)
454484
{
455485
InteractionService.DisplaySubtleMessage(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.NoPackagesMatchedSearchTerm, searchTerm));
456486
}
457487

458-
return await GetPackageByInteractiveFlow(workingDirectory, possiblePackages, preferredVersion, cancellationToken);
488+
return await GetPackageByInteractiveFlow(workingDirectory, possiblePackages, preferredVersion, cancellationToken, promptForSinglePackage);
459489
}
460490

461491
}

tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,6 +1467,76 @@ public async Task AddCommandInteractiveDoesNotPromptForVersionWhenSpecifiedVersi
14671467
Assert.All(exactMatchQueries, query => Assert.Equal("Aspire.Hosting.Redis", query));
14681468
}
14691469

1470+
[Theory]
1471+
[InlineData("redis")]
1472+
[InlineData("Aspire.Hosting.Redis")]
1473+
public async Task AddCommandInteractiveDoesNotPromptForIntegrationWhenExactMatchIsFound(string integrationName)
1474+
{
1475+
var promptedForIntegration = false;
1476+
var promptedForVersion = false;
1477+
var selectedPackageName = string.Empty;
1478+
var selectedPackageVersion = string.Empty;
1479+
1480+
using var workspace = TemporaryWorkspace.Create(outputHelper);
1481+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
1482+
{
1483+
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
1484+
options.AddCommandPrompterFactory = (sp) =>
1485+
{
1486+
var interactionService = sp.GetRequiredService<IInteractionService>();
1487+
var prompter = new TestAddCommandPrompter(interactionService);
1488+
prompter.PromptForIntegrationCallback = (packages) =>
1489+
{
1490+
promptedForIntegration = true;
1491+
throw new InvalidOperationException("Should not have been prompted for integration selection.");
1492+
};
1493+
prompter.PromptForIntegrationVersionCallback = (packages) =>
1494+
{
1495+
promptedForVersion = true;
1496+
return packages.Single(package => package.Package.Version == "13.2.0");
1497+
};
1498+
1499+
return prompter;
1500+
};
1501+
1502+
options.ProjectLocatorFactory = _ => new TestProjectLocator();
1503+
1504+
options.DotNetCliRunnerFactory = (sp) =>
1505+
{
1506+
var runner = new TestDotNetCliRunner();
1507+
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) =>
1508+
{
1509+
return (0, [
1510+
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.3.0" },
1511+
new NuGetPackage { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "13.2.0" }
1512+
]);
1513+
};
1514+
1515+
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) =>
1516+
{
1517+
selectedPackageName = packageName;
1518+
selectedPackageVersion = packageVersion;
1519+
return 0;
1520+
};
1521+
1522+
return runner;
1523+
};
1524+
});
1525+
1526+
using var provider = services.BuildServiceProvider();
1527+
1528+
var command = provider.GetRequiredService<AddCommand>();
1529+
var result = command.Parse($"add {integrationName}");
1530+
1531+
var exitCode = await result.InvokeAsync().DefaultTimeout();
1532+
1533+
Assert.Equal(0, exitCode);
1534+
Assert.False(promptedForIntegration);
1535+
Assert.True(promptedForVersion);
1536+
Assert.Equal("Aspire.Hosting.Redis", selectedPackageName);
1537+
Assert.Equal("13.2.0", selectedPackageVersion);
1538+
}
1539+
14701540
[Fact]
14711541
public async Task AddCommandSearchesEachPackageIdOnceWhenExactMatchFallsBackAcrossSharedChannel()
14721542
{
@@ -2811,6 +2881,131 @@ public async Task AddCommand_NonInteractive_ExactMatchWithoutVersion_StillSuccee
28112881
Assert.Equal("Aspire.Hosting.Kubernetes", addedPackage);
28122882
}
28132883

2884+
[Fact]
2885+
public async Task AddCommand_Interactive_SingleFuzzyMatchPromptsBeforeAdding_Regression17724()
2886+
{
2887+
var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>();
2888+
var addedPackage = string.Empty;
2889+
2890+
using var workspace = TemporaryWorkspace.Create(outputHelper);
2891+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
2892+
{
2893+
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
2894+
options.AddCommandPrompterFactory = (sp) =>
2895+
{
2896+
var interactionService = sp.GetRequiredService<IInteractionService>();
2897+
var prompter = new TestAddCommandPrompter(interactionService);
2898+
prompter.PromptForIntegrationCallback = (packages) =>
2899+
{
2900+
promptedPackages.AddRange(packages);
2901+
return packages.Single();
2902+
};
2903+
2904+
return prompter;
2905+
};
2906+
options.ProjectLocatorFactory = _ => new TestProjectLocator();
2907+
2908+
options.DotNetCliRunnerFactory = (sp) =>
2909+
{
2910+
var runner = new TestDotNetCliRunner();
2911+
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
2912+
{
2913+
return (
2914+
0,
2915+
new NuGetPackage[]
2916+
{
2917+
new() { Id = "Aspire.Hosting.Azure", Source = "nuget", Version = "9.2.0" }
2918+
});
2919+
};
2920+
2921+
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
2922+
{
2923+
addedPackage = packageName;
2924+
return 0;
2925+
};
2926+
2927+
return runner;
2928+
};
2929+
});
2930+
using var provider = services.BuildServiceProvider();
2931+
2932+
var command = provider.GetRequiredService<AddCommand>();
2933+
var result = command.Parse("add kube");
2934+
2935+
var exitCode = await result.InvokeAsync().DefaultTimeout();
2936+
2937+
var promptedPackage = Assert.Single(promptedPackages);
2938+
Assert.Equal(0, exitCode);
2939+
Assert.Equal("Aspire.Hosting.Azure", promptedPackage.Package.Id);
2940+
Assert.Equal("Aspire.Hosting.Azure", addedPackage);
2941+
}
2942+
2943+
[Fact]
2944+
public async Task AddCommand_Interactive_NoFuzzyMatchSinglePackagePromptsBeforeAdding()
2945+
{
2946+
var promptedPackages = new List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>();
2947+
var displayedSubtleMessage = string.Empty;
2948+
var addedPackage = string.Empty;
2949+
var testInteractionService = new TestInteractionService
2950+
{
2951+
DisplaySubtleMessageCallback = message => displayedSubtleMessage = message
2952+
};
2953+
2954+
using var workspace = TemporaryWorkspace.Create(outputHelper);
2955+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
2956+
{
2957+
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
2958+
options.InteractionServiceFactory = _ => testInteractionService;
2959+
options.AddCommandPrompterFactory = (sp) =>
2960+
{
2961+
var interactionService = sp.GetRequiredService<IInteractionService>();
2962+
var prompter = new TestAddCommandPrompter(interactionService);
2963+
prompter.PromptForIntegrationCallback = (packages) =>
2964+
{
2965+
promptedPackages.AddRange(packages);
2966+
return packages.Single();
2967+
};
2968+
2969+
return prompter;
2970+
};
2971+
options.ProjectLocatorFactory = _ => new TestProjectLocator();
2972+
2973+
options.DotNetCliRunnerFactory = (sp) =>
2974+
{
2975+
var runner = new TestDotNetCliRunner();
2976+
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
2977+
{
2978+
return (
2979+
0,
2980+
new NuGetPackage[]
2981+
{
2982+
new() { Id = "Aspire.Hosting.Redis", Source = "nuget", Version = "9.2.0" }
2983+
});
2984+
};
2985+
2986+
runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
2987+
{
2988+
addedPackage = packageName;
2989+
return 0;
2990+
};
2991+
2992+
return runner;
2993+
};
2994+
});
2995+
using var provider = services.BuildServiceProvider();
2996+
2997+
var command = provider.GetRequiredService<AddCommand>();
2998+
var result = command.Parse("add zzzzzzzzzz");
2999+
3000+
var exitCode = await result.InvokeAsync().DefaultTimeout();
3001+
3002+
var promptedPackage = Assert.Single(promptedPackages);
3003+
Assert.Equal(0, exitCode);
3004+
Assert.Equal(string.Format(AddCommandStrings.NoPackagesMatchedSearchTerm, "zzzzzzzzzz"), displayedSubtleMessage);
3005+
Assert.Equal("Aspire.Hosting.Redis", promptedPackage.Package.Id);
3006+
Assert.Equal("Aspire.Hosting.Redis", addedPackage);
3007+
}
3008+
28143009
[Fact]
28153010
public async Task AddCommand_WithVersionAndNonExactPackageName_FailsInsteadOfUsingFuzzySearch()
28163011
{

0 commit comments

Comments
 (0)