diff --git a/src/Aspire.Cli/Projects/FallbackProjectParser.cs b/src/Aspire.Cli/Projects/FallbackProjectParser.cs index 88c8501257a..3eb7b2eee40 100644 --- a/src/Aspire.Cli/Projects/FallbackProjectParser.cs +++ b/src/Aspire.Cli/Projects/FallbackProjectParser.cs @@ -3,97 +3,160 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using System.Xml.Linq; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; /// -/// Provides fallback XML parsing capabilities when MSBuild evaluation fails. +/// Provides fallback parsing capabilities when MSBuild evaluation fails. +/// Supports both .csproj XML files and .cs single-file apphost files. /// Used primarily for AppHost projects with unresolvable SDK versions. /// -internal sealed class FallbackProjectParser +internal sealed partial class FallbackProjectParser { private readonly ILogger _logger; + [GeneratedRegex(@"#:sdk\s+Aspire\.AppHost\.Sdk@([\d\.\-a-zA-Z]+|\*)")] + private static partial Regex SdkDirectiveRegex(); + + [GeneratedRegex(@"#:package\s+([a-zA-Z0-9\._]+)@([\d\.\-a-zA-Z]+|\*)")] + private static partial Regex PackageDirectiveRegex(); + public FallbackProjectParser(ILogger logger) { _logger = logger; } /// - /// Parses a project file using direct XML parsing to extract basic project information. + /// Parses a project file using direct parsing to extract basic project information. /// Returns a synthetic JsonDocument that mimics MSBuild's GetProjectItemsAndProperties output. + /// Supports both .csproj XML files and .cs single-file apphost files. /// public JsonDocument ParseProject(FileInfo projectFile) { try { - _logger.LogDebug("Parsing project file '{ProjectFile}' using fallback XML parser", projectFile.FullName); - - var doc = XDocument.Load(projectFile.FullName); - var root = doc.Root; + _logger.LogDebug("Parsing project file '{ProjectFile}' using fallback parser", projectFile.FullName); - if (root?.Name.LocalName != "Project") + // Detect file type and route to appropriate parser + if (string.Equals(projectFile.Extension, ".csproj", StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException($"Invalid project file format: {projectFile.FullName}"); + return ParseCsprojProjectFile(projectFile); } - - // Extract SDK information - var aspireHostingSdkVersion = ExtractAspireHostingSdkVersion(root); - - // Extract package references - var packageReferences = ExtractPackageReferences(root); - - // Extract project references - var projectReferences = ExtractProjectReferences(root, projectFile); - - // Build the synthetic JSON structure using JsonObject - var rootObject = new JsonObject(); - - // Items section - var itemsObject = new JsonObject(); - - // PackageReference items - var packageRefArray = new JsonArray(); - foreach (var pkg in packageReferences) + else if (string.Equals(projectFile.Extension, ".cs", StringComparison.OrdinalIgnoreCase)) { - var packageObj = new JsonObject(); - packageObj["Identity"] = JsonValue.Create(pkg.Identity); - packageObj["Version"] = JsonValue.Create(pkg.Version); - packageRefArray.Add((JsonNode?)packageObj); + return ParseCsAppHostFile(projectFile); } - itemsObject["PackageReference"] = packageRefArray; - - // ProjectReference items - var projectRefArray = new JsonArray(); - foreach (var proj in projectReferences) + else { - var projectObj = new JsonObject(); - projectObj["Identity"] = JsonValue.Create(proj.Identity); - projectObj["FullPath"] = JsonValue.Create(proj.FullPath); - projectRefArray.Add((JsonNode?)projectObj); + throw new ProjectUpdaterException($"Unsupported project file type: {projectFile.Extension}. Expected .csproj or .cs file."); } - itemsObject["ProjectReference"] = projectRefArray; - - rootObject["Items"] = itemsObject; - - // Properties section - var propertiesObject = new JsonObject(); - propertiesObject["AspireHostingSDKVersion"] = JsonValue.Create(aspireHostingSdkVersion); - rootObject["Properties"] = propertiesObject; - - // Fallback flag - rootObject["Fallback"] = JsonValue.Create(true); - - // Convert JsonObject to JsonDocument - return JsonDocument.Parse(rootObject.ToJsonString()); + } + catch (ProjectUpdaterException) + { + // Re-throw our custom exceptions + throw; } catch (Exception ex) { - _logger.LogError(ex, "Failed to parse project file '{ProjectFile}' using fallback XML parser", projectFile.FullName); - throw new ProjectUpdaterException($"Failed to parse project file '{projectFile.FullName}' using fallback XML parser: {ex.Message}", ex); + _logger.LogError(ex, "Failed to parse project file '{ProjectFile}' using fallback parser", projectFile.FullName); + throw new ProjectUpdaterException($"Failed to parse project file '{projectFile.FullName}' using fallback parser: {ex.Message}", ex); + } + } + + /// + /// Parses a .csproj XML project file to extract SDK and package information. + /// + private static JsonDocument ParseCsprojProjectFile(FileInfo projectFile) + { + var doc = XDocument.Load(projectFile.FullName); + var root = doc.Root; + + if (root?.Name.LocalName != "Project") + { + throw new InvalidOperationException($"Invalid project file format: {projectFile.FullName}"); } + + // Extract SDK information + var aspireHostingSdkVersion = ExtractAspireHostingSdkVersion(root); + + // Extract package references + var packageReferences = ExtractPackageReferences(root); + + // Extract project references + var projectReferences = ExtractProjectReferences(root, projectFile); + + return BuildJsonDocument(aspireHostingSdkVersion, packageReferences, projectReferences); + } + + /// + /// Parses a .cs single-file apphost to extract SDK and package information from directives. + /// + private static JsonDocument ParseCsAppHostFile(FileInfo projectFile) + { + var fileContent = File.ReadAllText(projectFile.FullName); + + // Extract SDK version from #:sdk directive + var aspireHostingSdkVersion = ExtractSdkVersionFromDirective(fileContent); + + // Extract package references from #:package directives + var packageReferences = ExtractPackageReferencesFromDirectives(fileContent); + + // Single-file apphost projects don't have project references + var projectReferences = Array.Empty(); + + return BuildJsonDocument(aspireHostingSdkVersion, packageReferences, projectReferences); + } + + /// + /// Builds a synthetic JsonDocument from extracted project information. + /// + private static JsonDocument BuildJsonDocument( + string? aspireHostingSdkVersion, + PackageReferenceInfo[] packageReferences, + ProjectReferenceInfo[] projectReferences) + { + var rootObject = new JsonObject(); + + // Items section + var itemsObject = new JsonObject(); + + // PackageReference items + var packageRefArray = new JsonArray(); + foreach (var pkg in packageReferences) + { + var packageObj = new JsonObject(); + packageObj["Identity"] = JsonValue.Create(pkg.Identity); + packageObj["Version"] = JsonValue.Create(pkg.Version); + packageRefArray.Add((JsonNode?)packageObj); + } + itemsObject["PackageReference"] = packageRefArray; + + // ProjectReference items + var projectRefArray = new JsonArray(); + foreach (var proj in projectReferences) + { + var projectObj = new JsonObject(); + projectObj["Identity"] = JsonValue.Create(proj.Identity); + projectObj["FullPath"] = JsonValue.Create(proj.FullPath); + projectRefArray.Add((JsonNode?)projectObj); + } + itemsObject["ProjectReference"] = projectRefArray; + + rootObject["Items"] = itemsObject; + + // Properties section + var propertiesObject = new JsonObject(); + propertiesObject["AspireHostingSDKVersion"] = JsonValue.Create(aspireHostingSdkVersion); + rootObject["Properties"] = propertiesObject; + + // Fallback flag + rootObject["Fallback"] = JsonValue.Create(true); + + // Convert JsonObject to JsonDocument + return JsonDocument.Parse(rootObject.ToJsonString()); } private static string? ExtractAspireHostingSdkVersion(XElement projectRoot) @@ -172,6 +235,51 @@ private static ProjectReferenceInfo[] ExtractProjectReferences(XElement projectR return projectReferences.ToArray(); } + + /// + /// Extracts the Aspire.AppHost.Sdk version from the #:sdk directive in a single-file apphost. + /// + private static string? ExtractSdkVersionFromDirective(string fileContent) + { + // Match: #:sdk Aspire.AppHost.Sdk@ + // Where version can be a semantic version or wildcard (*) + var match = SdkDirectiveRegex().Match(fileContent); + + if (match.Success) + { + return match.Groups[1].Value; + } + + return null; + } + + /// + /// Extracts package references from #:package directives in a single-file apphost. + /// + private static PackageReferenceInfo[] ExtractPackageReferencesFromDirectives(string fileContent) + { + var packageReferences = new List(); + + // Match: #:package @ + // Where version can be a semantic version or wildcard (*) + var matches = PackageDirectiveRegex().Matches(fileContent); + + foreach (Match match in matches) + { + var identity = match.Groups[1].Value; + var version = match.Groups[2].Value; + + var packageRef = new PackageReferenceInfo + { + Identity = identity, + Version = version + }; + + packageReferences.Add(packageRef); + } + + return packageReferences.ToArray(); + } } internal record PackageReferenceInfo diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 7277bc7f9c1..501d3e468d0 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -63,10 +63,10 @@ public async Task UpdateProjectAsync(FileInfo projectFile, interactionService.DisplayEmptyLine(); } - // Display warning if fallback XML parsing was used + // Display warning if fallback parsing was used if (fallbackUsed) { - interactionService.DisplayMessage("warning", "[yellow]Note: Update plan generated using fallback XML parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy.[/]"); + interactionService.DisplayMessage("warning", $"[yellow]{UpdateCommandStrings.FallbackParsingWarning}[/]"); interactionService.DisplayEmptyLine(); } @@ -157,7 +157,7 @@ private static bool IsGlobalNuGetConfig(string path) await analyzeStep.Callback(); } - return (context.UpdateSteps, context.FallbackXmlParsing); + return (context.UpdateSteps, context.FallbackParsing); } private const string ItemsAndPropertiesCacheKeyPrefix = "ItemsAndProperties"; @@ -202,12 +202,12 @@ private async Task GetItemsAndPropertiesWithFallbackAsync(FileInfo catch (ProjectUpdaterException ex) when (IsAppHostProject(projectFile, context)) { // Only use fallback for AppHost projects - logger.LogWarning("Falling back to XML parsing for '{ProjectFile}'. Reason: {Message}", projectFile.FullName, ex.Message); + logger.LogWarning("Falling back to parsing for '{ProjectFile}'. Reason: {Message}", projectFile.FullName, ex.Message); - if (!context.FallbackXmlParsing) + if (!context.FallbackParsing) { - context.FallbackXmlParsing = true; - logger.LogWarning("Update plan will be generated using fallback XML parsing; dependency accuracy may be reduced."); + context.FallbackParsing = true; + logger.LogWarning("Update plan will be generated using fallback parsing; dependency accuracy may be reduced."); } return fallbackParser.ParseProject(projectFile); @@ -863,7 +863,7 @@ internal sealed class UpdateContext(FileInfo appHostProjectFile, PackageChannel public ConcurrentQueue UpdateSteps { get; } = new(); public ConcurrentQueue AnalyzeSteps { get; } = new(); public HashSet VisitedProjects { get; } = new(); - public bool FallbackXmlParsing { get; set; } + public bool FallbackParsing { get; set; } } internal abstract record UpdateStep(string Description, Func Callback) diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs index 4ac9bed7349..e18496b9838 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs @@ -98,5 +98,6 @@ internal static string ProjectArgumentDescription { internal static string MappingAddedFormat => ResourceManager.GetString("MappingAddedFormat", resourceCulture); internal static string MappingRemovedFormat => ResourceManager.GetString("MappingRemovedFormat", resourceCulture); internal static string MappingRetainedFormat => ResourceManager.GetString("MappingRetainedFormat", resourceCulture); + internal static string FallbackParsingWarning => ResourceManager.GetString("FallbackParsingWarning", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx index 164b6857af6..a7dde2726fd 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx @@ -111,4 +111,7 @@ Mapping: {0} + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf index 31922d185d2..bc71025f673 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf @@ -77,6 +77,11 @@ Nepodařilo se aktualizovat odkaz na balíček pro {0} v projektu {1}. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Mapování: {0} (přidáno) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf index 05bf1b34eb8..37ce2c7e70f 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf @@ -77,6 +77,11 @@ Beim Aktualisieren des Paketverweises für {0} im Projekt {1} ist ein Fehler aufgetreten. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Zuordnung: {0} (hinzugefügt) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf index 2d83e9d6d78..0090072ba0e 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf @@ -77,6 +77,11 @@ No se pudo actualizar la referencia del paquete para {0} en el proyecto {1}. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Asignación: {0} (agregado) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf index 0ade8865774..3cb8ac57456 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf @@ -77,6 +77,11 @@ Échec de la mise à jour de la référence de package pour {0} dans le projet {1}. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Mappage : {0} (ajouté) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf index a89c47612f0..c8ac74cee07 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf @@ -77,6 +77,11 @@ Non è possibile aggiornare il riferimento al pacchetto per {0} nel progetto {1}. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Mapping: {0} (aggiunto) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf index ff647943924..db386eeef55 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf @@ -77,6 +77,11 @@ プロジェクト {1} の {0} に対するパッケージ参照の更新に失敗しました。 + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) マッピング: {0} (追加済み) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf index 9773bb39b53..f7c23864ac9 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf @@ -77,6 +77,11 @@ 프로젝트 {1}에서 {0}에 대한 패키지 참조를 업데이트하지 못 했습니다. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) 매핑: {0}(추가됨) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf index 33f6cbca0f7..727fe1f3fc2 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf @@ -77,6 +77,11 @@ Nie można zaktualizować odwołania do pakietu {0} w projekcie {1}. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Mapowanie: {0} (dodano) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf index 26e6aeb98d1..a1b80b9a975 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf @@ -77,6 +77,11 @@ Falha ao atualizar a referência de pacote para {0} no projeto {1}. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Mapeamento: {0} (adicionado) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf index ff91d82c2d7..450b502695c 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf @@ -77,6 +77,11 @@ Не удалось обновить ссылку на пакет для {0} в проекте {1}. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Сопоставление: {0} (добавлено) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf index 1b565e3c42f..8b53b4fa1d1 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf @@ -77,6 +77,11 @@ {1} projesinde {0} için paket başvurusu güncellenemedi. + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) Eşleme: {0} (eklendi) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf index eb496d06b16..d0663526544 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf @@ -77,6 +77,11 @@ 未能更新项目 {1} 中 {0} 的包引用。 + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) 映射: {0} (已添加) diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf index 15141f4ef52..11bc2dd1594 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf @@ -77,6 +77,11 @@ 無法更新專案 {1} 中 {0} 的套件參考。 + + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + Note: Update plan generated using fallback parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy. + + Mapping: {0} (added) 對應: {0} (已新增) diff --git a/tests/Aspire.Cli.Tests/Projects/FallbackProjectParserTests.cs b/tests/Aspire.Cli.Tests/Projects/FallbackProjectParserTests.cs index 69db21c45ae..53c01618087 100644 --- a/tests/Aspire.Cli.Tests/Projects/FallbackProjectParserTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/FallbackProjectParserTests.cs @@ -13,16 +13,16 @@ public class FallbackProjectParserTests public void ParseProject_ExtractsAspireAppHostSdk() { // Arrange - var tempDir = Path.GetTempPath(); - var projectFile = Path.Combine(tempDir, $"Test{Guid.NewGuid()}.csproj"); - var projectContent = """ - - - - """; - + var tempDir = Directory.CreateTempSubdirectory(); try { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.csproj"); + var projectContent = """ + + + + """; + File.WriteAllText(projectFile, projectContent); var parser = new FallbackProjectParser(NullLogger.Instance); @@ -39,10 +39,7 @@ public void ParseProject_ExtractsAspireAppHostSdk() } finally { - if (File.Exists(projectFile)) - { - File.Delete(projectFile); - } + tempDir.Delete(recursive: true); } } @@ -50,20 +47,20 @@ public void ParseProject_ExtractsAspireAppHostSdk() public void ParseProject_ExtractsPackageReferences() { // Arrange - var tempDir = Path.GetTempPath(); - var projectFile = Path.Combine(tempDir, $"Test{Guid.NewGuid()}.csproj"); - var projectContent = """ - - - - - - - - """; - + var tempDir = Directory.CreateTempSubdirectory(); try { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.csproj"); + var projectContent = """ + + + + + + + + """; + File.WriteAllText(projectFile, projectContent); var parser = new FallbackProjectParser(NullLogger.Instance); @@ -88,10 +85,7 @@ public void ParseProject_ExtractsPackageReferences() } finally { - if (File.Exists(projectFile)) - { - File.Delete(projectFile); - } + tempDir.Delete(recursive: true); } } @@ -99,20 +93,20 @@ public void ParseProject_ExtractsPackageReferences() public void ParseProject_ExtractsProjectReferences() { // Arrange - var tempDir = Path.GetTempPath(); - var projectFile = Path.Combine(tempDir, $"Test{Guid.NewGuid()}.csproj"); - var projectContent = """ - - - - - - - - """; - + var tempDir = Directory.CreateTempSubdirectory(); try { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.csproj"); + var projectContent = """ + + + + + + + + """; + File.WriteAllText(projectFile, projectContent); var parser = new FallbackProjectParser(NullLogger.Instance); @@ -135,10 +129,7 @@ public void ParseProject_ExtractsProjectReferences() } finally { - if (File.Exists(projectFile)) - { - File.Delete(projectFile); - } + tempDir.Delete(recursive: true); } } @@ -146,18 +137,18 @@ public void ParseProject_ExtractsProjectReferences() public void ParseProject_InvalidXml_ThrowsProjectUpdaterException() { // Arrange - var tempDir = Path.GetTempPath(); - var projectFile = Path.Combine(tempDir, $"Test{Guid.NewGuid()}.csproj"); - var invalidProjectContent = """ - - - - - - """; - + var tempDir = Directory.CreateTempSubdirectory(); try { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.csproj"); + var invalidProjectContent = """ + + + + + + """; + File.WriteAllText(projectFile, invalidProjectContent); var parser = new FallbackProjectParser(NullLogger.Instance); @@ -167,10 +158,262 @@ public void ParseProject_InvalidXml_ThrowsProjectUpdaterException() } finally { - if (File.Exists(projectFile)) - { - File.Delete(projectFile); - } + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void ParseProject_SingleFileAppHost_ExtractsAspireAppHostSdk() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.cs"); + var projectContent = """ + #:sdk Aspire.AppHost.Sdk@13.0.0-preview.1.25519.5 + #:package Aspire.Hosting.NodeJs@9.5.1 + + var builder = DistributedApplication.CreateBuilder(args); + builder.Build().Run(); + """; + + File.WriteAllText(projectFile, projectContent); + var parser = new FallbackProjectParser(NullLogger.Instance); + + // Act + var result = parser.ParseProject(new FileInfo(projectFile)); + + // Assert + var properties = result.RootElement.GetProperty("Properties"); + var sdkVersion = properties.GetProperty("AspireHostingSDKVersion").GetString(); + Assert.Equal("13.0.0-preview.1.25519.5", sdkVersion); + + // Should have fallback flag + Assert.True(result.RootElement.GetProperty("Fallback").GetBoolean()); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void ParseProject_SingleFileAppHost_ExtractsPackageReferences() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.cs"); + var projectContent = """ + #:sdk Aspire.AppHost.Sdk@13.0.0-preview.1.25519.5 + #:package Aspire.Hosting.NodeJs@9.5.1 + #:package Aspire.Hosting.Python@9.5.1 + #:package Aspire.Hosting.Redis@9.5.1 + #:package CommunityToolkit.Aspire.Hosting.NodeJS.Extensions@9.8.0 + + #pragma warning disable ASPIREHOSTINGPYTHON001 + + var builder = DistributedApplication.CreateBuilder(args); + var cache = builder.AddRedis("cache"); + builder.Build().Run(); + """; + + File.WriteAllText(projectFile, projectContent); + var parser = new FallbackProjectParser(NullLogger.Instance); + + // Act + var result = parser.ParseProject(new FileInfo(projectFile)); + + // Assert + var items = result.RootElement.GetProperty("Items"); + var packageRefs = items.GetProperty("PackageReference").EnumerateArray().ToArray(); + + Assert.Equal(4, packageRefs.Length); + + var nodeJsPkg = packageRefs.FirstOrDefault(p => + p.GetProperty("Identity").GetString() == "Aspire.Hosting.NodeJs"); + Assert.NotEqual(default(JsonElement), nodeJsPkg); + Assert.Equal("9.5.1", nodeJsPkg.GetProperty("Version").GetString()); + + var pythonPkg = packageRefs.FirstOrDefault(p => + p.GetProperty("Identity").GetString() == "Aspire.Hosting.Python"); + Assert.NotEqual(default(JsonElement), pythonPkg); + Assert.Equal("9.5.1", pythonPkg.GetProperty("Version").GetString()); + + var redisPkg = packageRefs.FirstOrDefault(p => + p.GetProperty("Identity").GetString() == "Aspire.Hosting.Redis"); + Assert.NotEqual(default(JsonElement), redisPkg); + Assert.Equal("9.5.1", redisPkg.GetProperty("Version").GetString()); + + var toolkitPkg = packageRefs.FirstOrDefault(p => + p.GetProperty("Identity").GetString() == "CommunityToolkit.Aspire.Hosting.NodeJS.Extensions"); + Assert.NotEqual(default(JsonElement), toolkitPkg); + Assert.Equal("9.8.0", toolkitPkg.GetProperty("Version").GetString()); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void ParseProject_SingleFileAppHost_NoPackageReferences() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.cs"); + var projectContent = """ + #:sdk Aspire.AppHost.Sdk@9.5.0 + + var builder = DistributedApplication.CreateBuilder(args); + builder.Build().Run(); + """; + + File.WriteAllText(projectFile, projectContent); + var parser = new FallbackProjectParser(NullLogger.Instance); + + // Act + var result = parser.ParseProject(new FileInfo(projectFile)); + + // Assert + var items = result.RootElement.GetProperty("Items"); + var packageRefs = items.GetProperty("PackageReference").EnumerateArray().ToArray(); + + Assert.Empty(packageRefs); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void ParseProject_SingleFileAppHost_WithWildcardVersion() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.cs"); + var projectContent = """ + #:sdk Aspire.AppHost.Sdk@* + #:package Aspire.Hosting.Redis@* + + var builder = DistributedApplication.CreateBuilder(args); + builder.Build().Run(); + """; + + File.WriteAllText(projectFile, projectContent); + var parser = new FallbackProjectParser(NullLogger.Instance); + + // Act + var result = parser.ParseProject(new FileInfo(projectFile)); + + // Assert + var properties = result.RootElement.GetProperty("Properties"); + var sdkVersion = properties.GetProperty("AspireHostingSDKVersion").GetString(); + Assert.Equal("*", sdkVersion); + + var items = result.RootElement.GetProperty("Items"); + var packageRefs = items.GetProperty("PackageReference").EnumerateArray().ToArray(); + Assert.Single(packageRefs); + Assert.Equal("*", packageRefs[0].GetProperty("Version").GetString()); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void ParseProject_SingleFileAppHost_NoProjectReferences() + { + // Arrange - single-file apphosts don't support project references + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.cs"); + var projectContent = """ + #:sdk Aspire.AppHost.Sdk@9.5.0 + + var builder = DistributedApplication.CreateBuilder(args); + builder.Build().Run(); + """; + + File.WriteAllText(projectFile, projectContent); + var parser = new FallbackProjectParser(NullLogger.Instance); + + // Act + var result = parser.ParseProject(new FileInfo(projectFile)); + + // Assert + var items = result.RootElement.GetProperty("Items"); + var projectRefs = items.GetProperty("ProjectReference").EnumerateArray().ToArray(); + + Assert.Empty(projectRefs); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void ParseProject_SingleFileAppHost_NoSdkDirective() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.cs"); + var projectContent = """ + // Missing SDK directive + var builder = DistributedApplication.CreateBuilder(args); + builder.Build().Run(); + """; + + File.WriteAllText(projectFile, projectContent); + var parser = new FallbackProjectParser(NullLogger.Instance); + + // Act + var result = parser.ParseProject(new FileInfo(projectFile)); + + // Assert - should return null SDK version + var properties = result.RootElement.GetProperty("Properties"); + var sdkVersion = properties.GetProperty("AspireHostingSDKVersion"); + Assert.Equal(JsonValueKind.Null, sdkVersion.ValueKind); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void ParseProject_UnsupportedFileType_ThrowsProjectUpdaterException() + { + // Arrange + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var projectFile = Path.Combine(tempDir.FullName, $"Test{Guid.NewGuid()}.txt"); + var projectContent = "Some random content"; + + File.WriteAllText(projectFile, projectContent); + var parser = new FallbackProjectParser(NullLogger.Instance); + + // Act & Assert + var exception = Assert.Throws(() => + parser.ParseProject(new FileInfo(projectFile))); + Assert.Contains("Unsupported project file type", exception.Message); + } + finally + { + tempDir.Delete(recursive: true); } } } \ No newline at end of file