// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Aspire.Cli.Commands;
internal static class InstallationInfoOutput
{
internal static readonly TimeSpan s_defaultDiscoveryTimeout = TimeSpan.FromSeconds(30);
public static Task<IReadOnlyList<InstallationInfo>> DiscoverAllSafelyAsync(
IInstallationDiscovery discovery,
WingetFirstRunProbe wingetFirstRunProbe,
ILogger logger,
CancellationToken cancellationToken)
=> DiscoverAllSafelyAsync(discovery, wingetFirstRunProbe, logger, s_defaultDiscoveryTimeout, cancellationToken);
internal static async Task<IReadOnlyList<InstallationInfo>> DiscoverAllSafelyAsync(
IInstallationDiscovery discovery,
WingetFirstRunProbe wingetFirstRunProbe,
ILogger logger,
TimeSpan timeout,
CancellationToken cancellationToken)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
// DiscoverAllCoreAsync catches and logs non-cancellation exceptions, so a task that
// continues after WaitAsync times out cannot later produce an unobserved fault.
var discoveryTask = Task.Run(
() => DiscoverAllCoreAsync(discovery, wingetFirstRunProbe, logger, timeoutCts.Token),
CancellationToken.None);
try
{
return await discoveryTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
logger.LogWarning("Aspire CLI installation discovery timed out after {TimeoutSeconds} seconds.", timeout.TotalSeconds);
return CreateFailedDiscoveryRow(string.Format(
CultureInfo.CurrentCulture,
DoctorCommandStrings.InstallationDiscoveryTimedOutReasonFormat,
timeout.TotalSeconds));
}
}
private static async Task<IReadOnlyList<InstallationInfo>> DiscoverAllCoreAsync(
IInstallationDiscovery discovery,
WingetFirstRunProbe wingetFirstRunProbe,
ILogger logger,
CancellationToken cancellationToken)
{
try
{
RunWingetFirstRunProbe(wingetFirstRunProbe);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Could not run the winget first-run install sidecar probe before doctor installation discovery.");
}
try
{
logger.LogDebug("Discovering Aspire CLI installations for doctor output.");
var installations = await discovery.DiscoverAllAsync(cancellationToken).ConfigureAwait(false);
logger.LogDebug("Discovered {InstallationCount} Aspire CLI installation(s) for doctor output.", installations.Count);
return installations;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not discover Aspire CLI installations for doctor output.");
return CreateFailedDiscoveryRow(DoctorCommandStrings.InstallationDiscoveryFailedReason);
}
}
public static IReadOnlyList<InstallationInfo> DescribeSelfSafely(IInstallationDiscovery discovery, ILogger logger)
{
try
{
return [discovery.DescribeSelf()];
}
catch (OperationCanceledException)
{
// Symmetric with DiscoverAllSafelyAsync: cancellation must propagate
// so the caller can honor the cancellation token even if DescribeSelf
// ever becomes cancellable.
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not describe the running Aspire CLI installation for doctor self-probe output.");
return CreateFailedDiscoveryRow(DoctorCommandStrings.InstallationDiscoveryFailedReason);
}
}
public static void RunWingetFirstRunProbe(WingetFirstRunProbe wingetFirstRunProbe)
{
// Give a never-run winget install a chance to stamp its sidecar before
// we read it. The probe writes nothing on non-Windows hosts or when the
// running binary isn't a winget portable install, so this is a cheap
// no-op in the common case.
var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
return;
}
var binaryDir = Path.GetDirectoryName(processPath);
if (!string.IsNullOrEmpty(binaryDir))
{
wingetFirstRunProbe.Run(binaryDir);
}
}
public static void OutputTable(IAnsiConsole ansiConsole, IReadOnlyList<InstallationInfo> installs)
{
ansiConsole.WriteLine();
ansiConsole.MarkupLine($"[bold]{DoctorCommandStrings.HeaderInstallations.EscapeMarkup()}[/]");
ansiConsole.WriteLine(new string('=', DoctorCommandStrings.HeaderInstallations.Length));
ansiConsole.WriteLine();
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn(DoctorCommandStrings.ColumnPath)
.AddColumn(DoctorCommandStrings.ColumnVersion)
.AddColumn(DoctorCommandStrings.ColumnChannel)
.AddColumn(DoctorCommandStrings.ColumnRoute)
.AddColumn(DoctorCommandStrings.ColumnPathStatus);
// The first row is, by contract, the running CLI (enforced by
// InstallationDiscovery, not by ordering here). Tag installs[0]
// directly rather than re-resolving Environment.ProcessPath and
// matching CanonicalPath: that re-derivation can disagree with
// the discovery layer's notion of self (e.g. when ProcessPath is
// unresolvable at render time but was resolvable when DescribeSelf
// ran, or when a peer happens to share a canonical path with the
// running CLI).
for (var i = 0; i < installs.Count; i++)
{
var install = installs[i];
var isSelf = i == 0;
var pathDisplay = string.IsNullOrEmpty(install.Path)
? DoctorCommandStrings.ValueUnknown
: install.Path;
pathDisplay = pathDisplay.EscapeMarkup();
if (isSelf)
{
pathDisplay = $"{pathDisplay} [grey]{DoctorCommandStrings.ValueCurrentMarker.EscapeMarkup()}[/]";
}
table.AddRow(
pathDisplay,
ValueOrPlaceholder(install.Version, install.Status),
ValueOrPlaceholder(install.Channel, install.Status),
ValueOrPlaceholder(install.Route, install.Status),
PathStatusDisplay(install.PathStatus));
}
ansiConsole.Write(table);
}
private static string PathStatusDisplay(string pathStatus)
{
return pathStatus switch
{
InstallationPathStatus.Active => DoctorCommandStrings.ValuePathActive,
InstallationPathStatus.Shadowed => DoctorCommandStrings.ValuePathShadowed,
InstallationPathStatus.NotOnPath => DoctorCommandStrings.ValuePathNotOnPath,
_ => pathStatus.EscapeMarkup(),
};
}
private static string ValueOrPlaceholder(string? value, string status)
{
if (!string.IsNullOrEmpty(value))
{
return value.EscapeMarkup();
}
// Missing fields mean different things for skipped rows, failed
// probes, and rows that responded but did not populate this value.
return status switch
{
InstallationInfoStatus.NotProbed => DoctorCommandStrings.ValueNotProbed,
InstallationInfoStatus.Failed => DoctorCommandStrings.ValueProbeFailed,
_ => DoctorCommandStrings.ValueUnknown,
};
}
private static IReadOnlyList<InstallationInfo> CreateFailedDiscoveryRow(string reason)
{
return
[
new InstallationInfo
{
Path = Environment.ProcessPath ?? string.Empty,
CanonicalPath = null,
PathStatus = InstallationPathStatus.NotOnPath,
Status = InstallationInfoStatus.Failed,
StatusReason = reason,
}
];
}
}